@kolkrabbi/kol-growth 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +29 -2
- package/package.json +33 -3
- package/src/engine.js +221 -0
- package/src/index.js +3 -0
- package/src/rng.js +13 -0
- package/src/selftest.mjs +31 -0
- package/src/shapes.js +40 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# @kolkrabbi/kol-growth
|
|
2
|
+
|
|
3
|
+
## 0.2.0 — 2026-10-30
|
|
4
|
+
|
|
5
|
+
- **`bleed` and `step(zone)`** (SignedOutStageStill, kol-fxr — a finished line jittered at the edges). `bleed` (default 0, today's framed clamp) lets nodes run that many units past every edge; a node outside the frame is frozen — no force, split or merge — and the spatial hash spans the bled frame. `step(zone)` with `{ x, y, r }` freezes every node outside the zone the same way. kol-fxr's fixed `engine.js`, carried verbatim; framed consumers are unchanged.
|
|
6
|
+
|
|
7
|
+
## 0.1.0 — 2026-10-30
|
|
8
|
+
|
|
9
|
+
- **First release — the engine tier's fourth package** (ARCHITECTURE §3, GrowthLineStage from kol-atlas). kol-fxr's
|
|
10
|
+
growing line moved in so fxr and every other app grow the same line from one engine.
|
|
11
|
+
- `Growth` · `densityFrom` · `SIM_W` — kol-fxr `src/loops/growth/engine.js`, carried verbatim.
|
|
12
|
+
- `mulberry32` — kol-fxr `src/loops/gl/rng.js`, verbatim.
|
|
13
|
+
- `orbCanvas` (fxr's `sourceCanvas('orb')`, verbatim) and `pathCanvas` (new: any SVG path `d` as a density source).
|
|
14
|
+
- `src/selftest.mjs` — grows to the cap, a seed repeats, `poke` moves the line.
|
package/README.md
CHANGED
|
@@ -1,3 +1,30 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @kolkrabbi/kol-growth
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The growing line: differential growth — one closed line that grows, folds and never crosses itself, packing tighter
|
|
4
|
+
where a density field is darker. Plain ESM, no React, no dependencies. Engine tier (ARCHITECTURE §3): the stage that
|
|
5
|
+
draws it on a page is kol-component's `GrowthLineStage`; kol-fxr's generator and effect run it in their own loops.
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
import { Growth, densityFrom, mulberry32, orbCanvas, pathCanvas } from '@kolkrabbi/kol-growth'
|
|
9
|
+
|
|
10
|
+
const sim = new Growth({ w, h, density: densityFrom(pathCanvas(d, w / h, 0.6)), rand: mulberry32(7), cap: 14000 })
|
|
11
|
+
sim.setParams({ minGap: 6, maxGap: 24, contrast: 1.4, growth: 8 })
|
|
12
|
+
sim.seed() // a small ring at the field's centre of darkness
|
|
13
|
+
sim.step() // one step: smooth, repel, resample, grow — call per frame until x.length hits cap
|
|
14
|
+
sim.step({ x, y, r }) // a finished line: only nodes inside the zone move, the rest stay frozen
|
|
15
|
+
sim.poke(px, py, down) // the pointer as a force: a push, or a blast when down
|
|
16
|
+
sim.draw(ctx, w, h, { ink, width: 1 }) // the one path onto a canvas
|
|
17
|
+
sim.pathD(w, h) // the same path as an SVG `d`
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
| export | does |
|
|
21
|
+
|---|---|
|
|
22
|
+
| `Growth` | the sim — `step`, `seed`, `poke`, `draw`, `pathD`, `setParams({ minGap, maxGap, contrast, growth, cap, bleed })` — `bleed` (default 0, the framed clamp) lets the line run that far past every edge, where it freezes |
|
|
23
|
+
| `densityFrom(canvas, cols?, invert?)` | 1 − luma on a grid, lightly blurred — the field the spacing reads |
|
|
24
|
+
| `SIM_W` | 1000, the sim width kol-fxr's loops use |
|
|
25
|
+
| `mulberry32(seed)` | the seeded 0..1 PRNG; pass as `rand` and the same seed grows the same line |
|
|
26
|
+
| `orbCanvas(aspect)` | the built-in orb, dark at the centre |
|
|
27
|
+
| `pathCanvas(d, aspect, fit?)` | any SVG path `d`, solid black on white, centred at `fit` of the frame |
|
|
28
|
+
|
|
29
|
+
`densityFrom`, `orbCanvas` and `pathCanvas` draw on a canvas, so they need a browser; the sim itself runs anywhere
|
|
30
|
+
(`pnpm --filter @kolkrabbi/kol-growth test`). Proved in `apps/growth`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-growth",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "The growing line engine, differential growth into shapes",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./src/index.js",
|
|
8
|
+
"module": "./src/index.js",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src",
|
|
14
|
+
"README.md",
|
|
15
|
+
"CHANGELOG.md"
|
|
16
|
+
],
|
|
17
|
+
"keywords": [
|
|
18
|
+
"kol",
|
|
19
|
+
"kolkrabbi",
|
|
20
|
+
"design-system",
|
|
21
|
+
"generative",
|
|
22
|
+
"differential-growth",
|
|
23
|
+
"engine"
|
|
24
|
+
],
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/Tor-Grimsson/kol-ds.git",
|
|
28
|
+
"directory": "packages/growth"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"test": "node src/selftest.mjs"
|
|
35
|
+
}
|
|
6
36
|
}
|
package/src/engine.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// The growing line (plan 43 W4) — differential growth: ONE closed line that grows, folds and never crosses itself,
|
|
2
|
+
// packing tighter where a density field is darker. The reference is algorigraph's plotted portraits (user 2026-10-28:
|
|
3
|
+
// *"a single elastic loop morphs into the shapes of a…"*). The generator (growthLine.js) feeds it a built-in image;
|
|
4
|
+
// the effect (filters/fxGrowth.js) feeds it the layer's own photo. Both draw it the same way: one path, ink on paper.
|
|
5
|
+
//
|
|
6
|
+
// A step: every node is pulled toward its neighbours' midpoint (smooth), pushed off every non-adjacent node closer than
|
|
7
|
+
// the local spacing (no crossings, even gaps), held inside the frame; edges longer than half the spacing split, shorter
|
|
8
|
+
// than a fifth merge, and `growth` random edges split per step so the line keeps lengthening until the node cap.
|
|
9
|
+
// Spacing is local: `minGap` where the density is 1, `maxGap` where it is 0. Units are sim px — the sim is 1000 wide
|
|
10
|
+
// and as tall as the frame's aspect, scaled to the frame at draw.
|
|
11
|
+
//
|
|
12
|
+
// THE EDGE (plan 44 6.1, user 2026-10-30: *"allow growth outside of viewport and limit it in some other way so it just
|
|
13
|
+
// sits still, not this jittering"*). By default the line is held 2 units inside the frame — the effect and the generator
|
|
14
|
+
// draw on framed paper. `bleed` lets it run that far past every edge instead, and a node outside the frame FREEZES: no
|
|
15
|
+
// force, no split, no merge, no growth. The wall the line piled into is gone; what leaves the frame sits still.
|
|
16
|
+
// A finished line never settles on its own (merges and splits trade nodes forever), so `step(zone)` freezes everything
|
|
17
|
+
// outside `zone` `{ x, y, r }` the same way — the pointer moves the line around it, the rest stays put.
|
|
18
|
+
|
|
19
|
+
export const SIM_W = 1000
|
|
20
|
+
|
|
21
|
+
/* density from any canvas: 1 − luma, on a grid `cols` wide, lightly blurred so the spacing changes smoothly */
|
|
22
|
+
export function densityFrom(canvas, cols = 160, invert = false) {
|
|
23
|
+
const rows = Math.max(1, Math.round(cols * canvas.height / canvas.width))
|
|
24
|
+
const c = document.createElement('canvas')
|
|
25
|
+
c.width = cols; c.height = rows
|
|
26
|
+
const g = c.getContext('2d', { willReadFrequently: true })
|
|
27
|
+
g.filter = 'blur(1px)'
|
|
28
|
+
g.drawImage(canvas, 0, 0, cols, rows)
|
|
29
|
+
const d = g.getImageData(0, 0, cols, rows).data
|
|
30
|
+
const out = new Float32Array(cols * rows)
|
|
31
|
+
for (let i = 0; i < out.length; i++) {
|
|
32
|
+
const j = i * 4
|
|
33
|
+
const a = d[j + 3] / 255
|
|
34
|
+
/* transparent reads as paper */
|
|
35
|
+
const lum = ((0.2126 * d[j] + 0.7152 * d[j + 1] + 0.0722 * d[j + 2]) / 255) * a + (1 - a)
|
|
36
|
+
out[i] = invert ? lum : 1 - lum
|
|
37
|
+
}
|
|
38
|
+
return { cols, rows, data: out }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class Growth {
|
|
42
|
+
constructor({ w = SIM_W, h = SIM_W, density = null, rand = Math.random, cap = 6000 }) {
|
|
43
|
+
this.w = w; this.h = h
|
|
44
|
+
this.density = density
|
|
45
|
+
this.rand = rand
|
|
46
|
+
this.cap = cap
|
|
47
|
+
this.minGap = 5; this.maxGap = 22; this.contrast = 1.4; this.growth = 4; this.bleed = 0
|
|
48
|
+
this.x = []; this.y = []
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
setParams({ minGap, maxGap, contrast, growth, cap, bleed }) {
|
|
52
|
+
if (minGap != null) this.minGap = minGap
|
|
53
|
+
if (maxGap != null) this.maxGap = Math.max(maxGap, this.minGap + 0.5)
|
|
54
|
+
if (contrast != null) this.contrast = contrast
|
|
55
|
+
if (growth != null) this.growth = growth
|
|
56
|
+
if (cap != null) this.cap = cap
|
|
57
|
+
if (bleed != null) this.bleed = bleed
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/* off the frame, with a bleed: frozen */
|
|
61
|
+
out(x, y, zone) {
|
|
62
|
+
if (zone && (x - zone.x) ** 2 + (y - zone.y) ** 2 > zone.r * zone.r) return true
|
|
63
|
+
return this.bleed > 0 && (x < 0 || y < 0 || x > this.w || y > this.h)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/* a small ring of nodes — at (cx, cy) in 0..1, or at the field's centre of darkness */
|
|
67
|
+
seed(cx, cy, r = 14) {
|
|
68
|
+
if (cx == null && this.density) {
|
|
69
|
+
const { cols, rows, data } = this.density
|
|
70
|
+
let sx = 0, sy = 0, sw = 0
|
|
71
|
+
for (let i = 0; i < data.length; i++) { const v = data[i] * data[i]; sx += v * (i % cols); sy += v * Math.floor(i / cols); sw += v }
|
|
72
|
+
if (sw > 0) { cx = (sx / sw + 0.5) / cols; cy = (sy / sw + 0.5) / rows }
|
|
73
|
+
}
|
|
74
|
+
cx = (cx ?? 0.5) * this.w; cy = (cy ?? 0.5) * this.h
|
|
75
|
+
const n = 24
|
|
76
|
+
this.x = []; this.y = []
|
|
77
|
+
for (let i = 0; i < n; i++) {
|
|
78
|
+
const a = (i / n) * Math.PI * 2
|
|
79
|
+
this.x.push(cx + Math.cos(a) * r); this.y.push(cy + Math.sin(a) * r)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
dens(x, y) {
|
|
84
|
+
const D = this.density
|
|
85
|
+
if (!D) return 0.5
|
|
86
|
+
const c = Math.min(D.cols - 1, Math.max(0, Math.floor((x / this.w) * D.cols)))
|
|
87
|
+
const r = Math.min(D.rows - 1, Math.max(0, Math.floor((y / this.h) * D.rows)))
|
|
88
|
+
return D.data[r * D.cols + c]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
gap(x, y) {
|
|
92
|
+
const d = Math.pow(Math.min(1, Math.max(0, this.dens(x, y))), 1 / this.contrast)
|
|
93
|
+
return this.maxGap + (this.minGap - this.maxGap) * d
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
step(zone = null) {
|
|
97
|
+
const { x, y, w, h } = this
|
|
98
|
+
let n = x.length
|
|
99
|
+
if (n < 3) return
|
|
100
|
+
const R = this.maxGap, b = this.bleed
|
|
101
|
+
/* spatial hash on the largest gap, over the frame and its bleed */
|
|
102
|
+
const cols = Math.ceil((w + 2 * b) / R) + 1, rows = Math.ceil((h + 2 * b) / R) + 1
|
|
103
|
+
const head = new Int32Array(cols * rows).fill(-1)
|
|
104
|
+
const next = new Int32Array(n)
|
|
105
|
+
const cell = (px, py) => Math.min(rows - 1, Math.max(0, Math.floor((py + b) / R))) * cols + Math.min(cols - 1, Math.max(0, Math.floor((px + b) / R)))
|
|
106
|
+
const frozen = new Uint8Array(n)
|
|
107
|
+
for (let i = 0; i < n; i++) frozen[i] = this.out(x[i], y[i], zone) ? 1 : 0
|
|
108
|
+
for (let i = 0; i < n; i++) { const c = cell(x[i], y[i]); next[i] = head[c]; head[c] = i }
|
|
109
|
+
const gaps = new Float32Array(n)
|
|
110
|
+
for (let i = 0; i < n; i++) gaps[i] = this.gap(x[i], y[i])
|
|
111
|
+
const fx = new Float32Array(n), fy = new Float32Array(n)
|
|
112
|
+
for (let i = 0; i < n; i++) {
|
|
113
|
+
if (frozen[i]) continue
|
|
114
|
+
const ip = (i - 1 + n) % n, inx = (i + 1) % n
|
|
115
|
+
/* smooth: toward the neighbours' midpoint */
|
|
116
|
+
fx[i] += ((x[ip] + x[inx]) / 2 - x[i]) * 0.35
|
|
117
|
+
fy[i] += ((y[ip] + y[inx]) / 2 - y[i]) * 0.35
|
|
118
|
+
/* repel: every non-adjacent node inside the local gap */
|
|
119
|
+
const gi = gaps[i]
|
|
120
|
+
const cx = Math.floor((x[i] + b) / R), cy = Math.floor((y[i] + b) / R)
|
|
121
|
+
for (let oy = -1; oy <= 1; oy++) {
|
|
122
|
+
const ry = cy + oy
|
|
123
|
+
if (ry < 0 || ry >= rows) continue
|
|
124
|
+
for (let ox = -1; ox <= 1; ox++) {
|
|
125
|
+
const rx = cx + ox
|
|
126
|
+
if (rx < 0 || rx >= cols) continue
|
|
127
|
+
for (let j = head[ry * cols + rx]; j !== -1; j = next[j]) {
|
|
128
|
+
if (j === i || j === ip || j === inx) continue
|
|
129
|
+
const dx = x[i] - x[j], dy = y[i] - y[j]
|
|
130
|
+
const g = (gi + gaps[j]) / 2
|
|
131
|
+
const d2 = dx * dx + dy * dy
|
|
132
|
+
if (d2 >= g * g || d2 === 0) continue
|
|
133
|
+
const d = Math.sqrt(d2)
|
|
134
|
+
const push = ((g - d) / g) * 0.5
|
|
135
|
+
fx[i] += (dx / d) * push * g * 0.5
|
|
136
|
+
fy[i] += (dy / d) * push * g * 0.5
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const m = b > 0 ? -b : 2
|
|
142
|
+
for (let i = 0; i < n; i++) {
|
|
143
|
+
if (frozen[i]) continue
|
|
144
|
+
const lim = gaps[i] * 0.25
|
|
145
|
+
x[i] = Math.min(w - m, Math.max(m, x[i] + Math.max(-lim, Math.min(lim, fx[i]))))
|
|
146
|
+
y[i] = Math.min(h - m, Math.max(m, y[i] + Math.max(-lim, Math.min(lim, fy[i]))))
|
|
147
|
+
}
|
|
148
|
+
/* resample: split long edges, merge short ones, then grow */
|
|
149
|
+
const nx = [], ny = []
|
|
150
|
+
let budget = this.cap - n
|
|
151
|
+
for (let i = 0; i < n; i++) {
|
|
152
|
+
const j = (i + 1) % n
|
|
153
|
+
const g = this.gap(x[i], y[i])
|
|
154
|
+
const dx = x[j] - x[i], dy = y[j] - y[i]
|
|
155
|
+
const len = Math.hypot(dx, dy)
|
|
156
|
+
/* an edge with a frozen end keeps its nodes as they are */
|
|
157
|
+
if (frozen[i] || frozen[j]) { nx.push(x[i]); ny.push(y[i]); continue }
|
|
158
|
+
if (len < g * 0.2 && n > 8) continue
|
|
159
|
+
nx.push(x[i]); ny.push(y[i])
|
|
160
|
+
if (len > g * 0.5 && budget > 0) { nx.push(x[i] + dx / 2); ny.push(y[i] + dy / 2); budget-- }
|
|
161
|
+
}
|
|
162
|
+
for (let k = 0; k < this.growth && nx.length < this.cap; k++) {
|
|
163
|
+
const i = Math.floor(this.rand() * nx.length), j = (i + 1) % nx.length
|
|
164
|
+
if (this.out(nx[i], ny[i], zone) || this.out(nx[j], ny[j], zone)) continue
|
|
165
|
+
/* a split nudged sideways starts a new fold */
|
|
166
|
+
const dx = nx[j] - nx[i], dy = ny[j] - ny[i]
|
|
167
|
+
const s = (this.rand() - 0.5) * 0.6
|
|
168
|
+
nx.splice(i + 1, 0, nx[i] + dx / 2 - dy * s)
|
|
169
|
+
ny.splice(i + 1, 0, ny[i] + dy / 2 + dx * s)
|
|
170
|
+
}
|
|
171
|
+
this.x = nx; this.y = ny
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/* THE POINTER AS A FORCE (sim units) — penrose/common.js's verbs on this line: a move pushes the nodes out of a ring
|
|
175
|
+
* around (px, py), a press blasts (3× reach, 6× strength, capped at 3% of the frame a step so it reads as a shove, not
|
|
176
|
+
* a teleport). The line's own smoothing and spacing refold the crater; `amount` 0 turns it off. */
|
|
177
|
+
poke(px, py, down = false, amount = 1) {
|
|
178
|
+
if (!amount) return
|
|
179
|
+
const { x, y, w, h } = this, s = Math.min(w, h), m = this.bleed > 0 ? -this.bleed : 2
|
|
180
|
+
const reach = s * 0.08 * (down ? 3 : 1), r2 = reach * reach
|
|
181
|
+
const strength = Math.min(s * 0.03, s * 0.006 * amount * (down ? 6 : 1))
|
|
182
|
+
for (let i = 0; i < x.length; i++) {
|
|
183
|
+
const dx = x[i] - px, dy = y[i] - py, d2 = dx * dx + dy * dy
|
|
184
|
+
if (d2 >= r2 || d2 < 1e-6 || this.out(x[i], y[i])) continue
|
|
185
|
+
const d = Math.sqrt(d2), f = (1 - d / reach) * strength
|
|
186
|
+
x[i] = Math.min(w - m, Math.max(m, x[i] + (dx / d) * f))
|
|
187
|
+
y[i] = Math.min(h - m, Math.max(m, y[i] + (dy / d) * f))
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/* the one path, scaled onto a w × h frame */
|
|
192
|
+
draw(ctx, fw, fh, { ink = '#111', paper = null, width = 1.2 } = {}) {
|
|
193
|
+
if (paper) { ctx.fillStyle = paper; ctx.fillRect(0, 0, fw, fh) }
|
|
194
|
+
const { x, y } = this
|
|
195
|
+
const n = x.length
|
|
196
|
+
if (n < 3) return
|
|
197
|
+
const sx = fw / this.w, sy = fh / this.h
|
|
198
|
+
ctx.beginPath()
|
|
199
|
+
/* through the edge midpoints with the nodes as control points — a smooth closed curve */
|
|
200
|
+
const mx = (i) => ((x[i] + x[(i + 1) % n]) / 2) * sx, my = (i) => ((y[i] + y[(i + 1) % n]) / 2) * sy
|
|
201
|
+
ctx.moveTo(mx(n - 1), my(n - 1))
|
|
202
|
+
for (let i = 0; i < n; i++) ctx.quadraticCurveTo(x[i] * sx, y[i] * sy, mx(i), my(i))
|
|
203
|
+
ctx.closePath()
|
|
204
|
+
ctx.strokeStyle = ink
|
|
205
|
+
ctx.lineWidth = width
|
|
206
|
+
ctx.lineJoin = 'round'
|
|
207
|
+
ctx.stroke()
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/* the same path as one SVG `d`, in frame units */
|
|
211
|
+
pathD(fw, fh) {
|
|
212
|
+
const { x, y } = this
|
|
213
|
+
const n = x.length
|
|
214
|
+
if (n < 3) return ''
|
|
215
|
+
const sx = fw / this.w, sy = fh / this.h, f = (v) => v.toFixed(2)
|
|
216
|
+
const mx = (i) => f(((x[i] + x[(i + 1) % n]) / 2) * sx), my = (i) => f(((y[i] + y[(i + 1) % n]) / 2) * sy)
|
|
217
|
+
let d = `M${mx(n - 1)} ${my(n - 1)}`
|
|
218
|
+
for (let i = 0; i < n; i++) d += `Q${f(x[i] * sx)} ${f(y[i] * sy)} ${mx(i)} ${my(i)}`
|
|
219
|
+
return d + 'Z'
|
|
220
|
+
}
|
|
221
|
+
}
|
package/src/index.js
ADDED
package/src/rng.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// rng — the seeded PRNG the growing line takes as `rand`, carried verbatim from kol-fxr (src/loops/gl/rng.js).
|
|
2
|
+
|
|
3
|
+
/** Deterministic 0..1 PRNG seeded by an integer. Same seed → same sequence. */
|
|
4
|
+
export function mulberry32(seed) {
|
|
5
|
+
let a = seed >>> 0
|
|
6
|
+
return function () {
|
|
7
|
+
a |= 0
|
|
8
|
+
a = (a + 0x6D2B79F5) | 0
|
|
9
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
|
10
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
|
11
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
|
12
|
+
}
|
|
13
|
+
}
|
package/src/selftest.mjs
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// node src/selftest.mjs — the engine with no density field (no DOM): it grows to the cap, a seed repeats, poke moves nodes.
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { Growth, mulberry32 } from './index.js'
|
|
4
|
+
|
|
5
|
+
const grow = (seed) => {
|
|
6
|
+
const g = new Growth({ w: 400, h: 300, rand: mulberry32(seed), cap: 600 })
|
|
7
|
+
g.setParams({ minGap: 6, maxGap: 24, contrast: 1.4, growth: 8 })
|
|
8
|
+
g.seed()
|
|
9
|
+
for (let i = 0; i < 400 && g.x.length < g.cap; i++) g.step()
|
|
10
|
+
return g
|
|
11
|
+
}
|
|
12
|
+
const a = grow(7), b = grow(7)
|
|
13
|
+
assert.ok(a.x.length >= a.cap - 8, `grows to the cap (${a.x.length}/${a.cap})`)
|
|
14
|
+
assert.deepEqual(a.x, b.x, 'same seed, same line')
|
|
15
|
+
assert.ok(a.x.every((v, i) => v >= 2 && v <= 398 && a.y[i] >= 2 && a.y[i] <= 298), 'held inside the frame')
|
|
16
|
+
const before = a.x.slice()
|
|
17
|
+
a.poke(a.x[0], a.y[0] + 1, true)
|
|
18
|
+
assert.notDeepEqual(a.x, before, 'poke moves the line')
|
|
19
|
+
assert.match(a.pathD(400, 300), /^M.*Z$/, 'one closed path')
|
|
20
|
+
/* a zone step freezes everything outside it */
|
|
21
|
+
const zone = { x: 200, y: 150, r: 40 }, out = a.x.map((v, i) => (v - 200) ** 2 + (a.y[i] - 150) ** 2 > 1600)
|
|
22
|
+
const px = a.x.slice(), py = a.y.slice(), pn = a.x.length
|
|
23
|
+
a.step(zone)
|
|
24
|
+
if (a.x.length === pn) assert.ok(out.every((o, i) => !o || (a.x[i] === px[i] && a.y[i] === py[i])), 'outside the zone stays put')
|
|
25
|
+
/* with a bleed the line may leave the frame, and stays inside the bled frame */
|
|
26
|
+
const c = new Growth({ w: 200, h: 150, rand: mulberry32(3), cap: 900 })
|
|
27
|
+
c.setParams({ minGap: 6, maxGap: 24, contrast: 1.4, growth: 8, bleed: 30 })
|
|
28
|
+
c.seed()
|
|
29
|
+
for (let i = 0; i < 600 && c.x.length < c.cap; i++) c.step()
|
|
30
|
+
assert.ok(c.x.every((v, i) => v >= -30 && v <= 230 && c.y[i] >= -30 && c.y[i] <= 180), 'held inside the bled frame')
|
|
31
|
+
console.log('kol-growth selftest ok')
|
package/src/shapes.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Shapes — the density sources the growing line reads with `densityFrom`: dark on light, at the frame's aspect.
|
|
2
|
+
// The one DOM in this engine (as `densityFrom` itself): a shape is a canvas, so these run in a browser.
|
|
3
|
+
|
|
4
|
+
/* the built-in orb, kol-fxr's `sourceCanvas('orb')` carried verbatim: a radial gradient, black at the centre */
|
|
5
|
+
export function orbCanvas(aspect) {
|
|
6
|
+
const W = 400, H = Math.max(1, Math.round(W / aspect))
|
|
7
|
+
const c = document.createElement('canvas')
|
|
8
|
+
c.width = W; c.height = H
|
|
9
|
+
const g = c.getContext('2d')
|
|
10
|
+
g.fillStyle = '#fff'; g.fillRect(0, 0, W, H)
|
|
11
|
+
const r = Math.min(W, H) * 0.46
|
|
12
|
+
const grd = g.createRadialGradient(W / 2, H / 2, 0, W / 2, H / 2, r)
|
|
13
|
+
grd.addColorStop(0, '#000'); grd.addColorStop(1, '#fff')
|
|
14
|
+
g.fillStyle = grd; g.fillRect(0, 0, W, H)
|
|
15
|
+
return c
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/* any SVG path `d`, filled solid black on white, centred and scaled to `fit` of the frame — kol-fxr's `vectorCanvas`
|
|
19
|
+
* for a bare path: synchronous (no image decode), the bounds measured by the browser's own SVG `getBBox` */
|
|
20
|
+
export function pathCanvas(d, aspect, fit = 0.6) {
|
|
21
|
+
const W = 400, H = Math.max(1, Math.round(W / aspect))
|
|
22
|
+
const c = document.createElement('canvas')
|
|
23
|
+
c.width = W; c.height = H
|
|
24
|
+
const g = c.getContext('2d')
|
|
25
|
+
g.fillStyle = '#fff'; g.fillRect(0, 0, W, H)
|
|
26
|
+
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
|
27
|
+
svg.setAttribute('style', 'position:absolute;width:0;height:0;overflow:hidden')
|
|
28
|
+
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
|
|
29
|
+
path.setAttribute('d', d)
|
|
30
|
+
svg.appendChild(path)
|
|
31
|
+
document.body.appendChild(svg)
|
|
32
|
+
let b
|
|
33
|
+
try { b = path.getBBox() } finally { svg.remove() }
|
|
34
|
+
if (!b.width || !b.height) return c
|
|
35
|
+
const k = Math.min(W / b.width, H / b.height) * fit
|
|
36
|
+
g.setTransform(k, 0, 0, k, (W - b.width * k) / 2 - b.x * k, (H - b.height * k) / 2 - b.y * k)
|
|
37
|
+
g.fillStyle = '#000'
|
|
38
|
+
g.fill(new Path2D(d))
|
|
39
|
+
return c
|
|
40
|
+
}
|