In [9]:
import brushcue

START = (100, 100)
CONTROL = (300, 500)
END = (1000, 200)

path = brushcue.Path.new()
path = path.move_to_point(
    brushcue.Point2f.from_components(*START)
)
path = path.quadratic_bezier_to_point(
    brushcue.Point2f.from_components(*CONTROL),
    brushcue.Point2f.from_components(*END)
)
In [10]:
CURVE_WIDTH = 20

painter = brushcue.Painter.new()
render_style = brushcue.RenderStyle.brush_only(
    brushcue.Brush.solid(
        brushcue.ProfiledColor.from_rgba_srgb(
            brushcue.RGBAColor.from_components(0.3, 0.3, 0.3, 1.0)
        ),
        CURVE_WIDTH
    )
)

control_polygon = brushcue.Path.new()
control_polygon = control_polygon.move_to_point(
    brushcue.Point2f.from_components(*START)
)
control_polygon = control_polygon.line_to_point(
    brushcue.Point2f.from_components(*CONTROL)
)
control_polygon = control_polygon.line_to_point(
    brushcue.Point2f.from_components(*END)
)
control_polygon_render_style = brushcue.RenderStyle.brush_only(
    brushcue.Brush.dashed(
        brushcue.ProfiledColor.from_rgba_srgb(
            brushcue.RGBAColor.from_components(0.45, 0.45, 0.45, 1.0)
        ),
        24,
        brushcue.ProfiledColor.from_rgba_srgb(
            brushcue.RGBAColor.from_components(0.45, 0.45, 0.45, 0.0)
        ),
        16,
        5
    )
)
painter = painter.add_path_with_render_style(path, render_style, brushcue.Transform2.identity().to_list())

painter = painter.add_path_with_render_style(
    control_polygon,
    control_polygon_render_style,
    brushcue.Transform2.identity().to_list()
)

POINT_DIAMETER = 40
point_render_style = brushcue.RenderStyle.fill_only(
    brushcue.Fill.solid(
        brushcue.ProfiledColor.from_rgba_srgb(
            brushcue.RGBAColor.from_components(0.15, 0.15, 0.15, 1.0)
        )
    )
)
for point in (START, CONTROL, END):
    painter = painter.add_ellipse_with_render_style(
        brushcue.Point2f.from_components(*point),
        brushcue.Vector2f.from_components(POINT_DIAMETER, POINT_DIAMETER),
        0,
        point_render_style,
        brushcue.Transform2.identity().to_list()
    )
In [11]:
import os
from pathlib import Path

def find_repo_root():
    for directory in (Path.cwd(), *Path.cwd().parents):
        if (directory / "writing/graphics/fonts").is_dir():
            return directory
    raise FileNotFoundError("Could not find the graphics book directory")

# BrushCue reads files relative to the working directory, so run from the
# monorepo root to load the book font stored in writing/graphics/fonts.
os.chdir(find_repo_root())
ctx = brushcue.Context()

BOOK_FONT = brushcue.Font.from_path(
    "writing/graphics/fonts/LibertinusSerif-Regular.otf"
)
LABEL_SIZE = 48
LABEL_GAP = 36

def label_character_style(render_style):
    @brushcue.brushcue_fn
    def style_label_character(character):
        return brushcue.StyledCharacter.new(
            character,
            BOOK_FONT,
            LABEL_SIZE,
            render_style
        )
    return style_label_character

def label_string(text, origin, render_style):
    characters = brushcue.String(
        brushcue.input_parsers.parse_string_graph(text)
    ).to_character_list()
    return brushcue.StyledString.styled_string_from_characters_free(
        characters.map(label_character_style(render_style)),
        brushcue.Point2f.from_components(*origin)
    )

