morphico 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +98 -41
- package/dist/esm/all.js +50 -0
- package/dist/esm/clip.js +283 -0
- package/dist/esm/engine.js +25 -0
- package/dist/esm/face.js +153 -0
- package/dist/esm/geometry.js +459 -0
- package/dist/esm/icon.js +41 -0
- package/dist/esm/index.js +50 -0
- package/dist/esm/morph.js +107 -0
- package/dist/esm/roundness.js +37 -0
- package/dist/esm/sets/battery.js +87 -0
- package/dist/esm/sets/bell.js +101 -0
- package/dist/esm/sets/characters.js +43 -0
- package/dist/esm/sets/check-mark.js +74 -0
- package/dist/esm/sets/checkbox.js +91 -0
- package/dist/esm/sets/chevron.js +70 -0
- package/dist/esm/sets/condensed.js +137 -0
- package/dist/esm/sets/copy.js +87 -0
- package/dist/esm/sets/eye.js +105 -0
- package/dist/esm/sets/faces.js +482 -0
- package/dist/esm/sets/folder.js +112 -0
- package/dist/esm/sets/handwritten.js +136 -0
- package/dist/esm/sets/lock.js +75 -0
- package/dist/esm/sets/lucide.js +66 -0
- package/dist/esm/sets/media.js +109 -0
- package/dist/esm/sets/menu.js +84 -0
- package/dist/esm/sets/mic.js +102 -0
- package/dist/esm/sets/radio.js +84 -0
- package/dist/esm/sets/round.js +244 -0
- package/dist/esm/sets/shapes.js +455 -0
- package/dist/esm/sets/slim.js +148 -0
- package/dist/esm/sets/square.js +149 -0
- package/dist/esm/sets/theme.js +107 -0
- package/dist/esm/sets/toggle.js +73 -0
- package/dist/esm/sets/volume.js +89 -0
- package/dist/esm/sets/weather.js +691 -0
- package/dist/esm/sets/wifi.js +68 -0
- package/dist/esm/svg.js +31 -0
- package/dist/esm/text.js +88 -0
- package/dist/esm/types.js +3 -0
- package/dist/morphico.js +4033 -3380
- package/dist/types/{iconSets.d.ts → all.d.ts} +0 -2
- package/dist/types/clip.d.ts +6 -11
- package/dist/types/face.d.ts +27 -0
- package/dist/types/icon.d.ts +4 -9
- package/dist/types/index.d.ts +5 -3
- package/dist/types/roundness.d.ts +8 -0
- package/dist/types/sets/battery.d.ts +1 -1
- package/dist/types/sets/bell.d.ts +1 -1
- package/dist/types/sets/checkbox.d.ts +1 -1
- package/dist/types/sets/chevron.d.ts +1 -1
- package/dist/types/sets/condensed.d.ts +1 -1
- package/dist/types/sets/copy.d.ts +1 -1
- package/dist/types/sets/eye.d.ts +1 -1
- package/dist/types/sets/faces.d.ts +25 -0
- package/dist/types/sets/folder.d.ts +1 -1
- package/dist/types/sets/handwritten.d.ts +1 -1
- package/dist/types/sets/lock.d.ts +1 -1
- package/dist/types/sets/media.d.ts +1 -1
- package/dist/types/sets/menu.d.ts +1 -1
- package/dist/types/sets/mic.d.ts +1 -1
- package/dist/types/sets/radio.d.ts +1 -1
- package/dist/types/sets/round.d.ts +1 -1
- package/dist/types/sets/slim.d.ts +1 -1
- package/dist/types/sets/square.d.ts +1 -1
- package/dist/types/sets/theme.d.ts +1 -1
- package/dist/types/sets/toggle.d.ts +1 -1
- package/dist/types/sets/volume.d.ts +1 -1
- package/dist/types/sets/weather.d.ts +1 -1
- package/dist/types/sets/wifi.d.ts +1 -1
- package/dist/types/text.d.ts +8 -13
- package/package.json +95 -3
- package/dist/morphico.mjs +0 -4003
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
/*! morphico v0.5.0 | MIT License, Copyright (c) 2026 Raycho Ivanov (see LICENSE) */
|
|
2
|
+
export const OUTLINE_POINTS = 24;
|
|
3
|
+
export const PIECE_NAMES = ['leftBrow', 'rightBrow', 'leftEye', 'rightEye', 'leftPupil', 'rightPupil', 'mouth'];
|
|
4
|
+
const DENSE_SAMPLES = 360;
|
|
5
|
+
const toRadians = (degrees) => (degrees * Math.PI) / 180;
|
|
6
|
+
const rotatePoint = ([x, y], degrees) => {
|
|
7
|
+
const angle = toRadians(degrees);
|
|
8
|
+
return [x * Math.cos(angle) - y * Math.sin(angle), x * Math.sin(angle) + y * Math.cos(angle)];
|
|
9
|
+
};
|
|
10
|
+
const denseBlobOutline = (options) => {
|
|
11
|
+
const { centre, width, height, rotation = 0, squareness = 0, topScale = 1 } = options;
|
|
12
|
+
const exponent = 2 + squareness * 6;
|
|
13
|
+
const halfWidth = width / 2;
|
|
14
|
+
const halfHeight = height / 2;
|
|
15
|
+
const outline = [];
|
|
16
|
+
for (let index = 0; index < DENSE_SAMPLES; index++) {
|
|
17
|
+
const angle = toRadians(-90 + (360 * index) / DENSE_SAMPLES);
|
|
18
|
+
const cosine = Math.cos(angle);
|
|
19
|
+
const sine = Math.sin(angle);
|
|
20
|
+
const y = halfHeight * Math.sign(sine) * Math.abs(sine) ** (2 / exponent);
|
|
21
|
+
const widthScale = 1 + ((topScale - 1) * (halfHeight - y)) / (2 * halfHeight);
|
|
22
|
+
const x = halfWidth * widthScale * Math.sign(cosine) * Math.abs(cosine) ** (2 / exponent);
|
|
23
|
+
const [turnedX, turnedY] = rotatePoint([x, y], rotation);
|
|
24
|
+
outline.push([centre[0] + turnedX, centre[1] + turnedY]);
|
|
25
|
+
}
|
|
26
|
+
const xs = outline.map(([x]) => x);
|
|
27
|
+
const left = Math.min(...xs);
|
|
28
|
+
const right = Math.max(...xs);
|
|
29
|
+
const lineY = ([leftY, rightY], x) => leftY + ((rightY - leftY) * (x - left)) / (right - left);
|
|
30
|
+
return outline.map(([x, y]) => {
|
|
31
|
+
let cutY = y;
|
|
32
|
+
if (options.cutTop)
|
|
33
|
+
cutY = Math.max(cutY, lineY(options.cutTop, x));
|
|
34
|
+
if (options.cutBottom)
|
|
35
|
+
cutY = Math.min(cutY, lineY(options.cutBottom, x));
|
|
36
|
+
return [x, cutY];
|
|
37
|
+
});
|
|
38
|
+
};
|
|
39
|
+
/** OUTLINE_POINTS points of a star-shaped outline, on rays at even angles from its box's centre (measured in the box
|
|
40
|
+
* stretched to a square, so a long flat shape still gets points on its round ends). */
|
|
41
|
+
const resampleByAngle = (outline) => {
|
|
42
|
+
const xs = outline.map(([x]) => x);
|
|
43
|
+
const ys = outline.map(([, y]) => y);
|
|
44
|
+
const centreX = (Math.min(...xs) + Math.max(...xs)) / 2;
|
|
45
|
+
const centreY = (Math.min(...ys) + Math.max(...ys)) / 2;
|
|
46
|
+
const halfX = Math.max((Math.max(...xs) - Math.min(...xs)) / 2, 1e-9);
|
|
47
|
+
const halfY = Math.max((Math.max(...ys) - Math.min(...ys)) / 2, 1e-9);
|
|
48
|
+
const normalized = outline.map(([x, y]) => [(x - centreX) / halfX, (y - centreY) / halfY]);
|
|
49
|
+
const points = [];
|
|
50
|
+
for (let index = 0; index < OUTLINE_POINTS; index++) {
|
|
51
|
+
const angle = toRadians(-90 + (360 * index) / OUTLINE_POINTS);
|
|
52
|
+
const directionX = Math.cos(angle);
|
|
53
|
+
const directionY = Math.sin(angle);
|
|
54
|
+
let farthest = 0;
|
|
55
|
+
normalized.forEach((start, edgeIndex) => {
|
|
56
|
+
const end = normalized[(edgeIndex + 1) % normalized.length];
|
|
57
|
+
const edgeX = end[0] - start[0];
|
|
58
|
+
const edgeY = end[1] - start[1];
|
|
59
|
+
const denominator = directionX * edgeY - directionY * edgeX;
|
|
60
|
+
if (Math.abs(denominator) < 1e-12)
|
|
61
|
+
return;
|
|
62
|
+
const distance = (start[0] * edgeY - start[1] * edgeX) / denominator;
|
|
63
|
+
const along = (start[0] * directionY - start[1] * directionX) / denominator;
|
|
64
|
+
// a little slack: a ray passing exactly through a corner of the dense outline must still hit one of its edges
|
|
65
|
+
if (along >= -1e-9 && along <= 1 + 1e-9 && distance > farthest)
|
|
66
|
+
farthest = distance;
|
|
67
|
+
});
|
|
68
|
+
points.push([centreX + directionX * farthest * halfX, centreY + directionY * farthest * halfY]);
|
|
69
|
+
}
|
|
70
|
+
return points;
|
|
71
|
+
};
|
|
72
|
+
const blob = (options) => resampleByAngle(denseBlobOutline(options));
|
|
73
|
+
const band = (options) => {
|
|
74
|
+
const { from, to, bend, thickness, endShare = 1 } = options;
|
|
75
|
+
const chordX = to[0] - from[0];
|
|
76
|
+
const chordY = to[1] - from[1];
|
|
77
|
+
const chordLength = Math.hypot(chordX, chordY);
|
|
78
|
+
// the quadratic's control point sits twice as far out as the curve's middle
|
|
79
|
+
const control = [
|
|
80
|
+
(from[0] + to[0]) / 2 + ((2 * bend * chordY) / chordLength),
|
|
81
|
+
(from[1] + to[1]) / 2 - ((2 * bend * chordX) / chordLength),
|
|
82
|
+
];
|
|
83
|
+
const pointAt = (along) => {
|
|
84
|
+
const rest = 1 - along;
|
|
85
|
+
return [
|
|
86
|
+
rest * rest * from[0] + 2 * rest * along * control[0] + along * along * to[0],
|
|
87
|
+
rest * rest * from[1] + 2 * rest * along * control[1] + along * along * to[1],
|
|
88
|
+
];
|
|
89
|
+
};
|
|
90
|
+
const tangentAt = (along) => {
|
|
91
|
+
const tangentX = 2 * (1 - along) * (control[0] - from[0]) + 2 * along * (to[0] - control[0]);
|
|
92
|
+
const tangentY = 2 * (1 - along) * (control[1] - from[1]) + 2 * along * (to[1] - control[1]);
|
|
93
|
+
const length = Math.hypot(tangentX, tangentY);
|
|
94
|
+
return [tangentX / length, tangentY / length];
|
|
95
|
+
};
|
|
96
|
+
const thicknessAt = (along) => thickness * (endShare + (1 - endShare) * Math.sin(Math.PI * along));
|
|
97
|
+
// side 1 = the upper edge (the left normal of a rightward curve), -1 = the lower edge
|
|
98
|
+
const edge = (along, side) => {
|
|
99
|
+
const [x, y] = pointAt(along);
|
|
100
|
+
const [tangentX, tangentY] = tangentAt(along);
|
|
101
|
+
const offset = (side * thicknessAt(along)) / 2;
|
|
102
|
+
return [x + tangentY * offset, y - tangentX * offset];
|
|
103
|
+
};
|
|
104
|
+
// a point on the half circle around an end: turn -90° = on the upper edge, 0 = the tip, 90° = on the lower edge
|
|
105
|
+
const capPoint = (along, turn) => {
|
|
106
|
+
const [x, y] = pointAt(along);
|
|
107
|
+
const [tangentX, tangentY] = tangentAt(along);
|
|
108
|
+
const radius = thicknessAt(along) / 2;
|
|
109
|
+
const outward = along === 1 ? 1 : -1;
|
|
110
|
+
const alongTangent = outward * Math.cos(turn) * radius;
|
|
111
|
+
const towardsLower = outward * Math.sin(turn) * radius;
|
|
112
|
+
return [x + tangentX * alongTangent - tangentY * towardsLower, y + tangentY * alongTangent + tangentX * towardsLower];
|
|
113
|
+
};
|
|
114
|
+
// clockwise from the top middle at the same angles as a blob's points (a quarter = the right end, half = the bottom
|
|
115
|
+
// middle, three quarters = the left end); the points within 30° of an end lie on its half circle, the rest along the
|
|
116
|
+
// edges
|
|
117
|
+
const capShare = Math.sin(Math.PI / 3);
|
|
118
|
+
return Array.from({ length: OUTLINE_POINTS }, (_, index) => {
|
|
119
|
+
const angle = (2 * Math.PI * index) / OUTLINE_POINTS;
|
|
120
|
+
const sine = Math.sin(angle);
|
|
121
|
+
if (Math.abs(sine) < capShare - 1e-9)
|
|
122
|
+
return edge(0.5 + (0.5 * sine) / capShare, Math.cos(angle) > 0 ? 1 : -1);
|
|
123
|
+
// the angle's distance past the cap's start, stretched from ±30° around the end to ±90° around the half circle
|
|
124
|
+
const endAngle = sine > 0 ? Math.PI / 2 : (3 * Math.PI) / 2;
|
|
125
|
+
return capPoint(sine > 0 ? 1 : 0, (angle - endAngle) * 3);
|
|
126
|
+
});
|
|
127
|
+
};
|
|
128
|
+
/** A piece that isn't there: every point on one spot (draws nothing). */
|
|
129
|
+
const hidden = (at) => Array.from({ length: OUTLINE_POINTS }, () => [at[0], at[1]]);
|
|
130
|
+
/** The piece mirrored onto the other half of the face, still clockwise from the top. */
|
|
131
|
+
const mirrored = (points) => points.map((_, index) => {
|
|
132
|
+
const [x, y] = points[(OUTLINE_POINTS - index) % OUTLINE_POINTS];
|
|
133
|
+
return [24 - x, y];
|
|
134
|
+
});
|
|
135
|
+
/** A face from its left half (as seen) + the mouth. */
|
|
136
|
+
const symmetricFace = ({ brow, eye, pupil, mouth }) => ({
|
|
137
|
+
leftBrow: brow,
|
|
138
|
+
rightBrow: mirrored(brow),
|
|
139
|
+
leftEye: eye,
|
|
140
|
+
rightEye: mirrored(eye),
|
|
141
|
+
leftPupil: pupil,
|
|
142
|
+
rightPupil: mirrored(pupil),
|
|
143
|
+
mouth,
|
|
144
|
+
});
|
|
145
|
+
/** The whole face mirrored: left and right pieces swap sides. */
|
|
146
|
+
const mirroredFace = (face) => ({
|
|
147
|
+
leftBrow: mirrored(face.rightBrow),
|
|
148
|
+
rightBrow: mirrored(face.leftBrow),
|
|
149
|
+
leftEye: mirrored(face.rightEye),
|
|
150
|
+
rightEye: mirrored(face.leftEye),
|
|
151
|
+
leftPupil: mirrored(face.rightPupil),
|
|
152
|
+
rightPupil: mirrored(face.leftPupil),
|
|
153
|
+
mouth: mirrored(face.mouth),
|
|
154
|
+
});
|
|
155
|
+
// ---- the expressions (names from the user's pictures) ----
|
|
156
|
+
export const faceExpressionNames = [
|
|
157
|
+
'normal',
|
|
158
|
+
'smiling',
|
|
159
|
+
'pleading',
|
|
160
|
+
'wonder',
|
|
161
|
+
'focused',
|
|
162
|
+
'fright',
|
|
163
|
+
'angry',
|
|
164
|
+
'asleep',
|
|
165
|
+
'sad',
|
|
166
|
+
'surprised',
|
|
167
|
+
'laughing',
|
|
168
|
+
'tired',
|
|
169
|
+
'wink-left',
|
|
170
|
+
'wink-right',
|
|
171
|
+
'suspicious',
|
|
172
|
+
'confused',
|
|
173
|
+
'smug',
|
|
174
|
+
'irritated',
|
|
175
|
+
'bored',
|
|
176
|
+
'curious',
|
|
177
|
+
'empathetic',
|
|
178
|
+
'guilty',
|
|
179
|
+
'worried',
|
|
180
|
+
'disappointed',
|
|
181
|
+
'ashamed',
|
|
182
|
+
'embarrassed',
|
|
183
|
+
'yearning',
|
|
184
|
+
'triumphant',
|
|
185
|
+
'proud',
|
|
186
|
+
'excited',
|
|
187
|
+
'scornful',
|
|
188
|
+
'disgusted',
|
|
189
|
+
];
|
|
190
|
+
const buildFaceExpressions = () => {
|
|
191
|
+
const winkRight = {
|
|
192
|
+
...symmetricFace({
|
|
193
|
+
brow: blob({ centre: [6.2, 4.4], width: 5.2, height: 1.9, squareness: 0.5, rotation: -6 }),
|
|
194
|
+
eye: blob({ centre: [6.4, 12], width: 8.4, height: 8.4 }),
|
|
195
|
+
pupil: blob({ centre: [7, 11.4], width: 3.2, height: 3.2 }),
|
|
196
|
+
mouth: band({ from: [9.6, 18.4], to: [14.8, 18], bend: -0.9, thickness: 1.2, endShare: 0.7 }),
|
|
197
|
+
}),
|
|
198
|
+
rightBrow: mirrored(blob({ centre: [6.2, 6], width: 5, height: 1.8, squareness: 0.5, rotation: 8 })),
|
|
199
|
+
rightEye: mirrored(band({ from: [3.2, 12.8], to: [9.6, 12.8], bend: 1.4, thickness: 1.6, endShare: 0.7 })),
|
|
200
|
+
rightPupil: hidden([17.6, 12]),
|
|
201
|
+
};
|
|
202
|
+
return {
|
|
203
|
+
normal: symmetricFace({
|
|
204
|
+
brow: blob({ centre: [6.2, 4.6], width: 5.2, height: 1.9, squareness: 0.5, rotation: -6 }),
|
|
205
|
+
eye: blob({ centre: [6.4, 12], width: 8.4, height: 8.4 }),
|
|
206
|
+
pupil: blob({ centre: [7, 10.7], width: 3.2, height: 3.2 }),
|
|
207
|
+
mouth: blob({ centre: [12, 18.8], width: 4.4, height: 1.8, squareness: 0.6 }),
|
|
208
|
+
}),
|
|
209
|
+
smiling: symmetricFace({
|
|
210
|
+
brow: blob({ centre: [6, 5.4], width: 5, height: 4.4, squareness: 0.2, cutBottom: [5.4, 5.4] }),
|
|
211
|
+
eye: blob({ centre: [6, 13], width: 9.2, height: 7.6 }),
|
|
212
|
+
pupil: blob({ centre: [6.8, 13.6], width: 3.6, height: 2.4 }),
|
|
213
|
+
mouth: band({ from: [9, 18.4], to: [15, 18.4], bend: -1, thickness: 1.3, endShare: 0.7 }),
|
|
214
|
+
}),
|
|
215
|
+
pleading: symmetricFace({
|
|
216
|
+
brow: blob({ centre: [6.6, 3.7], width: 4.6, height: 2, squareness: 0.5, rotation: -16 }),
|
|
217
|
+
eye: blob({ centre: [6.8, 12.4], width: 8.6, height: 9.6, topScale: 0.75, rotation: 12 }),
|
|
218
|
+
pupil: blob({ centre: [7.6, 10.7], width: 4, height: 4 }),
|
|
219
|
+
mouth: blob({ centre: [12, 17.6], width: 4.3, height: 4.8, squareness: 0.3, cutTop: [17.6, 17.6] }),
|
|
220
|
+
}),
|
|
221
|
+
wonder: symmetricFace({
|
|
222
|
+
brow: band({ from: [3.4, 4.8], to: [8.6, 3.3], bend: 0.7, thickness: 1.7 }),
|
|
223
|
+
eye: blob({ centre: [6.4, 12], width: 7.8, height: 9.6 }),
|
|
224
|
+
pupil: blob({ centre: [6.8, 11.2], width: 3.2, height: 4.2 }),
|
|
225
|
+
mouth: blob({ centre: [12, 18.6], width: 3.8, height: 1, squareness: 0.6 }),
|
|
226
|
+
}),
|
|
227
|
+
focused: symmetricFace({
|
|
228
|
+
brow: blob({ centre: [6, 4.4], width: 5.6, height: 2.2, squareness: 0.55, rotation: 3 }),
|
|
229
|
+
eye: blob({ centre: [6.2, 12.8], width: 9, height: 8, cutTop: [10, 11] }),
|
|
230
|
+
pupil: blob({ centre: [6.8, 13.2], width: 3.8, height: 2.8 }),
|
|
231
|
+
mouth: blob({ centre: [12, 18.8], width: 4.2, height: 1, squareness: 0.6 }),
|
|
232
|
+
}),
|
|
233
|
+
fright: symmetricFace({
|
|
234
|
+
brow: blob({ centre: [5.7, 3.5], width: 4.8, height: 2.2, squareness: 0.45, rotation: -8 }),
|
|
235
|
+
eye: blob({ centre: [6.2, 12.4], width: 8.4, height: 9.8, topScale: 0.7, rotation: 8 }),
|
|
236
|
+
pupil: blob({ centre: [7.2, 12], width: 3.6, height: 4.2 }),
|
|
237
|
+
mouth: blob({ centre: [12, 18.6], width: 3.8, height: 1.4, squareness: 0.5 }),
|
|
238
|
+
}),
|
|
239
|
+
angry: symmetricFace({
|
|
240
|
+
brow: blob({ centre: [6.4, 5.4], width: 5.2, height: 2, squareness: 0.5, rotation: 12 }),
|
|
241
|
+
eye: blob({ centre: [6.2, 12.6], width: 8.6, height: 8, cutTop: [9.2, 12] }),
|
|
242
|
+
pupil: blob({ centre: [7.4, 12.8], width: 3.4, height: 3.4 }),
|
|
243
|
+
mouth: blob({ centre: [12, 17.8], width: 4.2, height: 4, squareness: 0.3, cutTop: [17.8, 17.8] }),
|
|
244
|
+
}),
|
|
245
|
+
asleep: symmetricFace({
|
|
246
|
+
brow: blob({ centre: [6, 5.4], width: 4, height: 1.7, squareness: 0.4, rotation: 4 }),
|
|
247
|
+
eye: band({ from: [3, 12.4], to: [9.6, 12.4], bend: -1.2, thickness: 1.5, endShare: 0.6 }),
|
|
248
|
+
pupil: hidden([6.3, 13.4]),
|
|
249
|
+
mouth: blob({ centre: [12, 18.8], width: 2.6, height: 1.8 }),
|
|
250
|
+
}),
|
|
251
|
+
// more feelings, Claude's drafts (no pictures); the one-sided ones replace pieces of the right half — written in place
|
|
252
|
+
// (a blob or band is already clockwise from its top) or drawn on the left and mirrored
|
|
253
|
+
sad: symmetricFace({
|
|
254
|
+
brow: blob({ centre: [6.4, 5.2], width: 4.8, height: 1.9, squareness: 0.5, rotation: -18 }),
|
|
255
|
+
eye: blob({ centre: [6.4, 12.6], width: 8.4, height: 8, cutTop: [11.2, 9.6] }),
|
|
256
|
+
pupil: blob({ centre: [6.8, 13.8], width: 3.2, height: 3.4 }),
|
|
257
|
+
mouth: band({ from: [9.4, 19.2], to: [14.6, 19.2], bend: 0.9, thickness: 1.2, endShare: 0.7 }),
|
|
258
|
+
}),
|
|
259
|
+
surprised: symmetricFace({
|
|
260
|
+
brow: band({ from: [3.6, 3.6], to: [8.6, 2.8], bend: 0.6, thickness: 1.6 }),
|
|
261
|
+
eye: blob({ centre: [6.4, 12], width: 8.6, height: 9 }),
|
|
262
|
+
pupil: blob({ centre: [6.4, 12], width: 2.4, height: 2.4 }),
|
|
263
|
+
mouth: blob({ centre: [12, 19], width: 2.8, height: 3.4 }),
|
|
264
|
+
}),
|
|
265
|
+
laughing: symmetricFace({
|
|
266
|
+
brow: blob({ centre: [6, 4.6], width: 5, height: 4.4, squareness: 0.2, cutBottom: [4.6, 4.6] }),
|
|
267
|
+
eye: band({ from: [3.2, 13], to: [9.6, 13], bend: 1.6, thickness: 1.6, endShare: 0.7 }),
|
|
268
|
+
pupil: hidden([6.4, 12]),
|
|
269
|
+
mouth: blob({ centre: [12, 17], width: 6, height: 6, squareness: 0.2, cutTop: [17, 17] }),
|
|
270
|
+
}),
|
|
271
|
+
tired: symmetricFace({
|
|
272
|
+
brow: blob({ centre: [6, 6], width: 5, height: 1.7, squareness: 0.5, rotation: -4 }),
|
|
273
|
+
eye: blob({ centre: [6.4, 12.6], width: 8.4, height: 7.6, cutTop: [11.6, 11.6] }),
|
|
274
|
+
pupil: blob({ centre: [6.6, 13.6], width: 3.2, height: 3.2 }),
|
|
275
|
+
mouth: blob({ centre: [12, 19], width: 3.2, height: 0.9, squareness: 0.6 }),
|
|
276
|
+
}),
|
|
277
|
+
// wink-left / wink-right = the eye that closes, as seen; one is the other mirrored
|
|
278
|
+
'wink-left': mirroredFace(winkRight),
|
|
279
|
+
'wink-right': winkRight,
|
|
280
|
+
suspicious: {
|
|
281
|
+
...symmetricFace({
|
|
282
|
+
brow: band({ from: [3.4, 4], to: [8.8, 3], bend: 0.8, thickness: 1.6 }),
|
|
283
|
+
eye: blob({ centre: [6.4, 12.6], width: 8.6, height: 7.6, cutTop: [10.8, 10.8] }),
|
|
284
|
+
pupil: blob({ centre: [4.6, 13.2], width: 3.2, height: 3 }),
|
|
285
|
+
mouth: blob({ centre: [12.6, 18.8], width: 3.6, height: 1, squareness: 0.6, rotation: -10 }),
|
|
286
|
+
}),
|
|
287
|
+
rightBrow: mirrored(blob({ centre: [6.4, 6.4], width: 5.2, height: 1.8, squareness: 0.5, rotation: 10 })),
|
|
288
|
+
rightEye: mirrored(blob({ centre: [6.4, 12.8], width: 8.6, height: 7.2, cutTop: [11.8, 11.8] })),
|
|
289
|
+
rightPupil: blob({ centre: [16.2, 13.4], width: 3.2, height: 3 }),
|
|
290
|
+
},
|
|
291
|
+
confused: {
|
|
292
|
+
...symmetricFace({
|
|
293
|
+
brow: band({ from: [3.4, 3.8], to: [8.6, 2.6], bend: 0.7, thickness: 1.6 }),
|
|
294
|
+
eye: blob({ centre: [6.4, 12], width: 8.6, height: 9 }),
|
|
295
|
+
pupil: blob({ centre: [7.6, 10.6], width: 3, height: 3 }),
|
|
296
|
+
mouth: blob({ centre: [12.4, 19], width: 3.8, height: 1.1, squareness: 0.5, rotation: 12 }),
|
|
297
|
+
}),
|
|
298
|
+
rightBrow: mirrored(blob({ centre: [6.2, 5.8], width: 5, height: 1.8, squareness: 0.5, rotation: 10 })),
|
|
299
|
+
rightEye: mirrored(blob({ centre: [6.4, 12.6], width: 7.4, height: 7.4 })),
|
|
300
|
+
rightPupil: blob({ centre: [18.4, 11.4], width: 2.8, height: 2.8 }),
|
|
301
|
+
},
|
|
302
|
+
// the user's list of feelings (2026-11-01), Claude's drafts; the ones the set had already are skipped
|
|
303
|
+
smug: {
|
|
304
|
+
...symmetricFace({
|
|
305
|
+
brow: blob({ centre: [6.2, 5], width: 5, height: 1.8, squareness: 0.5, rotation: -4 }),
|
|
306
|
+
eye: blob({ centre: [6.4, 12.6], width: 8.4, height: 7.6, cutTop: [11.4, 11.4] }),
|
|
307
|
+
pupil: blob({ centre: [8, 13.4], width: 3, height: 3 }),
|
|
308
|
+
mouth: band({ from: [10.2, 18.8], to: [15.2, 17.6], bend: -0.6, thickness: 1.2, endShare: 0.6 }),
|
|
309
|
+
}),
|
|
310
|
+
rightBrow: mirrored(band({ from: [3.6, 4.2], to: [8.6, 3.4], bend: 0.6, thickness: 1.6 })),
|
|
311
|
+
rightPupil: blob({ centre: [19.4, 13.4], width: 3, height: 3 }),
|
|
312
|
+
},
|
|
313
|
+
irritated: symmetricFace({
|
|
314
|
+
brow: blob({ centre: [6.4, 5.8], width: 5.4, height: 1.8, squareness: 0.55, rotation: 6 }),
|
|
315
|
+
eye: blob({ centre: [6.4, 12.8], width: 8.6, height: 7.6, cutTop: [11.2, 11.8], cutBottom: [15.8, 15.8] }),
|
|
316
|
+
pupil: blob({ centre: [6.8, 13.6], width: 3.2, height: 3 }),
|
|
317
|
+
mouth: blob({ centre: [12.4, 18.8], width: 3.6, height: 0.9, squareness: 0.6, rotation: -8 }),
|
|
318
|
+
}),
|
|
319
|
+
bored: {
|
|
320
|
+
...symmetricFace({
|
|
321
|
+
brow: blob({ centre: [6, 6.2], width: 5, height: 1.7, squareness: 0.5 }),
|
|
322
|
+
eye: blob({ centre: [6.4, 12.6], width: 8.4, height: 7.6, cutTop: [11.8, 11.8] }),
|
|
323
|
+
pupil: blob({ centre: [8, 12.8], width: 3, height: 3 }),
|
|
324
|
+
mouth: blob({ centre: [13.4, 19], width: 3, height: 0.9, squareness: 0.6 }),
|
|
325
|
+
}),
|
|
326
|
+
rightPupil: blob({ centre: [19.4, 12.8], width: 3, height: 3 }),
|
|
327
|
+
},
|
|
328
|
+
curious: {
|
|
329
|
+
...symmetricFace({
|
|
330
|
+
brow: band({ from: [3.4, 3.6], to: [8.6, 2.6], bend: 0.7, thickness: 1.6 }),
|
|
331
|
+
eye: blob({ centre: [6.4, 12], width: 8.6, height: 8.8 }),
|
|
332
|
+
pupil: blob({ centre: [7.8, 10.8], width: 3.2, height: 3.2 }),
|
|
333
|
+
mouth: blob({ centre: [12.6, 18.8], width: 2, height: 2.2 }),
|
|
334
|
+
}),
|
|
335
|
+
rightBrow: mirrored(blob({ centre: [6.2, 5.2], width: 5, height: 1.8, squareness: 0.5, rotation: 4 })),
|
|
336
|
+
rightPupil: blob({ centre: [19, 10.8], width: 3.2, height: 3.2 }),
|
|
337
|
+
},
|
|
338
|
+
empathetic: symmetricFace({
|
|
339
|
+
brow: blob({ centre: [6.4, 4.8], width: 5, height: 1.9, squareness: 0.5, rotation: -10 }),
|
|
340
|
+
eye: blob({ centre: [6.4, 12.4], width: 8.4, height: 8, cutTop: [10, 9.2] }),
|
|
341
|
+
pupil: blob({ centre: [6.8, 13], width: 3.4, height: 3.4 }),
|
|
342
|
+
mouth: band({ from: [10, 18.6], to: [14, 18.6], bend: -0.5, thickness: 1.1, endShare: 0.7 }),
|
|
343
|
+
}),
|
|
344
|
+
guilty: {
|
|
345
|
+
...symmetricFace({
|
|
346
|
+
brow: blob({ centre: [6.4, 5], width: 4.6, height: 1.8, squareness: 0.5, rotation: -14 }),
|
|
347
|
+
eye: blob({ centre: [6.4, 12.6], width: 8.2, height: 8, cutTop: [10.6, 10.2] }),
|
|
348
|
+
pupil: blob({ centre: [5, 14.4], width: 3, height: 3 }),
|
|
349
|
+
mouth: blob({ centre: [11.4, 19], width: 2.8, height: 0.9, squareness: 0.6, rotation: 6 }),
|
|
350
|
+
}),
|
|
351
|
+
rightPupil: blob({ centre: [16.2, 14.4], width: 3, height: 3 }),
|
|
352
|
+
},
|
|
353
|
+
worried: symmetricFace({
|
|
354
|
+
brow: blob({ centre: [6.6, 4.2], width: 4.8, height: 1.9, squareness: 0.5, rotation: -20 }),
|
|
355
|
+
eye: blob({ centre: [6.4, 12.2], width: 8, height: 8.8, topScale: 0.85, rotation: 6 }),
|
|
356
|
+
pupil: blob({ centre: [6.8, 12.4], width: 2.6, height: 2.8 }),
|
|
357
|
+
mouth: band({ from: [10.2, 19], to: [13.8, 19], bend: 0.6, thickness: 1.1, endShare: 0.7 }),
|
|
358
|
+
}),
|
|
359
|
+
disappointed: symmetricFace({
|
|
360
|
+
brow: blob({ centre: [6.2, 5.6], width: 5, height: 1.8, squareness: 0.5, rotation: -8 }),
|
|
361
|
+
eye: blob({ centre: [6.4, 12.8], width: 8.4, height: 7.6, cutTop: [11.8, 11] }),
|
|
362
|
+
pupil: blob({ centre: [6.6, 14.4], width: 3, height: 3 }),
|
|
363
|
+
mouth: band({ from: [9.6, 19.2], to: [14.4, 19.2], bend: 0.4, thickness: 1.1, endShare: 0.8 }),
|
|
364
|
+
}),
|
|
365
|
+
ashamed: symmetricFace({
|
|
366
|
+
brow: blob({ centre: [6.4, 5.6], width: 4.6, height: 1.8, squareness: 0.5, rotation: -16 }),
|
|
367
|
+
eye: blob({ centre: [6.4, 13], width: 8.2, height: 7.6, cutTop: [12.2, 12.2] }),
|
|
368
|
+
pupil: blob({ centre: [6.4, 15], width: 3, height: 2.6 }),
|
|
369
|
+
mouth: blob({ centre: [12, 19.4], width: 2.4, height: 0.9, squareness: 0.6 }),
|
|
370
|
+
}),
|
|
371
|
+
embarrassed: {
|
|
372
|
+
...symmetricFace({
|
|
373
|
+
brow: blob({ centre: [6.2, 4.8], width: 4.8, height: 1.8, squareness: 0.5, rotation: -12 }),
|
|
374
|
+
eye: blob({ centre: [6.4, 12.4], width: 8.2, height: 8.2 }),
|
|
375
|
+
pupil: blob({ centre: [4.4, 13.6], width: 3, height: 3 }),
|
|
376
|
+
mouth: band({ from: [9.8, 18.8], to: [14.2, 18.6], bend: -0.5, thickness: 1.1, endShare: 0.7 }),
|
|
377
|
+
}),
|
|
378
|
+
rightPupil: blob({ centre: [15.8, 13.6], width: 3, height: 3 }),
|
|
379
|
+
},
|
|
380
|
+
yearning: symmetricFace({
|
|
381
|
+
brow: blob({ centre: [6.2, 4], width: 4.8, height: 1.8, squareness: 0.5, rotation: -10 }),
|
|
382
|
+
eye: blob({ centre: [6.4, 12.6], width: 8.6, height: 8, cutTop: [11, 11] }),
|
|
383
|
+
pupil: blob({ centre: [6.8, 13.6], width: 4.2, height: 4.2 }),
|
|
384
|
+
mouth: blob({ centre: [12, 18.8], width: 2.6, height: 1.6 }),
|
|
385
|
+
}),
|
|
386
|
+
triumphant: symmetricFace({
|
|
387
|
+
brow: blob({ centre: [6.2, 4], width: 5.2, height: 2, squareness: 0.5, rotation: 8 }),
|
|
388
|
+
eye: blob({ centre: [6.4, 11.8], width: 8.6, height: 8.6, cutBottom: [14, 14] }),
|
|
389
|
+
pupil: blob({ centre: [6.8, 11.6], width: 3.4, height: 3.4 }),
|
|
390
|
+
mouth: blob({ centre: [12, 16.8], width: 7, height: 6.4, squareness: 0.3, cutTop: [16.8, 16.8] }),
|
|
391
|
+
}),
|
|
392
|
+
proud: symmetricFace({
|
|
393
|
+
brow: blob({ centre: [6.2, 3.8], width: 5, height: 1.9, squareness: 0.5, rotation: -4 }),
|
|
394
|
+
eye: blob({ centre: [6.4, 12.4], width: 8.6, height: 8, cutTop: [11, 11] }),
|
|
395
|
+
pupil: blob({ centre: [6.6, 13.6], width: 3.2, height: 3 }),
|
|
396
|
+
mouth: band({ from: [8.8, 18], to: [15.2, 18], bend: -1.2, thickness: 1.3, endShare: 0.7 }),
|
|
397
|
+
}),
|
|
398
|
+
excited: symmetricFace({
|
|
399
|
+
brow: band({ from: [3.4, 3.6], to: [8.6, 2.8], bend: 0.8, thickness: 1.6 }),
|
|
400
|
+
eye: blob({ centre: [6.4, 12], width: 9, height: 9.2 }),
|
|
401
|
+
pupil: blob({ centre: [7, 11], width: 4, height: 4 }),
|
|
402
|
+
mouth: blob({ centre: [12, 16.8], width: 6, height: 6, squareness: 0.2, cutTop: [16.8, 16.8] }),
|
|
403
|
+
}),
|
|
404
|
+
scornful: {
|
|
405
|
+
...symmetricFace({
|
|
406
|
+
brow: blob({ centre: [6.2, 5.4], width: 5, height: 1.8, squareness: 0.5, rotation: 2 }),
|
|
407
|
+
eye: blob({ centre: [6.4, 12.6], width: 8.4, height: 7.6, cutTop: [11.4, 11.4] }),
|
|
408
|
+
pupil: blob({ centre: [5.4, 14], width: 3, height: 3 }),
|
|
409
|
+
mouth: band({ from: [9.2, 18.8], to: [14.6, 18], bend: -0.2, thickness: 1.1, endShare: 0.7 }),
|
|
410
|
+
}),
|
|
411
|
+
rightBrow: mirrored(band({ from: [3.6, 4], to: [8.6, 3.2], bend: 0.6, thickness: 1.6 })),
|
|
412
|
+
rightPupil: blob({ centre: [16.6, 14], width: 3, height: 3 }),
|
|
413
|
+
},
|
|
414
|
+
disgusted: symmetricFace({
|
|
415
|
+
brow: blob({ centre: [6.6, 6], width: 5, height: 1.8, squareness: 0.5, rotation: 14 }),
|
|
416
|
+
eye: blob({ centre: [6.4, 12.6], width: 8.4, height: 7.6, cutTop: [11.4, 12.2], cutBottom: [14.8, 14.8] }),
|
|
417
|
+
pupil: blob({ centre: [6.8, 13.2], width: 3, height: 2.8 }),
|
|
418
|
+
// an upside-down "D": open, corners pulled down
|
|
419
|
+
mouth: blob({ centre: [12, 19.6], width: 5, height: 4.8, squareness: 0.2, cutBottom: [19.6, 19.6] }),
|
|
420
|
+
}),
|
|
421
|
+
};
|
|
422
|
+
};
|
|
423
|
+
let builtExpressions = null;
|
|
424
|
+
/** Every expression's pieces, built on first use (nothing runs at import). */
|
|
425
|
+
export const faceExpressionPieces = () => (builtExpressions ??= buildFaceExpressions());
|
|
426
|
+
// ---- morphing and drawing ----
|
|
427
|
+
const interpolatePoints = (from, to, progress) => from.map(([fromX, fromY], index) => [fromX + (to[index][0] - fromX) * progress, fromY + (to[index][1] - fromY) * progress]);
|
|
428
|
+
export const interpolateFace = (from, to, progress) => Object.fromEntries(PIECE_NAMES.map((name) => [name, interpolatePoints(from[name], to[name], progress)]));
|
|
429
|
+
const boxCentre = (points) => {
|
|
430
|
+
const xs = points.map(([x]) => x);
|
|
431
|
+
const ys = points.map(([, y]) => y);
|
|
432
|
+
return [(Math.min(...xs) + Math.max(...xs)) / 2, (Math.min(...ys) + Math.max(...ys)) / 2];
|
|
433
|
+
};
|
|
434
|
+
/** The face with its eyes partly closed: openness 1 = as drawn, 0 = shut to a flat slit (pupils gone). */
|
|
435
|
+
export const blinked = (face, openness) => {
|
|
436
|
+
if (openness >= 1)
|
|
437
|
+
return face;
|
|
438
|
+
const eyeScale = 0.25 + 0.75 * openness;
|
|
439
|
+
const squash = (points, middleY) => points.map(([x, y]) => [x, middleY + (y - middleY) * eyeScale]);
|
|
440
|
+
const shrink = (points, [centreX, centreY]) => points.map(([x, y]) => [centreX + (x - centreX) * openness, centreY + (y - centreY) * openness]);
|
|
441
|
+
const leftMiddle = boxCentre(face.leftEye)[1];
|
|
442
|
+
const rightMiddle = boxCentre(face.rightEye)[1];
|
|
443
|
+
return {
|
|
444
|
+
...face,
|
|
445
|
+
leftEye: squash(face.leftEye, leftMiddle),
|
|
446
|
+
rightEye: squash(face.rightEye, rightMiddle),
|
|
447
|
+
leftPupil: squash(shrink(face.leftPupil, boxCentre(face.leftPupil)), leftMiddle),
|
|
448
|
+
rightPupil: squash(shrink(face.rightPupil, boxCentre(face.rightPupil)), rightMiddle),
|
|
449
|
+
};
|
|
450
|
+
};
|
|
451
|
+
const formatNumber = (value) => String(Math.round(value * 1000) / 1000);
|
|
452
|
+
/** A smooth closed curve through the points: each handle points along the neighbours' chord and is a third of its own
|
|
453
|
+
* segment long (no loops where points sit close together, as at a band's round end). Empty for a hidden piece. */
|
|
454
|
+
export const smoothClosedPath = (points) => {
|
|
455
|
+
const [firstX, firstY] = points[0];
|
|
456
|
+
if (points.every(([x, y]) => Math.abs(x - firstX) < 1e-6 && Math.abs(y - firstY) < 1e-6))
|
|
457
|
+
return '';
|
|
458
|
+
const count = points.length;
|
|
459
|
+
const directionAt = (index) => {
|
|
460
|
+
const previous = points[(index - 1 + count) % count];
|
|
461
|
+
const next = points[(index + 1) % count];
|
|
462
|
+
const length = Math.hypot(next[0] - previous[0], next[1] - previous[1]) || 1;
|
|
463
|
+
return [(next[0] - previous[0]) / length, (next[1] - previous[1]) / length];
|
|
464
|
+
};
|
|
465
|
+
let path = `M${formatNumber(firstX)} ${formatNumber(firstY)}`;
|
|
466
|
+
for (let index = 0; index < count; index++) {
|
|
467
|
+
const start = points[index];
|
|
468
|
+
const end = points[(index + 1) % count];
|
|
469
|
+
const handleLength = Math.hypot(end[0] - start[0], end[1] - start[1]) / 3;
|
|
470
|
+
const startDirection = directionAt(index);
|
|
471
|
+
const endDirection = directionAt(index + 1);
|
|
472
|
+
const control1 = [start[0] + startDirection[0] * handleLength, start[1] + startDirection[1] * handleLength];
|
|
473
|
+
const control2 = [end[0] - endDirection[0] * handleLength, end[1] - endDirection[1] * handleLength];
|
|
474
|
+
path += `C${[...control1, ...control2, ...end].map(formatNumber).join(' ')}`;
|
|
475
|
+
}
|
|
476
|
+
return `${path}Z`;
|
|
477
|
+
};
|
|
478
|
+
export const facePaths = (face) => ({
|
|
479
|
+
features: [face.leftBrow, face.rightBrow, face.leftEye, face.rightEye, face.mouth].map(smoothClosedPath).join(''),
|
|
480
|
+
eyes: [face.leftEye, face.rightEye].map(smoothClosedPath).join(''),
|
|
481
|
+
pupils: [face.leftPupil, face.rightPupil].map(smoothClosedPath).join(''),
|
|
482
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/*! morphico v0.5.0 | code: MIT License, Copyright (c) 2026 Raycho Ivanov (see LICENSE)
|
|
2
|
+
* Icons based on Lucide (https://lucide.dev), some of them from Feather (https://feathericons.com):
|
|
3
|
+
*
|
|
4
|
+
* ISC License
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) 2026 Lucide Icons and Contributors
|
|
7
|
+
*
|
|
8
|
+
* Permission to use, copy, modify, and/or distribute this software for any
|
|
9
|
+
* purpose with or without fee is hereby granted, provided that the above
|
|
10
|
+
* copyright notice and this permission notice appear in all copies.
|
|
11
|
+
*
|
|
12
|
+
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
13
|
+
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
14
|
+
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
15
|
+
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
16
|
+
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
17
|
+
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
18
|
+
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
|
19
|
+
*
|
|
20
|
+
* The MIT License (MIT) (for the Lucide icons derived from Feather, listed in LICENSE-lucide)
|
|
21
|
+
*
|
|
22
|
+
* Copyright (c) 2013-present Cole Bemis
|
|
23
|
+
*
|
|
24
|
+
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
25
|
+
* of this software and associated documentation files (the "Software"), to deal
|
|
26
|
+
* in the Software without restriction, including without limitation the rights
|
|
27
|
+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
28
|
+
* copies of the Software, and to permit persons to whom the Software is
|
|
29
|
+
* furnished to do so, subject to the following conditions:
|
|
30
|
+
*
|
|
31
|
+
* The above copyright notice and this permission notice shall be included in all
|
|
32
|
+
* copies or substantial portions of the Software.
|
|
33
|
+
*
|
|
34
|
+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
35
|
+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
36
|
+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
37
|
+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
38
|
+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
39
|
+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
40
|
+
* SOFTWARE.
|
|
41
|
+
*/
|
|
42
|
+
// Icon set "folder": closed, open — Lucide 1.61.0 icons "folder", "folder-open" (ISC, see LICENSE-lucide), imported
|
|
43
|
+
// with `node tools/import-lucide.ts folder folder-open`. Coordinates below are Lucide's; lucideLine / lucideArc scale
|
|
44
|
+
// them into our icon box (more margin, like the digits — see lucide.ts).
|
|
45
|
+
// One piece each. The open folder is one line: the front flap (from its left tip over its top edge, down its right
|
|
46
|
+
// edge), the bottom, the left side, the tab, the back panel's top edge and a short bit of its right side.
|
|
47
|
+
// closed ↔ open: the closed folder is traced the open folder's way ("folder-like-open", same picture) — starting at
|
|
48
|
+
// its top edge, which it runs along twice: the first pass drops and tilts into the flap's top edge, the second stays
|
|
49
|
+
// as the back panel's top edge.
|
|
50
|
+
import { arc, line, reversedStroke } from '../geometry.js';
|
|
51
|
+
import { fromLucide, LUCIDE_STROKE_WIDTH, lucideLength } from './lucide.js';
|
|
52
|
+
const lucideLine = (fromX, fromY, toX, toY) => line(...fromLucide([fromX, fromY]), ...fromLucide([toX, toY]));
|
|
53
|
+
const lucideArc = (centerX, centerY, radius, startAngle, endAngle) => arc(...fromLucide([centerX, centerY]), lucideLength(radius), startAngle, endAngle);
|
|
54
|
+
/** Lucide "folder": counter-clockwise from the bottom right corner. */
|
|
55
|
+
const FOLDER = [
|
|
56
|
+
lucideArc(20, 18, 2, 90, 0), // bottom right corner
|
|
57
|
+
lucideLine(22, 18, 22, 8), // right side, up
|
|
58
|
+
lucideArc(20, 8, 2, 0, -90), // top right corner
|
|
59
|
+
lucideLine(20, 6, 12.1, 6), // top edge, to the left
|
|
60
|
+
lucideArc(12.0804, 4.0001, 2, 89.4383, 146.6363),
|
|
61
|
+
lucideLine(10.41, 5.1, 9.6, 3.9), // tab slope
|
|
62
|
+
lucideArc(7.9297, 5, 2, -33.367, -89.9906),
|
|
63
|
+
lucideLine(7.93, 3, 4, 3), // tab top
|
|
64
|
+
lucideArc(4, 5, 2, -90, -180), // top left corner
|
|
65
|
+
lucideLine(2, 5, 2, 18), // left side, down
|
|
66
|
+
lucideArc(4, 18, 2, 180, 90), // bottom left corner
|
|
67
|
+
lucideLine(4, 20, 20, 20), // bottom
|
|
68
|
+
];
|
|
69
|
+
/** Lucide "folder-open": from the flap's left tip. */
|
|
70
|
+
const FOLDER_OPEN = [
|
|
71
|
+
lucideLine(6, 14, 7.5, 11.1), // flap's left tip
|
|
72
|
+
lucideArc(9.2863, 11.9995, 2, -153.2735, -91.3273),
|
|
73
|
+
lucideLine(9.24, 10, 20, 10), // flap's top edge
|
|
74
|
+
lucideArc(20.0035, 12, 2, -90.1005, 14.4776),
|
|
75
|
+
lucideLine(21.94, 12.5, 20.4, 18.5), // flap's right edge
|
|
76
|
+
lucideArc(18.4635, 18, 2, 14.4762, 90.3867),
|
|
77
|
+
lucideLine(18.45, 20, 4, 20), // bottom
|
|
78
|
+
lucideArc(4, 18, 2, 90, 180),
|
|
79
|
+
lucideLine(2, 18, 2, 5), // left side, up
|
|
80
|
+
lucideArc(4, 5, 2, 180, 270),
|
|
81
|
+
lucideLine(4, 3, 7.9, 3), // tab top
|
|
82
|
+
lucideArc(7.9196, 4.9999, 2, -90.5617, -33.3637),
|
|
83
|
+
lucideLine(9.59, 3.9, 10.4, 5.1), // tab slope
|
|
84
|
+
lucideArc(12.0703, 4, 2, 146.633, 90.0094),
|
|
85
|
+
lucideLine(12.07, 6, 18, 6), // back panel's top edge
|
|
86
|
+
lucideArc(18, 8, 2, -90, 0),
|
|
87
|
+
lucideLine(20, 8, 20, 10), // back panel's right side, down to the flap
|
|
88
|
+
];
|
|
89
|
+
const [bottomRight, rightSide, topRight, topEdge, tabArc1, tabSlope, tabArc2, tabTop, topLeft, leftSide, bottomLeft, bottom] = FOLDER.map(reversedStroke); // clockwise, like the open folder
|
|
90
|
+
/** The closed folder traced like the open one: top edge (→ flap), round the outline, top edge again (→ back panel). */
|
|
91
|
+
const FOLDER_LIKE_OPEN = [
|
|
92
|
+
topEdge, topRight, rightSide, bottomRight, bottom, bottomLeft, leftSide, topLeft,
|
|
93
|
+
tabTop, tabArc2, tabSlope, tabArc1, topEdge, topRight, lucideLine(22, 8, 22, 10),
|
|
94
|
+
];
|
|
95
|
+
export const iconsFolder = {
|
|
96
|
+
segmentCount: 20,
|
|
97
|
+
strokeWidth: LUCIDE_STROKE_WIDTH,
|
|
98
|
+
lineCap: 'round',
|
|
99
|
+
lineJoin: 'round',
|
|
100
|
+
strokes: { closed: FOLDER, open: FOLDER_OPEN },
|
|
101
|
+
alternativeStrokes: { 'folder-like-open': FOLDER_LIKE_OPEN },
|
|
102
|
+
// fitted with tools/check.ts (cost 7838 → 411)
|
|
103
|
+
pairLayouts: {
|
|
104
|
+
closed: {
|
|
105
|
+
open: {
|
|
106
|
+
tracing: 'folder-like-open',
|
|
107
|
+
breakpoints: [0, 0, 0, 1, 2, 2.5208, 3, 4, 4.5625, 5, 6, 6.5, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
spins: {},
|
|
112
|
+
};
|