@commandagi/graphics
v0.1.0
Published
The CommandAGI 2D graphics substrate: colour, surfaces, typography, geometry, Scene2D, CompositionPlan2D, typed view spaces, a region/tile runtime and pluggable backends that always report what they could not do.
Maintainers
Readme
@commandagi/graphics
One 2D graphics substrate, under every authoring app.
The apps stay different. Draw owns vector and diagram authoring; Paint owns brush-centric stroke history; Photo owns adjustment layers and high-precision imagery; Decks owns slides, masters and themes; Video owns time; EDA owns electrical topology; 3D owns geometry and UV projection. A common graphics runtime is not a common document schema.
What they share is the machine underneath, and one boundary makes it possible:
domain document ≠ Scene2D ≠ CompositionPlan2D ≠ output surface| layer | what it states |
| --- | --- |
| domain document | what the author made — a .drawx op-graph, a .imgx stack, a .deckx deck |
| Scene2D | what it LOOKS like: groups, paths, paints, images, text-layout references, clips, masks, blend/opacity/isolation, effects, bounds, provenance |
| CompositionPlan2D | the ordered blending PROGRAM: sources, stacks, masks, clip-to-previous, backdrop effects, isolation, intermediates |
| backend | pixels, vectors, PDF primitives, PPTX shapes, a hit map — and always a RenderReport |
Subpaths
@commandagi/graphics/color sRGB, HSL, the 16 W3C blend modes, 4 colour spaces, ONE CSS parser
@commandagi/graphics/image SurfaceDescriptor, Surface2D, regions, tiles, mips, PNG
@commandagi/graphics/text UAX#9 bidi, UAX#14 breaking, metrics providers, TextLayoutPlan
@commandagi/graphics/2d/geometry Mat2D, Path, exact bounds, stroke expansion, hit testing, spatial index
@commandagi/graphics/2d/scene Scene2D
@commandagi/graphics/2d/composition CompositionPlan2D
@commandagi/graphics/2d/view typed coordinate spaces, viewport, snapping, handles, edit intents
@commandagi/graphics/2d/runtime region requests, render reports, caches, the plan executor
@commandagi/graphics/2d/backends reference CPU, Canvas2D, SVG
@commandagi/graphics/operators semantic operators vs. their implementationsThree rules that are load-bearing
Storage is a request, not a property. SurfaceDescriptor names sample format, colour space, alpha
convention, storage location, extent, tiling and mip levels. A thumbnail asks for rgba8, a print
export asks for rgba32f in a wide gamut, and they are one operation with two requests — not two
engines. rgba16f is stored in f32 lanes whose values are always exactly representable as binary16,
so the precision is honestly half.
The request is a region. render(region, scale, target, policy), never "the whole buffer again".
A one-pixel edit in a gigapixel document touches one tile; report.cache.hits is the measurement.
A degradation that is not reported is a lie. Every render returns a RenderReport naming the
backend, the input digest, the fonts and assets actually used, the operator implementations selected,
the cache statistics, whether the output is deterministic, and every substitution, approximation,
dropped feature and refusal. A face with no registered outlines still draws as boxes in the reference
backend, named glyph-outlines-unavailable and counted; the SVG backend refuses textAsOutlines for
that face rather than emitting boxes that claim to be glyphs.
Typography is canonical
layoutText() produces a TextLayoutPlan: styled runs, resolved bidi levels, glyph ids, glyph
positions, line boxes, baselines, fonts with digests, substitutions, missing codepoints, and a named
conformance level for the bidi and line-break algorithms. Browser, canvas, SVG, PDF, PPTX, video and
thumbnail paths all consume it. They may rasterize glyphs differently. They may not independently
decide line breaks, fallback fonts or glyph placement.
Metrics come from a PROVIDER, and the provider's identity is part of the plan's digest — the same text against browser metrics and against AFM metrics is genuinely two layouts, and a cache that conflated them would serve one to the other.
A generated result is a pinned record, not a function call
blur resolves to whichever implementation policy asks for and the answer is the same either way.
inpaint does not: it INVENTS content, and that content becomes part of the document. So an operator
declaring requiresMaterialization is evaluated through operators/generative.ts, where reopening
returns the pinned asset and invokes nothing, a stale pin is reported and still shown, and generating
in the first place requires a policy that says so. decideGenerative is a pure function, which is the
only shape in which "would this reopen call a model" is answerable without a model.
What it does not do
Partial OpenType shaping, named rather than claimed. Cursive joining (from Unicode's own
DerivedJoiningType data) and GSUB SingleSubst/LigatureSubst DO run, so Arabic joins and fi
fuses. Mark attachment, cursive attachment, contextual substitution (GSUB 5-6) and Indic reordering do
NOT, so Devanagari and Khmer are still wrong. plan.shaper.openTypeShaping stays false because it
claims all four; plan.shaper.substitution says exactly what ran and what was refused.
Pair kerning IS applied when the metrics provider offers it, and it is positioning rather than
substitution — which is why it does not make openTypeShaping true. plan.shaper.kerning names
exactly which tables were read, which subtables were refused, and where the adjustments were applied.
Glyph outlines come from a parsed font file (text/fontFile.ts): TrueType glyf and CFF Type 2
charstrings, no dependency. A plan laid out against a real face renders real glyphs in the reference
and SVG backends. A plan laid out against the built-in AFM metrics has no font file behind it, so it
still draws boxes at the plan's exact positions and names the limit glyph-outlines-unavailable —
which is a different failure from the one this replaced: the old Draw pure rasterizer box-drew text
and measured it wrongly, so its line breaks disagreed with the browser's.
The numbers
pnpm --filter @commandagi/graphics bench (or node --import tsx bench/regions.ts) measures the
claims above and exits non-zero if one regresses. On a 2026 laptop:
| claim | measured | | --- | --- | | a one-pixel edit in a 2048² document, tiled vs. whole-frame | 9.8x cheaper — 1 tile recomputed, 63 from cache | | a fit-to-window preview of a 4096² document at 1/16 | mip level 4, 0.6 ms; the whole pyramid costs 85 MiB over a 256 MiB base | | a brush stroke on a 20000² canvas, tiled vs. dense | 3 MiB vs 5.96 GiB — 2035x, 3 tiles allocated | | editing a layer's samples | invalidates its tiles; 0 stale hits |
The first number was 1.6x until the benchmark existed. The cost was planNodeDigest hashing all
64 MB of a live layer's samples on every frame, which is correct and expensive; PlanSurfaceNode
gained an owner-supplied contentDigest for exactly that case. Omitting it is slow and never wrong;
supplying a stale one is wrong, which is why it is the buffer owner's job and not the runtime's guess.
