@slithy/prim-lib 0.1.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/LICENSE +91 -0
- package/README.md +68 -0
- package/dist/index.d.ts +206 -0
- package/dist/index.js +823 -0
- package/package.json +54 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# PolyForm Noncommercial License 1.0.0
|
|
2
|
+
|
|
3
|
+
<https://polyformproject.org/licenses/noncommercial/1.0.0>
|
|
4
|
+
|
|
5
|
+
Required Notice: Copyright Matthew Campagna
|
|
6
|
+
|
|
7
|
+
## Acceptance
|
|
8
|
+
|
|
9
|
+
In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses.
|
|
10
|
+
|
|
11
|
+
## Copyright License
|
|
12
|
+
|
|
13
|
+
The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license).
|
|
14
|
+
|
|
15
|
+
## Distribution License
|
|
16
|
+
|
|
17
|
+
The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license).
|
|
18
|
+
|
|
19
|
+
## Notices
|
|
20
|
+
|
|
21
|
+
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example:
|
|
22
|
+
|
|
23
|
+
> Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
|
|
24
|
+
|
|
25
|
+
## Changes and New Works License
|
|
26
|
+
|
|
27
|
+
The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose.
|
|
28
|
+
|
|
29
|
+
## Patent License
|
|
30
|
+
|
|
31
|
+
The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software.
|
|
32
|
+
|
|
33
|
+
## Noncommercial Purposes
|
|
34
|
+
|
|
35
|
+
Any noncommercial purpose is a permitted purpose.
|
|
36
|
+
|
|
37
|
+
## Personal Uses
|
|
38
|
+
|
|
39
|
+
Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, is use for a permitted purpose.
|
|
40
|
+
|
|
41
|
+
## Noncommercial Organizations
|
|
42
|
+
|
|
43
|
+
Use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution is use for a permitted purpose regardless of the source of funding or obligations resulting from the funding.
|
|
44
|
+
|
|
45
|
+
## Fair Use
|
|
46
|
+
|
|
47
|
+
You may have "fair use" rights for the software under the law. These terms do not limit them.
|
|
48
|
+
|
|
49
|
+
## No Other Rights
|
|
50
|
+
|
|
51
|
+
These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses.
|
|
52
|
+
|
|
53
|
+
## Patent Defense
|
|
54
|
+
|
|
55
|
+
If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
|
|
56
|
+
|
|
57
|
+
## Violations
|
|
58
|
+
|
|
59
|
+
The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately.
|
|
60
|
+
|
|
61
|
+
## No Liability
|
|
62
|
+
|
|
63
|
+
***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.***
|
|
64
|
+
|
|
65
|
+
## Definitions
|
|
66
|
+
|
|
67
|
+
The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms.
|
|
68
|
+
|
|
69
|
+
**You** refers to the individual or entity agreeing to these terms.
|
|
70
|
+
|
|
71
|
+
**Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. **Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
|
|
72
|
+
|
|
73
|
+
**Your licenses** are all the licenses granted to you for the software under these terms.
|
|
74
|
+
|
|
75
|
+
**Use** means anything you do with the software requiring one of your licenses.
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## Third-party attributions
|
|
80
|
+
|
|
81
|
+
This software is derived from the following MIT-licensed works:
|
|
82
|
+
|
|
83
|
+
**primitive** by Michael Fogleman
|
|
84
|
+
https://github.com/fogleman/primitive
|
|
85
|
+
Copyright (c) 2016 Michael Fogleman — MIT License
|
|
86
|
+
|
|
87
|
+
**primitive.js** by Ondřej Žára
|
|
88
|
+
https://github.com/ondras/primitive.js
|
|
89
|
+
Copyright (c) Ondřej Žára — MIT License
|
|
90
|
+
|
|
91
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notices and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# @slithy/prim-lib
|
|
2
|
+
|
|
3
|
+
Core engine for primitive-based image reconstruction. Consumed by `@slithy/prim-interface`.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
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.
|
|
8
|
+
|
|
9
|
+
## Exports
|
|
10
|
+
|
|
11
|
+
### Classes
|
|
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
|
|
15
|
+
- **`State`** — holds the current canvas and its distance from the target
|
|
16
|
+
- **`Step`** — a single candidate shape placement; computes best color and difference change
|
|
17
|
+
|
|
18
|
+
### Shapes
|
|
19
|
+
|
|
20
|
+
- **`Shape`** — abstract base
|
|
21
|
+
- **`Triangle`**, **`Rectangle`**, **`Ellipse`**, **`Hexagon`**, **`Smiley`**, **`Debug`**
|
|
22
|
+
|
|
23
|
+
### Types
|
|
24
|
+
|
|
25
|
+
**`Cfg`** — runtime config (all fields required):
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
interface Cfg {
|
|
29
|
+
width: number // image width for computation (set by Canvas.original)
|
|
30
|
+
height: number // image height for computation (set by Canvas.original)
|
|
31
|
+
steps: number // total shapes to place
|
|
32
|
+
shapes: number // candidate shapes evaluated per step
|
|
33
|
+
mutations: number // hill-climb attempts per candidate
|
|
34
|
+
alpha: number // starting shape opacity
|
|
35
|
+
mutateAlpha: boolean // whether opacity is mutated per candidate
|
|
36
|
+
computeSize: number // max image dimension for pixel distance calculations
|
|
37
|
+
viewSize: number // max image dimension for display canvas
|
|
38
|
+
scale?: number // viewSize / computeSize ratio (set by Canvas.original)
|
|
39
|
+
shapeTypes: Array<new (w: number, h: number) => ShapeInterface>
|
|
40
|
+
fill: 'auto' | string
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
- **`PreCfg`** — `Cfg` with optional `width`/`height`; used before image dimensions are known
|
|
45
|
+
- **`ShapeInterface`** — structural interface for shapes
|
|
46
|
+
- **`Bbox`**, **`Point`**, **`ImageDataLike`**, **`ShapeImageData`**
|
|
47
|
+
|
|
48
|
+
### Utilities
|
|
49
|
+
|
|
50
|
+
- **`stepPerf`** — accumulator for profiling rasterize vs pixel math time per step
|
|
51
|
+
|
|
52
|
+
## Differences from primitive.js
|
|
53
|
+
|
|
54
|
+
`prim-lib` is derived from [primitive.js](https://github.com/ondras/primitive.js), a JavaScript port of the Go [primitive](https://github.com/fogleman/primitive) library by Michael Fogleman. The core algorithm — iterative shape placement via hill-climbing — is unchanged. What's different:
|
|
55
|
+
|
|
56
|
+
- **TypeScript, strict mode** — fully typed throughout; `strict: true` on both packages
|
|
57
|
+
- **ESM only** — no CommonJS; no `importScripts()`
|
|
58
|
+
- **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)
|
|
59
|
+
- **`willReadFrequently: true`** — canvas contexts used for `getImageData` are created with this flag, keeping pixel data in CPU memory and suppressing browser warnings
|
|
60
|
+
- **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
|
|
61
|
+
- **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
|
|
62
|
+
- **`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
|
|
63
|
+
|
|
64
|
+
## Architecture notes
|
|
65
|
+
|
|
66
|
+
- `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)
|
|
67
|
+
- `Cfg.shapeTypes` holds shape constructors; shapes are chosen randomly each step
|
|
68
|
+
- `PreCfg` exists to bridge the gap between call time (dimensions unknown) and runtime (dimensions set by `Canvas.original()`)
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
type Point = [number, number];
|
|
2
|
+
interface Bbox {
|
|
3
|
+
left: number;
|
|
4
|
+
top: number;
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
}
|
|
8
|
+
/** Structural subset of ImageData used by pixel-walking functions. Avoids requiring
|
|
9
|
+
* the `colorSpace` field that DOM ImageData has but hand-crafted test objects lack. */
|
|
10
|
+
interface ImageDataLike {
|
|
11
|
+
data: Uint8ClampedArray;
|
|
12
|
+
width: number;
|
|
13
|
+
height: number;
|
|
14
|
+
}
|
|
15
|
+
interface ShapeImageData {
|
|
16
|
+
shape: ImageDataLike;
|
|
17
|
+
current: ImageDataLike;
|
|
18
|
+
target: ImageDataLike;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Structural interface for shapes. Defined here (rather than in shape.ts)
|
|
22
|
+
* so Cfg.shapeTypes can reference it without creating a circular import.
|
|
23
|
+
*/
|
|
24
|
+
interface ShapeInterface {
|
|
25
|
+
bbox: Bbox;
|
|
26
|
+
/** cfg is rarely used by implementations; accepts Partial to keep call sites flexible. */
|
|
27
|
+
mutate(cfg?: Partial<Cfg>): ShapeInterface;
|
|
28
|
+
toSVG(): SVGElement | undefined;
|
|
29
|
+
rasterize(alpha: number): {
|
|
30
|
+
getImageData(): ImageDataLike;
|
|
31
|
+
};
|
|
32
|
+
render(ctx: CanvasRenderingContext2D): void;
|
|
33
|
+
}
|
|
34
|
+
interface Cfg {
|
|
35
|
+
width: number;
|
|
36
|
+
height: number;
|
|
37
|
+
alpha: number;
|
|
38
|
+
mutateAlpha: boolean;
|
|
39
|
+
shapeTypes: Array<new (w: number, h: number) => ShapeInterface>;
|
|
40
|
+
shapes: number;
|
|
41
|
+
mutations: number;
|
|
42
|
+
steps: number;
|
|
43
|
+
fill: 'auto' | string;
|
|
44
|
+
computeSize: number;
|
|
45
|
+
viewSize: number;
|
|
46
|
+
scale?: number;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Config as provided by the caller before Canvas.original() runs.
|
|
50
|
+
* width/height/scale are absent until Canvas.original sets them.
|
|
51
|
+
*/
|
|
52
|
+
type PreCfg = Omit<Cfg, 'width' | 'height'> & {
|
|
53
|
+
width?: number;
|
|
54
|
+
height?: number;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
interface DrawableStep {
|
|
58
|
+
alpha: number;
|
|
59
|
+
color: string;
|
|
60
|
+
shape: {
|
|
61
|
+
render(ctx: CanvasRenderingContext2D): void;
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
declare class Canvas {
|
|
65
|
+
node: HTMLCanvasElement;
|
|
66
|
+
ctx: CanvasRenderingContext2D;
|
|
67
|
+
_imageData: ImageData | null;
|
|
68
|
+
static empty(cfg: Cfg, svg: true): SVGSVGElement;
|
|
69
|
+
static empty(cfg: Cfg, svg?: false): Canvas;
|
|
70
|
+
static original(url: string, cfg: PreCfg): Promise<Canvas>;
|
|
71
|
+
static test(cfg: PreCfg): Canvas;
|
|
72
|
+
constructor(width: number, height: number, willReadFrequently?: boolean);
|
|
73
|
+
clone(): Canvas;
|
|
74
|
+
fill(color: string): this;
|
|
75
|
+
getImageData(): ImageDataLike;
|
|
76
|
+
difference(otherCanvas: Canvas): number;
|
|
77
|
+
distance(otherCanvas: Canvas): number;
|
|
78
|
+
drawStep(step: DrawableStep): this;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
declare class State {
|
|
82
|
+
target: Canvas;
|
|
83
|
+
canvas: Canvas;
|
|
84
|
+
distance: number;
|
|
85
|
+
constructor(target: Canvas, canvas: Canvas, distance?: number);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
declare const stepPerf: {
|
|
89
|
+
rasterizeMs: number;
|
|
90
|
+
pixelMs: number;
|
|
91
|
+
calls: number;
|
|
92
|
+
reset(): void;
|
|
93
|
+
};
|
|
94
|
+
declare class Step {
|
|
95
|
+
shape: ShapeInterface;
|
|
96
|
+
cfg: Cfg;
|
|
97
|
+
alpha: number;
|
|
98
|
+
color: string;
|
|
99
|
+
distance: number;
|
|
100
|
+
constructor(shape: ShapeInterface, cfg: Cfg);
|
|
101
|
+
toSVG(): SVGElement;
|
|
102
|
+
apply(state: State): State;
|
|
103
|
+
compute(state: State): Promise<Step>;
|
|
104
|
+
mutate(): Step;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
declare class Optimizer {
|
|
108
|
+
cfg: Cfg;
|
|
109
|
+
state: State;
|
|
110
|
+
onStep: (step: Step | null) => void;
|
|
111
|
+
_steps: number;
|
|
112
|
+
_stopped: boolean;
|
|
113
|
+
_paused: boolean;
|
|
114
|
+
constructor(original: Canvas, cfg: Cfg);
|
|
115
|
+
start(): void;
|
|
116
|
+
stop(): void;
|
|
117
|
+
pause(): void;
|
|
118
|
+
resume(): void;
|
|
119
|
+
_addShape(): void;
|
|
120
|
+
_continue(): void;
|
|
121
|
+
_findBestStep(): Promise<Step | null>;
|
|
122
|
+
_optimizeStep(step: Step): Promise<Step>;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
declare class Shape implements ShapeInterface {
|
|
126
|
+
bbox: Bbox;
|
|
127
|
+
static randomPoint(width: number, height: number): Point;
|
|
128
|
+
static create(cfg: Cfg): ShapeInterface;
|
|
129
|
+
constructor(_w: number, _h: number);
|
|
130
|
+
mutate(_cfg?: Partial<Cfg>): ShapeInterface;
|
|
131
|
+
toSVG(): SVGElement | undefined;
|
|
132
|
+
rasterize(alpha: number): {
|
|
133
|
+
getImageData(): ImageDataLike;
|
|
134
|
+
};
|
|
135
|
+
render(_ctx: CanvasRenderingContext2D): void;
|
|
136
|
+
}
|
|
137
|
+
declare class Polygon extends Shape {
|
|
138
|
+
points: Point[];
|
|
139
|
+
constructor(w: number, h: number, count?: number);
|
|
140
|
+
protected _cloneEmpty(): Polygon;
|
|
141
|
+
render(ctx: CanvasRenderingContext2D): void;
|
|
142
|
+
toSVG(): SVGElement;
|
|
143
|
+
mutate(_cfg?: Partial<Cfg>): ShapeInterface;
|
|
144
|
+
computeBbox(): this;
|
|
145
|
+
_createPoints(w: number, h: number, count: number): Point[];
|
|
146
|
+
}
|
|
147
|
+
declare class Triangle extends Polygon {
|
|
148
|
+
constructor(w: number, h: number);
|
|
149
|
+
protected _cloneEmpty(): Triangle;
|
|
150
|
+
}
|
|
151
|
+
declare class Rectangle extends Polygon {
|
|
152
|
+
constructor(w: number, h: number);
|
|
153
|
+
protected _cloneEmpty(): Rectangle;
|
|
154
|
+
mutate(_cfg?: Partial<Cfg>): ShapeInterface;
|
|
155
|
+
_createPoints(w: number, h: number, _count: number): Point[];
|
|
156
|
+
}
|
|
157
|
+
declare class Ellipse extends Shape {
|
|
158
|
+
center: Point;
|
|
159
|
+
rx: number;
|
|
160
|
+
ry: number;
|
|
161
|
+
constructor(w: number, h: number);
|
|
162
|
+
render(ctx: CanvasRenderingContext2D): void;
|
|
163
|
+
toSVG(): SVGElement;
|
|
164
|
+
mutate(_cfg?: Partial<Cfg>): ShapeInterface;
|
|
165
|
+
computeBbox(): this;
|
|
166
|
+
}
|
|
167
|
+
declare class Smiley extends Shape {
|
|
168
|
+
center: Point;
|
|
169
|
+
text: string;
|
|
170
|
+
fontSize: number;
|
|
171
|
+
constructor(w: number, h: number, text?: string);
|
|
172
|
+
computeBbox(): this;
|
|
173
|
+
render(ctx: CanvasRenderingContext2D): void;
|
|
174
|
+
mutate(_cfg?: Partial<Cfg>): Smiley;
|
|
175
|
+
toSVG(): SVGElement;
|
|
176
|
+
}
|
|
177
|
+
declare class Hexagon extends Shape {
|
|
178
|
+
center: Point;
|
|
179
|
+
r: number;
|
|
180
|
+
angle: number;
|
|
181
|
+
_cachedPoints: Point[] | null;
|
|
182
|
+
constructor(w: number, h: number);
|
|
183
|
+
_points(): Point[];
|
|
184
|
+
computeBbox(): this;
|
|
185
|
+
render(ctx: CanvasRenderingContext2D): void;
|
|
186
|
+
toSVG(): SVGElement;
|
|
187
|
+
mutate(_cfg?: Partial<Cfg>): ShapeInterface;
|
|
188
|
+
}
|
|
189
|
+
declare class Debug extends Shape {
|
|
190
|
+
constructor(w: number, h: number);
|
|
191
|
+
render(ctx: CanvasRenderingContext2D): void;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
declare const SVGNS = "http://www.w3.org/2000/svg";
|
|
195
|
+
|
|
196
|
+
declare function clamp(x: number, min: number, max: number): number;
|
|
197
|
+
declare function clampColor(x: number): number;
|
|
198
|
+
declare function distanceToDifference(distance: number, pixels: number): number;
|
|
199
|
+
declare function differenceToDistance(difference: number, pixels: number): number;
|
|
200
|
+
declare function difference(data: ImageDataLike, dataOther: ImageDataLike): number;
|
|
201
|
+
declare function computeColorAndDifferenceChange(offset: Bbox, imageData: ShapeImageData, alpha: number): {
|
|
202
|
+
color: string;
|
|
203
|
+
differenceChange: number;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
export { type Bbox, Canvas, type Cfg, Debug, Ellipse, Hexagon, type ImageDataLike, Optimizer, type Point, type PreCfg, Rectangle, SVGNS, Shape, type ShapeImageData, type ShapeInterface, Smiley, State, Step, Triangle, clamp, clampColor, computeColorAndDifferenceChange, difference, differenceToDistance, distanceToDifference, stepPerf };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,823 @@
|
|
|
1
|
+
// src/util.ts
|
|
2
|
+
var SVGNS = "http://www.w3.org/2000/svg";
|
|
3
|
+
function clamp(x, min, max) {
|
|
4
|
+
return Math.max(min, Math.min(max, x));
|
|
5
|
+
}
|
|
6
|
+
function clampColor(x) {
|
|
7
|
+
return clamp(x, 0, 255);
|
|
8
|
+
}
|
|
9
|
+
function distanceToDifference(distance, pixels) {
|
|
10
|
+
return Math.pow(distance * 255, 2) * (3 * pixels);
|
|
11
|
+
}
|
|
12
|
+
function differenceToDistance(difference2, pixels) {
|
|
13
|
+
return Math.sqrt(difference2 / (3 * pixels)) / 255;
|
|
14
|
+
}
|
|
15
|
+
function difference(data, dataOther) {
|
|
16
|
+
let sum = 0, dr = 0, dg = 0, db = 0;
|
|
17
|
+
for (let i = 0; i < data.data.length; i += 4) {
|
|
18
|
+
dr = dataOther.data[i] - data.data[i];
|
|
19
|
+
dg = dataOther.data[i + 1] - data.data[i + 1];
|
|
20
|
+
db = dataOther.data[i + 2] - data.data[i + 2];
|
|
21
|
+
sum += dr * dr + dg * dg + db * db;
|
|
22
|
+
}
|
|
23
|
+
return sum;
|
|
24
|
+
}
|
|
25
|
+
function computeColorAndDifferenceChange(offset, imageData, alpha) {
|
|
26
|
+
const { shape, current, target } = imageData;
|
|
27
|
+
const shapeData = shape.data;
|
|
28
|
+
const currentData = current.data;
|
|
29
|
+
const targetData = target.data;
|
|
30
|
+
let si = 0, sx = 0, sy = 0, fi = 0, fx = 0, fy = 0;
|
|
31
|
+
const sw = shape.width;
|
|
32
|
+
const sh = shape.height;
|
|
33
|
+
const fw = current.width;
|
|
34
|
+
const fh = current.height;
|
|
35
|
+
let r = 0, g = 0, b = 0, count = 0;
|
|
36
|
+
for (sy = 0; sy < sh; sy++) {
|
|
37
|
+
fy = sy + offset.top;
|
|
38
|
+
if (fy < 0 || fy >= fh) {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
for (sx = 0; sx < sw; sx++) {
|
|
42
|
+
fx = offset.left + sx;
|
|
43
|
+
if (fx < 0 || fx >= fw) {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
si = 4 * (sx + sy * sw);
|
|
47
|
+
if (shapeData[si + 3] == 0) {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
fi = 4 * (fx + fy * fw);
|
|
51
|
+
r += (targetData[fi] - currentData[fi]) / alpha + currentData[fi];
|
|
52
|
+
g += (targetData[fi + 1] - currentData[fi + 1]) / alpha + currentData[fi + 1];
|
|
53
|
+
b += (targetData[fi + 2] - currentData[fi + 2]) / alpha + currentData[fi + 2];
|
|
54
|
+
count++;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const cr = clampColor(~~(r / count));
|
|
58
|
+
const cg = clampColor(~~(g / count));
|
|
59
|
+
const cb = clampColor(~~(b / count));
|
|
60
|
+
let sum = 0;
|
|
61
|
+
let a = 0, blend = 0, d1r = 0, d1g = 0, d1b = 0, d2r = 0, d2g = 0, d2b = 0;
|
|
62
|
+
for (sy = 0; sy < sh; sy++) {
|
|
63
|
+
fy = sy + offset.top;
|
|
64
|
+
if (fy < 0 || fy >= fh) {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
for (sx = 0; sx < sw; sx++) {
|
|
68
|
+
fx = offset.left + sx;
|
|
69
|
+
if (fx < 0 || fx >= fw) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
si = 4 * (sx + sy * sw);
|
|
73
|
+
a = shapeData[si + 3];
|
|
74
|
+
if (a == 0) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
fi = 4 * (fx + fy * fw);
|
|
78
|
+
a = a / 255;
|
|
79
|
+
blend = 1 - a;
|
|
80
|
+
d1r = targetData[fi] - currentData[fi];
|
|
81
|
+
d1g = targetData[fi + 1] - currentData[fi + 1];
|
|
82
|
+
d1b = targetData[fi + 2] - currentData[fi + 2];
|
|
83
|
+
d2r = targetData[fi] - (cr * a + currentData[fi] * blend);
|
|
84
|
+
d2g = targetData[fi + 1] - (cg * a + currentData[fi + 1] * blend);
|
|
85
|
+
d2b = targetData[fi + 2] - (cb * a + currentData[fi + 2] * blend);
|
|
86
|
+
sum -= d1r * d1r + d1g * d1g + d1b * d1b;
|
|
87
|
+
sum += d2r * d2r + d2g * d2g + d2b * d2b;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { color: `rgb(${cr}, ${cg}, ${cb})`, differenceChange: sum };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// src/canvas.ts
|
|
94
|
+
function getScale(width, height, limit) {
|
|
95
|
+
return Math.max(width / limit, height / limit, 1);
|
|
96
|
+
}
|
|
97
|
+
function getFill(canvas) {
|
|
98
|
+
const data = canvas.getImageData();
|
|
99
|
+
const w = data.width;
|
|
100
|
+
const h = data.height;
|
|
101
|
+
const d = data.data;
|
|
102
|
+
let rgb = [0, 0, 0];
|
|
103
|
+
let count = 0;
|
|
104
|
+
let i = 0;
|
|
105
|
+
for (let x = 0; x < w; x++) {
|
|
106
|
+
for (let y = 0; y < h; y++) {
|
|
107
|
+
if (x > 0 && y > 0 && x < w - 1 && y < h - 1) {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
count++;
|
|
111
|
+
i = 4 * (x + y * w);
|
|
112
|
+
rgb[0] += d[i];
|
|
113
|
+
rgb[1] += d[i + 1];
|
|
114
|
+
rgb[2] += d[i + 2];
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
rgb = rgb.map((x) => ~~(x / count)).map(clampColor);
|
|
118
|
+
return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
|
|
119
|
+
}
|
|
120
|
+
function svgRect(w, h) {
|
|
121
|
+
const node = document.createElementNS(SVGNS, "rect");
|
|
122
|
+
node.setAttribute("x", "0");
|
|
123
|
+
node.setAttribute("y", "0");
|
|
124
|
+
node.setAttribute("width", String(w));
|
|
125
|
+
node.setAttribute("height", String(h));
|
|
126
|
+
return node;
|
|
127
|
+
}
|
|
128
|
+
var Canvas = class _Canvas {
|
|
129
|
+
node;
|
|
130
|
+
ctx;
|
|
131
|
+
_imageData;
|
|
132
|
+
static empty(cfg, svg) {
|
|
133
|
+
if (svg) {
|
|
134
|
+
const node = document.createElementNS(SVGNS, "svg");
|
|
135
|
+
node.setAttribute("viewBox", `0 0 ${cfg.width} ${cfg.height}`);
|
|
136
|
+
node.setAttribute("clip-path", "url(#clip)");
|
|
137
|
+
const defs = document.createElementNS(SVGNS, "defs");
|
|
138
|
+
node.appendChild(defs);
|
|
139
|
+
const cp = document.createElementNS(SVGNS, "clipPath");
|
|
140
|
+
defs.appendChild(cp);
|
|
141
|
+
cp.setAttribute("id", "clip");
|
|
142
|
+
cp.setAttribute("clipPathUnits", "objectBoundingBox");
|
|
143
|
+
let rect = svgRect(cfg.width, cfg.height);
|
|
144
|
+
cp.appendChild(rect);
|
|
145
|
+
rect = svgRect(cfg.width, cfg.height);
|
|
146
|
+
rect.setAttribute("fill", cfg.fill);
|
|
147
|
+
node.appendChild(rect);
|
|
148
|
+
return node;
|
|
149
|
+
} else {
|
|
150
|
+
return new this(cfg.width, cfg.height).fill(cfg.fill);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
static original(url, cfg) {
|
|
154
|
+
if (url == "test") {
|
|
155
|
+
return Promise.resolve(this.test(cfg));
|
|
156
|
+
}
|
|
157
|
+
return new Promise((resolve) => {
|
|
158
|
+
const img = new Image();
|
|
159
|
+
img.crossOrigin = "anonymous";
|
|
160
|
+
img.src = url;
|
|
161
|
+
img.onload = () => {
|
|
162
|
+
const w = img.naturalWidth;
|
|
163
|
+
const h = img.naturalHeight;
|
|
164
|
+
const computeScale = getScale(w, h, cfg.computeSize);
|
|
165
|
+
cfg.width = w / computeScale;
|
|
166
|
+
cfg.height = h / computeScale;
|
|
167
|
+
const viewScale = getScale(w, h, cfg.viewSize);
|
|
168
|
+
cfg.scale = computeScale / viewScale;
|
|
169
|
+
const fullCfg = { ...cfg, width: cfg.width, height: cfg.height };
|
|
170
|
+
const canvas = this.empty(fullCfg);
|
|
171
|
+
canvas.ctx.drawImage(img, 0, 0, fullCfg.width, fullCfg.height);
|
|
172
|
+
if (cfg.fill == "auto") {
|
|
173
|
+
cfg.fill = getFill(canvas);
|
|
174
|
+
}
|
|
175
|
+
resolve(canvas);
|
|
176
|
+
};
|
|
177
|
+
img.onerror = (e) => {
|
|
178
|
+
console.error(e);
|
|
179
|
+
alert("The image URL cannot be loaded. Does the server support CORS?");
|
|
180
|
+
};
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
static test(cfg) {
|
|
184
|
+
cfg.width = cfg.computeSize;
|
|
185
|
+
cfg.height = cfg.computeSize;
|
|
186
|
+
cfg.scale = 1;
|
|
187
|
+
const [w, h] = [cfg.computeSize, cfg.computeSize];
|
|
188
|
+
const canvas = new this(w, h);
|
|
189
|
+
canvas.fill("#fff");
|
|
190
|
+
const ctx = canvas.ctx;
|
|
191
|
+
ctx.fillStyle = "#f00";
|
|
192
|
+
ctx.beginPath();
|
|
193
|
+
ctx.arc(w / 4, h / 2, w / 7, 0, 2 * Math.PI, true);
|
|
194
|
+
ctx.fill();
|
|
195
|
+
ctx.fillStyle = "#0f0";
|
|
196
|
+
ctx.beginPath();
|
|
197
|
+
ctx.arc(w / 2, h / 2, w / 7, 0, 2 * Math.PI, true);
|
|
198
|
+
ctx.fill();
|
|
199
|
+
ctx.fillStyle = "#00f";
|
|
200
|
+
ctx.beginPath();
|
|
201
|
+
ctx.arc(w * 3 / 4, h / 2, w / 7, 0, 2 * Math.PI, true);
|
|
202
|
+
ctx.fill();
|
|
203
|
+
if (cfg.fill == "auto") {
|
|
204
|
+
cfg.fill = getFill(canvas);
|
|
205
|
+
}
|
|
206
|
+
return canvas;
|
|
207
|
+
}
|
|
208
|
+
constructor(width, height, willReadFrequently = false) {
|
|
209
|
+
this.node = document.createElement("canvas");
|
|
210
|
+
this.node.width = width;
|
|
211
|
+
this.node.height = height;
|
|
212
|
+
this.ctx = this.node.getContext("2d", { willReadFrequently });
|
|
213
|
+
this._imageData = null;
|
|
214
|
+
}
|
|
215
|
+
clone() {
|
|
216
|
+
const other = new _Canvas(this.node.width, this.node.height);
|
|
217
|
+
other.ctx.drawImage(this.node, 0, 0);
|
|
218
|
+
return other;
|
|
219
|
+
}
|
|
220
|
+
fill(color) {
|
|
221
|
+
this.ctx.fillStyle = color;
|
|
222
|
+
this.ctx.fillRect(0, 0, this.node.width, this.node.height);
|
|
223
|
+
return this;
|
|
224
|
+
}
|
|
225
|
+
getImageData() {
|
|
226
|
+
if (!this._imageData) {
|
|
227
|
+
this._imageData = this.ctx.getImageData(0, 0, this.node.width, this.node.height);
|
|
228
|
+
}
|
|
229
|
+
return this._imageData;
|
|
230
|
+
}
|
|
231
|
+
difference(otherCanvas) {
|
|
232
|
+
const data = this.getImageData();
|
|
233
|
+
const dataOther = otherCanvas.getImageData();
|
|
234
|
+
return difference(data, dataOther);
|
|
235
|
+
}
|
|
236
|
+
distance(otherCanvas) {
|
|
237
|
+
const difference2 = this.difference(otherCanvas);
|
|
238
|
+
return differenceToDistance(difference2, this.node.width * this.node.height);
|
|
239
|
+
}
|
|
240
|
+
drawStep(step) {
|
|
241
|
+
this.ctx.globalAlpha = step.alpha;
|
|
242
|
+
this.ctx.fillStyle = step.color;
|
|
243
|
+
step.shape.render(this.ctx);
|
|
244
|
+
return this;
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
// src/state.ts
|
|
249
|
+
var State = class {
|
|
250
|
+
target;
|
|
251
|
+
canvas;
|
|
252
|
+
distance;
|
|
253
|
+
constructor(target, canvas, distance = Infinity) {
|
|
254
|
+
this.target = target;
|
|
255
|
+
this.canvas = canvas;
|
|
256
|
+
this.distance = distance == Infinity ? target.distance(canvas) : distance;
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
// src/step.ts
|
|
261
|
+
var stepPerf = {
|
|
262
|
+
rasterizeMs: 0,
|
|
263
|
+
pixelMs: 0,
|
|
264
|
+
calls: 0,
|
|
265
|
+
reset() {
|
|
266
|
+
this.rasterizeMs = 0;
|
|
267
|
+
this.pixelMs = 0;
|
|
268
|
+
this.calls = 0;
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
var Step = class _Step {
|
|
272
|
+
shape;
|
|
273
|
+
cfg;
|
|
274
|
+
alpha;
|
|
275
|
+
color;
|
|
276
|
+
distance;
|
|
277
|
+
constructor(shape, cfg) {
|
|
278
|
+
this.shape = shape;
|
|
279
|
+
this.cfg = cfg;
|
|
280
|
+
this.alpha = cfg.alpha;
|
|
281
|
+
this.color = "#000";
|
|
282
|
+
this.distance = Infinity;
|
|
283
|
+
}
|
|
284
|
+
toSVG() {
|
|
285
|
+
const node = this.shape.toSVG();
|
|
286
|
+
if (!node) throw new Error("Shape does not support SVG export");
|
|
287
|
+
node.setAttribute("fill", this.color);
|
|
288
|
+
node.setAttribute("fill-opacity", this.alpha.toFixed(2));
|
|
289
|
+
return node;
|
|
290
|
+
}
|
|
291
|
+
/* apply this step to a state to get a new state. call only after .compute */
|
|
292
|
+
apply(state) {
|
|
293
|
+
const newCanvas = state.canvas.clone().drawStep(this);
|
|
294
|
+
return new State(state.target, newCanvas, this.distance);
|
|
295
|
+
}
|
|
296
|
+
/* find optimal color and compute the resulting distance */
|
|
297
|
+
compute(state) {
|
|
298
|
+
const pixels = state.canvas.node.width * state.canvas.node.height;
|
|
299
|
+
const offset = this.shape.bbox;
|
|
300
|
+
const t0 = performance.now();
|
|
301
|
+
const shapeImageData = this.shape.rasterize(this.alpha).getImageData();
|
|
302
|
+
const t1 = performance.now();
|
|
303
|
+
const imageData = {
|
|
304
|
+
shape: shapeImageData,
|
|
305
|
+
current: state.canvas.getImageData(),
|
|
306
|
+
target: state.target.getImageData()
|
|
307
|
+
};
|
|
308
|
+
const { color, differenceChange } = computeColorAndDifferenceChange(offset, imageData, this.alpha);
|
|
309
|
+
const t2 = performance.now();
|
|
310
|
+
stepPerf.rasterizeMs += t1 - t0;
|
|
311
|
+
stepPerf.pixelMs += t2 - t1;
|
|
312
|
+
stepPerf.calls++;
|
|
313
|
+
this.color = color;
|
|
314
|
+
const currentDifference = distanceToDifference(state.distance, pixels);
|
|
315
|
+
this.distance = differenceToDistance(currentDifference + differenceChange, pixels);
|
|
316
|
+
return Promise.resolve(this);
|
|
317
|
+
}
|
|
318
|
+
/* return a slightly mutated step */
|
|
319
|
+
mutate() {
|
|
320
|
+
const newShape = this.shape.mutate(this.cfg);
|
|
321
|
+
const mutated = new _Step(newShape, this.cfg);
|
|
322
|
+
if (this.cfg.mutateAlpha) {
|
|
323
|
+
const mutatedAlpha = this.alpha + (Math.random() - 0.5) * 0.08;
|
|
324
|
+
mutated.alpha = clamp(mutatedAlpha, 0.1, 1);
|
|
325
|
+
}
|
|
326
|
+
return mutated;
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
// src/shape.ts
|
|
331
|
+
var _rasterCanvas = null;
|
|
332
|
+
var Shape = class {
|
|
333
|
+
bbox;
|
|
334
|
+
static randomPoint(width, height) {
|
|
335
|
+
return [~~(Math.random() * width), ~~(Math.random() * height)];
|
|
336
|
+
}
|
|
337
|
+
static create(cfg) {
|
|
338
|
+
const ctors = cfg.shapeTypes;
|
|
339
|
+
const index = Math.floor(Math.random() * ctors.length);
|
|
340
|
+
const ctor = ctors[index];
|
|
341
|
+
return new ctor(cfg.width, cfg.height);
|
|
342
|
+
}
|
|
343
|
+
constructor(_w, _h) {
|
|
344
|
+
this.bbox = { left: 0, top: 0, width: 0, height: 0 };
|
|
345
|
+
}
|
|
346
|
+
mutate(_cfg) {
|
|
347
|
+
return this;
|
|
348
|
+
}
|
|
349
|
+
toSVG() {
|
|
350
|
+
return void 0;
|
|
351
|
+
}
|
|
352
|
+
/* get a new smaller canvas with this shape */
|
|
353
|
+
rasterize(alpha) {
|
|
354
|
+
const w = this.bbox.width;
|
|
355
|
+
const h = this.bbox.height;
|
|
356
|
+
if (!_rasterCanvas || _rasterCanvas.node.width < w || _rasterCanvas.node.height < h) {
|
|
357
|
+
_rasterCanvas = new Canvas(
|
|
358
|
+
Math.max(w, _rasterCanvas?.node.width ?? 0),
|
|
359
|
+
Math.max(h, _rasterCanvas?.node.height ?? 0),
|
|
360
|
+
true
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
const ctx = _rasterCanvas.ctx;
|
|
364
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
365
|
+
ctx.clearRect(0, 0, w, h);
|
|
366
|
+
ctx.fillStyle = "#000";
|
|
367
|
+
ctx.globalAlpha = alpha;
|
|
368
|
+
ctx.translate(-this.bbox.left, -this.bbox.top);
|
|
369
|
+
this.render(ctx);
|
|
370
|
+
const data = ctx.getImageData(0, 0, w, h);
|
|
371
|
+
return { getImageData: () => data };
|
|
372
|
+
}
|
|
373
|
+
render(_ctx) {
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
var Polygon = class _Polygon extends Shape {
|
|
377
|
+
points;
|
|
378
|
+
constructor(w, h, count) {
|
|
379
|
+
super(w, h);
|
|
380
|
+
this.points = this._createPoints(w, h, count ?? 3);
|
|
381
|
+
this.computeBbox();
|
|
382
|
+
}
|
|
383
|
+
_cloneEmpty() {
|
|
384
|
+
return new _Polygon(0, 0);
|
|
385
|
+
}
|
|
386
|
+
render(ctx) {
|
|
387
|
+
ctx.beginPath();
|
|
388
|
+
this.points.forEach(([x, y], index) => {
|
|
389
|
+
if (index) {
|
|
390
|
+
ctx.lineTo(x, y);
|
|
391
|
+
} else {
|
|
392
|
+
ctx.moveTo(x, y);
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
ctx.closePath();
|
|
396
|
+
ctx.fill();
|
|
397
|
+
}
|
|
398
|
+
toSVG() {
|
|
399
|
+
const path = document.createElementNS(SVGNS, "path");
|
|
400
|
+
const d = this.points.map((point, index) => {
|
|
401
|
+
const cmd = index ? "L" : "M";
|
|
402
|
+
return `${cmd}${point.join(",")}`;
|
|
403
|
+
}).join("");
|
|
404
|
+
path.setAttribute("d", `${d}Z`);
|
|
405
|
+
return path;
|
|
406
|
+
}
|
|
407
|
+
mutate(_cfg) {
|
|
408
|
+
const clone = this._cloneEmpty();
|
|
409
|
+
clone.points = this.points.map(([x, y]) => [x, y]);
|
|
410
|
+
const index = Math.floor(Math.random() * this.points.length);
|
|
411
|
+
const point = clone.points[index];
|
|
412
|
+
const angle = Math.random() * 2 * Math.PI;
|
|
413
|
+
const radius = Math.random() * 20;
|
|
414
|
+
point[0] += ~~(radius * Math.cos(angle));
|
|
415
|
+
point[1] += ~~(radius * Math.sin(angle));
|
|
416
|
+
return clone.computeBbox();
|
|
417
|
+
}
|
|
418
|
+
computeBbox() {
|
|
419
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
420
|
+
for (const [x, y] of this.points) {
|
|
421
|
+
if (x < minX) minX = x;
|
|
422
|
+
if (y < minY) minY = y;
|
|
423
|
+
if (x > maxX) maxX = x;
|
|
424
|
+
if (y > maxY) maxY = y;
|
|
425
|
+
}
|
|
426
|
+
this.bbox = {
|
|
427
|
+
left: minX,
|
|
428
|
+
top: minY,
|
|
429
|
+
width: maxX - minX || 1,
|
|
430
|
+
/* fallback for deformed shapes */
|
|
431
|
+
height: maxY - minY || 1
|
|
432
|
+
};
|
|
433
|
+
return this;
|
|
434
|
+
}
|
|
435
|
+
_createPoints(w, h, count) {
|
|
436
|
+
const first = Shape.randomPoint(w, h);
|
|
437
|
+
const points = [first];
|
|
438
|
+
for (let i = 1; i < count; i++) {
|
|
439
|
+
const angle = Math.random() * 2 * Math.PI;
|
|
440
|
+
const radius = Math.random() * 20;
|
|
441
|
+
points.push([
|
|
442
|
+
first[0] + ~~(radius * Math.cos(angle)),
|
|
443
|
+
first[1] + ~~(radius * Math.sin(angle))
|
|
444
|
+
]);
|
|
445
|
+
}
|
|
446
|
+
return points;
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
var Triangle = class _Triangle extends Polygon {
|
|
450
|
+
constructor(w, h) {
|
|
451
|
+
super(w, h, 3);
|
|
452
|
+
}
|
|
453
|
+
_cloneEmpty() {
|
|
454
|
+
return new _Triangle(0, 0);
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
var Rectangle = class _Rectangle extends Polygon {
|
|
458
|
+
constructor(w, h) {
|
|
459
|
+
super(w, h, 4);
|
|
460
|
+
}
|
|
461
|
+
_cloneEmpty() {
|
|
462
|
+
return new _Rectangle(0, 0);
|
|
463
|
+
}
|
|
464
|
+
mutate(_cfg) {
|
|
465
|
+
const clone = this._cloneEmpty();
|
|
466
|
+
clone.points = this.points.map(([x, y]) => [x, y]);
|
|
467
|
+
const amount = ~~((Math.random() - 0.5) * 20);
|
|
468
|
+
switch (Math.floor(Math.random() * 4)) {
|
|
469
|
+
case 0:
|
|
470
|
+
clone.points[0][0] += amount;
|
|
471
|
+
clone.points[3][0] += amount;
|
|
472
|
+
break;
|
|
473
|
+
case 1:
|
|
474
|
+
clone.points[0][1] += amount;
|
|
475
|
+
clone.points[1][1] += amount;
|
|
476
|
+
break;
|
|
477
|
+
case 2:
|
|
478
|
+
clone.points[1][0] += amount;
|
|
479
|
+
clone.points[2][0] += amount;
|
|
480
|
+
break;
|
|
481
|
+
case 3:
|
|
482
|
+
clone.points[2][1] += amount;
|
|
483
|
+
clone.points[3][1] += amount;
|
|
484
|
+
break;
|
|
485
|
+
}
|
|
486
|
+
return clone.computeBbox();
|
|
487
|
+
}
|
|
488
|
+
_createPoints(w, h, _count) {
|
|
489
|
+
const p1 = Shape.randomPoint(w, h);
|
|
490
|
+
const p2 = Shape.randomPoint(w, h);
|
|
491
|
+
const left = Math.min(p1[0], p2[0]);
|
|
492
|
+
const right = Math.max(p1[0], p2[0]);
|
|
493
|
+
const top = Math.min(p1[1], p2[1]);
|
|
494
|
+
const bottom = Math.max(p1[1], p2[1]);
|
|
495
|
+
return [
|
|
496
|
+
[left, top],
|
|
497
|
+
[right, top],
|
|
498
|
+
[right, bottom],
|
|
499
|
+
[left, bottom]
|
|
500
|
+
];
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
var Ellipse = class _Ellipse extends Shape {
|
|
504
|
+
center;
|
|
505
|
+
rx;
|
|
506
|
+
ry;
|
|
507
|
+
constructor(w, h) {
|
|
508
|
+
super(w, h);
|
|
509
|
+
this.center = Shape.randomPoint(w, h);
|
|
510
|
+
this.rx = 1 + ~~(Math.random() * 20);
|
|
511
|
+
this.ry = 1 + ~~(Math.random() * 20);
|
|
512
|
+
this.computeBbox();
|
|
513
|
+
}
|
|
514
|
+
render(ctx) {
|
|
515
|
+
ctx.beginPath();
|
|
516
|
+
ctx.ellipse(this.center[0], this.center[1], this.rx, this.ry, 0, 0, 2 * Math.PI, false);
|
|
517
|
+
ctx.fill();
|
|
518
|
+
}
|
|
519
|
+
toSVG() {
|
|
520
|
+
const node = document.createElementNS(SVGNS, "ellipse");
|
|
521
|
+
node.setAttribute("cx", String(this.center[0]));
|
|
522
|
+
node.setAttribute("cy", String(this.center[1]));
|
|
523
|
+
node.setAttribute("rx", String(this.rx));
|
|
524
|
+
node.setAttribute("ry", String(this.ry));
|
|
525
|
+
return node;
|
|
526
|
+
}
|
|
527
|
+
mutate(_cfg) {
|
|
528
|
+
const clone = new _Ellipse(0, 0);
|
|
529
|
+
clone.center = [this.center[0], this.center[1]];
|
|
530
|
+
clone.rx = this.rx;
|
|
531
|
+
clone.ry = this.ry;
|
|
532
|
+
switch (Math.floor(Math.random() * 3)) {
|
|
533
|
+
case 0: {
|
|
534
|
+
const angle = Math.random() * 2 * Math.PI;
|
|
535
|
+
const radius = Math.random() * 20;
|
|
536
|
+
clone.center[0] += ~~(radius * Math.cos(angle));
|
|
537
|
+
clone.center[1] += ~~(radius * Math.sin(angle));
|
|
538
|
+
break;
|
|
539
|
+
}
|
|
540
|
+
case 1:
|
|
541
|
+
clone.rx += (Math.random() - 0.5) * 20;
|
|
542
|
+
clone.rx = Math.max(1, ~~clone.rx);
|
|
543
|
+
break;
|
|
544
|
+
case 2:
|
|
545
|
+
clone.ry += (Math.random() - 0.5) * 20;
|
|
546
|
+
clone.ry = Math.max(1, ~~clone.ry);
|
|
547
|
+
break;
|
|
548
|
+
}
|
|
549
|
+
return clone.computeBbox();
|
|
550
|
+
}
|
|
551
|
+
computeBbox() {
|
|
552
|
+
this.bbox = {
|
|
553
|
+
left: this.center[0] - this.rx,
|
|
554
|
+
top: this.center[1] - this.ry,
|
|
555
|
+
width: 2 * this.rx,
|
|
556
|
+
height: 2 * this.ry
|
|
557
|
+
};
|
|
558
|
+
return this;
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
var Smiley = class _Smiley extends Shape {
|
|
562
|
+
center;
|
|
563
|
+
text;
|
|
564
|
+
fontSize;
|
|
565
|
+
constructor(w, h, text = "\u263A") {
|
|
566
|
+
super(w, h);
|
|
567
|
+
this.center = Shape.randomPoint(w, h);
|
|
568
|
+
this.text = text;
|
|
569
|
+
this.fontSize = 16;
|
|
570
|
+
this.computeBbox();
|
|
571
|
+
}
|
|
572
|
+
computeBbox() {
|
|
573
|
+
const tmp = new Canvas(1, 1);
|
|
574
|
+
tmp.ctx.font = `${this.fontSize}px sans-serif`;
|
|
575
|
+
const w = ~~tmp.ctx.measureText(this.text).width;
|
|
576
|
+
this.bbox = {
|
|
577
|
+
left: ~~(this.center[0] - w / 2),
|
|
578
|
+
top: ~~(this.center[1] - this.fontSize / 2),
|
|
579
|
+
width: w,
|
|
580
|
+
height: this.fontSize
|
|
581
|
+
};
|
|
582
|
+
return this;
|
|
583
|
+
}
|
|
584
|
+
render(ctx) {
|
|
585
|
+
ctx.textAlign = "center";
|
|
586
|
+
ctx.textBaseline = "middle";
|
|
587
|
+
ctx.font = `${this.fontSize}px sans-serif`;
|
|
588
|
+
ctx.fillText(this.text, this.center[0], this.center[1]);
|
|
589
|
+
}
|
|
590
|
+
mutate(_cfg) {
|
|
591
|
+
const clone = new _Smiley(0, 0, this.text);
|
|
592
|
+
clone.center = [this.center[0], this.center[1]];
|
|
593
|
+
clone.fontSize = this.fontSize;
|
|
594
|
+
switch (Math.floor(Math.random() * 2)) {
|
|
595
|
+
case 0: {
|
|
596
|
+
const angle = Math.random() * 2 * Math.PI;
|
|
597
|
+
const radius = Math.random() * 20;
|
|
598
|
+
clone.center[0] += ~~(radius * Math.cos(angle));
|
|
599
|
+
clone.center[1] += ~~(radius * Math.sin(angle));
|
|
600
|
+
break;
|
|
601
|
+
}
|
|
602
|
+
case 1:
|
|
603
|
+
clone.fontSize += Math.random() > 0.5 ? 1 : -1;
|
|
604
|
+
clone.fontSize = Math.max(10, clone.fontSize);
|
|
605
|
+
break;
|
|
606
|
+
}
|
|
607
|
+
return clone.computeBbox();
|
|
608
|
+
}
|
|
609
|
+
toSVG() {
|
|
610
|
+
const text = document.createElementNS(SVGNS, "text");
|
|
611
|
+
text.appendChild(document.createTextNode(this.text));
|
|
612
|
+
text.setAttribute("text-anchor", "middle");
|
|
613
|
+
text.setAttribute("dominant-baseline", "central");
|
|
614
|
+
text.setAttribute("font-size", String(this.fontSize));
|
|
615
|
+
text.setAttribute("font-family", "sans-serif");
|
|
616
|
+
text.setAttribute("x", String(this.center[0]));
|
|
617
|
+
text.setAttribute("y", String(this.center[1]));
|
|
618
|
+
return text;
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
var Hexagon = class _Hexagon extends Shape {
|
|
622
|
+
center;
|
|
623
|
+
r;
|
|
624
|
+
angle;
|
|
625
|
+
_cachedPoints;
|
|
626
|
+
constructor(w, h) {
|
|
627
|
+
super(w, h);
|
|
628
|
+
this.center = Shape.randomPoint(w, h);
|
|
629
|
+
this.r = 1 + ~~(Math.random() * 20);
|
|
630
|
+
this.angle = Math.random() * Math.PI / 3;
|
|
631
|
+
this._cachedPoints = null;
|
|
632
|
+
this.computeBbox();
|
|
633
|
+
}
|
|
634
|
+
_points() {
|
|
635
|
+
if (!this._cachedPoints) {
|
|
636
|
+
this._cachedPoints = Array.from({ length: 6 }, (_, i) => {
|
|
637
|
+
const a = this.angle + i * Math.PI / 3;
|
|
638
|
+
return [
|
|
639
|
+
~~(this.center[0] + this.r * Math.cos(a)),
|
|
640
|
+
~~(this.center[1] + this.r * Math.sin(a))
|
|
641
|
+
];
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
return this._cachedPoints;
|
|
645
|
+
}
|
|
646
|
+
computeBbox() {
|
|
647
|
+
this._cachedPoints = null;
|
|
648
|
+
const pts = this._points();
|
|
649
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
650
|
+
for (const [x, y] of pts) {
|
|
651
|
+
if (x < minX) minX = x;
|
|
652
|
+
if (y < minY) minY = y;
|
|
653
|
+
if (x > maxX) maxX = x;
|
|
654
|
+
if (y > maxY) maxY = y;
|
|
655
|
+
}
|
|
656
|
+
this.bbox = {
|
|
657
|
+
left: minX,
|
|
658
|
+
top: minY,
|
|
659
|
+
width: maxX - minX || 1,
|
|
660
|
+
height: maxY - minY || 1
|
|
661
|
+
};
|
|
662
|
+
return this;
|
|
663
|
+
}
|
|
664
|
+
render(ctx) {
|
|
665
|
+
const pts = this._points();
|
|
666
|
+
ctx.beginPath();
|
|
667
|
+
pts.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y));
|
|
668
|
+
ctx.closePath();
|
|
669
|
+
ctx.fill();
|
|
670
|
+
}
|
|
671
|
+
toSVG() {
|
|
672
|
+
const node = document.createElementNS(SVGNS, "polygon");
|
|
673
|
+
node.setAttribute("points", this._points().map((p) => p.join(",")).join(" "));
|
|
674
|
+
return node;
|
|
675
|
+
}
|
|
676
|
+
mutate(_cfg) {
|
|
677
|
+
const clone = new _Hexagon(0, 0);
|
|
678
|
+
clone.center = [this.center[0], this.center[1]];
|
|
679
|
+
clone.r = this.r;
|
|
680
|
+
clone.angle = this.angle;
|
|
681
|
+
switch (Math.floor(Math.random() * 3)) {
|
|
682
|
+
case 0: {
|
|
683
|
+
const a = Math.random() * 2 * Math.PI;
|
|
684
|
+
const d = Math.random() * 20;
|
|
685
|
+
clone.center[0] += ~~(d * Math.cos(a));
|
|
686
|
+
clone.center[1] += ~~(d * Math.sin(a));
|
|
687
|
+
break;
|
|
688
|
+
}
|
|
689
|
+
case 1:
|
|
690
|
+
clone.r += (Math.random() - 0.5) * 20;
|
|
691
|
+
clone.r = Math.max(1, ~~clone.r);
|
|
692
|
+
break;
|
|
693
|
+
case 2:
|
|
694
|
+
clone.angle += (Math.random() - 0.5) * Math.PI / 3;
|
|
695
|
+
break;
|
|
696
|
+
}
|
|
697
|
+
return clone.computeBbox();
|
|
698
|
+
}
|
|
699
|
+
};
|
|
700
|
+
var Debug = class extends Shape {
|
|
701
|
+
constructor(w, h) {
|
|
702
|
+
super(w, h);
|
|
703
|
+
this.bbox = { left: 0, top: 0, width: w, height: h };
|
|
704
|
+
}
|
|
705
|
+
render(ctx) {
|
|
706
|
+
ctx.fillRect(0, 0, 1.5, 1.5);
|
|
707
|
+
}
|
|
708
|
+
};
|
|
709
|
+
|
|
710
|
+
// src/optimizer.ts
|
|
711
|
+
var Optimizer = class {
|
|
712
|
+
cfg;
|
|
713
|
+
state;
|
|
714
|
+
onStep;
|
|
715
|
+
_steps;
|
|
716
|
+
_stopped;
|
|
717
|
+
_paused;
|
|
718
|
+
constructor(original, cfg) {
|
|
719
|
+
this.cfg = cfg;
|
|
720
|
+
this.state = new State(original, Canvas.empty(cfg));
|
|
721
|
+
this._steps = 0;
|
|
722
|
+
this._stopped = false;
|
|
723
|
+
this._paused = false;
|
|
724
|
+
this.onStep = () => {
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
start() {
|
|
728
|
+
this._stopped = false;
|
|
729
|
+
this._paused = false;
|
|
730
|
+
this._addShape();
|
|
731
|
+
}
|
|
732
|
+
stop() {
|
|
733
|
+
this._stopped = true;
|
|
734
|
+
}
|
|
735
|
+
pause() {
|
|
736
|
+
this._paused = true;
|
|
737
|
+
}
|
|
738
|
+
resume() {
|
|
739
|
+
if (this._paused && !this._stopped) {
|
|
740
|
+
this._paused = false;
|
|
741
|
+
this._addShape();
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
_addShape() {
|
|
745
|
+
this._findBestStep().then((step) => step ? this._optimizeStep(step) : null).then((step) => {
|
|
746
|
+
this._steps++;
|
|
747
|
+
if (step && step.distance < this.state.distance) {
|
|
748
|
+
this.state = step.apply(this.state);
|
|
749
|
+
this.onStep(step);
|
|
750
|
+
} else {
|
|
751
|
+
this.onStep(null);
|
|
752
|
+
}
|
|
753
|
+
this._continue();
|
|
754
|
+
}).catch(() => this._continue());
|
|
755
|
+
}
|
|
756
|
+
_continue() {
|
|
757
|
+
if (this._stopped || this._paused) {
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
if (this._steps < this.cfg.steps) {
|
|
761
|
+
requestAnimationFrame(() => this._addShape());
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
_findBestStep() {
|
|
765
|
+
const LIMIT = this.cfg.shapes;
|
|
766
|
+
let bestStep = null;
|
|
767
|
+
const promises = [];
|
|
768
|
+
for (let i = 0; i < LIMIT; i++) {
|
|
769
|
+
const shape = Shape.create(this.cfg);
|
|
770
|
+
const promise = new Step(shape, this.cfg).compute(this.state).then((step) => {
|
|
771
|
+
if (!bestStep || step.distance < bestStep.distance) {
|
|
772
|
+
bestStep = step;
|
|
773
|
+
}
|
|
774
|
+
});
|
|
775
|
+
promises.push(promise);
|
|
776
|
+
}
|
|
777
|
+
return Promise.all(promises).then(() => bestStep);
|
|
778
|
+
}
|
|
779
|
+
_optimizeStep(step) {
|
|
780
|
+
const LIMIT = this.cfg.mutations;
|
|
781
|
+
let failedAttempts = 0;
|
|
782
|
+
let resolve;
|
|
783
|
+
let bestStep = step;
|
|
784
|
+
const promise = new Promise((r) => resolve = r);
|
|
785
|
+
const tryMutation = () => {
|
|
786
|
+
if (failedAttempts >= LIMIT) {
|
|
787
|
+
return resolve(bestStep);
|
|
788
|
+
}
|
|
789
|
+
bestStep.mutate().compute(this.state).then((mutatedStep) => {
|
|
790
|
+
if (mutatedStep.distance < bestStep.distance) {
|
|
791
|
+
failedAttempts = 0;
|
|
792
|
+
bestStep = mutatedStep;
|
|
793
|
+
} else {
|
|
794
|
+
failedAttempts++;
|
|
795
|
+
}
|
|
796
|
+
tryMutation();
|
|
797
|
+
});
|
|
798
|
+
};
|
|
799
|
+
tryMutation();
|
|
800
|
+
return promise;
|
|
801
|
+
}
|
|
802
|
+
};
|
|
803
|
+
export {
|
|
804
|
+
Canvas,
|
|
805
|
+
Debug,
|
|
806
|
+
Ellipse,
|
|
807
|
+
Hexagon,
|
|
808
|
+
Optimizer,
|
|
809
|
+
Rectangle,
|
|
810
|
+
SVGNS,
|
|
811
|
+
Shape,
|
|
812
|
+
Smiley,
|
|
813
|
+
State,
|
|
814
|
+
Step,
|
|
815
|
+
Triangle,
|
|
816
|
+
clamp,
|
|
817
|
+
clampColor,
|
|
818
|
+
computeColorAndDifferenceChange,
|
|
819
|
+
difference,
|
|
820
|
+
differenceToDistance,
|
|
821
|
+
distanceToDifference,
|
|
822
|
+
stepPerf
|
|
823
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@slithy/prim-lib",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Core engine for primitive-based image reconstruction.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"import": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist"
|
|
14
|
+
],
|
|
15
|
+
"sideEffects": false,
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@vitest/coverage-v8": "^4.1.0",
|
|
18
|
+
"jsdom": "^28",
|
|
19
|
+
"tsup": "^8",
|
|
20
|
+
"typescript": "^5",
|
|
21
|
+
"vitest": "^4",
|
|
22
|
+
"@slithy/eslint-config": "0.0.0",
|
|
23
|
+
"@slithy/tsconfig": "0.0.0"
|
|
24
|
+
},
|
|
25
|
+
"author": {
|
|
26
|
+
"name": "Matthew Campagna",
|
|
27
|
+
"url": "https://github.com/mjcampagna"
|
|
28
|
+
},
|
|
29
|
+
"license": "PolyForm-Noncommercial-1.0.0",
|
|
30
|
+
"keywords": [
|
|
31
|
+
"image",
|
|
32
|
+
"reconstruction",
|
|
33
|
+
"primitive",
|
|
34
|
+
"shapes",
|
|
35
|
+
"canvas"
|
|
36
|
+
],
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "https://github.com/mjcampagna/slithy.git",
|
|
40
|
+
"directory": "packages/prim-lib"
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"clean": "rm -rf dist",
|
|
47
|
+
"build": "rm -rf dist && tsup src/index.ts --format esm --dts",
|
|
48
|
+
"dev": "tsup src/index.ts --format esm --watch",
|
|
49
|
+
"typecheck": "tsc --noEmit",
|
|
50
|
+
"lint": "eslint .",
|
|
51
|
+
"test": "vitest run",
|
|
52
|
+
"test:watch": "vitest"
|
|
53
|
+
}
|
|
54
|
+
}
|