Skip to content

Backlog

Randy Zwitch edited this page Sep 13, 2026 · 15 revisions

canvas_mojo backlog

What's deliberately not built yet, and why — plus where this package's own scope ends. Everything here was considered directly against a concrete question, not left unexamined by default; each entry says what would make it worth building. For what's already built and the reasoning behind it, see Changelog.

Deferred on purpose, not forgotten

  • UnsafePointer-backed buffer instead of List[UInt8] — a performance path, not a correctness one. This item's own condition was "don't reach for it before profiling says the List bounds-checking is actually the bottleneck," and profiling has since said so: a checked byte write measures 1.73ns against 0.26ns unchecked, which at three bytes a pixel was roughly half the cost of a solid fill.

    What that produced was narrower than this item, though, so the item stands. Storage is still a List; only the paths that had already established their bounds read and write through unsafe_ptr -- Canvas.write_pixel/read_pixel, and the hot per-byte loops in deflate, the PNG codec and downsample (see the Changelog). set_pixel/get_pixel remain the checked entry points. Changing what Canvas owns is a different and larger question, and nothing has needed it: the measured cost was the check, not the container.

  • Named color palettes were removed from Color once (see color.mojo's history) and came back on their own terms as canvas.named_colors (#222): the CSS names as comptime constants in an opt-in module, not on the core type. What stays deferred is a palette -- an ordered set for categorical series -- which is chart vocabulary and belongs one layer up.

  • Point-in-shape hit-testing left this list with #213: Path.in_fill and Path.in_stroke are public, under either fill rule, alongside Transform2D.inverse_point for the data-space question ("which data point is nearest the cursor") that a chart usually asks first and Path.bounds() for a coarse pass.

  • Marker/symbol shape presets (star, cross, diamond, triangle -- common scatter-plot marker shapes beyond a plain circle). Considered and deliberately left out: every one of these is already directly expressible with existing primitives (fill_polygon/fill_polygon_ aa for triangle/diamond/star, two thick draw_line_aa calls for a cross) with no new canvas-level math behind any of them, unlike every primitive actually built this session (each needed its own real geometry or coverage algorithm). A marker preset library is a convenience/vocabulary layer that belongs one layer up, in whatever charting library gets built on top of this -- it's the one that knows which marker set an actual chart type wants, not canvas guessing ahead of time.

  • Selectable line joins/caps for hard-edged strokes (miter/bevel/ round on draw_line/draw_polyline/draw_polygon, the way SVG and Canvas stroke APIs expose them). The antialiased strokes have had real LineCap and LineJoin parameters since the stroke was rewritten to fill its own outline, so this is now only about the hard-edged family -- which is definitionally 1px Bresenham, where a join style has nothing to act on. A thick hard-edged stroke with a chosen join would mean giving those functions a width first, and thick strokes in charts are reached through the antialiased path or through fill_path/fill_path_aa as a filled ribbon instead. Real, isolable feature if a concrete need shows up.

  • Anti-aliased axis-aligned rects (fill_rect with Float64 bounds and edge coverage). Since #244 every Int-taking primitive on the trait has a Float64 overload, and fill_rect's snaps each edge to the nearest pixel boundary -- so a tightly-packed bar chart no longer accumulates a width wobble from each bar rounding on its own, which was the case that raised this. What remains deferred is a rect whose edges carry partial coverage: that is Path.rect with fill_path_aa today, and a dedicated primitive is worth building only if that route measures as a cost in a real chart.

  • CFF subsetting in PdfCanvas. A CFF-flavored OpenType font is embedded whole (FontFile3), which is correct but adds the entire file per document -- a few hundred kilobytes for a Nimbus face -- where a TrueType font adds a few kilobytes of subset. Subsetting a CFF program means rewriting its INDEX structures and charset, a separate job from the glyf/loca rebuild pdf_font.mojo does. Do it if PDF size with CFF fonts turns out to matter to someone.

  • Color emoji and text on a path in PdfCanvas. A color bitmap glyph has no outline in the embedded program and draws nothing; text on a path has no DrawTarget form and stays raster and SVG only. Both are reachable through text_outline/text_path as shapes today, which is what a chart needs of them.

  • Linear-light blending in SvgCanvas and PdfCanvas. The SVG backend asks for it with color-interpolation="linearRGB" and viewers mostly ignore the request; PDF has no such switch. Both keep the setting for color_space and otherwise leave it to the raster backend, which is the only place it can be guaranteed.

  • A tiled renderer (each worker owning a screen tile with an L1-resident accumulator, instead of row bands), step 5 of #382. Deliberately not built, from a measurement taken after the batch landed (v0.29.0, 2026-09-10): pinning the whole process to the L3 slice that holds the canvas is still worth 1.7x-2x on a large single shape (the r=250 disk 137 -> 82 us, the donut 73 -> 37) and 1.1x-1.4x on the chart-shaped batch rows (2,000 mixed disks 1,280 -> 917 at 32 pinned threads, 2,000 path markers 2,553 -> 1,813 on one slice), but that gain is task placement: every worker sitting on the slice the canvas lives in. A tile still writes the shared canvas, and the accumulator locality a tile would add is what the per-band scratch already provides. What would make it worth building: a runtime way to place tasks on a chosen CCX (upstream), or a canvas allocated across slices so no placement is wrong -- either of which is a change to where the pixels live, not to how they are split. The same run showed 32 pinned threads beating 64 unpinned on the compute-bound batch rows, which is a worker-cap question, filed separately.

  • Depth buffering for meshes. fill_mesh draws faces in the order given, which is the painter's algorithm a depth-sorted surface relies on. Intersecting surfaces, and faces that cross each other in depth, have no correct draw order under it. A z-buffer would fix that and every static plotting library lives without one, because it brings the seam problem back in a harder form: a depth test needs edge ownership of a different kind, a top-left rule on a non-anti-aliased triangle fill, which is a second rasterizer beside the mesh one rather than an extension of it. Worth building only for a 3D scene with self-intersecting geometry, which a plot is not.

  • A smooth-shaded mesh in SvgCanvas. Mesh gradients are SVG 2 and no shipping browser renders them, so fill_mesh_shaded emits each face flat at the mean of its corners there, the way every SVG exporter does. Subdividing until the steps are sub-pixel multiplies the face count by the square of the subdivision. Revisit when a browser ships mesh gradients.

Explicitly out of scope for canvas

Anything chart-shaped — axes, scales, legends, series types, figure layout. That's a higher-level charting library's job, built on top of canvas and depending on it, never the reverse (see the repo root README.md).

A window, an input layer, a frame loop, or a GPU. The package draws into buffers and files. What an interactive application or a game engine would need on top is a display surface, input, timing, a premultiplied-alpha pipeline for compositing-heavy frames, and blits that record into the batch; all of it is a layer above this one, and the parts that are rendering rather than glue are recorded as such here rather than left implicit.

Rich display integration — a Julia/IPython-style display() protocol, so a Canvas renders inline in a notebook. Built once on a display-protocol branch (2026-08-20) and deleted unmerged on 2026-08-24: the display protocol is language- or ecosystem-level infrastructure, and belongs in Mojo proper or in a package dedicated to it, not bolted onto one drawing library. canvas_mojo's side of that contract is already complete anyway -- write_png produces the bytes any such protocol would hand to a frontend. If Mojo grows a display protocol, wiring a Canvas into it should be small and belongs wherever the protocol lives.

Foundation review: is this enough to build a charting library on?

Yes. canvas can fill any shape it can describe -- convex or not, self-intersecting or not, straight-edged or curved, hard-edged or antialiased, flat or gradient-filled (linear or radial), correctly hole-punched or union-merged depending on winding rule -- which is the actual precondition for a higher-level charting layer to render bar/line/area/scatter/pie/donut charts, stacked and overlapping regions, and gradient-shaded fills without ever needing to drop back down into raw pixel manipulation itself. Text can be measured, aligned, rotated, and multi-line-laid-out, and now its rotated footprint can be known before drawing (measure_text_block) -- exactly what axis-tick-label placement needs. Every deferred item above was considered and rejected for a concrete reason, usually that it is already directly expressible with what exists or belongs one layer up in a charting library that knows its own vocabulary. A real need surfacing downstream is the signal to build one, and that is how the bulk markers, the batch, the supersampled region and the mesh arrived: each from a measured need in the charting library built on this package.

That argument is made from capability alone -- canvas can draw anything a chart needs, therefore it's enough. Coverage checks the same claim from outside instead, against what real visualizations actually use (Beagle's 41,000-visualization corpus) and against the mark vocabulary Vega-Lite independently settled on. Both agree with the conclusion here, which is worth more than either argument alone.

The review did miss one thing: an ellipse turned out to be the single primitive shape a trait-targeting caller could not draw exactly, because fill_path_aa -- the escape hatch every other absent shape relies on -- can only approximate one. Both fill_ellipse_aa and draw_ellipse_aa were added to the trait as a result, making the ellipse the only shape there carrying a fill and an outline.

Clone this wiki locally