@slithy/prim-lib
v0.9.1
Published
Core engine for primitive-based image reconstruction.
Maintainers
Readme
@slithy/prim-lib
Core engine for primitive-based image reconstruction. Consumed by @slithy/prim-interface.
License
This package is dual-licensed.
- Public use is licensed under
PolyForm-Noncommercial-1.0.0in LICENSE. - Separate commercial use rights, if any, are granted only by a separate written commercial license. The current repo-tracked commercial grant is in LICENSE-COMMERCIAL.md.
This package also includes upstream MIT attributions for primitive and primitive.js in its LICENSE file.
What it does
Reconstructs an image by iteratively placing geometric shapes. Each step evaluates candidate shapes, mutates them, picks the one that most reduces the difference from the target, and draws it onto the working canvas.
Exports
Classes
Canvas— wrapsHTMLCanvasElementorOffscreenCanvas(environment-detected); handles image loading, pixel reads, drawing steps, and SVG outputOptimizer— runs the step loop; callsonStepafter each shape is placed. Accepts an optionalschedulefunction (defaults torequestAnimationFrame; passfn => setTimeout(fn, 0)for worker contexts)State— holds the current canvas and its distance from the targetStep— a single candidate shape placement; computes best color and difference change
Shapes
Fixed shapes (class constructors, pass directly in shapeTypes):
Triangle,Rectangle,Ellipse,Circle,Square,Hexagon,Glyph,Debug
Configurable shapes (factory functions that return a constructor):
makeNGon(opts)— N-sided polygon; supports regular and irregular modesmakeRect(opts?)— axis-aligned or rotatable rectangle with independent width/height controlmakeCircle(opts?)— circle with configurable size range and pixel-area constraintsmakeEllipse(opts?)— ellipse with independent rx/ry ranges and optional aspect ratio lockmakeGlyph(opts?)— Unicode character shape; measures glyph bounds at render time and serializes as SVG<text>
shapeTypes: [Triangle, makeNGon({ sides: 6, regular: true }), makeCircle({ sizeRange: [5, 20] })]Factory options
NGonOptions
interface NGonOptions {
sides: number // number of vertices (≥ 3, required)
regular?: boolean // true = equidistant vertices (default: true)
rotatable?: boolean // regular only: random rotation each instance (default: true)
startAngle?: number // regular, non-rotatable only: fixed orientation in radians
// default: -π/2 (top vertex pointing up)
noise?: number // regular only: 0–1 vertex jitter (default: 0)
convex?: boolean // irregular only: apply convex hull (default: false)
sizeRange?: [number, number] // vertex radius range in compute-space px (default: [1, 20])
// regular: radius from center; irregular: scatter radius from first point
mutationScale?: number // max mutation step size in px (default: 20)
// pixel-area constraints (see Pixel-area constraints)
tonalRange?: [number, number]
invertTonal?: boolean
saturationRange?: [number, number]
invertSaturation?: boolean
hueCenter?: number
hueTolerance?: number
invertHue?: boolean
}RectOptions
interface RectOptions {
widthRange?: [number, number] // half-width range in compute-space px (default: [5, 40])
heightRange?: [number, number] // half-height range in compute-space px (default: [5, 40])
// ignored when aspectRatio is set
aspectRatio?: number // width ÷ height; locks proportions, derives hh from hw
// e.g. 1.78 for 16:9, 1.33 for 4:3, 1.0 for square
rotatable?: boolean // random rotation per instance (default: false)
mutationScale?: number // max mutation step size in px (default: 20)
// pixel-area constraints (see Pixel-area constraints)
tonalRange?: [number, number]
invertTonal?: boolean
saturationRange?: [number, number]
invertSaturation?: boolean
hueCenter?: number
hueTolerance?: number
invertHue?: boolean
}CircleOptions
interface CircleOptions {
sizeRange?: [number, number] // radius range in compute-space px (default: [1, 20])
mutationScale?: number // max mutation step size in px (default: 20)
// pixel-area constraints (see Pixel-area constraints)
tonalRange?: [number, number]
invertTonal?: boolean
saturationRange?: [number, number]
invertSaturation?: boolean
hueCenter?: number
hueTolerance?: number
invertHue?: boolean
}EllipseOptions
interface EllipseOptions {
rxRange?: [number, number] // x-radius range in compute-space px (default: [1, 20])
ryRange?: [number, number] // y-radius range in compute-space px (default: [1, 20])
// ignored when aspectRatio is set
aspectRatio?: number // rx ÷ ry; locks proportions, derives ry from rx
mutationScale?: number // max mutation step size in px (default: 20)
// pixel-area constraints (see Pixel-area constraints)
tonalRange?: [number, number]
invertTonal?: boolean
saturationRange?: [number, number]
invertSaturation?: boolean
hueCenter?: number
hueTolerance?: number
invertHue?: boolean
}GlyphOptions
interface GlyphOptions {
char?: string // Unicode character (default: '☺')
fontFamily?: string // CSS font-family string (default: 'sans-serif')
sizeRange?: [number, number] // font-size range in compute-space px (default: [10, 30])
mutationScale?: number // max mutation step size in px (default: 20)
// pixel-area constraints (see Pixel-area constraints)
tonalRange?: [number, number]
invertTonal?: boolean
saturationRange?: [number, number]
invertSaturation?: boolean
hueCenter?: number
hueTolerance?: number
invertHue?: boolean
}Pixel-area constraints
All factory shapes (makeNGon, makeRect, makeCircle, makeEllipse, makeGlyph) accept optional pixel-area constraint fields. Before rasterizing, the optimizer samples the pixels the shape would cover in the target image. If the area fails any active check, the shape is discarded without consuming the step budget. Shapes placed entirely outside the image bounds skip all checks.
tonalRange: [min, max] — BT.601 luma (0–255). Discard if the covered area's average luminance falls outside [min, max]. Set invertTonal: true to keep shapes outside the range instead.
saturationRange: [min, max] — HSL saturation (0–100). Discard if average saturation falls outside [min, max]. Set invertSaturation: true to invert.
hueCenter / hueTolerance — center hue (0–360°) and allowed angular deviation in degrees. Discard if the covered area's average hue differs from hueCenter by more than hueTolerance. Set invertHue: true to invert. Both fields must be set together to activate the hue check.
// circles only in bright areas; glyphs only in desaturated areas
shapeTypes: [
makeCircle({ sizeRange: [5, 20], tonalRange: [180, 255] }),
makeGlyph({ char: '·', saturationRange: [0, 20] }),
]Types
Cfg — runtime config (all fields required):
interface Cfg {
width: number // image width for computation (set by Canvas.original)
height: number // image height for computation (set by Canvas.original)
steps: number // total shapes to place
shapes: number // candidate shapes evaluated per step
mutations: number // hill-climb attempts per candidate
alpha: number // starting shape opacity
mutateAlpha: boolean // whether opacity is mutated per candidate
computeSize: number // max image dimension for pixel distance calculations
viewSize: number // max image dimension for display canvas
allowUpscale?: boolean // allow output larger than source image (default: false)
scale?: number // viewSize / computeSize ratio (set by Canvas.original)
shapeTypes: Array<new (w: number, h: number) => ShapeInterface>
shapeWeights?: number[] // per-shape selection weights (same length as shapeTypes)
fill: 'auto' | string
}PreCfg—Cfgwith optionalwidth/height; used before image dimensions are knownShapeInterface— structural interface for shapes; includestoData(alpha, color): StepDataCtx2D—CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; used for canvas operations that work in both main-thread and worker contextsBbox,Point,ImageDataLike,ShapeImageData
RGB — [number, number, number] tuple
StepData — discriminated union of serialized shape data, keyed by t:
type StepData =
| { t: 't'; a: number; c: RGB; pts: [number, number][] } // Triangle
| { t: 'r'; a: number; c: RGB; pts: [number, number][] } // Rectangle
| { t: 'p'; a: number; c: RGB; pts: [number, number][] } // makeNGon (regular and irregular)
| { t: 'e'; a: number; c: RGB; cx: number; cy: number; rx: number; ry: number } // Ellipse
| { t: 'c'; a: number; c: RGB; cx: number; cy: number; r: number } // Circle
| { t: 's'; a: number; c: RGB; cx: number; cy: number; r: number } // Square
| { t: 'h'; a: number; c: RGB; cx: number; cy: number; r: number; angle: number } // Hexagon
| { t: 'sm'; a: number; c: RGB; cx: number; cy: number; fs: number; text: string } // Glyph
| { t: 'rc'; a: number; c: RGB; cx: number; cy: number; hw: number; hh: number; angle: number } // makeRectSerializedOutput — compact, storage-ready representation of a completed run:
interface SerializedOutput {
v: 1 // schema version
w: number // compute width
h: number // compute height
scale: number // viewSize / computeSize ratio
fill: RGB // background fill color
steps: StepData[]
}ReplayResult — returned by replayOutput:
interface ReplayResult {
raster: HTMLCanvasElement | OffscreenCanvas
svg: SVGSVGElement
svgString: string
}Functions
replayOutput(data: SerializedOutput): ReplayResult — reconstructs canvas and SVG natively from serialized output, without re-running the optimizer. Useful for restoring saved results from localStorage or a database.
renderStepToCtx(data: StepData, ctx: Ctx2D): void — renders a single StepData onto a 2D canvas context. Used internally by replayOutput and by runWorker to incrementally apply worker-posted steps to the display canvas.
stepDataToSVGElement(data: StepData): SVGElement — creates a DOM SVGElement from a StepData. Used internally by replayOutput and by runWorker to build the live SVG incrementally on the main thread.
Utilities
getFill(data: ImageDataLike): string— computes a fill color (average of corner pixels) from image dataparseColor(str: string): RGB— parses"rgb(r, g, b)"to an[r, g, b]tuplestepPerf— accumulator for profiling rasterize vs pixel math time per step
Differences from primitive.js
prim-lib is derived from primitive.js, a JavaScript port of the Go primitive library by Michael Fogleman. The core algorithm — iterative shape placement via hill-climbing — is unchanged. What's different:
- TypeScript, strict mode — fully typed throughout;
strict: trueon both packages - ESM only — no CommonJS; no
importScripts() - Canvas reuse — the original creates a new
<canvas>element for every shape rasterization call (~58,000 per run at default settings).prim-libreuses a single module-level canvas, growing it only when a larger bbox is seen. This delivered a ~27× speedup (37 s → ~0.8 s rasterize time per run) willReadFrequently: true— canvas contexts used forgetImageDataare created with this flag, keeping pixel data in CPU memory and suppressing browser warnings- Worker-compatible —
Canvasdetects its environment: in a browser context it createsHTMLCanvasElement; in a worker it createsOffscreenCanvas(same 2D API).Canvas.fromBitmap()loads an image from anImageBitmap(worker-compatible) rather thannew Image().Optimizeraccepts an injectableschedulefunction so callers can substitutesetTimeoutforrequestAnimationFramein worker contexts. The original includedimportScripts()-based worker stubs (not ESM-compatible) which were removed; worker support is now done properly viaprim-interface'srunWorker() - Architecture split — the original is a single-layer library. Here,
prim-libis the pure algorithm (no knowledge of how images arrive or where results go), andprim-interfaceis the adapter that wires it to the browser PreCfg/Cfgdistinction —Canvas.original()acceptsPreCfg(optionalwidth/height) and resolves to a fully-populatedCfg, making the config lifecycle explicit in the types- Serialization —
StepData/SerializedOutputallow completed runs to be stored compactly and replayed viareplayOutput()without re-running the optimizer - Additional shapes —
Circle,Square,Hexagon, andGlyphare not in the original;Circleis a uniform-radius variant ofEllipse;makeNGonandmakeRectare configurable factory shapes also not in the original - Shape weighting —
shapeWeightsallows biased shape selection with a guaranteed distribution: the optimizer pre-allocates exact per-shape step counts (largest-remainder rounding) and shuffles them, so the final mix always matches the requested weights
Architecture notes
Shape.rasterize()reuses a single module-level canvas (_rasterCanvas) grown to the max shape size seen; in a worker, this is anOffscreenCanvas(environment-detected by theCanvasconstructor). Avoids per-call canvas creation which was the dominant cost (27x speedup)Cfg.shapeTypesholds shape constructors; shapes are chosen randomly each step unlessshapeWeightsis set- When
shapeWeightsis provided (same length asshapeTypes),Optimizerbuilds a step plan at construction time: each shape type is allocated an exact number of slots proportional to its weight (using largest-remainder rounding), then the plan is Fisher-Yates shuffled. This guarantees the final shape distribution matches the weights, rather than just biasing random selection PreCfgexists to bridge the gap between call time (dimensions unknown) and runtime (dimensions set byCanvas.original()orCanvas.fromBitmap())Canvas.svgRoot()is a static helper shared byCanvas.empty()andreplayOutput()to create the SVG root with clip path and background fill; it uses DOM APIs and is main-thread only- Factory shapes (
makeNGon,makeRect,makeCircle,makeEllipse,makeGlyph) capture their options in a closure and return an inner class. The class carries a static_shapeSpecproperty ({ f: string, o: opts }) used byrunWorkerto serialize and reconstruct the factory call inside the worker
