@motion-proto/live-tokens 0.65.1 → 0.67.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/CHANGELOG.md +184 -0
- package/dist-plugin/{chunk-NDJJORKJ.js → chunk-T4PMCFJN.js} +220 -114
- package/dist-plugin/index.cjs +253 -129
- package/dist-plugin/index.js +27 -9
- package/dist-plugin/migrateData/index.cjs +220 -114
- package/dist-plugin/migrateData/index.js +1 -1
- package/package.json +8 -1
- package/src/editor/bootstrap.ts +12 -0
- package/src/editor/core/productionPulse.ts +1 -1
- package/src/editor/core/sketch/index.ts +78 -21
- package/src/editor/core/sketch/maskField.ts +200 -62
- package/src/editor/core/sketch/sketchLayer.ts +10 -4
- package/src/editor/core/sketch/sketchRegistry.ts +98 -0
- package/src/editor/core/sketch/sketchStore.ts +110 -31
- package/src/editor/core/sketch/sketchStyleService.ts +3 -0
- package/src/editor/core/sketch/sketchStyles.ts +63 -96
- package/src/editor/core/themes/themeInit.ts +4 -13
- package/src/editor/docs/content/sketch-mode.md +82 -13
- package/src/editor/docs/content.generated.ts +1 -1
- package/src/editor/ui/sketch/SketchTab.svelte +219 -74
- package/src/live-tokens/data/sketch-styles/dashed.json +47 -0
- package/src/live-tokens/data/sketch-styles/dry.json +47 -0
- package/src/live-tokens/data/sketch-styles/hatched.json +47 -0
- package/src/live-tokens/data/sketch-styles/marker.json +47 -0
- package/src/live-tokens/data/sketch-styles/napkin.json +47 -0
- package/src/live-tokens/data/sketch-styles/pencil.json +47 -0
- package/src/live-tokens/data/sketch-styles/whiteboard.json +47 -0
- package/src/live-tokens/data/themes/autumn.json +4 -4
- package/src/live-tokens/data/themes/halloween.json +4 -4
- package/src/live-tokens/data/themes/midnight-study.json +4 -4
- package/src/live-tokens/data/themes/ocean.json +4 -4
- package/src/live-tokens/data/themes/royal-velvet.json +4 -4
- package/src/live-tokens/data/themes/sketchy.json +4 -4
- package/src/live-tokens/data/themes/spring-meadow.json +4 -4
- package/src/live-tokens/data/themes/sunset.json +4 -4
- package/src/system/components/Button.svelte +2 -2
- package/src/system/components/IconButton.svelte +2 -2
- package/src/system/styles/fonts.css +6 -6
- package/template/src/main.ts +14 -1
|
@@ -1,18 +1,42 @@
|
|
|
1
|
-
import { derived, type Readable } from 'svelte/store';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { derived, get, type Readable } from 'svelte/store';
|
|
2
|
+
import { THEME_SKETCH_ID } from './sketchStyles';
|
|
3
|
+
import { lookById, sketchLooks, type SketchLook } from './sketchRegistry';
|
|
4
|
+
import {
|
|
5
|
+
sameLook,
|
|
6
|
+
selectSketchStyle,
|
|
7
|
+
selectThemeSketchStyle,
|
|
8
|
+
setSketchEnabled,
|
|
9
|
+
sketchEnabled,
|
|
10
|
+
sketchStyleName,
|
|
11
|
+
themeSketchStyle,
|
|
12
|
+
} from './sketchStore';
|
|
4
13
|
|
|
5
|
-
export
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
14
|
+
export { sketchLooks, registerSketchLook } from './sketchRegistry';
|
|
15
|
+
export type { SketchLook, SketchLookSource, RegisterSketchLookInput } from './sketchRegistry';
|
|
16
|
+
|
|
17
|
+
/** A look as one picker row. The theme's own look is offered this way too, and
|
|
18
|
+
it belongs to no pool entry, so the fields only a pool entry has are off. */
|
|
19
|
+
export type SketchLookRow = Omit<SketchLook, 'settings' | 'source'>;
|
|
11
20
|
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
21
|
+
/**
|
|
22
|
+
* The look the open theme carries, as one more row for a picker: same shape as
|
|
23
|
+
* a shipped look, and `setSketch` takes its id like any other. Null when the
|
|
24
|
+
* theme carries no sketchstyle, and null when what it carries IS one of the
|
|
25
|
+
* shipped looks, since that look's own row already names it.
|
|
26
|
+
*
|
|
27
|
+
* Without this row the theme's look is a one-way door: a visitor lands on it,
|
|
28
|
+
* picks Pencil, and nothing can take them back. It is also the only thing that
|
|
29
|
+
* can name that look, which no shipped label can do honestly. A theme tuned off
|
|
30
|
+
* `marker` still carries the label "Marker", so a row built from the style's
|
|
31
|
+
* own label would sit beside the shipped Marker claiming to be it.
|
|
32
|
+
*/
|
|
33
|
+
export const themeSketchLook: Readable<SketchLookRow | null> = derived(
|
|
34
|
+
[themeSketchStyle, sketchLooks],
|
|
35
|
+
([style, looks]) => {
|
|
36
|
+
if (!style) return null;
|
|
37
|
+
if (looks.some((look) => sameLook(look.settings, style))) return null;
|
|
38
|
+
return { id: THEME_SKETCH_ID, label: 'Theme', blurb: 'The look this theme carries.' };
|
|
39
|
+
},
|
|
16
40
|
);
|
|
17
41
|
|
|
18
42
|
/**
|
|
@@ -27,20 +51,20 @@ export const SKETCH_LOOKS: readonly SketchLook[] = Object.entries(SKETCH_STYLES)
|
|
|
27
51
|
*/
|
|
28
52
|
export type SketchPick =
|
|
29
53
|
| { state: 'off' }
|
|
30
|
-
| { state: 'look'; look:
|
|
54
|
+
| { state: 'look'; look: SketchLookRow }
|
|
31
55
|
| { state: 'adjusted' };
|
|
32
56
|
|
|
33
57
|
export const sketchPick: Readable<SketchPick> = derived(
|
|
34
|
-
[sketchEnabled, sketchStyleName],
|
|
35
|
-
([on, name]): SketchPick => {
|
|
58
|
+
[sketchEnabled, sketchStyleName, sketchLooks, themeSketchLook],
|
|
59
|
+
([on, name, looks, themeLook]): SketchPick => {
|
|
36
60
|
if (!on) return { state: 'off' };
|
|
37
|
-
const look =
|
|
38
|
-
return look ? { state: 'look', look } : { state: 'adjusted' };
|
|
61
|
+
const look = looks.find((l) => l.id === name) ?? (themeLook?.id === name ? themeLook : undefined);
|
|
62
|
+
return look ? { state: 'look', look: { id: look.id, label: look.label, blurb: look.blurb } } : { state: 'adjusted' };
|
|
39
63
|
},
|
|
40
64
|
);
|
|
41
65
|
|
|
42
66
|
/**
|
|
43
|
-
* Draw the page with one of the
|
|
67
|
+
* Draw the page with one of the looks in the pool, or `null` for none.
|
|
44
68
|
*
|
|
45
69
|
* The only supported way for a consumer to drive the effect. Reaching for
|
|
46
70
|
* `applySketchLayer` instead paints a stylesheet the store does not know it
|
|
@@ -52,8 +76,16 @@ export function setSketch(id: string | null): void {
|
|
|
52
76
|
setSketchEnabled(false);
|
|
53
77
|
return;
|
|
54
78
|
}
|
|
55
|
-
if (
|
|
56
|
-
|
|
79
|
+
if (id === THEME_SKETCH_ID) {
|
|
80
|
+
if (!get(themeSketchStyle)) {
|
|
81
|
+
throw new Error('No theme sketchstyle to draw with. `themeSketchLook` is null unless a theme carries one.');
|
|
82
|
+
}
|
|
83
|
+
selectThemeSketchStyle();
|
|
84
|
+
setSketchEnabled(true);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (!lookById(id)) {
|
|
88
|
+
throw new Error(`Unknown sketchstyle "${id}". Ids come from sketchLooks and themeSketchLook.`);
|
|
57
89
|
}
|
|
58
90
|
selectSketchStyle(id);
|
|
59
91
|
setSketchEnabled(true);
|
|
@@ -68,3 +100,28 @@ export function setSketch(id: string | null): void {
|
|
|
68
100
|
* key directly is not an option worth offering — it is ours to rename.
|
|
69
101
|
*/
|
|
70
102
|
export { hasPersistedSketchState } from './sketchStore';
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Draw the page with the sketchstyle a theme carries, unless this browser has
|
|
106
|
+
* already decided for itself.
|
|
107
|
+
*
|
|
108
|
+
* The route from a saved theme to a built page. Hand it the theme's
|
|
109
|
+
* `sketchStyle` field, raw: a built site has no theme API, so it reads its own
|
|
110
|
+
* theme JSON and this hydrates what it finds. Absent, `null`, or anything that
|
|
111
|
+
* is not an object all mean the same thing, which is no sketch.
|
|
112
|
+
*
|
|
113
|
+
* Call it before mounting, the way dev boot does (`bootstrap.ts` awaits
|
|
114
|
+
* `initializeTheme` first), so the look is up on the first frame rather than
|
|
115
|
+
* arriving over a crisp page.
|
|
116
|
+
*
|
|
117
|
+
* A visitor who has recorded a pick of their own keeps it, None included: this
|
|
118
|
+
* seeds an undecided browser and never overwrites a decided one, so it is safe
|
|
119
|
+
* to call on every boot. `themeSketchLook` is populated either way, so a picker
|
|
120
|
+
* can offer the theme's look as a row whether or not this painted it.
|
|
121
|
+
*/
|
|
122
|
+
export { seedSketchFromTheme } from './sketchStore';
|
|
123
|
+
|
|
124
|
+
/** The dial set a theme's `sketchStyle` field holds, for a consumer typing the
|
|
125
|
+
value it pulled out of its own theme JSON. `seedSketchFromTheme` takes it
|
|
126
|
+
raw, so nothing has to be cast to hand it over. */
|
|
127
|
+
export type { SketchStyle } from './sketchStyles';
|
|
@@ -21,16 +21,120 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import type { SketchStyle } from './sketchStyles';
|
|
23
23
|
|
|
24
|
-
/**
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
/** Samples across the tile, on both axes. Fixed, whatever the blobs measure:
|
|
25
|
+
the field is blobs and gradients, so the browser stretching the tile back to
|
|
26
|
+
page px costs nothing visible, and the data URI stays one size. */
|
|
27
|
+
const RASTER = 300;
|
|
28
28
|
|
|
29
|
-
/**
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const
|
|
29
|
+
/** Blobs per tile, and so how far the field runs before it repeats. Held in
|
|
30
|
+
this band rather than taken from the blob size alone: under two the lattice
|
|
31
|
+
is smaller than a pattern, and over forty a blob is down to seven samples
|
|
32
|
+
across and the finest octave starts to alias. */
|
|
33
|
+
const CELLS = { min: 2, max: 40 };
|
|
34
|
+
|
|
35
|
+
/** The tile the band is aimed at, in page px. A blob wants enough of them
|
|
36
|
+
around it that the repeat is not a thing you can see. */
|
|
37
|
+
const TARGET_TILE = 600;
|
|
38
|
+
|
|
39
|
+
/** How far the cell count is allowed to drift from the tile it is aimed at to
|
|
40
|
+
buy a degree of rotation, as degrees per e-fold. A tilt the dial cannot
|
|
41
|
+
reach is worth more than a tile at exactly 600px. */
|
|
42
|
+
const CELL_DRIFT = 12;
|
|
43
|
+
|
|
44
|
+
type Vec = readonly [number, number];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Where the page's own axes land on the noise lattice, and the tile that puts
|
|
48
|
+
* them there.
|
|
49
|
+
*
|
|
50
|
+
* The field wraps by reducing every lattice point modulo the pair, so both
|
|
51
|
+
* vectors have to be whole cells. Axis-aligned that is no constraint at all —
|
|
52
|
+
* a tile some whole number of blobs wide — but a tilted pattern only meets
|
|
53
|
+
* itself if the turn lands the page's axes back on the lattice, which most
|
|
54
|
+
* angles do not. The dial is answered with the nearest pair that does, and
|
|
55
|
+
* `angle` reports what that came out as rather than what was asked for.
|
|
56
|
+
*
|
|
57
|
+
* Rotation is dropped when the two blob sizes match: the field under it is the
|
|
58
|
+
* same in every direction, so turning it only draws a different tile of the
|
|
59
|
+
* same thing, and the fit is exact left alone.
|
|
60
|
+
*/
|
|
61
|
+
export interface MaskLattice {
|
|
62
|
+
/** The page's x axis in cells, then its y axis. */
|
|
63
|
+
vx: Vec;
|
|
64
|
+
vy: Vec;
|
|
65
|
+
/** The tile in page px. */
|
|
66
|
+
w: number;
|
|
67
|
+
h: number;
|
|
68
|
+
/** The turn the pair actually describes, in degrees. */
|
|
69
|
+
angle: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function maskLattice(s: SketchStyle): MaskLattice {
|
|
73
|
+
const ask = s.maskBlobX === s.maskBlobY ? 0 : s.maskAngle;
|
|
74
|
+
const rad = ask * (Math.PI / 180);
|
|
75
|
+
const cos = Math.cos(rad), sin = Math.sin(rad);
|
|
76
|
+
const dx: Vec = [cos / s.maskBlobX, -sin / s.maskBlobY];
|
|
77
|
+
const dy: Vec = [sin / s.maskBlobX, cos / s.maskBlobY];
|
|
78
|
+
const vx = fit(dx, ask, (v) => turnOf(-v[1] * s.maskBlobY, v[0] * s.maskBlobX));
|
|
79
|
+
let vy = fit(dy, ask, (v) => turnOf(v[0] * s.maskBlobX, v[1] * s.maskBlobY));
|
|
80
|
+
// Two vectors along one line describe no tile at all. The turn of the first
|
|
81
|
+
// is the whole look, so the second is the one that gives way.
|
|
82
|
+
if (vx[0] * vy[1] - vx[1] * vy[0] === 0) vy = [-vx[1], vx[0]];
|
|
83
|
+
return {
|
|
84
|
+
vx,
|
|
85
|
+
vy,
|
|
86
|
+
w: Math.hypot(...vx) / Math.hypot(...dx),
|
|
87
|
+
h: Math.hypot(...vy) / Math.hypot(...dy),
|
|
88
|
+
angle: turnOf(-vx[1] * s.maskBlobY, vx[0] * s.maskBlobX),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A direction as a turn from level, 0 to 180: a stretch has a slant, not a
|
|
93
|
+
heading, so pointing back the way it came is the same slant. */
|
|
94
|
+
function turnOf(rise: number, run: number): number {
|
|
95
|
+
return (((Math.atan2(rise, run) * (180 / Math.PI)) % 180) + 180) % 180;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The whole-cell vector that lands the tile nearest the turn the dial asks for,
|
|
100
|
+
* weighing that against the number of cells it spends getting there.
|
|
101
|
+
*
|
|
102
|
+
* Scored on the turn the page ends up showing, never on the one the vector
|
|
103
|
+
* makes against the lattice. The two part company as soon as the blob sizes
|
|
104
|
+
* do: at seven to one, a vector a degree off square on the lattice comes out
|
|
105
|
+
* eight degrees off on the page, and a fit that reads the lattice believes it
|
|
106
|
+
* has landed.
|
|
107
|
+
*/
|
|
108
|
+
function fit(d: Vec, ask: number, turn: (v: Vec) => number): Vec {
|
|
109
|
+
const length = Math.hypot(...d);
|
|
110
|
+
const unit: Vec = [d[0] / length, d[1] / length];
|
|
111
|
+
const target = Math.min(CELLS.max, Math.max(CELLS.min, TARGET_TILE * length));
|
|
112
|
+
let best: Vec = [1, 0];
|
|
113
|
+
let bestCost = Infinity;
|
|
114
|
+
for (let cells = CELLS.min; cells <= CELLS.max; cells++) {
|
|
115
|
+
const v: Vec = [Math.round(unit[0] * cells), Math.round(unit[1] * cells)];
|
|
116
|
+
const spent = Math.hypot(...v);
|
|
117
|
+
if (spent === 0) continue;
|
|
118
|
+
const off = Math.abs(turn(v) - ask);
|
|
119
|
+
const cost = Math.min(off, 180 - off) + CELL_DRIFT * Math.abs(Math.log(spent / target));
|
|
120
|
+
if (cost < bestCost) { bestCost = cost; best = v; }
|
|
121
|
+
}
|
|
122
|
+
return best;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* What the tile spans in page px, as `mask-size` takes it.
|
|
127
|
+
*
|
|
128
|
+
* A whole number of blobs along each of the page's axes, at exactly the size
|
|
129
|
+
* the dials state, so the number on the Scale dial is the number of pixels a
|
|
130
|
+
* blob measures on the page. The tile used to be a fixed 600px with the blobs
|
|
131
|
+
* fitted to it, which meant the dial could only reach the sizes that divide
|
|
132
|
+
* 600: it said 250px and painted 300px, and there was nothing above that.
|
|
133
|
+
*/
|
|
134
|
+
export function maskTile(s: SketchStyle): { w: number; h: number } {
|
|
135
|
+
const { w, h } = maskLattice(s);
|
|
136
|
+
return { w, h };
|
|
137
|
+
}
|
|
34
138
|
|
|
35
139
|
/** The stage previews, in the order the field is built. */
|
|
36
140
|
export type MaskStage = 'noise' | 'levels' | 'blur';
|
|
@@ -48,8 +152,9 @@ function mulberry32(seed: number): () => number {
|
|
|
48
152
|
}
|
|
49
153
|
|
|
50
154
|
/**
|
|
51
|
-
* Perlin noise on a lattice that wraps
|
|
52
|
-
* from it meets itself at both edges
|
|
155
|
+
* Perlin noise on a lattice that wraps on the tile's own two vectors, so a tile
|
|
156
|
+
* drawn from it meets itself at both edges however the pattern is turned.
|
|
157
|
+
* Returns roughly -0.7 to 0.7.
|
|
53
158
|
*
|
|
54
159
|
* Hand-rolled because the wrap is the whole requirement and no established
|
|
55
160
|
* noise package offers one: `simplex-noise` and its neighbours all sample an
|
|
@@ -57,7 +162,8 @@ function mulberry32(seed: number): () => number {
|
|
|
57
162
|
* noise, and it degenerates at the ratio this effect works at — a tile only
|
|
58
163
|
* four or five blobs across makes those circles smaller than one noise feature,
|
|
59
164
|
* and the field comes back as a regular grid of dots instead of cloud. A
|
|
60
|
-
* lattice
|
|
165
|
+
* lattice wraps exactly at any size, and on any pair of whole-cell vectors,
|
|
166
|
+
* which is what lets the pattern be turned.
|
|
61
167
|
*
|
|
62
168
|
* Gradients are drawn from the whole circle, one per lattice point, rather than
|
|
63
169
|
* from the eight compass directions the textbook version uses. Eight leaves
|
|
@@ -65,7 +171,16 @@ function mulberry32(seed: number): () => number {
|
|
|
65
171
|
*/
|
|
66
172
|
const GRAD_COUNT = 256;
|
|
67
173
|
|
|
68
|
-
|
|
174
|
+
/** One octave's tile vectors, and the determinant that reduces against them. */
|
|
175
|
+
interface Wrap { ax: number; ay: number; bx: number; by: number; det: number }
|
|
176
|
+
|
|
177
|
+
function wrapOn(vx: Vec, vy: Vec, octave: number): Wrap {
|
|
178
|
+
const ax = vx[0] * octave, ay = vx[1] * octave;
|
|
179
|
+
const bx = vy[0] * octave, by = vy[1] * octave;
|
|
180
|
+
return { ax, ay, bx, by, det: ax * by - bx * ay };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function makePerlin(seed: number): (x: number, y: number, w: Wrap) => number {
|
|
69
184
|
const rand = mulberry32(seed);
|
|
70
185
|
const perm = new Uint8Array(512);
|
|
71
186
|
const src = new Uint8Array(256);
|
|
@@ -85,21 +200,26 @@ function makePerlin(seed: number): (x: number, y: number, period: number) => num
|
|
|
85
200
|
const fade = (t: number) => t * t * t * (t * (t * 6 - 15) + 10);
|
|
86
201
|
const lerp = (t: number, a: number, b: number) => a + t * (b - a);
|
|
87
202
|
|
|
88
|
-
|
|
203
|
+
/** The gradient at a lattice point, taken at that point's stand-in inside the
|
|
204
|
+
tile. Two points a tile apart reduce to the same stand-in and so carry the
|
|
205
|
+
same gradient, which is the whole of the wrap: it holds on any pair of
|
|
206
|
+
vectors, not only on the two an axis-aligned tile runs along. */
|
|
207
|
+
const g = (i: number, j: number, w: Wrap, dx: number, dy: number) => {
|
|
208
|
+
const alpha = Math.floor((i * w.by - w.bx * j) / w.det);
|
|
209
|
+
const beta = Math.floor((w.ax * j - i * w.ay) / w.det);
|
|
210
|
+
const cx = i - alpha * w.ax - beta * w.bx;
|
|
211
|
+
const cy = j - alpha * w.ay - beta * w.by;
|
|
212
|
+
const h = perm[(perm[cx & 255] + cy) & 255];
|
|
213
|
+
return gx[h] * dx + gy[h] * dy;
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
return (x, y, w) => {
|
|
89
217
|
const x0 = Math.floor(x), y0 = Math.floor(y);
|
|
90
218
|
const xf = x - x0, yf = y - y0;
|
|
91
|
-
const p = Math.max(1, Math.round(period));
|
|
92
|
-
const xi = ((x0 % p) + p) % p, yi = ((y0 % p) + p) % p;
|
|
93
|
-
const xi1 = (xi + 1) % p, yi1 = (yi + 1) % p;
|
|
94
|
-
|
|
95
|
-
const g = (cx: number, cy: number, dx: number, dy: number) => {
|
|
96
|
-
const h = perm[(perm[cx & 255] + cy) & 255];
|
|
97
|
-
return gx[h] * dx + gy[h] * dy;
|
|
98
|
-
};
|
|
99
219
|
const u = fade(xf), v = fade(yf);
|
|
100
220
|
return lerp(v,
|
|
101
|
-
lerp(u, g(
|
|
102
|
-
lerp(u, g(
|
|
221
|
+
lerp(u, g(x0, y0, w, xf, yf), g(x0 + 1, y0, w, xf - 1, yf)),
|
|
222
|
+
lerp(u, g(x0, y0 + 1, w, xf, yf - 1), g(x0 + 1, y0 + 1, w, xf - 1, yf - 1)));
|
|
103
223
|
};
|
|
104
224
|
}
|
|
105
225
|
|
|
@@ -111,22 +231,31 @@ function makePerlin(seed: number): (x: number, y: number, period: number) => num
|
|
|
111
231
|
* wherever the octave crossed zero, which is the marbled look; plain sum is
|
|
112
232
|
* cloud.
|
|
113
233
|
*/
|
|
114
|
-
function rawField(
|
|
234
|
+
function rawField(
|
|
235
|
+
s: SketchStyle, seed: number, raster: number, lattice: MaskLattice,
|
|
236
|
+
): Float32Array {
|
|
115
237
|
const noise = makePerlin(seed);
|
|
116
238
|
const veined = s.maskGrain === 'turbulence';
|
|
117
239
|
const octaves = Math.max(1, Math.round(s.maskOctaves));
|
|
118
240
|
const out = new Float32Array(raster * raster);
|
|
119
|
-
const
|
|
241
|
+
const wraps = Array.from({ length: octaves }, (_, o) => wrapOn(lattice.vx, lattice.vy, 1 << o));
|
|
120
242
|
|
|
121
243
|
let weightSum = 0;
|
|
122
244
|
for (let o = 0; o < octaves; o++) weightSum += 1 / (1 << o);
|
|
123
245
|
|
|
246
|
+
// A sample's place in the tile, carried onto the lattice by the tile's own
|
|
247
|
+
// two vectors. Square on, they are (cells, 0) and (0, cells) and this is the
|
|
248
|
+
// walk it always was; turned, they lean, and so does the field.
|
|
124
249
|
for (let y = 0; y < raster; y++) {
|
|
250
|
+
const ty = y / raster;
|
|
125
251
|
for (let x = 0; x < raster; x++) {
|
|
252
|
+
const tx = x / raster;
|
|
253
|
+
const a = tx * lattice.vx[0] + ty * lattice.vy[0];
|
|
254
|
+
const b = tx * lattice.vx[1] + ty * lattice.vy[1];
|
|
126
255
|
let sum = 0;
|
|
127
256
|
for (let o = 0; o < octaves; o++) {
|
|
128
257
|
const f = 1 << o;
|
|
129
|
-
const n = noise(
|
|
258
|
+
const n = noise(a * f, b * f, wraps[o]);
|
|
130
259
|
sum += (veined ? Math.abs(n) : n) / f;
|
|
131
260
|
}
|
|
132
261
|
out[y * raster + x] = sum / weightSum;
|
|
@@ -200,50 +329,53 @@ function posterise(f: Float32Array, steps: number): Float32Array {
|
|
|
200
329
|
|
|
201
330
|
Three boxes at the width the filter spec derives land on a gaussian of a
|
|
202
331
|
given deviation, which is what makes the dial's px the px it gets. But a box
|
|
203
|
-
radius is a whole number of samples
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
332
|
+
radius is a whole number of samples, so rounding to one put the dial on a
|
|
333
|
+
ladder with a dead zone at the bottom: every small setting came out
|
|
334
|
+
perfectly sharp and a spread of them came out identical. Mixing the two
|
|
335
|
+
radii either side of the exact one puts the dial back on a continuous scale,
|
|
336
|
+
and radius zero is the field itself, so the bottom of the travel eases in
|
|
337
|
+
instead of switching on.
|
|
338
|
+
|
|
339
|
+
Run one axis at a time, since a sample is a different number of page px
|
|
340
|
+
across than it is down whenever the two blob sizes disagree. A gaussian is
|
|
341
|
+
separable, so a pass along each axis is the same blur the square one gave. */
|
|
342
|
+
function blur(f: Float32Array, raster: number, stdX: number, stdY: number): Float32Array {
|
|
343
|
+
return blurAxis(blurAxis(f, raster, stdX, 1), raster, stdY, raster);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function blurAxis(f: Float32Array, n: number, std: number, stride: number): Float32Array {
|
|
210
347
|
const exact = (std * 3 * Math.sqrt(2 * Math.PI) / 4 - 1) / 2;
|
|
211
348
|
if (exact <= 0) return f;
|
|
212
349
|
const lower = Math.floor(exact);
|
|
213
350
|
const mix = exact - lower;
|
|
214
|
-
const low = lower < 1 ? f : boxes(f,
|
|
351
|
+
const low = lower < 1 ? f : boxes(f, n, lower, stride);
|
|
215
352
|
if (mix < 1e-6) return low;
|
|
216
|
-
const high = boxes(f,
|
|
353
|
+
const high = boxes(f, n, lower + 1, stride);
|
|
217
354
|
const out = new Float32Array(f.length);
|
|
218
355
|
for (let i = 0; i < f.length; i++) out[i] = low[i] + (high[i] - low[i]) * mix;
|
|
219
356
|
return out;
|
|
220
357
|
}
|
|
221
358
|
|
|
222
|
-
function boxes(f: Float32Array,
|
|
359
|
+
function boxes(f: Float32Array, n: number, radius: number, stride: number): Float32Array {
|
|
223
360
|
let cur = f;
|
|
224
|
-
for (let pass = 0; pass < 3; pass++) cur = boxPass(cur,
|
|
361
|
+
for (let pass = 0; pass < 3; pass++) cur = boxPass(cur, n, radius, stride);
|
|
225
362
|
return cur;
|
|
226
363
|
}
|
|
227
364
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
for (let y = 0; y < n; y++) {
|
|
232
|
-
const row = y * n;
|
|
233
|
-
let sum = 0;
|
|
234
|
-
for (let i = -radius; i <= radius; i++) sum += f[row + ((i % n) + n) % n];
|
|
235
|
-
for (let x = 0; x < n; x++) {
|
|
236
|
-
mid[row + x] = sum / width;
|
|
237
|
-
sum += f[row + ((x + radius + 1) % n)] - f[row + ((x - radius + n) % n)];
|
|
238
|
-
}
|
|
239
|
-
}
|
|
365
|
+
/** One pass along a single axis. `stride` is 1 across a row and the raster width
|
|
366
|
+
down a column, so one sliding window serves both. */
|
|
367
|
+
function boxPass(f: Float32Array, n: number, radius: number, stride: number): Float32Array {
|
|
240
368
|
const out = new Float32Array(f.length);
|
|
241
|
-
|
|
369
|
+
const lineStep = stride === 1 ? n : 1;
|
|
370
|
+
const width = radius * 2 + 1;
|
|
371
|
+
for (let line = 0; line < n; line++) {
|
|
372
|
+
const base = line * lineStep;
|
|
373
|
+
const at = (i: number) => f[base + ((((i % n) + n) % n)) * stride];
|
|
242
374
|
let sum = 0;
|
|
243
|
-
for (let i = -radius; i <= radius; i++) sum +=
|
|
244
|
-
for (let
|
|
245
|
-
out[
|
|
246
|
-
sum +=
|
|
375
|
+
for (let i = -radius; i <= radius; i++) sum += at(i);
|
|
376
|
+
for (let i = 0; i < n; i++) {
|
|
377
|
+
out[base + i * stride] = sum / width;
|
|
378
|
+
sum += at(i + radius + 1) - at(i - radius);
|
|
247
379
|
}
|
|
248
380
|
}
|
|
249
381
|
return out;
|
|
@@ -257,11 +389,12 @@ function boxPass(f: Float32Array, n: number, radius: number): Float32Array {
|
|
|
257
389
|
let rawCache: { key: string; field: Float32Array } | null = null;
|
|
258
390
|
|
|
259
391
|
function cachedRaw(s: SketchStyle, seed: number): Float32Array {
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
392
|
+
const lattice = maskLattice(s);
|
|
393
|
+
// Keyed on the tile vectors rather than the blob sizes: most of the Scale
|
|
394
|
+
// dial's travel paints the same lattice at another size, and the field is
|
|
395
|
+
// the expensive half of the build.
|
|
396
|
+
const key = [...lattice.vx, ...lattice.vy, s.maskOctaves, s.maskGrain, seed].join('|');
|
|
397
|
+
if (rawCache?.key !== key) rawCache = { key, field: rawField(s, seed, RASTER, lattice) };
|
|
265
398
|
return rawCache.field;
|
|
266
399
|
}
|
|
267
400
|
|
|
@@ -284,7 +417,11 @@ export function buildMaskField(
|
|
|
284
417
|
);
|
|
285
418
|
if (through === 'levels') return { field: levelled, raster: RASTER };
|
|
286
419
|
|
|
287
|
-
|
|
420
|
+
const tile = maskTile(s);
|
|
421
|
+
return {
|
|
422
|
+
field: blur(levelled, RASTER, s.maskSoftness / (tile.w / RASTER), s.maskSoftness / (tile.h / RASTER)),
|
|
423
|
+
raster: RASTER,
|
|
424
|
+
};
|
|
288
425
|
}
|
|
289
426
|
|
|
290
427
|
/* ------------------------------------------------------------------ png --- */
|
|
@@ -398,7 +535,8 @@ export function fieldToPng(field: Float32Array, raster: number): string {
|
|
|
398
535
|
never triggers another. The tab holds four of them at once, the field at
|
|
399
536
|
three stages beside the finished one, with headroom over that. */
|
|
400
537
|
const KEYS = [
|
|
401
|
-
'
|
|
538
|
+
'maskBlobX', 'maskBlobY', 'maskAngle', 'maskOctaves', 'maskGrain',
|
|
539
|
+
'maskOutputMin', 'maskOutputMax',
|
|
402
540
|
'maskPosterize', 'maskSoftness',
|
|
403
541
|
] as const;
|
|
404
542
|
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* data-sketch present = on, absent = off
|
|
16
16
|
*/
|
|
17
17
|
import { getSyncedDocuments } from '../cssVarSync';
|
|
18
|
-
import { buildMaskUri,
|
|
18
|
+
import { buildMaskUri, maskTile } from './maskField';
|
|
19
19
|
import type { SketchStyle } from './sketchStyles';
|
|
20
20
|
|
|
21
21
|
const DEFS_ATTR = 'data-sketch-defs';
|
|
@@ -664,6 +664,10 @@ export function buildStylesheet(s: SketchStyle): string {
|
|
|
664
664
|
// can no longer flatten the corners of every other one.
|
|
665
665
|
const registrations = `@property --sketch-radius{syntax:"<length>";inherits:true;initial-value:0px;}`;
|
|
666
666
|
|
|
667
|
+
// Two sizes, not one: the field is painted at whatever the blob dials
|
|
668
|
+
// multiply out to, and they part company as soon as the pair is unlinked.
|
|
669
|
+
const tile = maskTile(s);
|
|
670
|
+
|
|
667
671
|
const vars =
|
|
668
672
|
`${on}{` +
|
|
669
673
|
`--sketch-stroke-width:${s.strokeWidth}px;` +
|
|
@@ -674,7 +678,8 @@ export function buildStylesheet(s: SketchStyle): string {
|
|
|
674
678
|
`--sketch-fill-filter:url(#${ID}-fill-0);` +
|
|
675
679
|
`--sketch-stroke-filter:url(#${ID}-stroke-0);` +
|
|
676
680
|
`--sketch-mask:${s.maskOn || s.iconMaskOn ? buildMaskUri(s) : 'none'};` +
|
|
677
|
-
`--sketch-mask-tile:${
|
|
681
|
+
`--sketch-mask-tile-w:${Number(tile.w.toFixed(2))}px;` +
|
|
682
|
+
`--sketch-mask-tile-h:${Number(tile.h.toFixed(2))}px;` +
|
|
678
683
|
`--sketch-icon-mask-tile:${Math.round(s.iconMaskScale * 100)}%;` +
|
|
679
684
|
// Named here rather than on the icons themselves, so an ancestor asking
|
|
680
685
|
// for the soft bank resolves it against a value it can actually see.
|
|
@@ -852,7 +857,7 @@ export function buildStylesheet(s: SketchStyle): string {
|
|
|
852
857
|
// width of the bleed; every other part's token is --shadow-none anyway.
|
|
853
858
|
(bleed === 0 ? 'box-shadow:var(--sketch-shadow, none);' : '') +
|
|
854
859
|
`filter:var(--sketch-fill-filter);` +
|
|
855
|
-
(s.maskOn ? coverage('var(--sketch-mask-tile) var(--sketch-mask-tile)', '--sketch-mask-pos') : '') +
|
|
860
|
+
(s.maskOn ? coverage('var(--sketch-mask-tile-w) var(--sketch-mask-tile-h)', '--sketch-mask-pos') : '') +
|
|
856
861
|
`transform:translate(` +
|
|
857
862
|
`calc(var(--sketch-jx, 0) * var(--sketch-jit-x, 0px)),` +
|
|
858
863
|
`calc(var(--sketch-jy, 0) * var(--sketch-jit-y, 0px))` +
|
|
@@ -1008,7 +1013,8 @@ export function buildStylesheet(s: SketchStyle): string {
|
|
|
1008
1013
|
// lands wholly inside a patch and comes out either untouched or gone.
|
|
1009
1014
|
// Shrinking the tile puts several blotches across it, which is the same
|
|
1010
1015
|
// move the icon mask makes for a glyph.
|
|
1011
|
-
`--sketch-mask-tile:${Math.round(
|
|
1016
|
+
`--sketch-mask-tile-w:${Math.round(tile.w * 0.3)}px;` +
|
|
1017
|
+
`--sketch-mask-tile-h:${Math.round(tile.h * 0.3)}px;` +
|
|
1012
1018
|
`--sketch-jit-rot:calc(var(--sketch-jit-rot-base) * 1.6);` +
|
|
1013
1019
|
`--sketch-jit-x:calc(var(--sketch-jit-x-base) * 0.5);` +
|
|
1014
1020
|
`--sketch-jit-y:calc(var(--sketch-jit-y-base) * 0.5);` +
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { derived, get, writable, type Readable } from 'svelte/store';
|
|
2
|
+
import { SKETCH_STYLES, hydrateSketchStyle, type SketchStyle } from './sketchStyles';
|
|
3
|
+
|
|
4
|
+
/** Where a look came from, which is what decides the affordances on offer.
|
|
5
|
+
`shipped` is served by the package: it has a file, but not one in this
|
|
6
|
+
project, so there is nothing here to save over or delete until the editor
|
|
7
|
+
writes a copy. `file` lives in this project's data tree, so the editor can
|
|
8
|
+
save over it and delete it. `registered` was handed to `bootLiveTokens`, so
|
|
9
|
+
it is real on a built site but owns no file the editor could touch. */
|
|
10
|
+
export type SketchLookSource = 'shipped' | 'file' | 'registered';
|
|
11
|
+
|
|
12
|
+
export interface SketchLook {
|
|
13
|
+
/** What `setSketch` takes. A saved sketchstyle's file slug is its id, so a
|
|
14
|
+
look picked in dev keeps working once the site is built. */
|
|
15
|
+
id: string;
|
|
16
|
+
label: string;
|
|
17
|
+
blurb: string;
|
|
18
|
+
settings: SketchStyle;
|
|
19
|
+
source: SketchLookSource;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface RegisterSketchLookInput {
|
|
23
|
+
id: string;
|
|
24
|
+
label: string;
|
|
25
|
+
blurb?: string;
|
|
26
|
+
settings: unknown;
|
|
27
|
+
/** Only `replaceRegisteredLooks` sets this. A listing carries the package's
|
|
28
|
+
own sketchstyles as well as the project's, and the two differ in what the
|
|
29
|
+
editor may do to them, so the row's origin has to survive the trip. */
|
|
30
|
+
source?: SketchLookSource;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const SHIPPED: ReadonlyMap<string, SketchLook> = new Map(
|
|
34
|
+
Object.entries(SKETCH_STYLES).map(([id, style]) => [
|
|
35
|
+
id,
|
|
36
|
+
{ id, label: style.label, blurb: style.blurb, settings: style, source: 'shipped' as const },
|
|
37
|
+
]),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
/** Seeded with the shipped looks, then written by `registerSketchLook`. A Map
|
|
41
|
+
because re-setting an existing key keeps its position, so a project's own
|
|
42
|
+
`pencil` lands where the shipped Pencil sat rather than at the end of the
|
|
43
|
+
grid. */
|
|
44
|
+
const looks = writable(new Map(SHIPPED));
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Every sketchstyle on offer: the shipped looks, plus whatever this project
|
|
48
|
+
* registered over and beside them. A store rather than a constant, because
|
|
49
|
+
* registration happens after this module is imported.
|
|
50
|
+
*
|
|
51
|
+
* A picker adds its own "None" row, since off is a state of the effect rather
|
|
52
|
+
* than one of the looks. A theme's own look is not here either; `themeSketchLook`
|
|
53
|
+
* carries that one row.
|
|
54
|
+
*/
|
|
55
|
+
export const sketchLooks: Readable<SketchLook[]> = derived(looks, (m) => [...m.values()]);
|
|
56
|
+
|
|
57
|
+
export function lookById(id: string): SketchLook | undefined {
|
|
58
|
+
return get(looks).get(id);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function put(m: Map<string, SketchLook>, look: RegisterSketchLookInput, source: SketchLookSource) {
|
|
62
|
+
// Hydrated on the way in: a consumer hands over raw JSON it imported, so this
|
|
63
|
+
// is the only place a look stored under a retired dial name gets carried
|
|
64
|
+
// forward, the same job `seedSketchFromTheme` does for a theme.
|
|
65
|
+
const settings = hydrateSketchStyle(look.settings);
|
|
66
|
+
return m.set(look.id, {
|
|
67
|
+
id: look.id,
|
|
68
|
+
label: look.label,
|
|
69
|
+
blurb: look.blurb ?? settings.blurb ?? '',
|
|
70
|
+
settings,
|
|
71
|
+
source,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function registerSketchLook(look: RegisterSketchLookInput): void {
|
|
76
|
+
looks.update((m) => put(new Map(m), look, 'registered'));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Ids the last file listing claimed. The sweep below removes these and nothing
|
|
80
|
+
else, so a look handed to `bootLiveTokens` survives the editor opening. */
|
|
81
|
+
let fromFiles: string[] = [];
|
|
82
|
+
|
|
83
|
+
/** The editor re-lists its files after every save and delete, so a file removed
|
|
84
|
+
on disk has to leave the pool too. A file that had shadowed a shipped look
|
|
85
|
+
hands the id back on its way out. */
|
|
86
|
+
export function replaceRegisteredLooks(next: RegisterSketchLookInput[]): void {
|
|
87
|
+
looks.update((m) => {
|
|
88
|
+
const out = new Map(m);
|
|
89
|
+
for (const id of fromFiles) {
|
|
90
|
+
const shipped = SHIPPED.get(id);
|
|
91
|
+
if (shipped) out.set(id, shipped);
|
|
92
|
+
else out.delete(id);
|
|
93
|
+
}
|
|
94
|
+
for (const look of next) put(out, look, look.source ?? 'file');
|
|
95
|
+
return out;
|
|
96
|
+
});
|
|
97
|
+
fromFiles = next.map((l) => l.id);
|
|
98
|
+
}
|