@slithy/prim-lib 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,15 +10,15 @@ Reconstructs an image by iteratively placing geometric shapes. Each step evaluat
10
10
 
11
11
  ### Classes
12
12
 
13
- - **`Canvas`** — wraps `HTMLCanvasElement`; handles image loading, pixel reads, drawing steps, and SVG output
14
- - **`Optimizer`** — runs the rAF loop; calls `onStep` after each shape is placed
13
+ - **`Canvas`** — wraps `HTMLCanvasElement` or `OffscreenCanvas` (environment-detected); handles image loading, pixel reads, drawing steps, and SVG output
14
+ - **`Optimizer`** — runs the step loop; calls `onStep` after each shape is placed. Accepts an optional `schedule` function (defaults to `requestAnimationFrame`; pass `fn => setTimeout(fn, 0)` for worker contexts)
15
15
  - **`State`** — holds the current canvas and its distance from the target
16
16
  - **`Step`** — a single candidate shape placement; computes best color and difference change
17
17
 
18
18
  ### Shapes
19
19
 
20
20
  - **`Shape`** — abstract base
21
- - **`Triangle`**, **`Rectangle`**, **`Ellipse`**, **`Square`**, **`Hexagon`**, **`Glyph`**, **`Debug`**
21
+ - **`Triangle`**, **`Rectangle`**, **`Ellipse`**, **`Circle`**, **`Square`**, **`Hexagon`**, **`Glyph`**, **`Debug`**
22
22
 
23
23
  ### Types
24
24
 
@@ -38,12 +38,14 @@ interface Cfg {
38
38
  allowUpscale?: boolean // allow output larger than source image (default: false)
39
39
  scale?: number // viewSize / computeSize ratio (set by Canvas.original)
40
40
  shapeTypes: Array<new (w: number, h: number) => ShapeInterface>
41
+ shapeWeights?: number[] // per-shape selection weights (same length as shapeTypes)
41
42
  fill: 'auto' | string
42
43
  }
43
44
  ```
44
45
 
45
46
  - **`PreCfg`** — `Cfg` with optional `width`/`height`; used before image dimensions are known
46
47
  - **`ShapeInterface`** — structural interface for shapes; includes `toData(alpha, color): StepData`
48
+ - **`Ctx2D`** — `CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D`; used for canvas operations that work in both main-thread and worker contexts
47
49
  - **`Bbox`**, **`Point`**, **`ImageDataLike`**, **`ShapeImageData`**
48
50
 
49
51
  **`RGB`** — `[number, number, number]` tuple
@@ -55,6 +57,7 @@ type StepData =
55
57
  | { t: 't'; a: number; c: RGB; pts: [number, number][] } // Triangle
56
58
  | { t: 'r'; a: number; c: RGB; pts: [number, number][] } // Rectangle
57
59
  | { t: 'e'; a: number; c: RGB; cx: number; cy: number; rx: number; ry: number } // Ellipse
60
+ | { t: 'c'; a: number; c: RGB; cx: number; cy: number; r: number } // Circle
58
61
  | { t: 's'; a: number; c: RGB; cx: number; cy: number; r: number } // Square
59
62
  | { t: 'h'; a: number; c: RGB; cx: number; cy: number; r: number; angle: number } // Hexagon
60
63
  | { t: 'sm'; a: number; c: RGB; cx: number; cy: number; fs: number; text: string } // Glyph
@@ -77,7 +80,7 @@ interface SerializedOutput {
77
80
 
78
81
  ```ts
79
82
  interface ReplayResult {
80
- raster: HTMLCanvasElement
83
+ raster: HTMLCanvasElement | OffscreenCanvas
81
84
  svg: SVGSVGElement
82
85
  svgString: string
83
86
  }
@@ -87,6 +90,10 @@ interface ReplayResult {
87
90
 
88
91
  **`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.
89
92
 
93
+ **`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.
94
+
95
+ **`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.
96
+
90
97
  ### Utilities
91
98
 
92
99
  - **`getFill(data: ImageDataLike): string`** — computes a fill color (average of corner pixels) from image data
@@ -101,14 +108,17 @@ interface ReplayResult {
101
108
  - **ESM only** — no CommonJS; no `importScripts()`
102
109
  - **Canvas reuse** — the original creates a new `<canvas>` element for every shape rasterization call (~58,000 per run at default settings). `prim-lib` reuses 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)
103
110
  - **`willReadFrequently: true`** — canvas contexts used for `getImageData` are created with this flag, keeping pixel data in CPU memory and suppressing browser warnings
104
- - **Workers removed** — the original included worker stubs (`importScripts()`-based, not ESM-compatible). Workers were investigated and benchmarked; after the canvas reuse optimization, a batched worker implementation matched single-threaded performance. Workers are not used
111
+ - **Worker-compatible** — `Canvas` detects its environment: in a browser context it creates `HTMLCanvasElement`; in a worker it creates `OffscreenCanvas` (same 2D API). `Canvas.fromBitmap()` loads an image from an `ImageBitmap` (worker-compatible) rather than `new Image()`. `Optimizer` accepts an injectable `schedule` function so callers can substitute `setTimeout` for `requestAnimationFrame` in worker contexts. The original included `importScripts()`-based worker stubs (not ESM-compatible) which were removed; worker support is now done properly via `prim-interface`'s `runWorker()`
105
112
  - **Architecture split** — the original is a single-layer library. Here, `prim-lib` is the pure algorithm (no knowledge of how images arrive or where results go), and `prim-interface` is the adapter that wires it to the browser
106
113
  - **`PreCfg` / `Cfg` distinction** — `Canvas.original()` accepts `PreCfg` (optional `width`/`height`) and resolves to a fully-populated `Cfg`, making the config lifecycle explicit in the types
107
114
  - **Serialization** — `StepData` / `SerializedOutput` allow completed runs to be stored compactly and replayed via `replayOutput()` without re-running the optimizer
115
+ - **Additional shapes** — `Circle`, `Square`, `Hexagon`, and `Glyph` are not in the original; `Circle` is a uniform-radius variant of `Ellipse`
116
+ - **Shape weighting** — `shapeWeights` allows 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
108
117
 
109
118
  ## Architecture notes
110
119
 
111
- - `Shape.rasterize()` reuses a single module-level canvas (`_rasterCanvas`) grown to the max shape size seen; avoids per-call `createElement('canvas')` which was the dominant cost (27x speedup)
112
- - `Cfg.shapeTypes` holds shape constructors; shapes are chosen randomly each step
113
- - `PreCfg` exists to bridge the gap between call time (dimensions unknown) and runtime (dimensions set by `Canvas.original()`)
114
- - `Canvas.svgRoot()` is a static helper shared by `Canvas.empty()` and `replayOutput()` to create the SVG root with clip path and background fill
120
+ - `Shape.rasterize()` reuses a single module-level canvas (`_rasterCanvas`) grown to the max shape size seen; in a worker, this is an `OffscreenCanvas` (environment-detected by the `Canvas` constructor). Avoids per-call canvas creation which was the dominant cost (27x speedup)
121
+ - `Cfg.shapeTypes` holds shape constructors; shapes are chosen randomly each step unless `shapeWeights` is set
122
+ - When `shapeWeights` is provided (same length as `shapeTypes`), `Optimizer` builds 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
123
+ - `PreCfg` exists to bridge the gap between call time (dimensions unknown) and runtime (dimensions set by `Canvas.original()` or `Canvas.fromBitmap()`)
124
+ - `Canvas.svgRoot()` is a static helper shared by `Canvas.empty()` and `replayOutput()` to create the SVG root with clip path and background fill; it uses DOM APIs and is main-thread only
package/dist/index.d.ts CHANGED
@@ -30,7 +30,7 @@ interface ShapeInterface {
30
30
  rasterize(alpha: number): {
31
31
  getImageData(): ImageDataLike;
32
32
  };
33
- render(ctx: CanvasRenderingContext2D): void;
33
+ render(ctx: Ctx2D): void;
34
34
  }
35
35
  interface Cfg {
36
36
  width: number;
@@ -57,6 +57,7 @@ type PreCfg = Omit<Cfg, 'width' | 'height'> & {
57
57
  height?: number;
58
58
  };
59
59
  type RGB = [number, number, number];
60
+ type Ctx2D = CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
60
61
  type StepData = {
61
62
  t: 't';
62
63
  a: number;
@@ -119,17 +120,18 @@ interface DrawableStep {
119
120
  alpha: number;
120
121
  color: string;
121
122
  shape: {
122
- render(ctx: CanvasRenderingContext2D): void;
123
+ render(ctx: Ctx2D): void;
123
124
  };
124
125
  }
125
126
  declare class Canvas {
126
- node: HTMLCanvasElement;
127
- ctx: CanvasRenderingContext2D;
127
+ node: HTMLCanvasElement | OffscreenCanvas;
128
+ ctx: Ctx2D;
128
129
  _imageData: ImageData | null;
129
130
  static svgRoot(width: number, height: number, fill: string): SVGSVGElement;
130
131
  static empty(cfg: Cfg, svg: true): SVGSVGElement;
131
132
  static empty(cfg: Cfg, svg?: false): Canvas;
132
133
  static original(url: string, cfg: PreCfg): Promise<Canvas>;
134
+ static fromBitmap(bitmap: ImageBitmap, cfg: PreCfg): Canvas;
133
135
  static test(cfg: PreCfg): Canvas;
134
136
  constructor(width: number, height: number, willReadFrequently?: boolean);
135
137
  clone(): Canvas;
@@ -167,6 +169,7 @@ declare class Step {
167
169
  mutate(): Step;
168
170
  }
169
171
 
172
+ type ShapeCtor = new (w: number, h: number) => ShapeInterface;
170
173
  declare class Optimizer {
171
174
  cfg: Cfg;
172
175
  state: State;
@@ -174,7 +177,9 @@ declare class Optimizer {
174
177
  _steps: number;
175
178
  _stopped: boolean;
176
179
  _paused: boolean;
177
- constructor(original: Canvas, cfg: Cfg);
180
+ _stepPlan: ShapeCtor[];
181
+ _schedule: (fn: () => void) => void;
182
+ constructor(original: Canvas, cfg: Cfg, schedule?: (fn: () => void) => void);
178
183
  start(): void;
179
184
  stop(): void;
180
185
  pause(): void;
@@ -196,7 +201,7 @@ declare class Shape implements ShapeInterface {
196
201
  rasterize(alpha: number): {
197
202
  getImageData(): ImageDataLike;
198
203
  };
199
- render(_ctx: CanvasRenderingContext2D): void;
204
+ render(_ctx: Ctx2D): void;
200
205
  }
201
206
  declare class Polygon extends Shape {
202
207
  points: Point[];
@@ -294,11 +299,13 @@ declare function computeColorAndDifferenceChange(offset: Bbox, imageData: ShapeI
294
299
  differenceChange: number;
295
300
  };
296
301
 
302
+ declare function renderStepToCtx(data: StepData, ctx: Ctx2D): void;
303
+ declare function stepDataToSVGElement(data: StepData): SVGElement;
297
304
  interface ReplayResult {
298
- raster: HTMLCanvasElement;
305
+ raster: HTMLCanvasElement | OffscreenCanvas;
299
306
  svg: SVGSVGElement;
300
307
  svgString: string;
301
308
  }
302
309
  declare function replayOutput(data: SerializedOutput): ReplayResult;
303
310
 
304
- export { type Bbox, Canvas, type Cfg, Circle, Debug, Ellipse, Glyph, Hexagon, type ImageDataLike, Optimizer, type Point, type PreCfg, type RGB, Rectangle, type ReplayResult, SVGNS, type SerializedOutput, Shape, type ShapeImageData, type ShapeInterface, Square, State, Step, type StepData, Triangle, clamp, clampColor, computeColorAndDifferenceChange, difference, differenceToDistance, distanceToDifference, getFill, parseColor, replayOutput, stepPerf };
311
+ export { type Bbox, Canvas, type Cfg, Circle, type Ctx2D, Debug, Ellipse, Glyph, Hexagon, type ImageDataLike, Optimizer, type Point, type PreCfg, type RGB, Rectangle, type ReplayResult, SVGNS, type SerializedOutput, Shape, type ShapeImageData, type ShapeInterface, Square, State, Step, type StepData, Triangle, clamp, clampColor, computeColorAndDifferenceChange, difference, differenceToDistance, distanceToDifference, getFill, parseColor, renderStepToCtx, replayOutput, stepDataToSVGElement, stepPerf };
package/dist/index.js CHANGED
@@ -190,6 +190,22 @@ var Canvas = class _Canvas {
190
190
  };
191
191
  });
192
192
  }
193
+ static fromBitmap(bitmap, cfg) {
194
+ const w = bitmap.width;
195
+ const h = bitmap.height;
196
+ const computeScale = getScale(w, h, cfg.computeSize, cfg.allowUpscale);
197
+ cfg.width = w / computeScale;
198
+ cfg.height = h / computeScale;
199
+ const viewScale = getScale(w, h, cfg.viewSize, cfg.allowUpscale);
200
+ cfg.scale = computeScale / viewScale;
201
+ const fullCfg = { ...cfg, width: cfg.width, height: cfg.height };
202
+ const canvas = this.empty(fullCfg);
203
+ canvas.ctx.drawImage(bitmap, 0, 0, fullCfg.width, fullCfg.height);
204
+ if (cfg.fill === "auto") {
205
+ cfg.fill = getFill(canvas.getImageData());
206
+ }
207
+ return canvas;
208
+ }
193
209
  static test(cfg) {
194
210
  cfg.width = cfg.computeSize;
195
211
  cfg.height = cfg.computeSize;
@@ -216,10 +232,17 @@ var Canvas = class _Canvas {
216
232
  return canvas;
217
233
  }
218
234
  constructor(width, height, willReadFrequently = false) {
219
- this.node = document.createElement("canvas");
220
- this.node.width = width;
221
- this.node.height = height;
222
- this.ctx = this.node.getContext("2d", { willReadFrequently });
235
+ if (typeof document !== "undefined") {
236
+ const el = document.createElement("canvas");
237
+ el.width = width;
238
+ el.height = height;
239
+ this.node = el;
240
+ this.ctx = el.getContext("2d", { willReadFrequently });
241
+ } else {
242
+ const el = new OffscreenCanvas(width, height);
243
+ this.node = el;
244
+ this.ctx = el.getContext("2d", { willReadFrequently });
245
+ }
223
246
  this._imageData = null;
224
247
  }
225
248
  clone() {
@@ -854,6 +877,22 @@ var Debug = class extends Shape {
854
877
  };
855
878
 
856
879
  // src/optimizer.ts
880
+ function buildStepPlan(cfg) {
881
+ const { shapeTypes, shapeWeights, steps } = cfg;
882
+ if (!shapeWeights || shapeWeights.length !== shapeTypes.length) return [];
883
+ const total = shapeWeights.reduce((sum, w) => sum + w, 0);
884
+ const floats = shapeWeights.map((w) => w / total * steps);
885
+ const floors = floats.map(Math.floor);
886
+ const remainder = steps - floors.reduce((s, n) => s + n, 0);
887
+ const indices = floats.map((f, i) => ({ i, frac: f - floors[i] })).sort((a, b) => b.frac - a.frac).slice(0, remainder).map((x) => x.i);
888
+ const counts = floors.map((n, i) => indices.includes(i) ? n + 1 : n);
889
+ const plan = shapeTypes.flatMap((ctor, i) => Array(counts[i]).fill(ctor));
890
+ for (let i = plan.length - 1; i > 0; i--) {
891
+ const j = Math.floor(Math.random() * (i + 1));
892
+ [plan[i], plan[j]] = [plan[j], plan[i]];
893
+ }
894
+ return plan;
895
+ }
857
896
  var Optimizer = class {
858
897
  cfg;
859
898
  state;
@@ -861,7 +900,9 @@ var Optimizer = class {
861
900
  _steps;
862
901
  _stopped;
863
902
  _paused;
864
- constructor(original, cfg) {
903
+ _stepPlan;
904
+ _schedule;
905
+ constructor(original, cfg, schedule = (fn) => requestAnimationFrame(fn)) {
865
906
  this.cfg = cfg;
866
907
  this.state = new State(original, Canvas.empty(cfg));
867
908
  this._steps = 0;
@@ -869,6 +910,8 @@ var Optimizer = class {
869
910
  this._paused = false;
870
911
  this.onStep = () => {
871
912
  };
913
+ this._stepPlan = buildStepPlan(cfg);
914
+ this._schedule = schedule;
872
915
  }
873
916
  start() {
874
917
  this._stopped = false;
@@ -904,15 +947,16 @@ var Optimizer = class {
904
947
  return;
905
948
  }
906
949
  if (this._steps < this.cfg.steps) {
907
- requestAnimationFrame(() => this._addShape());
950
+ this._schedule(() => this._addShape());
908
951
  }
909
952
  }
910
953
  _findBestStep() {
911
954
  const LIMIT = this.cfg.shapes;
955
+ const ctor = this._stepPlan[this._steps];
912
956
  let bestStep = null;
913
957
  const promises = [];
914
958
  for (let i = 0; i < LIMIT; i++) {
915
- const shape = Shape.create(this.cfg);
959
+ const shape = ctor ? new ctor(this.cfg.width, this.cfg.height) : Shape.create(this.cfg);
916
960
  const promise = new Step(shape, this.cfg).compute(this.state).then((step) => {
917
961
  if (!bestStep || step.distance < bestStep.distance) {
918
962
  bestStep = step;
@@ -957,7 +1001,7 @@ function hexPoints(cx, cy, r, angle) {
957
1001
  return [~~(cx + r * Math.cos(a)), ~~(cy + r * Math.sin(a))];
958
1002
  });
959
1003
  }
960
- function renderStep(data, ctx) {
1004
+ function renderStepToCtx(data, ctx) {
961
1005
  ctx.globalAlpha = data.a;
962
1006
  ctx.fillStyle = rgbString(data.c);
963
1007
  switch (data.t) {
@@ -1002,7 +1046,7 @@ function renderStep(data, ctx) {
1002
1046
  }
1003
1047
  }
1004
1048
  }
1005
- function stepToSVG(data) {
1049
+ function stepDataToSVGElement(data) {
1006
1050
  const color = rgbString(data.c);
1007
1051
  const opacity = data.a.toFixed(2);
1008
1052
  let node;
@@ -1070,8 +1114,8 @@ function replayOutput(data) {
1070
1114
  svg.setAttribute("width", String(vw));
1071
1115
  svg.setAttribute("height", String(vh));
1072
1116
  for (const step of data.steps) {
1073
- renderStep(step, raster.ctx);
1074
- svg.appendChild(stepToSVG(step));
1117
+ renderStepToCtx(step, raster.ctx);
1118
+ svg.appendChild(stepDataToSVGElement(step));
1075
1119
  }
1076
1120
  const svgString = new XMLSerializer().serializeToString(svg);
1077
1121
  return { raster: raster.node, svg, svgString };
@@ -1099,6 +1143,8 @@ export {
1099
1143
  distanceToDifference,
1100
1144
  getFill,
1101
1145
  parseColor,
1146
+ renderStepToCtx,
1102
1147
  replayOutput,
1148
+ stepDataToSVGElement,
1103
1149
  stepPerf
1104
1150
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@slithy/prim-lib",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Core engine for primitive-based image reconstruction.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -14,11 +14,11 @@
14
14
  ],
15
15
  "sideEffects": false,
16
16
  "devDependencies": {
17
- "@vitest/coverage-v8": "^4.1.0",
18
- "jsdom": "^28",
17
+ "@vitest/coverage-v8": "^4.1.2",
18
+ "jsdom": "^29.0.1",
19
19
  "tsup": "^8",
20
20
  "typescript": "^5",
21
- "vitest": "^4",
21
+ "vitest": "^4.1.2",
22
22
  "@slithy/eslint-config": "0.0.0",
23
23
  "@slithy/tsconfig": "0.0.0"
24
24
  },