BrushCue 1.4.15 is mostly about painters and paths. A painter is no longer something you can only add to and render. You can now take one apart, change the pieces, put it back together, and export it as an SVG. Paths get the same treatment through path commands. To make room for this, we removed streams and folded their operations into lists.
Streams Are Gone
BrushCue Script used to have two kinds of sequences of values: lists and streams. To map, filter, or fold over a list, you first had to convert it with to_stream(), and then convert it back with collect() if you needed a list again. Having two types for the same idea was confusing for people and for AI agents, and it added extra steps to every script.
Now map, filter, and fold work directly on lists, and the Stream type is removed from BrushCue Script, the bcue command line tool, and the Python API. In most scripts, the fix is to delete .to_stream():
// Before
Int::range(1, 5).to_stream().fold(1, multiply_step)
// After
Int::range(1, 5).fold(1, multiply_step)
The removed nodes are ListToStream, ObjectToStream, StreamMap, StreamFilter, StreamFold, StreamFirst, StreamCollect, and IntStreamSum. Use ListMap, ListFilter, ListFold, ListFirst, ObjectToList, and IntListSum instead.
Under the hood, list operations also do less repeated work. BrushCue now runs the part of a graph that doesn't change between elements once, and then runs only the remaining part for each element of a map, filter, or fold. The same applies to each frame of a sequence.
Export Painters to SVG
The new PainterToSvg operation (painter.to_svg()) writes everything a painter draws as an SVG document. Rectangles, ellipses, and paths become SVG shapes. Each one is defined once and drawn for each of its instance transforms, so repeated shapes stay small. Curved path segments, including Catmull-Rom and cardinal splines, are exported as exact cubic Béziers, so the SVG matches what BrushCue renders.
ink = ProfiledColor::from_srgb_hex("#1F3A5F")
paper = ProfiledColor::from_srgb_hex("#F2C14E")
style = RenderStyle::brush_and_fill(Brush::solid(ink, 4.0), Fill::solid(paper))
wave = Path::new()
.move_to_point(Point2f::from_components(20.0, 120.0))
.line_to_point(Point2f::from_components(80.0, 60.0))
.line_to_point(Point2f::from_components(140.0, 120.0))
painter = Painter::new()
.add_rectangle_with_render_style(Point2f::from_components(100.0, 100.0), Vector2f::from_components(160.0, 120.0), 0.0, style, Transform2::identity().to_list())
.add_path_with_render_style(wave, RenderStyle::brush_only(Brush::solid(ink, 6.0)), Transform2::identity().to_list())
painter.to_svg()
Run this with bcue run and it prints the SVG, ready to save to a file or open in a vector editor. The result is a String, so you can also pass it to other operations in your script.
SVG export supports solid brushes and solid fills. Custom brushes, dashed brushes, custom fills, and erasing don't have an SVG equivalent yet, so a painter that uses them returns an error instead of a wrong SVG.
Take a Painter Apart
A painter is a list of painted illustrations, drawn from back to front. A painted illustration is one path, rectangle, or ellipse, together with its render style and the transforms it is drawn with. New operations let you work with that list directly:
PainterToPaintedIllustrationsgets the list of painted illustrations in a painter.PainterFromPaintedIllustrationsbuilds a painter from a list of painted illustrations.PaintedIllustrationIsPath,PaintedIllustrationIsRectangle, andPaintedIllustrationIsEllipsetell you what kind of shape an illustration is.PaintedIllustrationPathgets the path of a path illustration.PaintedIllustrationCenter,PaintedIllustrationDimensions, andPaintedIllustrationRotationget the geometry of a rectangle or ellipse.PaintedIllustrationRenderStylegets the brush and fill an illustration is drawn with.PaintedIllustrationInstancesgets the transforms an illustration is drawn with.PainterBoundsgets the bounding box of everything a painter draws, including the brush radius.
Combined with list operations, this lets you edit a painter after it's built. For example, this keeps only the paths in an existing painter and measures what's left:
fn keep_paths(illustration: PaintedIllustration) => Bool {
illustration.is_path()
}
paths_only = Painter::from_painted_illustrations(painter.to_painted_illustrations().filter(keep_paths))
paths_only.bounds()
The same pattern works with map to change every shape, such as moving the points of each path.
Edit Paths Point by Point
Paths work the same way. PathToCommands turns a path into a list of path commands, such as move to, line to, and the various curve commands. PathFromCommands builds a path from that list again. In between, you can read and change each command:
PathCommandCoordinate,PathCommandPressure,PathCommandTime,PathCommandAzimuthAngle,PathCommandAltitudeAngle, and the control point operations read a command's values.- The matching
with_operations, such asPathCommandWithCoordinateandPathCommandWithPressure, return the command with one value replaced. PathCommandIsLine,PathCommandIsMove, and the otheris_operations tell you what kind of command it is.- The new
Point2fAddVector2foperation (point.add_vector(vector)) offsets a point by a vector, which comes up often when moving path points.
Here, every point in an existing path is pushed by a wind vector:
wind = Vector2f::from_components(12.0, -4.0)
fn push(command: PathCommand) => PathCommand {
command.with_coordinate(command.coordinate().add_vector(wind))
}
Path::from_commands(path.to_commands().map(push))
Because pressure, time, and pen angles come along with each command, this works well on strokes recorded from a stylus. Make the wind vector depend on time inside a sequence, scale it by each point's pressure and its distance from the base of the stroke, and a hand-drawn branch sways in the breeze, with thinner parts moving more than thicker ones.
Upgrading
Update the bcue command line tool with sudo snap refresh bcue on Linux, brew upgrade bcue on Mac, or download it from GitHub releases. Update the Python API with pip install --upgrade brushcue. If your scripts use streams, remove .to_stream() and .collect() and call the same operations on the list.