feat(studio): linen and paper grain, two quiet textures among the patterns

This commit is contained in:
Maurice
2026-10-24 09:29:33 +02:00
parent d17bd47a85
commit 3feb8fe94f
31 changed files with 100 additions and 9 deletions
+41 -8
View File
@@ -46,6 +46,11 @@ interface PatternGeometry {
const r2 = (v: number) => Math.round(v * 100) / 100 || 0;
const f = (v: number) => String(r2(v));
/** Threads per cell each way in linen: fine enough to read as cloth, few enough for a whole page under the cap. */
const LINEN_THREADS = 6;
/** Specks per cell in grain. */
const GRAIN_SPECKS = 3;
/**
* How many primitives a repeat needs over a region: `area` primitives per
* cell squared, `lin` per cell along the width and the height. Read before
@@ -66,6 +71,8 @@ const DENSITY: Record<BookPattern, { area: number; lin: number }> = {
scales: { area: 2, lin: 0 },
sunburst: { area: 0, lin: 0 },
confetti: { area: 1.2, lin: 0 },
linen: { area: 0, lin: 2 * LINEN_THREADS },
grain: { area: GRAIN_SPECKS, lin: 0 },
};
/** A primitive count estimate for a repeat over a region at a cell size. */
@@ -269,6 +276,24 @@ const DRAW: Record<Exclude<BookPattern, 'sunburst'>, Draw> = {
]);
}
},
// Threads of uneven weight both ways, on two half-clear layers: where they cross the cloth reads denser.
linen: (pen, g, s, alt) => {
const rand = mulberry32(Math.round(s * 1000) + 31);
const pitch = s / LINEN_THREADS;
for (const y of steps(g.y0, g.y1, pitch)) pen.rect(g.x0, y, g.x1 - g.x0, pitch * (0.2 + rand() * 0.35));
for (const x of steps(g.x0, g.x1, pitch)) alt.rect(x, g.y0, pitch * (0.2 + rand() * 0.35), g.y1 - g.y0);
},
// Specks of a few sizes, the larger ones fainter, scattered the same way on every render.
grain: (pen, g, s, alt) => {
const rand = mulberry32(Math.round(s * 1000) + 43);
const n = Math.round((((g.x1 - g.x0) * (g.y1 - g.y0)) / (s * s)) * GRAIN_SPECKS);
for (let i = 0; i < n && !pen.full && !alt.full; i++) {
const x = g.x0 + rand() * (g.x1 - g.x0);
const y = g.y0 + rand() * (g.y1 - g.y0);
if (rand() < 0.6) pen.circle(x, y, s * (0.012 + rand() * 0.012));
else alt.circle(x, y, s * (0.025 + rand() * 0.02));
}
},
};
/** A seeded generator, so a confetti page draws the same scatter on every render and in print. */
@@ -307,6 +332,13 @@ function sunburst(pen: Pen, box: PatternBox, cell: number, angle: number) {
}
}
/** The repeats drawn on two layers, and how clear each layer is: textures stay quiet over their ground. */
const LAYER_OPACITY: Partial<Record<BookPattern, readonly [number, number]>> = {
gingham: [0.5, 0.5],
linen: [0.32, 0.32],
grain: [0.5, 0.28],
};
/**
* A repeat over a box. The geometry covers the box however it is turned: the
* region drawn is the square around the box's circumscribed circle, and the
@@ -338,17 +370,18 @@ export function patternGeometry(
return { layers: [{ d: pen.d, opacity: 1 }], count: pen.count, cell };
}
// The second layer only ever holds gingham's cross bands; the cell fitted above keeps both under the cap together.
// The second layer holds gingham's and linen's cross bands and grain's larger specks;
// the cell fitted above keeps both under the cap together.
const alt = new Pen(cap);
DRAW[paint.pattern](pen, region, cell, alt);
const count = pen.count + alt.count;
const layers =
paint.pattern === 'gingham'
? [
{ d: pen.d, opacity: 0.5 },
{ d: alt.d, opacity: 0.5 },
]
: [{ d: pen.d, opacity: 1 }];
const two = LAYER_OPACITY[paint.pattern];
const layers = two
? [
{ d: pen.d, opacity: two[0] },
{ d: alt.d, opacity: two[1] },
]
: [{ d: pen.d, opacity: 1 }];
return {
layers: layers.filter((l) => l.d),
count,
+2
View File
@@ -42,6 +42,8 @@ export const BOOK_PATTERNS = [
'scales',
'sunburst',
'confetti',
'linen',
'grain',
] as const;
export type BookPattern = (typeof BOOK_PATTERNS)[number];
+1 -1
View File
@@ -71,7 +71,7 @@ describe('the paint contract', () => {
for (const p of [{ type: 'solid', color: '#abcdef', alpha: 0.4 }, linear, radial, stripes]) {
expect(bookPaintSchema.parse(p)).toEqual(p);
}
expect(BOOK_PATTERNS).toHaveLength(12);
expect(BOOK_PATTERNS).toHaveLength(14);
for (const pattern of BOOK_PATTERNS) expect(bookPaintSchema.safeParse({ ...stripes, pattern }).success).toBe(true);
});
@@ -138,4 +138,6 @@ export const BOOK_PATTERN_LINES: Readonly<Record<BookPattern, string>> = {
scales: 'Overlapping half circles, fish scales or roof tiles.',
sunburst: 'Rays from a centre, a sunburst.',
confetti: 'Scattered confetti.',
linen: 'Fine woven threads, the texture of a cloth-bound book, best in a colour close to its ground.',
grain: 'Fine scattered specks, the grain of paper or kraft card, best in a colour close to its ground.',
};
+2
View File
@@ -45,5 +45,7 @@ const studioPaint: TranslationStrings = {
'journey.studio.pattern.scales': 'حراشف',
'journey.studio.pattern.sunburst': 'أشعة الشمس',
'journey.studio.pattern.confetti': 'قصاصات ملونة',
'journey.studio.pattern.linen': 'كتان',
'journey.studio.pattern.grain': 'حبيبات الورق',
};
export default studioPaint;
+2
View File
@@ -45,5 +45,7 @@ const studioPaint: TranslationStrings = {
'journey.studio.pattern.scales': 'Pulcuqlar',
'journey.studio.pattern.sunburst': 'Şüalar',
'journey.studio.pattern.confetti': 'Konfeti',
'journey.studio.pattern.linen': 'Kətan',
'journey.studio.pattern.grain': 'Kağız dənəsi',
};
export default studioPaint;
+2
View File
@@ -45,5 +45,7 @@ const studioPaint: TranslationStrings = {
'journey.studio.pattern.scales': 'Escamas',
'journey.studio.pattern.sunburst': 'Raios de sol',
'journey.studio.pattern.confetti': 'Confete',
'journey.studio.pattern.linen': 'Linho',
'journey.studio.pattern.grain': 'Textura de papel',
};
export default studioPaint;
+2
View File
@@ -45,5 +45,7 @@ const studioPaint: TranslationStrings = {
'journey.studio.pattern.scales': 'Escates',
'journey.studio.pattern.sunburst': 'Raigs',
'journey.studio.pattern.confetti': 'Confeti',
'journey.studio.pattern.linen': 'Lli',
'journey.studio.pattern.grain': 'Gra de paper',
};
export default studioPaint;
+2
View File
@@ -45,5 +45,7 @@ const studioPaint: TranslationStrings = {
'journey.studio.pattern.scales': 'Šupiny',
'journey.studio.pattern.sunburst': 'Paprsky',
'journey.studio.pattern.confetti': 'Konfety',
'journey.studio.pattern.linen': 'Plátno',
'journey.studio.pattern.grain': 'Zrno papíru',
};
export default studioPaint;
+2
View File
@@ -45,5 +45,7 @@ const studioPaint: TranslationStrings = {
'journey.studio.pattern.scales': 'Schuppen',
'journey.studio.pattern.sunburst': 'Strahlen',
'journey.studio.pattern.confetti': 'Konfetti',
'journey.studio.pattern.linen': 'Leinen',
'journey.studio.pattern.grain': 'Papierkorn',
};
export default studioPaint;
+2
View File
@@ -45,5 +45,7 @@ const studioPaint: TranslationStrings = {
'journey.studio.pattern.scales': 'Scales',
'journey.studio.pattern.sunburst': 'Sunburst',
'journey.studio.pattern.confetti': 'Confetti',
'journey.studio.pattern.linen': 'Linen',
'journey.studio.pattern.grain': 'Paper grain',
};
export default studioPaint;
+2
View File
@@ -45,5 +45,7 @@ const studioPaint: TranslationStrings = {
'journey.studio.pattern.scales': 'Escamas',
'journey.studio.pattern.sunburst': 'Rayos',
'journey.studio.pattern.confetti': 'Confeti',
'journey.studio.pattern.linen': 'Lino',
'journey.studio.pattern.grain': 'Grano de papel',
};
export default studioPaint;
+2
View File
@@ -45,5 +45,7 @@ const studioPaint: TranslationStrings = {
'journey.studio.pattern.scales': 'Soomused',
'journey.studio.pattern.sunburst': 'Päikesekiired',
'journey.studio.pattern.confetti': 'Konfetid',
'journey.studio.pattern.linen': 'Linane',
'journey.studio.pattern.grain': 'Paberi tekstuur',
};
export default studioPaint;
+2
View File
@@ -45,5 +45,7 @@ const studioPaint: TranslationStrings = {
'journey.studio.pattern.scales': 'Écailles',
'journey.studio.pattern.sunburst': 'Rayons',
'journey.studio.pattern.confetti': 'Confettis',
'journey.studio.pattern.linen': 'Lin',
'journey.studio.pattern.grain': 'Grain de papier',
};
export default studioPaint;
+2
View File
@@ -45,5 +45,7 @@ const studioPaint: TranslationStrings = {
'journey.studio.pattern.scales': 'Λέπια',
'journey.studio.pattern.sunburst': 'Ηλιαχτίδες',
'journey.studio.pattern.confetti': 'Κομφετί',
'journey.studio.pattern.linen': 'Λινό',
'journey.studio.pattern.grain': 'Υφή χαρτιού',
};
export default studioPaint;
+2
View File
@@ -45,5 +45,7 @@ const studioPaint: TranslationStrings = {
'journey.studio.pattern.scales': 'Pikkelyek',
'journey.studio.pattern.sunburst': 'Sugarak',
'journey.studio.pattern.confetti': 'Konfetti',
'journey.studio.pattern.linen': 'Vászon',
'journey.studio.pattern.grain': 'Papírszemcse',
};
export default studioPaint;
+2
View File
@@ -45,5 +45,7 @@ const studioPaint: TranslationStrings = {
'journey.studio.pattern.scales': 'Sisik',
'journey.studio.pattern.sunburst': 'Sinar matahari',
'journey.studio.pattern.confetti': 'Konfeti',
'journey.studio.pattern.linen': 'Linen', // same-as-en
'journey.studio.pattern.grain': 'Tekstur kertas',
};
export default studioPaint;
+2
View File
@@ -45,5 +45,7 @@ const studioPaint: TranslationStrings = {
'journey.studio.pattern.scales': 'Squame',
'journey.studio.pattern.sunburst': 'Raggiera',
'journey.studio.pattern.confetti': 'Coriandoli',
'journey.studio.pattern.linen': 'Lino',
'journey.studio.pattern.grain': 'Grana della carta',
};
export default studioPaint;
+2
View File
@@ -45,5 +45,7 @@ const studioPaint: TranslationStrings = {
'journey.studio.pattern.scales': 'うろこ',
'journey.studio.pattern.sunburst': 'サンバースト',
'journey.studio.pattern.confetti': '紙吹雪',
'journey.studio.pattern.linen': 'リネン',
'journey.studio.pattern.grain': '紙の質感',
};
export default studioPaint;
+2
View File
@@ -45,5 +45,7 @@ const studioPaint: TranslationStrings = {
'journey.studio.pattern.scales': '비늘',
'journey.studio.pattern.sunburst': '햇살',
'journey.studio.pattern.confetti': '색종이 조각',
'journey.studio.pattern.linen': '리넨',
'journey.studio.pattern.grain': '종이 질감',
};
export default studioPaint;
+2
View File
@@ -45,5 +45,7 @@ const studioPaint: TranslationStrings = {
'journey.studio.pattern.scales': 'Schubben',
'journey.studio.pattern.sunburst': 'Zonnestralen',
'journey.studio.pattern.confetti': 'Confetti', // same-as-en
'journey.studio.pattern.linen': 'Linnen',
'journey.studio.pattern.grain': 'Papierkorrel',
};
export default studioPaint;
+2
View File
@@ -45,5 +45,7 @@ const studioPaint: TranslationStrings = {
'journey.studio.pattern.scales': 'Łuski',
'journey.studio.pattern.sunburst': 'Promienie',
'journey.studio.pattern.confetti': 'Konfetti',
'journey.studio.pattern.linen': 'Len',
'journey.studio.pattern.grain': 'Faktura papieru',
};
export default studioPaint;
+2
View File
@@ -45,5 +45,7 @@ const studioPaint: TranslationStrings = {
'journey.studio.pattern.scales': 'Чешуя',
'journey.studio.pattern.sunburst': 'Солнечные лучи',
'journey.studio.pattern.confetti': 'Конфетти',
'journey.studio.pattern.linen': 'Лён',
'journey.studio.pattern.grain': 'Зерно бумаги',
};
export default studioPaint;
+2
View File
@@ -45,5 +45,7 @@ const studioPaint: TranslationStrings = {
'journey.studio.pattern.scales': 'Šupiny',
'journey.studio.pattern.sunburst': 'Lúče',
'journey.studio.pattern.confetti': 'Konfety',
'journey.studio.pattern.linen': 'Plátno',
'journey.studio.pattern.grain': 'Zrno papiera',
};
export default studioPaint;
+2
View File
@@ -45,5 +45,7 @@ const studioPaint: TranslationStrings = {
'journey.studio.pattern.scales': 'Fjäll',
'journey.studio.pattern.sunburst': 'Solstrålar',
'journey.studio.pattern.confetti': 'Konfetti',
'journey.studio.pattern.linen': 'Linne',
'journey.studio.pattern.grain': 'Pappersstruktur',
};
export default studioPaint;
+2
View File
@@ -45,5 +45,7 @@ const studioPaint: TranslationStrings = {
'journey.studio.pattern.scales': 'เกล็ด',
'journey.studio.pattern.sunburst': 'รัศมีแสง',
'journey.studio.pattern.confetti': 'กระดาษสี',
'journey.studio.pattern.linen': 'ผ้าลินิน',
'journey.studio.pattern.grain': 'เนื้อกระดาษ',
};
export default studioPaint;
+2
View File
@@ -45,5 +45,7 @@ const studioPaint: TranslationStrings = {
'journey.studio.pattern.scales': 'Pullar',
'journey.studio.pattern.sunburst': 'Güneş ışınları',
'journey.studio.pattern.confetti': 'Konfeti',
'journey.studio.pattern.linen': 'Keten',
'journey.studio.pattern.grain': 'Kâğıt dokusu',
};
export default studioPaint;
+2
View File
@@ -45,5 +45,7 @@ const studioPaint: TranslationStrings = {
'journey.studio.pattern.scales': 'Луска',
'journey.studio.pattern.sunburst': 'Сонячні промені',
'journey.studio.pattern.confetti': 'Конфеті',
'journey.studio.pattern.linen': 'Льон',
'journey.studio.pattern.grain': 'Зерно паперу',
};
export default studioPaint;
+2
View File
@@ -45,5 +45,7 @@ const studioPaint: TranslationStrings = {
'journey.studio.pattern.scales': 'Vảy',
'journey.studio.pattern.sunburst': 'Tia nắng',
'journey.studio.pattern.confetti': 'Hoa giấy',
'journey.studio.pattern.linen': 'Vải lanh',
'journey.studio.pattern.grain': 'Vân giấy',
};
export default studioPaint;
+2
View File
@@ -44,5 +44,7 @@ const studioPaint: TranslationStrings = {
'journey.studio.pattern.scales': '鱗片',
'journey.studio.pattern.sunburst': '放射光芒',
'journey.studio.pattern.confetti': '彩色紙花',
'journey.studio.pattern.linen': '亞麻',
'journey.studio.pattern.grain': '紙張紋理',
};
export default studioPaint;
+2
View File
@@ -44,5 +44,7 @@ const studioPaint: TranslationStrings = {
'journey.studio.pattern.scales': '鳞片',
'journey.studio.pattern.sunburst': '放射光',
'journey.studio.pattern.confetti': '彩纸屑',
'journey.studio.pattern.linen': '亚麻',
'journey.studio.pattern.grain': '纸张纹理',
};
export default studioPaint;