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)
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]: