Draw lines and curves as a path, then fill its interior and stroke its outline onto image pixels.
dotnet add package RecursiveAI.OneImageRequires .NET 10 (net10.0); .NET 9 and earlier aren’t supported.
using OneImage; using OneImage.Drawing; using Path = OneImage.Drawing.Path; // background.png is in the zip. Image image = Image.Load("background.png"); using var canvas = image.ToCanvas(); var path = Path.ParseSvg( "M20 90 V50 C20 10 140 10 140 50 V90 Z"); canvas.Fill( new Rgba32(30, 120, 220, 255), path); canvas.Draw( new Rgba32(0, 0, 0, 255), 4, path); canvas.Snapshot().Save("path.png");
Saves the canvas snapshot to path.png, with the filled shape and black outline over the original background. Snapshot() returns an immutable image of the canvas’s current pixels; the loaded image is unchanged.
Unzip the example, then dotnet run.
The full program
background.png is a small, freely redistributable 160 × 120 RGB gradient in the zip. The full program draws onto that image with PathBuilder, chooses round joins, and compares saved pixels with the original to report the changed region.
using System.Linq; using OneImage; using OneImage.Drawing; Image image = Image.Load("background.png"); using var canvas = image.ToCanvas(); var path = new PathBuilder().MoveTo(20, 90) .LineTo(20, 50).CubicBezierTo(20, 10, 140, 10, 140, 50) .LineTo(140, 90).Close().Build(); canvas.Fill(Brush.Solid(new Rgba32(30, 120, 220, 255)), path); canvas.Draw(new Pen(Brush.Solid(new Rgba32(0, 0, 0, 255)), 4, StrokeJoin.Round), path); canvas.Snapshot().Save("path.png"); Image saved = Image.Load("path.png"); Rgba32[] before = image.ToArray<Rgba32>(); Rgba32[] after = saved.ToArray<Rgba32>(); int[] changed = Enumerable.Range(0, after.Length) .Where(i => !before[i].Equals(after[i])).ToArray(); Console.WriteLine($"path.png: {saved.Width}x{saved.Height}"); Console.WriteLine($"Changed pixel bounds (inclusive): " + $"{changed.Min(i => i % saved.Width)},{changed.Min(i => i / saved.Width)} to " + $"{changed.Max(i => i % saved.Width)},{changed.Max(i => i / saved.Width)}");
dotnet run prints:
path.png: 160x120 Changed pixel bounds (inclusive): 18,18 to 141,91
<img src="/assets/oneimage/draw-vector-path-on-image/path.png" alt="Blue curved shape with a black outline drawn over a pale gradient background" width="160" height="120" loading="lazy">
Nonfinite coordinates passed to PathBuilder throw ArgumentOutOfRangeException.
Variations
Start with a blank canvas. This saves the filled path on a transparent background instead of loading an image.
using OneImage; using OneImage.Drawing; using Path = OneImage.Drawing.Path; using var canvas = new Canvas(160, 120, PixelFormat.Rgba8); var path = Path.ParseSvg("M20 90 V50 C20 10 140 10 140 50 V90 Z"); canvas.Fill(new Rgba32(30, 120, 220, 255), path); canvas.Draw(new Rgba32(0, 0, 0, 255), 4, path); canvas.Snapshot().Save("blank-path.png"); Console.WriteLine("Wrote blank-path.png");
Wrote blank-path.pngStroke an open curve with a dashed pen. There is no fill or closing segment in this variation.
using OneImage; using OneImage.Drawing; using Path = OneImage.Drawing.Path; using var canvas = new Canvas(160, 120, PixelFormat.Rgba8); var curve = Path.ParseSvg("M20 90 C20 10 140 10 140 90"); canvas.Draw(Pen.Dash(new Rgba32(20, 80, 180, 255), 4), curve); canvas.Snapshot().Save("dashed.png"); Console.WriteLine("Wrote dashed.png");
Wrote dashed.pngRelated recipes
The short answer
Load the image and call ToCanvas(). Build a OneImage.Drawing.Path, call Canvas.Fill for its interior and Canvas.Draw for its stroke, then save a snapshot. Use a brush to choose the paint and a pen to choose stroke width and joins. Vector drawing is included in the core OneImage package.
The output here is a PNG: the geometry is rasterized into pixels. It is not an SVG export or an image-to-vector tracing operation. The path remains reusable geometry in your program, but the saved PNG contains its rendered appearance.
How do you draw a cubic Bézier curve?
Start with a point, then supply two control points and an endpoint. In the full program, MoveTo(20, 90) establishes the start. LineTo(20, 50) draws the left edge. CubicBezierTo(20, 10, 140, 10, 140, 50) draws the curved top, using the first two coordinate pairs as control points and the last pair as the endpoint. Another line reaches the bottom-right corner, and Close() adds the closing edge.
Coordinates are in pixels: x increases rightward and y increases downward. The control points steer the curve; they are not necessarily points on the curve. This is why the curve’s visible top does not simply equal the smallest control-point y coordinate. The full program’s measured bounds cover pixels changed from the original, including the stroke and anti-aliased edges, rather than just the path’s endpoints.
PathBuilder.Build() returns a path. Paths are immutable; methods such as Translate, Scale and Rotate return new paths rather than modifying the original. Save the returned value if you want the transformed geometry.
What is the difference between fill and stroke?
Fill and stroke are separate operations. A brush supplies paint inside the path. A pen supplies paint and the geometry of the stroke: width, joins, caps and optional dashes. Draw the fill first and the stroke second when the outline should remain visible above the interior.
The full example uses a solid blue brush and a black pen with width 4 and round joins. The stroke extends beyond the path centerline, so leave room around the geometry. A path touching the canvas boundary can have part of its stroke clipped. The reported changed-pixel bounds are inclusive coordinates, not a width and height.
For an existing image, obtain a canvas with image.ToCanvas() and use the same drawing calls. Brush values are interpreted in the image’s own color space, not automatically converted from another profile. By default blending uses stored encoded values. The drawing guide explains the optional linear-light mode and fill rules.
How do you draw an SVG path in C#?
Path.ParseSvg reads path-data commands such as the string in the snippet at the top. M moves, V adds a vertical segment, C adds a cubic curve, and Z closes the figure. The alias using Path = OneImage.Drawing.Path; avoids ambiguity with the file-system Path type that console projects commonly import.
A path-data string is not an entire SVG document. This method creates geometry; it does not load styles, text, or a document tree. Supply the brush and pen yourself, as this recipe does. If you already have a complete SVG file, use the separate SVG-to-PNG recipe rather than treating its XML as path data.
The dashed variation demonstrates an open cubic curve. Omitting Close() leaves its endpoints disconnected. Dashes are measured in path units; the preset scales its pattern with the pen width. Caps apply to individual dashes, so cap selection can change how closely spaced dashes look. Choose the stroke settings before relying on pixel bounds for placement.