Skip to content

Commit fc5b939

Browse files
committed
add smoothing util
1 parent 045c137 commit fc5b939

File tree

6 files changed

+164
-121
lines changed

6 files changed

+164
-121
lines changed

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# blobs
2+
3+
<!--
4+
5+
TODO
6+
- seeded randomness
7+
8+
-->

blob.svg

Lines changed: 1 addition & 1 deletion
Loading

index.ts

Lines changed: 26 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
1-
import {distance, rad, loopAccess} from "./util";
2-
import {Point, renderClosed} from "./render";
1+
// https://www.blobmaker.app/
2+
// https://math.stackexchange.com/questions/873224/calculate-control-points-of-cubic-bezier-curve-approximating-a-part-of-a-circle
33

4-
export interface BlobOptions {
5-
size: number;
6-
color: string;
7-
complexity: number;
8-
contrast: number;
9-
}
4+
import {rad, smooth} from "./util";
5+
import {render} from "./render";
6+
import {Point, BlobOptions} from "./types";
7+
8+
export {BlobOptions} from "./types";
109

1110
// Generates a random rounded shape.
1211
export const blob = (opt: BlobOptions): string => {
1312
opt = Object.assign({}, opt);
1413

14+
if (!opt.stroke && !opt.color) {
15+
throw new Error("no color or stroke specified")
16+
}
17+
1518
if (opt.complexity <= 0 || opt.complexity > 1) {
1619
throw new Error("complexity out of range ]0,1]");
1720
}
@@ -22,8 +25,7 @@ export const blob = (opt: BlobOptions): string => {
2225

2326
const count = 3 + Math.floor(14 * opt.complexity);
2427
const angle = 360 / count;
25-
const radius = opt.size / 3;
26-
const handle = radius * (4 / 3) * Math.tan(rad(angle / 4));
28+
const radius = opt.size / Math.E;
2729

2830
const points: Point[] = [];
2931
for (let i = 0; i < count; i++) {
@@ -32,33 +34,23 @@ export const blob = (opt: BlobOptions): string => {
3234
points.push({
3335
x: Math.sin(rad(i * angle)) * radius * rand + opt.size / 2,
3436
y: Math.cos(rad(i * angle)) * radius * rand + opt.size / 2,
35-
handles: {
36-
angle: -i * angle,
37-
in: handle,
38-
out: handle,
39-
},
4037
});
4138
}
4239

43-
// Adjust handle lengths according to proximity with adjacent points.
44-
const expected = 2 * radius * Math.sin(rad(angle / 2));
45-
for (let i = 0; i < count; i++) {
46-
const point = loopAccess(points)(i);
47-
if (!point.handles) continue; // Should not happen.
48-
49-
const {handles} = point;
50-
handles.in = (handles.in * distance(point, loopAccess(points)(i - 1))) / expected;
51-
handles.out = (handles.out * distance(point, loopAccess(points)(i + 1))) / expected;
52-
}
40+
const smoothed = smooth(points, {
41+
closed: true,
42+
strength: (4/3) * Math.tan(rad(angle/4)) / Math.sin(rad(angle/2)),
43+
});
5344

54-
return renderClosed(points, {
45+
return render(smoothed, {
46+
closed: true,
5547
width: opt.size,
5648
height: opt.size,
5749
fill: opt.color,
5850
transform: `rotate(${Math.random() * angle},${opt.size / 2},${opt.size / 2})`,
59-
stroke: "red",
60-
strokeWidth: 2,
61-
guides: true,
51+
stroke: (opt.stroke && opt.stroke.color),
52+
strokeWidth: (opt.stroke && opt.stroke.width),
53+
guides: opt.guides,
6254
});
6355
};
6456

@@ -68,24 +60,10 @@ console.log(
6860
complexity: 0.2,
6961
contrast: 1,
7062
size: 600,
63+
guides: true,
64+
stroke: {
65+
color: "red",
66+
width: 1.8,
67+
},
7168
}),
7269
);
73-
74-
// console.log(
75-
// renderClosed(
76-
// [
77-
// {x: 700, y: 200, handles: {angle: -135, out: 80, in: 80}},
78-
// {x: 300, y: 200, handles: {angle: 135, out: 80, in: 80}},
79-
// {x: 300, y: 600, handles: {angle: 45, out: 80, in: 80}},
80-
// {x: 700, y: 600, handles: {angle: -45, out: 80, in: 80}},
81-
// ],
82-
// {
83-
// width: 1000,
84-
// height: 800,
85-
// fill: "pink",
86-
// stroke: "red",
87-
// strokeWidth: 2,
88-
// guides: true,
89-
// },
90-
// ),
91-
// );

render.ts

Lines changed: 5 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,72 +1,9 @@
1-
import {loopAccess, rad} from "./util";
1+
import {loopAccess, interpolate} from "./util";
2+
import {Point, RenderOptions} from "./types";
23

3-
export interface Point {
4-
// Cartesian coordinates (starting at [0,0] in the bottom left).
5-
x: number;
6-
y: number;
7-
8-
// Optional cubic bezier handle configuration.
9-
handles?: {
10-
// Direction of the outgoing path in degrees. Value is relative to the 3:00 position
11-
// on a clock and the positive direction is counter-clockwise.
12-
angle: number;
13-
14-
// Distance between each handle and the point.
15-
out: number;
16-
in: number;
17-
};
18-
}
19-
20-
interface InternalPoint {
21-
// Coordinates of the point in the SVG viewport.
22-
x: number;
23-
y: number;
24-
25-
// Cubic bezier handle configuration.
26-
handles: {
27-
// Direction of the outgoing path in radians. Value is relative to the 9:00 position
28-
// on a clock and the positive direction is counter-clockwise.
29-
angle: number;
30-
31-
// Distance between each handle and the point.
32-
out: number;
33-
in: number;
34-
};
35-
}
36-
37-
export interface RenderOptions {
38-
// Viewport size.
39-
width: number;
40-
height: number;
41-
42-
// Transformation applied to all drawn points.
43-
transform?: string;
44-
45-
// Output path styling.
46-
fill?: string;
47-
stroke?: string;
48-
strokeWidth?: number;
49-
50-
// Option to render guides (points, handles and viewport).
51-
guides?: boolean;
52-
boundingBox?: boolean;
53-
}
54-
55-
// Translates a point's [x,y] cartesian coordinates into values relative to the viewport.
56-
// Translates the angle from degrees to radians and moves the start angle a half rotation.
57-
const cleanupPoint = (point: Point, opt: RenderOptions): InternalPoint => {
58-
const handles = point.handles || {angle: 0, out: 0, in: 0};
59-
handles.angle = Math.PI + rad(handles.angle);
60-
return {
61-
x: point.x,
62-
y: opt.height - point.y,
63-
handles,
64-
};
65-
};
66-
67-
// Renders a closed shape made up of the input points.
68-
export const renderClosed = (p: Point[], opt: RenderOptions): string => {
69-
const points = p.map((point) => cleanupPoint(point, opt));
4+
// Renders a shape made up of the input points.
5+
export const render = (p: Point[], opt: RenderOptions): string => {
6+
const points = p.map((point) => interpolate(point, opt));
707

718
// Compute guides from input point data.
729
const handles: {x1: number; y1: number; x2: number; y2: number}[] = [];

types.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
export interface Point {
2+
// Cartesian coordinates (starting at [0,0] in the bottom left).
3+
x: number;
4+
y: number;
5+
6+
// Optional cubic bezier handle configuration.
7+
handles?: {
8+
// Direction of the outgoing path in degrees. Value is relative to the 3:00 position
9+
// on a clock and the positive direction is counter-clockwise.
10+
angle: number;
11+
12+
// Distance between each handle and the point.
13+
out: number;
14+
in: number;
15+
};
16+
}
17+
18+
export interface SVGPoint {
19+
// Coordinates of the point in the SVG viewport.
20+
x: number;
21+
y: number;
22+
23+
// Cubic bezier handle configuration.
24+
handles: {
25+
// Direction of the outgoing path in radians. Value is relative to the 9:00 position
26+
// on a clock and the positive direction is counter-clockwise.
27+
angle: number;
28+
29+
// Distance between each handle and the point.
30+
out: number;
31+
in: number;
32+
};
33+
}
34+
35+
export interface BlobOptions {
36+
size: number;
37+
color?: string;
38+
stroke?: {
39+
color: string;
40+
width: number;
41+
};
42+
guides?: boolean;
43+
complexity: number;
44+
contrast: number;
45+
}
46+
47+
export interface SmoothingOptions {
48+
// Declare whether the path is closed.
49+
// This option is currently always true.
50+
closed: true;
51+
52+
// Smoothing strength as ration [0,1].
53+
strength: number;
54+
}
55+
56+
export interface RenderOptions {
57+
// Viewport size.
58+
width: number;
59+
height: number;
60+
61+
// Transformation applied to all drawn points.
62+
transform?: string;
63+
64+
// Declare whether the path should be closed.
65+
// This option is currently always true.
66+
closed: true;
67+
68+
// Output path styling.
69+
fill?: string;
70+
stroke?: string;
71+
strokeWidth?: number;
72+
73+
// Option to render guides (points, handles and viewport).
74+
guides?: boolean;
75+
boundingBox?: boolean;
76+
}

util.ts

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,4 @@
1-
interface Point {
2-
x: number;
3-
y: number;
4-
}
1+
import {Point, RenderOptions, SVGPoint, SmoothingOptions} from "./types";
52

63
// Safe array access at any index using a modulo operation that will always be positive.
74
export const loopAccess = <T>(arr: T[]) => (i: number): T => {
@@ -13,7 +10,54 @@ export const rad = (deg: number) => {
1310
return (deg / 360) * 2 * Math.PI;
1411
};
1512

13+
// Converts radians to degrees.
14+
export const deg = (rad: number) => {
15+
return (((rad / Math.PI) * 1) / 2) * 360;
16+
};
17+
1618
// Calculates distance between two points.
1719
export const distance = (p1: Point, p2: Point): number => {
1820
return Math.sqrt((p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2);
1921
};
22+
23+
// Calculates the angle of the line from p1 to p2 in degrees.
24+
export const angle = (p1: Point, p2: Point): number => {
25+
return deg(Math.atan2(p2.y - p1.y, p2.x - p1.x));
26+
};
27+
28+
// Translates a point's [x,y] cartesian coordinates into values relative to the viewport.
29+
// Translates the angle from degrees to radians and moves the start angle a half rotation.
30+
export const interpolate = (point: Point, opt: RenderOptions): SVGPoint => {
31+
const handles = point.handles || {angle: 0, out: 0, in: 0};
32+
handles.angle = Math.PI + rad(handles.angle);
33+
return {
34+
x: point.x,
35+
y: opt.height - point.y,
36+
handles,
37+
};
38+
};
39+
40+
// Smooths out the path made up of the given points. This will override the existing handles.
41+
export const smooth = (points: Point[], opt: SmoothingOptions): Point[] => {
42+
if (points.length === 2) return points;
43+
44+
const out: Point[] = [];
45+
46+
for (let i = 0; i < points.length; i++) {
47+
const point = loopAccess(points)(i);
48+
const before = loopAccess(points)(i - 1);
49+
const after = loopAccess(points)(i + 1);
50+
51+
out.push({
52+
x: point.x,
53+
y: point.y,
54+
handles: {
55+
angle: angle(before, after),
56+
in: opt.strength * (1 / 2) * distance(point, before),
57+
out: opt.strength * (1 / 2) * distance(point, after),
58+
},
59+
});
60+
}
61+
62+
return out;
63+
};

0 commit comments

Comments
 (0)