cloudswg 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +98 -0
- package/dist/index.cjs +550 -0
- package/dist/index.d.cts +27 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +520 -0
- package/package.json +55 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Gideon Needleman
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# cloudswg
|
|
2
|
+
|
|
3
|
+
Procedurally generate SVG clouds. A single seeded call returns a clean, standalone `<svg>` string built from minimal, smooth bezier geometry — deterministic per seed, dependency-free, and usable in both Node and the browser.
|
|
4
|
+
|
|
5
|
+
Three cloud types are supported: **cumulus**, **stratus**, and **cirrus**.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install cloudswg
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { generateCloud } from 'cloudswg'
|
|
17
|
+
|
|
18
|
+
const { svg } = generateCloud({ type: 'cumulus', seed: 42, puffiness: 0.7 })
|
|
19
|
+
// → '<svg xmlns="..." viewBox="0 0 300 180" ...>...</svg>'
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The same seed always produces the same SVG; omit `seed` for a random cloud:
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
generateCloud({ type: 'cumulus' }) // random every call
|
|
26
|
+
generateCloud({ type: 'cumulus', seed: 42 }) // reproducible
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
In the browser you can get a live DOM node instead of a string:
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
const cloud = generateCloud({ type: 'stratus', seed: 7 })
|
|
33
|
+
document.body.appendChild(cloud.element()) // returns an SVGSVGElement
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
> `element()` requires a browser environment (uses `DOMParser`) and throws in Node. Use `svg` to write files or render server-side.
|
|
37
|
+
|
|
38
|
+
## Options
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
generateCloud({
|
|
42
|
+
type: 'cumulus', // 'cumulus' | 'stratus' | 'cirrus'
|
|
43
|
+
seed: 42, // number; omit for random
|
|
44
|
+
puffiness: 0.7, // 0..1, effect is per-type (see below)
|
|
45
|
+
width: 300, // optional; sensible per-type default
|
|
46
|
+
height: 180, // optional; sensible per-type default
|
|
47
|
+
style: {
|
|
48
|
+
fill: 'white', // any CSS color
|
|
49
|
+
stroke: 'none', // outline color
|
|
50
|
+
strokeWidth: 1,
|
|
51
|
+
opacity: 1,
|
|
52
|
+
},
|
|
53
|
+
})
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
| Option | Type | Default | Notes |
|
|
57
|
+
| --- | --- | --- | --- |
|
|
58
|
+
| `type` | `CloudType` | `'cumulus'` | Cloud morphology. |
|
|
59
|
+
| `seed` | `number` | random | Same seed ⇒ identical output. |
|
|
60
|
+
| `puffiness` | `number` | random per seed | 0–1; per-type meaning (turret count, band thickness, curl, tower height). Omit for a value drawn from the seed (deterministic per seed). |
|
|
61
|
+
| `width` / `height` | `number` | per-type | Viewbox size. Defaults: cumulus `300×180`, cirrus `400×200`, stratus `1200×120`. |
|
|
62
|
+
| `style` | `CloudStyle` | `{}` | Fill, stroke, and opacity. No blur filter is baked in — soften at the consumer via CSS `filter: blur()`. |
|
|
63
|
+
|
|
64
|
+
### Return value
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
interface CloudResult {
|
|
68
|
+
svg: string // serialized <svg> string
|
|
69
|
+
element(): SVGSVGElement // browser-only; parses svg into a DOM node
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Cloud types
|
|
74
|
+
|
|
75
|
+
- **cumulus** — puffy fair-weather cloud with a flat base and cauliflower turrets that boom upward off-center, so each seed looks distinct. `puffiness` drives turret count.
|
|
76
|
+
- **stratus** — a long, wavy horizontal band with gently lumpy edges and rounded ends. Uses a wide default viewBox (`1200×120`). `puffiness` drives thickness and edge lumpiness.
|
|
77
|
+
- **cirrus** — a field of filled, hooked "mares'-tail" wisps (cirrus uncinus): tapered comma shapes that curl up at the head. `puffiness` drives hook strength and head fatness. Ships crisp; for a soft look apply CSS `filter: blur()` at the consumer.
|
|
78
|
+
|
|
79
|
+
## Types
|
|
80
|
+
|
|
81
|
+
`CloudOptions`, `CloudResult`, `CloudType`, and `CloudStyle` are all exported for TypeScript consumers.
|
|
82
|
+
|
|
83
|
+
## Development
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
npm run build # bundle to dist/ (ESM + CJS + .d.ts) and rebuild the demo bundle
|
|
87
|
+
npm run dev # rebuild on change
|
|
88
|
+
npm test # run the test suite (vitest)
|
|
89
|
+
npm run typecheck # tsc --noEmit
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Open `examples/index.html` in a browser (after `npm run build`) for a no-server gallery of all cloud types with download buttons.
|
|
93
|
+
|
|
94
|
+
See [CLAUDE.md](./CLAUDE.md) for architecture and internals.
|
|
95
|
+
|
|
96
|
+
## License
|
|
97
|
+
|
|
98
|
+
MIT
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
generateCirrus: () => generateCirrus2,
|
|
24
|
+
generateCloud: () => generateCloud,
|
|
25
|
+
generateCumulus: () => generateCumulus2,
|
|
26
|
+
generateStratus: () => generateStratus2
|
|
27
|
+
});
|
|
28
|
+
module.exports = __toCommonJS(index_exports);
|
|
29
|
+
|
|
30
|
+
// src/rng.ts
|
|
31
|
+
function createRng(seed) {
|
|
32
|
+
let s = seed >>> 0;
|
|
33
|
+
return function() {
|
|
34
|
+
s += 1831565813;
|
|
35
|
+
let t = s;
|
|
36
|
+
t = Math.imul(t ^ t >>> 15, t | 1);
|
|
37
|
+
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
|
|
38
|
+
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// src/svg.ts
|
|
43
|
+
function buildPathAttrs(style) {
|
|
44
|
+
const attrs = [];
|
|
45
|
+
attrs.push(`fill="${style.fill ?? "white"}"`);
|
|
46
|
+
if (style.stroke && style.stroke !== "none") {
|
|
47
|
+
attrs.push(`stroke="${style.stroke}"`);
|
|
48
|
+
attrs.push(`stroke-width="${style.strokeWidth ?? 1}"`);
|
|
49
|
+
}
|
|
50
|
+
if (style.opacity !== void 0 && style.opacity !== 1) {
|
|
51
|
+
attrs.push(`opacity="${style.opacity}"`);
|
|
52
|
+
}
|
|
53
|
+
return attrs.join(" ");
|
|
54
|
+
}
|
|
55
|
+
function assembleSvg(width, height, paths, style) {
|
|
56
|
+
const attrs = buildPathAttrs(style);
|
|
57
|
+
const pathElements = paths.filter((d) => d.length > 0).map((d) => `<path d="${d}" ${attrs}/>`).join("\n ");
|
|
58
|
+
return [
|
|
59
|
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">`,
|
|
60
|
+
` ${pathElements}`,
|
|
61
|
+
`</svg>`
|
|
62
|
+
].join("\n");
|
|
63
|
+
}
|
|
64
|
+
function makeResult(svgString) {
|
|
65
|
+
return {
|
|
66
|
+
svg: svgString,
|
|
67
|
+
element() {
|
|
68
|
+
if (typeof DOMParser === "undefined") {
|
|
69
|
+
throw new Error("element() requires a browser environment with DOMParser");
|
|
70
|
+
}
|
|
71
|
+
const doc = new DOMParser().parseFromString(svgString, "image/svg+xml");
|
|
72
|
+
return doc.documentElement;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// src/generators/cumulus.ts
|
|
78
|
+
function fmt(n) {
|
|
79
|
+
return Number(n.toFixed(3)).toString();
|
|
80
|
+
}
|
|
81
|
+
function topY(c, x) {
|
|
82
|
+
const dx2 = (x - c.x) ** 2;
|
|
83
|
+
const r2 = c.r * c.r;
|
|
84
|
+
if (dx2 > r2 + 1e-9) return Infinity;
|
|
85
|
+
return c.y - Math.sqrt(Math.max(0, r2 - dx2));
|
|
86
|
+
}
|
|
87
|
+
function arcToBeziers(cx, cy, r, thetaStart, thetaEnd) {
|
|
88
|
+
const total = thetaEnd - thetaStart;
|
|
89
|
+
const pieces = Math.max(1, Math.ceil(Math.abs(total) / (Math.PI / 2)));
|
|
90
|
+
const step = total / pieces;
|
|
91
|
+
const k = 4 / 3 * Math.tan(step / 4);
|
|
92
|
+
let out = "";
|
|
93
|
+
for (let i = 0; i < pieces; i++) {
|
|
94
|
+
const a = thetaStart + step * i;
|
|
95
|
+
const b = a + step;
|
|
96
|
+
const cosA = Math.cos(a);
|
|
97
|
+
const sinA = Math.sin(a);
|
|
98
|
+
const cosB = Math.cos(b);
|
|
99
|
+
const sinB = Math.sin(b);
|
|
100
|
+
const ex = cx + r * cosB;
|
|
101
|
+
const ey = cy + r * sinB;
|
|
102
|
+
const c1x = cx + r * cosA - k * r * sinA;
|
|
103
|
+
const c1y = cy + r * sinA + k * r * cosA;
|
|
104
|
+
const c2x = ex + k * r * sinB;
|
|
105
|
+
const c2y = ey - k * r * cosB;
|
|
106
|
+
out += ` C ${fmt(c1x)},${fmt(c1y)} ${fmt(c2x)},${fmt(c2y)} ${fmt(ex)},${fmt(ey)}`;
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
function generateCumulus(width, height, puffiness, rng) {
|
|
111
|
+
const rBase = height * (0.135 + rng() * 0.035);
|
|
112
|
+
const rMax = rBase * 1.15;
|
|
113
|
+
const rMin = rBase * 0.8;
|
|
114
|
+
const rAvg = rBase;
|
|
115
|
+
const margin = rMax;
|
|
116
|
+
const xLeft = margin;
|
|
117
|
+
const xRight = width - margin;
|
|
118
|
+
const baseY = height * 0.95;
|
|
119
|
+
const topMargin = height * 0.02;
|
|
120
|
+
let rLeft = rAvg + rng() * (rMax - rAvg);
|
|
121
|
+
let rRight = rAvg + rng() * (rMax - rAvg);
|
|
122
|
+
if (rLeft / rRight > 1.5) rLeft = rRight * 1.5;
|
|
123
|
+
else if (rRight / rLeft > 1.5) rRight = rLeft * 1.5;
|
|
124
|
+
const leftCircle = { x: xLeft, y: baseY - rLeft, r: rLeft };
|
|
125
|
+
const rightCircle = { x: xRight, y: baseY - rRight, r: rRight };
|
|
126
|
+
const spanCenters = xRight - xLeft;
|
|
127
|
+
const fillCount = Math.max(3, Math.min(8, Math.round(spanCenters / (1.2 * rAvg)) - 1));
|
|
128
|
+
const fillCircles = [];
|
|
129
|
+
const spacing = spanCenters / (fillCount + 1);
|
|
130
|
+
for (let i = 0; i < fillCount; i++) {
|
|
131
|
+
const t = (i + 1) / (fillCount + 1);
|
|
132
|
+
const r = rBase * (0.9 + rng() * 0.2);
|
|
133
|
+
const rawX = xLeft + t * spanCenters + (rng() - 0.5) * spacing * 0.3;
|
|
134
|
+
const x = Math.max(xLeft, Math.min(xRight, rawX));
|
|
135
|
+
fillCircles.push({ x, y: baseY - r, r });
|
|
136
|
+
}
|
|
137
|
+
const allCircles = [leftCircle, ...fillCircles, rightCircle];
|
|
138
|
+
allCircles.sort((a, b) => a.x - b.x);
|
|
139
|
+
if (allCircles.length > 2) {
|
|
140
|
+
const leftNbr = allCircles[1];
|
|
141
|
+
const rEdgeL = allCircles[0].r;
|
|
142
|
+
leftNbr.r = Math.max(0.85 * rEdgeL, Math.min(rEdgeL, leftNbr.r));
|
|
143
|
+
leftNbr.y = baseY - leftNbr.r;
|
|
144
|
+
const rightNbr = allCircles[allCircles.length - 2];
|
|
145
|
+
const rEdgeR = allCircles[allCircles.length - 1].r;
|
|
146
|
+
rightNbr.r = Math.max(0.85 * rEdgeR, Math.min(rEdgeR, rightNbr.r));
|
|
147
|
+
rightNbr.y = baseY - rightNbr.r;
|
|
148
|
+
}
|
|
149
|
+
for (let i = 1; i < allCircles.length; i++) {
|
|
150
|
+
const prev = allCircles[i - 1];
|
|
151
|
+
const curr = allCircles[i];
|
|
152
|
+
const overlapTarget = 0.4 * Math.min(prev.r, curr.r);
|
|
153
|
+
const need = curr.x - prev.x + overlapTarget - (prev.r + curr.r);
|
|
154
|
+
if (need > 0) {
|
|
155
|
+
if (i === 1) {
|
|
156
|
+
curr.r += need;
|
|
157
|
+
curr.y = baseY - curr.r;
|
|
158
|
+
} else if (i === allCircles.length - 1) {
|
|
159
|
+
prev.r += need;
|
|
160
|
+
prev.y = baseY - prev.r;
|
|
161
|
+
} else {
|
|
162
|
+
prev.r += need / 2;
|
|
163
|
+
prev.y = baseY - prev.r;
|
|
164
|
+
curr.r += need / 2;
|
|
165
|
+
curr.y = baseY - curr.r;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
for (let i = 1; i < allCircles.length - 1; i++) {
|
|
170
|
+
const c = allCircles[i];
|
|
171
|
+
if (c.y < c.r + topMargin) c.y = c.r + topMargin;
|
|
172
|
+
}
|
|
173
|
+
const outerLeft = leftCircle.x - leftCircle.r;
|
|
174
|
+
const outerRight = rightCircle.x + rightCircle.r;
|
|
175
|
+
const envAt = (px) => {
|
|
176
|
+
let m = Infinity;
|
|
177
|
+
for (const c of allCircles) {
|
|
178
|
+
const y = topY(c, px);
|
|
179
|
+
if (y < m) m = y;
|
|
180
|
+
}
|
|
181
|
+
return m;
|
|
182
|
+
};
|
|
183
|
+
const topLimit = topMargin + height * 0.1;
|
|
184
|
+
const numTurrets = Math.round(puffiness * (3 + rng() * 1.5));
|
|
185
|
+
for (let turret = 0; turret < numTurrets; turret++) {
|
|
186
|
+
let cx = outerLeft + rBase + rng() * (outerRight - outerLeft - 2 * rBase);
|
|
187
|
+
const turretR = rBase * (1.15 + rng() * 0.55);
|
|
188
|
+
const halfW0 = turretR * (0.8 + rng() * 0.8);
|
|
189
|
+
const drama = rng() ** 1.8;
|
|
190
|
+
const baseTop = envAt(cx);
|
|
191
|
+
if (!isFinite(baseTop)) continue;
|
|
192
|
+
const targetTop = baseTop - drama * (baseTop - topLimit);
|
|
193
|
+
if (targetTop >= baseTop) continue;
|
|
194
|
+
for (let step2 = 0; step2 < 16; step2++) {
|
|
195
|
+
const envY = envAt(cx);
|
|
196
|
+
if (!isFinite(envY) || envY <= targetTop) break;
|
|
197
|
+
const frac = (envY - targetTop) / (baseTop - targetTop);
|
|
198
|
+
const halfW = turretR * 0.5 + halfW0 * Math.max(0, frac);
|
|
199
|
+
const count = Math.max(1, Math.round(2 * halfW / (0.85 * turretR)));
|
|
200
|
+
for (let j = 0; j < count; j++) {
|
|
201
|
+
const t = count === 1 ? 0.5 : j / (count - 1);
|
|
202
|
+
const px = Math.max(
|
|
203
|
+
outerLeft,
|
|
204
|
+
Math.min(outerRight, cx - halfW + t * 2 * halfW + (rng() - 0.5) * turretR * 0.3)
|
|
205
|
+
);
|
|
206
|
+
const overlapFrac = 0.46 + rng() * 0.1;
|
|
207
|
+
const rRaw = turretR * (0.85 + rng() * 0.3);
|
|
208
|
+
const envYp = envAt(px);
|
|
209
|
+
if (!isFinite(envYp) || envYp <= topLimit) continue;
|
|
210
|
+
let r = Math.min(rRaw, (envYp - topLimit) / (1 - overlapFrac));
|
|
211
|
+
for (let k = 0; k < 8 && r > rMin * 0.6; k++) {
|
|
212
|
+
const support = Math.max(envAt(px - r * 0.8), envAt(px + r * 0.8));
|
|
213
|
+
if (isFinite(support) && support - envYp <= 0.5 * r) break;
|
|
214
|
+
r *= 0.85;
|
|
215
|
+
}
|
|
216
|
+
if (r < rMin * 0.6) continue;
|
|
217
|
+
allCircles.push({ x: px, y: envYp + r * overlapFrac, r });
|
|
218
|
+
}
|
|
219
|
+
cx += (rng() - 0.5) * turretR * 0.5;
|
|
220
|
+
cx = Math.max(outerLeft + turretR, Math.min(outerRight - turretR, cx));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const domAt = (px) => {
|
|
224
|
+
let best = 0;
|
|
225
|
+
let minY = Infinity;
|
|
226
|
+
for (let k = 0; k < allCircles.length; k++) {
|
|
227
|
+
const y = topY(allCircles[k], px);
|
|
228
|
+
if (y < minY) {
|
|
229
|
+
minY = y;
|
|
230
|
+
best = k;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return best;
|
|
234
|
+
};
|
|
235
|
+
const span = outerRight - outerLeft;
|
|
236
|
+
const nSamples = Math.max(400, Math.ceil(span * 4));
|
|
237
|
+
const step = span / nSamples;
|
|
238
|
+
const segs = [];
|
|
239
|
+
let segStart = outerLeft;
|
|
240
|
+
let prevDom = domAt(outerLeft);
|
|
241
|
+
let prevX = outerLeft;
|
|
242
|
+
for (let s = 1; s <= nSamples; s++) {
|
|
243
|
+
const x = outerLeft + s * step;
|
|
244
|
+
const dom = domAt(x);
|
|
245
|
+
if (dom !== prevDom) {
|
|
246
|
+
let lo = prevX;
|
|
247
|
+
let hi = x;
|
|
248
|
+
for (let k = 0; k < 40; k++) {
|
|
249
|
+
const mid = (lo + hi) / 2;
|
|
250
|
+
if (domAt(mid) === prevDom) lo = mid;
|
|
251
|
+
else hi = mid;
|
|
252
|
+
}
|
|
253
|
+
const tx = (lo + hi) / 2;
|
|
254
|
+
segs.push({ x0: segStart, x1: tx, ci: prevDom });
|
|
255
|
+
segStart = tx;
|
|
256
|
+
prevDom = dom;
|
|
257
|
+
}
|
|
258
|
+
prevX = x;
|
|
259
|
+
}
|
|
260
|
+
segs.push({ x0: segStart, x1: outerRight, ci: prevDom });
|
|
261
|
+
let topPath = "";
|
|
262
|
+
for (let i = segs.length - 1; i >= 0; i--) {
|
|
263
|
+
const { x0, x1, ci } = segs[i];
|
|
264
|
+
const top = allCircles[ci];
|
|
265
|
+
const cx1 = Math.max(top.x - top.r, Math.min(top.x + top.r, x1));
|
|
266
|
+
const cx0 = Math.max(top.x - top.r, Math.min(top.x + top.r, x0));
|
|
267
|
+
const yStart = topY(top, cx1);
|
|
268
|
+
const yEnd = topY(top, cx0);
|
|
269
|
+
let thetaStart = Math.atan2(yStart - top.y, cx1 - top.x);
|
|
270
|
+
let thetaEnd = Math.atan2(yEnd - top.y, cx0 - top.x);
|
|
271
|
+
if (thetaStart > 0) thetaStart -= 2 * Math.PI;
|
|
272
|
+
if (thetaEnd > 0) thetaEnd -= 2 * Math.PI;
|
|
273
|
+
topPath += arcToBeziers(top.x, top.y, top.r, thetaStart, thetaEnd);
|
|
274
|
+
}
|
|
275
|
+
const rL = leftCircle.r;
|
|
276
|
+
const rR = rightCircle.r;
|
|
277
|
+
const rightOuter = arcToBeziers(xRight, baseY - rR, rR, Math.PI / 2, 0);
|
|
278
|
+
const leftOuter = arcToBeziers(xLeft, baseY - rL, rL, Math.PI, Math.PI / 2);
|
|
279
|
+
return [
|
|
280
|
+
`M ${fmt(xLeft)},${fmt(baseY)}`,
|
|
281
|
+
`L ${fmt(xRight)},${fmt(baseY)}`,
|
|
282
|
+
rightOuter.trim(),
|
|
283
|
+
topPath.trim(),
|
|
284
|
+
leftOuter.trim(),
|
|
285
|
+
"Z"
|
|
286
|
+
].join(" ");
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// src/geometry.ts
|
|
290
|
+
function smoothPolygon(pts, alpha = 0.5) {
|
|
291
|
+
const n = pts.length;
|
|
292
|
+
const segments = [];
|
|
293
|
+
const knot = (a, b) => Math.max(Math.hypot(b.x - a.x, b.y - a.y), 1e-9) ** alpha;
|
|
294
|
+
for (let i = 0; i < n; i++) {
|
|
295
|
+
const p0 = pts[(i - 1 + n) % n];
|
|
296
|
+
const p1 = pts[i];
|
|
297
|
+
const p2 = pts[(i + 1) % n];
|
|
298
|
+
const p3 = pts[(i + 2) % n];
|
|
299
|
+
const d1 = knot(p0, p1);
|
|
300
|
+
const d2 = knot(p1, p2);
|
|
301
|
+
const d3 = knot(p2, p3);
|
|
302
|
+
const m1x = ((p1.x - p0.x) / d1 - (p2.x - p0.x) / (d1 + d2) + (p2.x - p1.x) / d2) * d2;
|
|
303
|
+
const m1y = ((p1.y - p0.y) / d1 - (p2.y - p0.y) / (d1 + d2) + (p2.y - p1.y) / d2) * d2;
|
|
304
|
+
const m2x = ((p2.x - p1.x) / d2 - (p3.x - p1.x) / (d2 + d3) + (p3.x - p2.x) / d3) * d2;
|
|
305
|
+
const m2y = ((p2.y - p1.y) / d2 - (p3.y - p1.y) / (d2 + d3) + (p3.y - p2.y) / d3) * d2;
|
|
306
|
+
const cp1 = { x: p1.x + m1x / 3, y: p1.y + m1y / 3 };
|
|
307
|
+
const cp2 = { x: p2.x - m2x / 3, y: p2.y - m2y / 3 };
|
|
308
|
+
segments.push({ p: p2, cp1, cp2 });
|
|
309
|
+
}
|
|
310
|
+
return segments;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// src/path.ts
|
|
314
|
+
function fmt2(n) {
|
|
315
|
+
return Number(n.toFixed(3)).toString();
|
|
316
|
+
}
|
|
317
|
+
function p(pt) {
|
|
318
|
+
return `${fmt2(pt.x)},${fmt2(pt.y)}`;
|
|
319
|
+
}
|
|
320
|
+
function polygonToPath(polygon) {
|
|
321
|
+
if (polygon.length < 3) return "";
|
|
322
|
+
const segments = smoothPolygon(polygon);
|
|
323
|
+
const start = polygon[0];
|
|
324
|
+
let d = `M ${p(start)}`;
|
|
325
|
+
for (const seg of segments) {
|
|
326
|
+
d += ` C ${p(seg.cp1)} ${p(seg.cp2)} ${p(seg.p)}`;
|
|
327
|
+
}
|
|
328
|
+
d += " Z";
|
|
329
|
+
return d;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// src/generators/stratus.ts
|
|
333
|
+
var TWO_PI = Math.PI * 2;
|
|
334
|
+
function makeWave(rng, fundamental, harmonics, decay) {
|
|
335
|
+
const terms = [];
|
|
336
|
+
let norm = 0;
|
|
337
|
+
for (let k = 1; k <= harmonics; k++) {
|
|
338
|
+
const weight = 1 / k ** decay;
|
|
339
|
+
norm += weight;
|
|
340
|
+
terms.push({ freq: fundamental * k, weight, phase: rng() * TWO_PI });
|
|
341
|
+
}
|
|
342
|
+
const wave = (u) => {
|
|
343
|
+
let v = 0;
|
|
344
|
+
for (const t of terms) v += t.weight * Math.sin(TWO_PI * t.freq * u + t.phase);
|
|
345
|
+
return v / norm;
|
|
346
|
+
};
|
|
347
|
+
const maxFreq = fundamental * harmonics;
|
|
348
|
+
return { wave, maxFreq };
|
|
349
|
+
}
|
|
350
|
+
function smoothstep(t) {
|
|
351
|
+
if (t <= 0) return 0;
|
|
352
|
+
if (t >= 1) return 1;
|
|
353
|
+
return t * t * (3 - 2 * t);
|
|
354
|
+
}
|
|
355
|
+
function generateStratus(width, height, puffiness, rng) {
|
|
356
|
+
const midY = height / 2;
|
|
357
|
+
const topMargin = height * 0.06;
|
|
358
|
+
const lumpAmp = 0.08 + puffiness * 0.24;
|
|
359
|
+
let maxHalf = height * (0.1 + puffiness * 0.18);
|
|
360
|
+
const capR = maxHalf * (0.85 + rng() * 0.1);
|
|
361
|
+
let waveAmp = height * (0.07 + rng() * 0.04);
|
|
362
|
+
waveAmp = Math.min(waveAmp, maxHalf * 0.7);
|
|
363
|
+
let maxOffset = maxHalf * (1 + lumpAmp);
|
|
364
|
+
const halfBudget = height / 2 - topMargin;
|
|
365
|
+
if (waveAmp + maxOffset > halfBudget) {
|
|
366
|
+
waveAmp = Math.max(0, halfBudget - maxOffset);
|
|
367
|
+
if (waveAmp + maxOffset > halfBudget) {
|
|
368
|
+
maxOffset = halfBudget - waveAmp;
|
|
369
|
+
maxHalf = maxOffset / (1 + lumpAmp);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
const margin = width * 0.02;
|
|
373
|
+
const capBuffer = capR * (1 + lumpAmp) + 1;
|
|
374
|
+
const x0 = margin + capBuffer;
|
|
375
|
+
const x1 = width - margin - capBuffer;
|
|
376
|
+
const span = x1 - x0;
|
|
377
|
+
const spineWave = makeWave(rng, 1 + rng() * 1, 3, 1.5);
|
|
378
|
+
const spineY = (u) => midY + waveAmp * spineWave.wave(u);
|
|
379
|
+
const edgeEase = 0.12;
|
|
380
|
+
const baseHalf = (u) => {
|
|
381
|
+
let e;
|
|
382
|
+
if (u < edgeEase) e = smoothstep(u / edgeEase);
|
|
383
|
+
else if (u > 1 - edgeEase) e = smoothstep((1 - u) / edgeEase);
|
|
384
|
+
else e = 1;
|
|
385
|
+
return capR + (maxHalf - capR) * e;
|
|
386
|
+
};
|
|
387
|
+
const lumpUp = makeWave(rng, 2 + rng() * 1, 2, 1);
|
|
388
|
+
const lumpLo = makeWave(rng, 2 + rng() * 1, 2, 1);
|
|
389
|
+
const upperOffset = (u) => baseHalf(u) * (1 + lumpAmp * lumpUp.wave(u));
|
|
390
|
+
const lowerOffset = (u) => baseHalf(u) * (1 + lumpAmp * lumpLo.wave(u));
|
|
391
|
+
const maxFreq = Math.max(spineWave.maxFreq, lumpUp.maxFreq, lumpLo.maxFreq);
|
|
392
|
+
const N = Math.max(14, Math.min(26, Math.round(maxFreq * 3.5) + 2));
|
|
393
|
+
const upper = [];
|
|
394
|
+
const lower = [];
|
|
395
|
+
for (let i = 0; i < N; i++) {
|
|
396
|
+
const u = i / (N - 1);
|
|
397
|
+
const x = x0 + u * span;
|
|
398
|
+
const sy = spineY(u);
|
|
399
|
+
upper.push({ x, y: sy - upperOffset(u) });
|
|
400
|
+
lower.push({ x, y: sy + lowerOffset(u) });
|
|
401
|
+
}
|
|
402
|
+
const capN = 4;
|
|
403
|
+
function cap(u, x, topOff, botOff, side) {
|
|
404
|
+
const sy = spineY(u);
|
|
405
|
+
const pts = [];
|
|
406
|
+
for (let k = 1; k <= capN; k++) {
|
|
407
|
+
const t = k / (capN + 1);
|
|
408
|
+
const angle = -Math.PI / 2 + t * Math.PI;
|
|
409
|
+
const r = topOff + (botOff - topOff) * t;
|
|
410
|
+
pts.push({ x: x + side * r * Math.cos(angle), y: sy + r * Math.sin(angle) });
|
|
411
|
+
}
|
|
412
|
+
return pts;
|
|
413
|
+
}
|
|
414
|
+
const loop = [...upper];
|
|
415
|
+
loop.push(...cap(1, x1, upperOffset(1), lowerOffset(1), 1));
|
|
416
|
+
for (let i = N - 1; i >= 0; i--) loop.push(lower[i]);
|
|
417
|
+
loop.push(...cap(0, x0, upperOffset(0), lowerOffset(0), -1).reverse());
|
|
418
|
+
return polygonToPath(loop);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// src/generators/cirrus.ts
|
|
422
|
+
function smoothstep2(t) {
|
|
423
|
+
if (t <= 0) return 0;
|
|
424
|
+
if (t >= 1) return 1;
|
|
425
|
+
return t * t * (3 - 2 * t);
|
|
426
|
+
}
|
|
427
|
+
function generateCirrus(width, height, puffiness, rng) {
|
|
428
|
+
const margin = Math.min(width, height) * 0.05;
|
|
429
|
+
const availW = width - 2 * margin;
|
|
430
|
+
const availH = height - 2 * margin;
|
|
431
|
+
const frameDiag = Math.hypot(width, height);
|
|
432
|
+
const baseTheta = -0.3 + (rng() - 0.5) * 0.4;
|
|
433
|
+
const K = 3 + Math.floor(rng() * 4);
|
|
434
|
+
const paths = [];
|
|
435
|
+
for (let w = 0; w < K; w++) {
|
|
436
|
+
const theta0 = baseTheta + (rng() - 0.5) * 0.25;
|
|
437
|
+
const curl = 0.6 + rng() * 0.5 + puffiness * 0.6;
|
|
438
|
+
const curlStart = 0.55 + rng() * 0.15;
|
|
439
|
+
const L = frameDiag * (0.28 + rng() * 0.24);
|
|
440
|
+
const maxHalf = L * (0.026 + rng() * 0.022) * (0.7 + puffiness * 0.7);
|
|
441
|
+
const uPeak = 0.66 + rng() * 0.12;
|
|
442
|
+
const lumpAmt = 0.1 + rng() * 0.08;
|
|
443
|
+
const lumpFreq = 2 + Math.floor(rng() * 2);
|
|
444
|
+
const lumpPhase = rng() * Math.PI * 2;
|
|
445
|
+
const N = 9;
|
|
446
|
+
const spine = [];
|
|
447
|
+
let px = 0;
|
|
448
|
+
let py = 0;
|
|
449
|
+
const ds = L / (N - 1);
|
|
450
|
+
for (let i = 0; i < N; i++) {
|
|
451
|
+
const u = i / (N - 1);
|
|
452
|
+
spine.push({ x: px, y: py });
|
|
453
|
+
const h = theta0 - curl * smoothstep2((u - curlStart) / (1 - curlStart));
|
|
454
|
+
px += ds * Math.cos(h);
|
|
455
|
+
py += ds * Math.sin(h);
|
|
456
|
+
}
|
|
457
|
+
const halfT = (u) => {
|
|
458
|
+
const bump = smoothstep2(Math.min(u / uPeak, (1 - u) / (1 - uPeak)));
|
|
459
|
+
const lump = 1 + lumpAmt * Math.sin(lumpFreq * Math.PI * u + lumpPhase);
|
|
460
|
+
return maxHalf * bump * lump;
|
|
461
|
+
};
|
|
462
|
+
const upper = [];
|
|
463
|
+
const lower = [];
|
|
464
|
+
for (let i = 0; i < N; i++) {
|
|
465
|
+
const u = i / (N - 1);
|
|
466
|
+
const a = spine[Math.max(0, i - 1)];
|
|
467
|
+
const b = spine[Math.min(N - 1, i + 1)];
|
|
468
|
+
const tx = b.x - a.x;
|
|
469
|
+
const ty = b.y - a.y;
|
|
470
|
+
const len = Math.hypot(tx, ty) || 1;
|
|
471
|
+
const nx = -ty / len;
|
|
472
|
+
const ny = tx / len;
|
|
473
|
+
const t = halfT(u);
|
|
474
|
+
upper.push({ x: spine[i].x + t * nx, y: spine[i].y + t * ny });
|
|
475
|
+
lower.push({ x: spine[i].x - t * nx, y: spine[i].y - t * ny });
|
|
476
|
+
}
|
|
477
|
+
const loop = [spine[0]];
|
|
478
|
+
for (let i = 1; i < N - 1; i++) loop.push(upper[i]);
|
|
479
|
+
loop.push(spine[N - 1]);
|
|
480
|
+
for (let i = N - 2; i >= 1; i--) loop.push(lower[i]);
|
|
481
|
+
let minX = Infinity;
|
|
482
|
+
let minY = Infinity;
|
|
483
|
+
let maxX = -Infinity;
|
|
484
|
+
let maxY = -Infinity;
|
|
485
|
+
for (const pt of loop) {
|
|
486
|
+
if (pt.x < minX) minX = pt.x;
|
|
487
|
+
if (pt.y < minY) minY = pt.y;
|
|
488
|
+
if (pt.x > maxX) maxX = pt.x;
|
|
489
|
+
if (pt.y > maxY) maxY = pt.y;
|
|
490
|
+
}
|
|
491
|
+
const bw = maxX - minX || 1;
|
|
492
|
+
const bh = maxY - minY || 1;
|
|
493
|
+
const s = Math.min(1, availW / bw, availH / bh);
|
|
494
|
+
const placedW = s * bw;
|
|
495
|
+
const placedH = s * bh;
|
|
496
|
+
const ox = margin + rng() * (availW - placedW);
|
|
497
|
+
const oy = margin + rng() * (availH - placedH);
|
|
498
|
+
const placed = loop.map((pt) => ({
|
|
499
|
+
x: ox + (pt.x - minX) * s,
|
|
500
|
+
y: oy + (pt.y - minY) * s
|
|
501
|
+
}));
|
|
502
|
+
paths.push(polygonToPath(placed));
|
|
503
|
+
}
|
|
504
|
+
return paths;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// src/index.ts
|
|
508
|
+
var DEFAULT_DIMS = {
|
|
509
|
+
cumulus: { width: 300, height: 180 },
|
|
510
|
+
stratus: { width: 1200, height: 120 },
|
|
511
|
+
cirrus: { width: 400, height: 200 }
|
|
512
|
+
};
|
|
513
|
+
function buildCloudResult(dims, buildPaths, options) {
|
|
514
|
+
const { seed = Math.floor(Math.random() * 4294967295), style = {} } = options;
|
|
515
|
+
const { width = dims.width, height = dims.height } = options;
|
|
516
|
+
const rng = createRng(seed);
|
|
517
|
+
const puffiness = options.puffiness ?? rng();
|
|
518
|
+
const paths = buildPaths(width, height, puffiness, rng);
|
|
519
|
+
const svg = assembleSvg(width, height, paths, style);
|
|
520
|
+
return makeResult(svg);
|
|
521
|
+
}
|
|
522
|
+
function generateCumulus2(options = {}) {
|
|
523
|
+
return buildCloudResult(DEFAULT_DIMS.cumulus, (w, h, p2, r) => [generateCumulus(w, h, p2, r)], options);
|
|
524
|
+
}
|
|
525
|
+
function generateStratus2(options = {}) {
|
|
526
|
+
return buildCloudResult(DEFAULT_DIMS.stratus, (w, h, p2, r) => [generateStratus(w, h, p2, r)], options);
|
|
527
|
+
}
|
|
528
|
+
function generateCirrus2(options = {}) {
|
|
529
|
+
return buildCloudResult(DEFAULT_DIMS.cirrus, generateCirrus, options);
|
|
530
|
+
}
|
|
531
|
+
function generateCloud(options = {}) {
|
|
532
|
+
const { type = "cumulus" } = options;
|
|
533
|
+
switch (type) {
|
|
534
|
+
case "cumulus":
|
|
535
|
+
return generateCumulus2(options);
|
|
536
|
+
case "stratus":
|
|
537
|
+
return generateStratus2(options);
|
|
538
|
+
case "cirrus":
|
|
539
|
+
return generateCirrus2(options);
|
|
540
|
+
default:
|
|
541
|
+
throw new Error(`Unknown cloud type: ${type}`);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
545
|
+
0 && (module.exports = {
|
|
546
|
+
generateCirrus,
|
|
547
|
+
generateCloud,
|
|
548
|
+
generateCumulus,
|
|
549
|
+
generateStratus
|
|
550
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
type CloudType = 'cumulus' | 'stratus' | 'cirrus';
|
|
2
|
+
interface CloudStyle {
|
|
3
|
+
fill?: string;
|
|
4
|
+
stroke?: string;
|
|
5
|
+
strokeWidth?: number;
|
|
6
|
+
opacity?: number;
|
|
7
|
+
}
|
|
8
|
+
interface CloudOptions {
|
|
9
|
+
type?: CloudType;
|
|
10
|
+
width?: number;
|
|
11
|
+
height?: number;
|
|
12
|
+
seed?: number;
|
|
13
|
+
puffiness?: number;
|
|
14
|
+
style?: CloudStyle;
|
|
15
|
+
}
|
|
16
|
+
type CloudTypeOptions = Omit<CloudOptions, 'type'>;
|
|
17
|
+
interface CloudResult {
|
|
18
|
+
svg: string;
|
|
19
|
+
element(): SVGSVGElement;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
declare function generateCumulus(options?: CloudTypeOptions): CloudResult;
|
|
23
|
+
declare function generateStratus(options?: CloudTypeOptions): CloudResult;
|
|
24
|
+
declare function generateCirrus(options?: CloudTypeOptions): CloudResult;
|
|
25
|
+
declare function generateCloud(options?: CloudOptions): CloudResult;
|
|
26
|
+
|
|
27
|
+
export { type CloudOptions, type CloudResult, type CloudStyle, type CloudType, type CloudTypeOptions, generateCirrus, generateCloud, generateCumulus, generateStratus };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
type CloudType = 'cumulus' | 'stratus' | 'cirrus';
|
|
2
|
+
interface CloudStyle {
|
|
3
|
+
fill?: string;
|
|
4
|
+
stroke?: string;
|
|
5
|
+
strokeWidth?: number;
|
|
6
|
+
opacity?: number;
|
|
7
|
+
}
|
|
8
|
+
interface CloudOptions {
|
|
9
|
+
type?: CloudType;
|
|
10
|
+
width?: number;
|
|
11
|
+
height?: number;
|
|
12
|
+
seed?: number;
|
|
13
|
+
puffiness?: number;
|
|
14
|
+
style?: CloudStyle;
|
|
15
|
+
}
|
|
16
|
+
type CloudTypeOptions = Omit<CloudOptions, 'type'>;
|
|
17
|
+
interface CloudResult {
|
|
18
|
+
svg: string;
|
|
19
|
+
element(): SVGSVGElement;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
declare function generateCumulus(options?: CloudTypeOptions): CloudResult;
|
|
23
|
+
declare function generateStratus(options?: CloudTypeOptions): CloudResult;
|
|
24
|
+
declare function generateCirrus(options?: CloudTypeOptions): CloudResult;
|
|
25
|
+
declare function generateCloud(options?: CloudOptions): CloudResult;
|
|
26
|
+
|
|
27
|
+
export { type CloudOptions, type CloudResult, type CloudStyle, type CloudType, type CloudTypeOptions, generateCirrus, generateCloud, generateCumulus, generateStratus };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
// src/rng.ts
|
|
2
|
+
function createRng(seed) {
|
|
3
|
+
let s = seed >>> 0;
|
|
4
|
+
return function() {
|
|
5
|
+
s += 1831565813;
|
|
6
|
+
let t = s;
|
|
7
|
+
t = Math.imul(t ^ t >>> 15, t | 1);
|
|
8
|
+
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
|
|
9
|
+
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// src/svg.ts
|
|
14
|
+
function buildPathAttrs(style) {
|
|
15
|
+
const attrs = [];
|
|
16
|
+
attrs.push(`fill="${style.fill ?? "white"}"`);
|
|
17
|
+
if (style.stroke && style.stroke !== "none") {
|
|
18
|
+
attrs.push(`stroke="${style.stroke}"`);
|
|
19
|
+
attrs.push(`stroke-width="${style.strokeWidth ?? 1}"`);
|
|
20
|
+
}
|
|
21
|
+
if (style.opacity !== void 0 && style.opacity !== 1) {
|
|
22
|
+
attrs.push(`opacity="${style.opacity}"`);
|
|
23
|
+
}
|
|
24
|
+
return attrs.join(" ");
|
|
25
|
+
}
|
|
26
|
+
function assembleSvg(width, height, paths, style) {
|
|
27
|
+
const attrs = buildPathAttrs(style);
|
|
28
|
+
const pathElements = paths.filter((d) => d.length > 0).map((d) => `<path d="${d}" ${attrs}/>`).join("\n ");
|
|
29
|
+
return [
|
|
30
|
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">`,
|
|
31
|
+
` ${pathElements}`,
|
|
32
|
+
`</svg>`
|
|
33
|
+
].join("\n");
|
|
34
|
+
}
|
|
35
|
+
function makeResult(svgString) {
|
|
36
|
+
return {
|
|
37
|
+
svg: svgString,
|
|
38
|
+
element() {
|
|
39
|
+
if (typeof DOMParser === "undefined") {
|
|
40
|
+
throw new Error("element() requires a browser environment with DOMParser");
|
|
41
|
+
}
|
|
42
|
+
const doc = new DOMParser().parseFromString(svgString, "image/svg+xml");
|
|
43
|
+
return doc.documentElement;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// src/generators/cumulus.ts
|
|
49
|
+
function fmt(n) {
|
|
50
|
+
return Number(n.toFixed(3)).toString();
|
|
51
|
+
}
|
|
52
|
+
function topY(c, x) {
|
|
53
|
+
const dx2 = (x - c.x) ** 2;
|
|
54
|
+
const r2 = c.r * c.r;
|
|
55
|
+
if (dx2 > r2 + 1e-9) return Infinity;
|
|
56
|
+
return c.y - Math.sqrt(Math.max(0, r2 - dx2));
|
|
57
|
+
}
|
|
58
|
+
function arcToBeziers(cx, cy, r, thetaStart, thetaEnd) {
|
|
59
|
+
const total = thetaEnd - thetaStart;
|
|
60
|
+
const pieces = Math.max(1, Math.ceil(Math.abs(total) / (Math.PI / 2)));
|
|
61
|
+
const step = total / pieces;
|
|
62
|
+
const k = 4 / 3 * Math.tan(step / 4);
|
|
63
|
+
let out = "";
|
|
64
|
+
for (let i = 0; i < pieces; i++) {
|
|
65
|
+
const a = thetaStart + step * i;
|
|
66
|
+
const b = a + step;
|
|
67
|
+
const cosA = Math.cos(a);
|
|
68
|
+
const sinA = Math.sin(a);
|
|
69
|
+
const cosB = Math.cos(b);
|
|
70
|
+
const sinB = Math.sin(b);
|
|
71
|
+
const ex = cx + r * cosB;
|
|
72
|
+
const ey = cy + r * sinB;
|
|
73
|
+
const c1x = cx + r * cosA - k * r * sinA;
|
|
74
|
+
const c1y = cy + r * sinA + k * r * cosA;
|
|
75
|
+
const c2x = ex + k * r * sinB;
|
|
76
|
+
const c2y = ey - k * r * cosB;
|
|
77
|
+
out += ` C ${fmt(c1x)},${fmt(c1y)} ${fmt(c2x)},${fmt(c2y)} ${fmt(ex)},${fmt(ey)}`;
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
function generateCumulus(width, height, puffiness, rng) {
|
|
82
|
+
const rBase = height * (0.135 + rng() * 0.035);
|
|
83
|
+
const rMax = rBase * 1.15;
|
|
84
|
+
const rMin = rBase * 0.8;
|
|
85
|
+
const rAvg = rBase;
|
|
86
|
+
const margin = rMax;
|
|
87
|
+
const xLeft = margin;
|
|
88
|
+
const xRight = width - margin;
|
|
89
|
+
const baseY = height * 0.95;
|
|
90
|
+
const topMargin = height * 0.02;
|
|
91
|
+
let rLeft = rAvg + rng() * (rMax - rAvg);
|
|
92
|
+
let rRight = rAvg + rng() * (rMax - rAvg);
|
|
93
|
+
if (rLeft / rRight > 1.5) rLeft = rRight * 1.5;
|
|
94
|
+
else if (rRight / rLeft > 1.5) rRight = rLeft * 1.5;
|
|
95
|
+
const leftCircle = { x: xLeft, y: baseY - rLeft, r: rLeft };
|
|
96
|
+
const rightCircle = { x: xRight, y: baseY - rRight, r: rRight };
|
|
97
|
+
const spanCenters = xRight - xLeft;
|
|
98
|
+
const fillCount = Math.max(3, Math.min(8, Math.round(spanCenters / (1.2 * rAvg)) - 1));
|
|
99
|
+
const fillCircles = [];
|
|
100
|
+
const spacing = spanCenters / (fillCount + 1);
|
|
101
|
+
for (let i = 0; i < fillCount; i++) {
|
|
102
|
+
const t = (i + 1) / (fillCount + 1);
|
|
103
|
+
const r = rBase * (0.9 + rng() * 0.2);
|
|
104
|
+
const rawX = xLeft + t * spanCenters + (rng() - 0.5) * spacing * 0.3;
|
|
105
|
+
const x = Math.max(xLeft, Math.min(xRight, rawX));
|
|
106
|
+
fillCircles.push({ x, y: baseY - r, r });
|
|
107
|
+
}
|
|
108
|
+
const allCircles = [leftCircle, ...fillCircles, rightCircle];
|
|
109
|
+
allCircles.sort((a, b) => a.x - b.x);
|
|
110
|
+
if (allCircles.length > 2) {
|
|
111
|
+
const leftNbr = allCircles[1];
|
|
112
|
+
const rEdgeL = allCircles[0].r;
|
|
113
|
+
leftNbr.r = Math.max(0.85 * rEdgeL, Math.min(rEdgeL, leftNbr.r));
|
|
114
|
+
leftNbr.y = baseY - leftNbr.r;
|
|
115
|
+
const rightNbr = allCircles[allCircles.length - 2];
|
|
116
|
+
const rEdgeR = allCircles[allCircles.length - 1].r;
|
|
117
|
+
rightNbr.r = Math.max(0.85 * rEdgeR, Math.min(rEdgeR, rightNbr.r));
|
|
118
|
+
rightNbr.y = baseY - rightNbr.r;
|
|
119
|
+
}
|
|
120
|
+
for (let i = 1; i < allCircles.length; i++) {
|
|
121
|
+
const prev = allCircles[i - 1];
|
|
122
|
+
const curr = allCircles[i];
|
|
123
|
+
const overlapTarget = 0.4 * Math.min(prev.r, curr.r);
|
|
124
|
+
const need = curr.x - prev.x + overlapTarget - (prev.r + curr.r);
|
|
125
|
+
if (need > 0) {
|
|
126
|
+
if (i === 1) {
|
|
127
|
+
curr.r += need;
|
|
128
|
+
curr.y = baseY - curr.r;
|
|
129
|
+
} else if (i === allCircles.length - 1) {
|
|
130
|
+
prev.r += need;
|
|
131
|
+
prev.y = baseY - prev.r;
|
|
132
|
+
} else {
|
|
133
|
+
prev.r += need / 2;
|
|
134
|
+
prev.y = baseY - prev.r;
|
|
135
|
+
curr.r += need / 2;
|
|
136
|
+
curr.y = baseY - curr.r;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
for (let i = 1; i < allCircles.length - 1; i++) {
|
|
141
|
+
const c = allCircles[i];
|
|
142
|
+
if (c.y < c.r + topMargin) c.y = c.r + topMargin;
|
|
143
|
+
}
|
|
144
|
+
const outerLeft = leftCircle.x - leftCircle.r;
|
|
145
|
+
const outerRight = rightCircle.x + rightCircle.r;
|
|
146
|
+
const envAt = (px) => {
|
|
147
|
+
let m = Infinity;
|
|
148
|
+
for (const c of allCircles) {
|
|
149
|
+
const y = topY(c, px);
|
|
150
|
+
if (y < m) m = y;
|
|
151
|
+
}
|
|
152
|
+
return m;
|
|
153
|
+
};
|
|
154
|
+
const topLimit = topMargin + height * 0.1;
|
|
155
|
+
const numTurrets = Math.round(puffiness * (3 + rng() * 1.5));
|
|
156
|
+
for (let turret = 0; turret < numTurrets; turret++) {
|
|
157
|
+
let cx = outerLeft + rBase + rng() * (outerRight - outerLeft - 2 * rBase);
|
|
158
|
+
const turretR = rBase * (1.15 + rng() * 0.55);
|
|
159
|
+
const halfW0 = turretR * (0.8 + rng() * 0.8);
|
|
160
|
+
const drama = rng() ** 1.8;
|
|
161
|
+
const baseTop = envAt(cx);
|
|
162
|
+
if (!isFinite(baseTop)) continue;
|
|
163
|
+
const targetTop = baseTop - drama * (baseTop - topLimit);
|
|
164
|
+
if (targetTop >= baseTop) continue;
|
|
165
|
+
for (let step2 = 0; step2 < 16; step2++) {
|
|
166
|
+
const envY = envAt(cx);
|
|
167
|
+
if (!isFinite(envY) || envY <= targetTop) break;
|
|
168
|
+
const frac = (envY - targetTop) / (baseTop - targetTop);
|
|
169
|
+
const halfW = turretR * 0.5 + halfW0 * Math.max(0, frac);
|
|
170
|
+
const count = Math.max(1, Math.round(2 * halfW / (0.85 * turretR)));
|
|
171
|
+
for (let j = 0; j < count; j++) {
|
|
172
|
+
const t = count === 1 ? 0.5 : j / (count - 1);
|
|
173
|
+
const px = Math.max(
|
|
174
|
+
outerLeft,
|
|
175
|
+
Math.min(outerRight, cx - halfW + t * 2 * halfW + (rng() - 0.5) * turretR * 0.3)
|
|
176
|
+
);
|
|
177
|
+
const overlapFrac = 0.46 + rng() * 0.1;
|
|
178
|
+
const rRaw = turretR * (0.85 + rng() * 0.3);
|
|
179
|
+
const envYp = envAt(px);
|
|
180
|
+
if (!isFinite(envYp) || envYp <= topLimit) continue;
|
|
181
|
+
let r = Math.min(rRaw, (envYp - topLimit) / (1 - overlapFrac));
|
|
182
|
+
for (let k = 0; k < 8 && r > rMin * 0.6; k++) {
|
|
183
|
+
const support = Math.max(envAt(px - r * 0.8), envAt(px + r * 0.8));
|
|
184
|
+
if (isFinite(support) && support - envYp <= 0.5 * r) break;
|
|
185
|
+
r *= 0.85;
|
|
186
|
+
}
|
|
187
|
+
if (r < rMin * 0.6) continue;
|
|
188
|
+
allCircles.push({ x: px, y: envYp + r * overlapFrac, r });
|
|
189
|
+
}
|
|
190
|
+
cx += (rng() - 0.5) * turretR * 0.5;
|
|
191
|
+
cx = Math.max(outerLeft + turretR, Math.min(outerRight - turretR, cx));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const domAt = (px) => {
|
|
195
|
+
let best = 0;
|
|
196
|
+
let minY = Infinity;
|
|
197
|
+
for (let k = 0; k < allCircles.length; k++) {
|
|
198
|
+
const y = topY(allCircles[k], px);
|
|
199
|
+
if (y < minY) {
|
|
200
|
+
minY = y;
|
|
201
|
+
best = k;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return best;
|
|
205
|
+
};
|
|
206
|
+
const span = outerRight - outerLeft;
|
|
207
|
+
const nSamples = Math.max(400, Math.ceil(span * 4));
|
|
208
|
+
const step = span / nSamples;
|
|
209
|
+
const segs = [];
|
|
210
|
+
let segStart = outerLeft;
|
|
211
|
+
let prevDom = domAt(outerLeft);
|
|
212
|
+
let prevX = outerLeft;
|
|
213
|
+
for (let s = 1; s <= nSamples; s++) {
|
|
214
|
+
const x = outerLeft + s * step;
|
|
215
|
+
const dom = domAt(x);
|
|
216
|
+
if (dom !== prevDom) {
|
|
217
|
+
let lo = prevX;
|
|
218
|
+
let hi = x;
|
|
219
|
+
for (let k = 0; k < 40; k++) {
|
|
220
|
+
const mid = (lo + hi) / 2;
|
|
221
|
+
if (domAt(mid) === prevDom) lo = mid;
|
|
222
|
+
else hi = mid;
|
|
223
|
+
}
|
|
224
|
+
const tx = (lo + hi) / 2;
|
|
225
|
+
segs.push({ x0: segStart, x1: tx, ci: prevDom });
|
|
226
|
+
segStart = tx;
|
|
227
|
+
prevDom = dom;
|
|
228
|
+
}
|
|
229
|
+
prevX = x;
|
|
230
|
+
}
|
|
231
|
+
segs.push({ x0: segStart, x1: outerRight, ci: prevDom });
|
|
232
|
+
let topPath = "";
|
|
233
|
+
for (let i = segs.length - 1; i >= 0; i--) {
|
|
234
|
+
const { x0, x1, ci } = segs[i];
|
|
235
|
+
const top = allCircles[ci];
|
|
236
|
+
const cx1 = Math.max(top.x - top.r, Math.min(top.x + top.r, x1));
|
|
237
|
+
const cx0 = Math.max(top.x - top.r, Math.min(top.x + top.r, x0));
|
|
238
|
+
const yStart = topY(top, cx1);
|
|
239
|
+
const yEnd = topY(top, cx0);
|
|
240
|
+
let thetaStart = Math.atan2(yStart - top.y, cx1 - top.x);
|
|
241
|
+
let thetaEnd = Math.atan2(yEnd - top.y, cx0 - top.x);
|
|
242
|
+
if (thetaStart > 0) thetaStart -= 2 * Math.PI;
|
|
243
|
+
if (thetaEnd > 0) thetaEnd -= 2 * Math.PI;
|
|
244
|
+
topPath += arcToBeziers(top.x, top.y, top.r, thetaStart, thetaEnd);
|
|
245
|
+
}
|
|
246
|
+
const rL = leftCircle.r;
|
|
247
|
+
const rR = rightCircle.r;
|
|
248
|
+
const rightOuter = arcToBeziers(xRight, baseY - rR, rR, Math.PI / 2, 0);
|
|
249
|
+
const leftOuter = arcToBeziers(xLeft, baseY - rL, rL, Math.PI, Math.PI / 2);
|
|
250
|
+
return [
|
|
251
|
+
`M ${fmt(xLeft)},${fmt(baseY)}`,
|
|
252
|
+
`L ${fmt(xRight)},${fmt(baseY)}`,
|
|
253
|
+
rightOuter.trim(),
|
|
254
|
+
topPath.trim(),
|
|
255
|
+
leftOuter.trim(),
|
|
256
|
+
"Z"
|
|
257
|
+
].join(" ");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// src/geometry.ts
|
|
261
|
+
function smoothPolygon(pts, alpha = 0.5) {
|
|
262
|
+
const n = pts.length;
|
|
263
|
+
const segments = [];
|
|
264
|
+
const knot = (a, b) => Math.max(Math.hypot(b.x - a.x, b.y - a.y), 1e-9) ** alpha;
|
|
265
|
+
for (let i = 0; i < n; i++) {
|
|
266
|
+
const p0 = pts[(i - 1 + n) % n];
|
|
267
|
+
const p1 = pts[i];
|
|
268
|
+
const p2 = pts[(i + 1) % n];
|
|
269
|
+
const p3 = pts[(i + 2) % n];
|
|
270
|
+
const d1 = knot(p0, p1);
|
|
271
|
+
const d2 = knot(p1, p2);
|
|
272
|
+
const d3 = knot(p2, p3);
|
|
273
|
+
const m1x = ((p1.x - p0.x) / d1 - (p2.x - p0.x) / (d1 + d2) + (p2.x - p1.x) / d2) * d2;
|
|
274
|
+
const m1y = ((p1.y - p0.y) / d1 - (p2.y - p0.y) / (d1 + d2) + (p2.y - p1.y) / d2) * d2;
|
|
275
|
+
const m2x = ((p2.x - p1.x) / d2 - (p3.x - p1.x) / (d2 + d3) + (p3.x - p2.x) / d3) * d2;
|
|
276
|
+
const m2y = ((p2.y - p1.y) / d2 - (p3.y - p1.y) / (d2 + d3) + (p3.y - p2.y) / d3) * d2;
|
|
277
|
+
const cp1 = { x: p1.x + m1x / 3, y: p1.y + m1y / 3 };
|
|
278
|
+
const cp2 = { x: p2.x - m2x / 3, y: p2.y - m2y / 3 };
|
|
279
|
+
segments.push({ p: p2, cp1, cp2 });
|
|
280
|
+
}
|
|
281
|
+
return segments;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// src/path.ts
|
|
285
|
+
function fmt2(n) {
|
|
286
|
+
return Number(n.toFixed(3)).toString();
|
|
287
|
+
}
|
|
288
|
+
function p(pt) {
|
|
289
|
+
return `${fmt2(pt.x)},${fmt2(pt.y)}`;
|
|
290
|
+
}
|
|
291
|
+
function polygonToPath(polygon) {
|
|
292
|
+
if (polygon.length < 3) return "";
|
|
293
|
+
const segments = smoothPolygon(polygon);
|
|
294
|
+
const start = polygon[0];
|
|
295
|
+
let d = `M ${p(start)}`;
|
|
296
|
+
for (const seg of segments) {
|
|
297
|
+
d += ` C ${p(seg.cp1)} ${p(seg.cp2)} ${p(seg.p)}`;
|
|
298
|
+
}
|
|
299
|
+
d += " Z";
|
|
300
|
+
return d;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// src/generators/stratus.ts
|
|
304
|
+
var TWO_PI = Math.PI * 2;
|
|
305
|
+
function makeWave(rng, fundamental, harmonics, decay) {
|
|
306
|
+
const terms = [];
|
|
307
|
+
let norm = 0;
|
|
308
|
+
for (let k = 1; k <= harmonics; k++) {
|
|
309
|
+
const weight = 1 / k ** decay;
|
|
310
|
+
norm += weight;
|
|
311
|
+
terms.push({ freq: fundamental * k, weight, phase: rng() * TWO_PI });
|
|
312
|
+
}
|
|
313
|
+
const wave = (u) => {
|
|
314
|
+
let v = 0;
|
|
315
|
+
for (const t of terms) v += t.weight * Math.sin(TWO_PI * t.freq * u + t.phase);
|
|
316
|
+
return v / norm;
|
|
317
|
+
};
|
|
318
|
+
const maxFreq = fundamental * harmonics;
|
|
319
|
+
return { wave, maxFreq };
|
|
320
|
+
}
|
|
321
|
+
function smoothstep(t) {
|
|
322
|
+
if (t <= 0) return 0;
|
|
323
|
+
if (t >= 1) return 1;
|
|
324
|
+
return t * t * (3 - 2 * t);
|
|
325
|
+
}
|
|
326
|
+
function generateStratus(width, height, puffiness, rng) {
|
|
327
|
+
const midY = height / 2;
|
|
328
|
+
const topMargin = height * 0.06;
|
|
329
|
+
const lumpAmp = 0.08 + puffiness * 0.24;
|
|
330
|
+
let maxHalf = height * (0.1 + puffiness * 0.18);
|
|
331
|
+
const capR = maxHalf * (0.85 + rng() * 0.1);
|
|
332
|
+
let waveAmp = height * (0.07 + rng() * 0.04);
|
|
333
|
+
waveAmp = Math.min(waveAmp, maxHalf * 0.7);
|
|
334
|
+
let maxOffset = maxHalf * (1 + lumpAmp);
|
|
335
|
+
const halfBudget = height / 2 - topMargin;
|
|
336
|
+
if (waveAmp + maxOffset > halfBudget) {
|
|
337
|
+
waveAmp = Math.max(0, halfBudget - maxOffset);
|
|
338
|
+
if (waveAmp + maxOffset > halfBudget) {
|
|
339
|
+
maxOffset = halfBudget - waveAmp;
|
|
340
|
+
maxHalf = maxOffset / (1 + lumpAmp);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
const margin = width * 0.02;
|
|
344
|
+
const capBuffer = capR * (1 + lumpAmp) + 1;
|
|
345
|
+
const x0 = margin + capBuffer;
|
|
346
|
+
const x1 = width - margin - capBuffer;
|
|
347
|
+
const span = x1 - x0;
|
|
348
|
+
const spineWave = makeWave(rng, 1 + rng() * 1, 3, 1.5);
|
|
349
|
+
const spineY = (u) => midY + waveAmp * spineWave.wave(u);
|
|
350
|
+
const edgeEase = 0.12;
|
|
351
|
+
const baseHalf = (u) => {
|
|
352
|
+
let e;
|
|
353
|
+
if (u < edgeEase) e = smoothstep(u / edgeEase);
|
|
354
|
+
else if (u > 1 - edgeEase) e = smoothstep((1 - u) / edgeEase);
|
|
355
|
+
else e = 1;
|
|
356
|
+
return capR + (maxHalf - capR) * e;
|
|
357
|
+
};
|
|
358
|
+
const lumpUp = makeWave(rng, 2 + rng() * 1, 2, 1);
|
|
359
|
+
const lumpLo = makeWave(rng, 2 + rng() * 1, 2, 1);
|
|
360
|
+
const upperOffset = (u) => baseHalf(u) * (1 + lumpAmp * lumpUp.wave(u));
|
|
361
|
+
const lowerOffset = (u) => baseHalf(u) * (1 + lumpAmp * lumpLo.wave(u));
|
|
362
|
+
const maxFreq = Math.max(spineWave.maxFreq, lumpUp.maxFreq, lumpLo.maxFreq);
|
|
363
|
+
const N = Math.max(14, Math.min(26, Math.round(maxFreq * 3.5) + 2));
|
|
364
|
+
const upper = [];
|
|
365
|
+
const lower = [];
|
|
366
|
+
for (let i = 0; i < N; i++) {
|
|
367
|
+
const u = i / (N - 1);
|
|
368
|
+
const x = x0 + u * span;
|
|
369
|
+
const sy = spineY(u);
|
|
370
|
+
upper.push({ x, y: sy - upperOffset(u) });
|
|
371
|
+
lower.push({ x, y: sy + lowerOffset(u) });
|
|
372
|
+
}
|
|
373
|
+
const capN = 4;
|
|
374
|
+
function cap(u, x, topOff, botOff, side) {
|
|
375
|
+
const sy = spineY(u);
|
|
376
|
+
const pts = [];
|
|
377
|
+
for (let k = 1; k <= capN; k++) {
|
|
378
|
+
const t = k / (capN + 1);
|
|
379
|
+
const angle = -Math.PI / 2 + t * Math.PI;
|
|
380
|
+
const r = topOff + (botOff - topOff) * t;
|
|
381
|
+
pts.push({ x: x + side * r * Math.cos(angle), y: sy + r * Math.sin(angle) });
|
|
382
|
+
}
|
|
383
|
+
return pts;
|
|
384
|
+
}
|
|
385
|
+
const loop = [...upper];
|
|
386
|
+
loop.push(...cap(1, x1, upperOffset(1), lowerOffset(1), 1));
|
|
387
|
+
for (let i = N - 1; i >= 0; i--) loop.push(lower[i]);
|
|
388
|
+
loop.push(...cap(0, x0, upperOffset(0), lowerOffset(0), -1).reverse());
|
|
389
|
+
return polygonToPath(loop);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// src/generators/cirrus.ts
|
|
393
|
+
function smoothstep2(t) {
|
|
394
|
+
if (t <= 0) return 0;
|
|
395
|
+
if (t >= 1) return 1;
|
|
396
|
+
return t * t * (3 - 2 * t);
|
|
397
|
+
}
|
|
398
|
+
function generateCirrus(width, height, puffiness, rng) {
|
|
399
|
+
const margin = Math.min(width, height) * 0.05;
|
|
400
|
+
const availW = width - 2 * margin;
|
|
401
|
+
const availH = height - 2 * margin;
|
|
402
|
+
const frameDiag = Math.hypot(width, height);
|
|
403
|
+
const baseTheta = -0.3 + (rng() - 0.5) * 0.4;
|
|
404
|
+
const K = 3 + Math.floor(rng() * 4);
|
|
405
|
+
const paths = [];
|
|
406
|
+
for (let w = 0; w < K; w++) {
|
|
407
|
+
const theta0 = baseTheta + (rng() - 0.5) * 0.25;
|
|
408
|
+
const curl = 0.6 + rng() * 0.5 + puffiness * 0.6;
|
|
409
|
+
const curlStart = 0.55 + rng() * 0.15;
|
|
410
|
+
const L = frameDiag * (0.28 + rng() * 0.24);
|
|
411
|
+
const maxHalf = L * (0.026 + rng() * 0.022) * (0.7 + puffiness * 0.7);
|
|
412
|
+
const uPeak = 0.66 + rng() * 0.12;
|
|
413
|
+
const lumpAmt = 0.1 + rng() * 0.08;
|
|
414
|
+
const lumpFreq = 2 + Math.floor(rng() * 2);
|
|
415
|
+
const lumpPhase = rng() * Math.PI * 2;
|
|
416
|
+
const N = 9;
|
|
417
|
+
const spine = [];
|
|
418
|
+
let px = 0;
|
|
419
|
+
let py = 0;
|
|
420
|
+
const ds = L / (N - 1);
|
|
421
|
+
for (let i = 0; i < N; i++) {
|
|
422
|
+
const u = i / (N - 1);
|
|
423
|
+
spine.push({ x: px, y: py });
|
|
424
|
+
const h = theta0 - curl * smoothstep2((u - curlStart) / (1 - curlStart));
|
|
425
|
+
px += ds * Math.cos(h);
|
|
426
|
+
py += ds * Math.sin(h);
|
|
427
|
+
}
|
|
428
|
+
const halfT = (u) => {
|
|
429
|
+
const bump = smoothstep2(Math.min(u / uPeak, (1 - u) / (1 - uPeak)));
|
|
430
|
+
const lump = 1 + lumpAmt * Math.sin(lumpFreq * Math.PI * u + lumpPhase);
|
|
431
|
+
return maxHalf * bump * lump;
|
|
432
|
+
};
|
|
433
|
+
const upper = [];
|
|
434
|
+
const lower = [];
|
|
435
|
+
for (let i = 0; i < N; i++) {
|
|
436
|
+
const u = i / (N - 1);
|
|
437
|
+
const a = spine[Math.max(0, i - 1)];
|
|
438
|
+
const b = spine[Math.min(N - 1, i + 1)];
|
|
439
|
+
const tx = b.x - a.x;
|
|
440
|
+
const ty = b.y - a.y;
|
|
441
|
+
const len = Math.hypot(tx, ty) || 1;
|
|
442
|
+
const nx = -ty / len;
|
|
443
|
+
const ny = tx / len;
|
|
444
|
+
const t = halfT(u);
|
|
445
|
+
upper.push({ x: spine[i].x + t * nx, y: spine[i].y + t * ny });
|
|
446
|
+
lower.push({ x: spine[i].x - t * nx, y: spine[i].y - t * ny });
|
|
447
|
+
}
|
|
448
|
+
const loop = [spine[0]];
|
|
449
|
+
for (let i = 1; i < N - 1; i++) loop.push(upper[i]);
|
|
450
|
+
loop.push(spine[N - 1]);
|
|
451
|
+
for (let i = N - 2; i >= 1; i--) loop.push(lower[i]);
|
|
452
|
+
let minX = Infinity;
|
|
453
|
+
let minY = Infinity;
|
|
454
|
+
let maxX = -Infinity;
|
|
455
|
+
let maxY = -Infinity;
|
|
456
|
+
for (const pt of loop) {
|
|
457
|
+
if (pt.x < minX) minX = pt.x;
|
|
458
|
+
if (pt.y < minY) minY = pt.y;
|
|
459
|
+
if (pt.x > maxX) maxX = pt.x;
|
|
460
|
+
if (pt.y > maxY) maxY = pt.y;
|
|
461
|
+
}
|
|
462
|
+
const bw = maxX - minX || 1;
|
|
463
|
+
const bh = maxY - minY || 1;
|
|
464
|
+
const s = Math.min(1, availW / bw, availH / bh);
|
|
465
|
+
const placedW = s * bw;
|
|
466
|
+
const placedH = s * bh;
|
|
467
|
+
const ox = margin + rng() * (availW - placedW);
|
|
468
|
+
const oy = margin + rng() * (availH - placedH);
|
|
469
|
+
const placed = loop.map((pt) => ({
|
|
470
|
+
x: ox + (pt.x - minX) * s,
|
|
471
|
+
y: oy + (pt.y - minY) * s
|
|
472
|
+
}));
|
|
473
|
+
paths.push(polygonToPath(placed));
|
|
474
|
+
}
|
|
475
|
+
return paths;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// src/index.ts
|
|
479
|
+
var DEFAULT_DIMS = {
|
|
480
|
+
cumulus: { width: 300, height: 180 },
|
|
481
|
+
stratus: { width: 1200, height: 120 },
|
|
482
|
+
cirrus: { width: 400, height: 200 }
|
|
483
|
+
};
|
|
484
|
+
function buildCloudResult(dims, buildPaths, options) {
|
|
485
|
+
const { seed = Math.floor(Math.random() * 4294967295), style = {} } = options;
|
|
486
|
+
const { width = dims.width, height = dims.height } = options;
|
|
487
|
+
const rng = createRng(seed);
|
|
488
|
+
const puffiness = options.puffiness ?? rng();
|
|
489
|
+
const paths = buildPaths(width, height, puffiness, rng);
|
|
490
|
+
const svg = assembleSvg(width, height, paths, style);
|
|
491
|
+
return makeResult(svg);
|
|
492
|
+
}
|
|
493
|
+
function generateCumulus2(options = {}) {
|
|
494
|
+
return buildCloudResult(DEFAULT_DIMS.cumulus, (w, h, p2, r) => [generateCumulus(w, h, p2, r)], options);
|
|
495
|
+
}
|
|
496
|
+
function generateStratus2(options = {}) {
|
|
497
|
+
return buildCloudResult(DEFAULT_DIMS.stratus, (w, h, p2, r) => [generateStratus(w, h, p2, r)], options);
|
|
498
|
+
}
|
|
499
|
+
function generateCirrus2(options = {}) {
|
|
500
|
+
return buildCloudResult(DEFAULT_DIMS.cirrus, generateCirrus, options);
|
|
501
|
+
}
|
|
502
|
+
function generateCloud(options = {}) {
|
|
503
|
+
const { type = "cumulus" } = options;
|
|
504
|
+
switch (type) {
|
|
505
|
+
case "cumulus":
|
|
506
|
+
return generateCumulus2(options);
|
|
507
|
+
case "stratus":
|
|
508
|
+
return generateStratus2(options);
|
|
509
|
+
case "cirrus":
|
|
510
|
+
return generateCirrus2(options);
|
|
511
|
+
default:
|
|
512
|
+
throw new Error(`Unknown cloud type: ${type}`);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
export {
|
|
516
|
+
generateCirrus2 as generateCirrus,
|
|
517
|
+
generateCloud,
|
|
518
|
+
generateCumulus2 as generateCumulus,
|
|
519
|
+
generateStratus2 as generateStratus
|
|
520
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cloudswg",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Procedurally generate SVG clouds",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"import": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"require": {
|
|
14
|
+
"types": "./dist/index.d.cts",
|
|
15
|
+
"default": "./dist/index.cjs"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"main": "./dist/index.cjs",
|
|
20
|
+
"module": "./dist/index.js",
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"files": [
|
|
23
|
+
"dist"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsup",
|
|
27
|
+
"dev": "tsup --watch",
|
|
28
|
+
"test": "vitest run",
|
|
29
|
+
"test:watch": "vitest",
|
|
30
|
+
"typecheck": "tsc --noEmit",
|
|
31
|
+
"prepublishOnly": "npm run build && npm test"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"playwright-core": "^1.61.1",
|
|
35
|
+
"tsup": "^8.0.0",
|
|
36
|
+
"typescript": "^5.0.0",
|
|
37
|
+
"vitest": "^2.0.0"
|
|
38
|
+
},
|
|
39
|
+
"keywords": [
|
|
40
|
+
"svg",
|
|
41
|
+
"cloud",
|
|
42
|
+
"procedural",
|
|
43
|
+
"generative"
|
|
44
|
+
],
|
|
45
|
+
"license": "MIT",
|
|
46
|
+
"author": "Gideon Needleman",
|
|
47
|
+
"repository": {
|
|
48
|
+
"type": "git",
|
|
49
|
+
"url": "git+https://gitlab.com/gideonneedleman/clouds.git"
|
|
50
|
+
},
|
|
51
|
+
"homepage": "https://gitlab.com/gideonneedleman/clouds",
|
|
52
|
+
"bugs": {
|
|
53
|
+
"url": "https://gitlab.com/gideonneedleman/clouds/-/issues"
|
|
54
|
+
}
|
|
55
|
+
}
|