def add_label(
    painter,
    text,
    point,
    side,
    render_style=point_render_style,
    clear_of=()
):
    # Measure the label at the origin so it can be placed beside the point.
    bounds = brushcue.Composition.painter(
        brushcue.Painter.new().add_styled_text(
            label_string(text, (0, 0), render_style)
        )
    ).bounds().execute(ctx)
    offset = POINT_DIAMETER / 2 + LABEL_GAP
    if side == "above":
        x = point[0] - bounds.x - bounds.width / 2
        y = point[1] - offset - bounds.y - bounds.height
    elif side == "below":
        x = point[0] - bounds.x - bounds.width / 2
        y = point[1] + offset - bounds.y
    elif side == "left":
        x = point[0] - offset - bounds.x - bounds.width
        y = point[1] - bounds.y - bounds.height / 2
    elif side == "right":
        x = point[0] + offset - bounds.x
        y = point[1] - bounds.y - bounds.height / 2
    else:
        raise ValueError(f"Unknown label side: {side}")
    if side == "above":
        # Raise the label until it clears the curve samples beneath it.
        left = x + bounds.x - CURVE_WIDTH / 2
        right = left + bounds.width + CURVE_WIDTH
        tops = [sample_y for sample_x, sample_y in clear_of if left <= sample_x <= right]
        if tops:
            highest_bottom = min(tops) - CURVE_WIDTH / 2 - LABEL_GAP / 2
            y = min(y, highest_bottom - bounds.y - bounds.height)
    return painter.add_styled_text(label_string(text, (x, y), render_style))

for name, point, side in (
    ("P₀", START, "above"),
    ("P₁", CONTROL, "below"),
    ("P₂", END, "above"),
):
    painter = add_label(painter, f"{name} ({point[0]}, {point[1]})", point, side)
[wgpu] using backend Vulkan — adapter 'NVIDIA GeForce RTX 5070' (DiscreteGpu), driver 'NVIDIA'
In [12]:
from IPython.display import Image, display

# The working directory is the monorepo root (see the label cell above).
OUTPUT_DIRECTORY = Path("writing/graphics/chapters/curves/assets/bezier")

def save_figure(painter, name):
    composition = brushcue.Composition.painter(painter)
    print(composition.bounds().execute(ctx))
    image_bytes = composition.execute(ctx).to_image_bytes(ctx)
    OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True)
    (OUTPUT_DIRECTORY / f"{name}.png").write_bytes(image_bytes)
    display(Image(image_bytes))

save_figure(painter, "bezier")
Bounds(x=-96.79999542236328, y=-114.36800384521484, width=1239.0400390625, height=740.6399536132813)
No description has been provided for this image
In [ ]:
EVALUATED_COLOR = brushcue.ProfiledColor.from_rgba_srgb(
    brushcue.RGBAColor.from_components(0.1, 0.55, 0.2, 1.0)
)
evaluated_render_style = brushcue.RenderStyle.fill_only(
    brushcue.Fill.solid(EVALUATED_COLOR)
)

def evaluate_quadratic_bezier(t):
    return tuple(
        (1 - t) ** 2 * p0 + 2 * (1 - t) * t * p1 + t ** 2 * p2
        for p0, p1, p2 in zip(START, CONTROL, END)
    )

CURVE_SAMPLES = [evaluate_quadratic_bezier(i / 1000) for i in range(1001)]

def add_evaluated_point(painter, t, side):
    point = evaluate_quadratic_bezier(t)
    painter = painter.add_ellipse_with_render_style(
        brushcue.Point2f.from_components(*point),
        brushcue.Vector2f.from_components(POINT_DIAMETER, POINT_DIAMETER),
        0,
        evaluated_render_style,
        brushcue.Transform2.identity().to_list()
    )
    return add_label(
        painter,
        f"B({t:g}) = ({point[0]:g}, {point[1]:g})",
        point,
        side,
        evaluated_render_style,
        clear_of=CURVE_SAMPLES
    )

# At t = 0 and t = 1 the curve is at P₀ and P₂, whose labels are above them,
# so these labels go to the side.
save_figure(add_evaluated_point(painter, 0, "left"), "bezier_t_0")
save_figure(add_evaluated_point(painter, 1, "right"), "bezier_t_1")
save_figure(add_evaluated_point(painter, 0.5, "above"), "bezier_t_0_5")
save_figure(add_evaluated_point(painter, 0.75, "above"), "bezier_t_0_75")
In [12]: