@kolkrabbi/kol-growth 0.1.0 → 0.3.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 +9 -0
- package/README.md +2 -1
- package/package.json +1 -1
- package/src/engine.js +66 -19
- package/src/selftest.mjs +18 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# @kolkrabbi/kol-growth
|
|
2
2
|
|
|
3
|
+
## 0.3.0 — 2026-10-30
|
|
4
|
+
|
|
5
|
+
- **`settle` — the line rests** (kol-fxr plan 44 6.1 — user: *"it doesnt need to be so active when nothing is happening, its just the mouse interaction and the growth edges that are subject to constant change"*). 0.2.0 still jittered: a packed line never comes to rest (strands two nodes apart push along it), and growth split edges anywhere, so every split shoved the whole line. With `settle` > 0 a node with packed neighbours (14+ strands within 1.5 gaps) freezes after `settle` steps, growth splits only the front, and `poke` re-heats what it touches. Measured on a 1440×900 orb: 98% of the line moving every step → 8–13% while growing, 3% when grown, ~10× cheaper a step. Default 0 = 0.2.0's behaviour.
|
|
6
|
+
- **`step(zone)` retired** — `settle` does its job everywhere, not only around the pointer. kol-fxr's `engine.js`, carried verbatim.
|
|
7
|
+
|
|
8
|
+
## 0.2.0 — 2026-10-30
|
|
9
|
+
|
|
10
|
+
- **`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.
|
|
11
|
+
|
|
3
12
|
## 0.1.0 — 2026-10-30
|
|
4
13
|
|
|
5
14
|
- **First release — the engine tier's fourth package** (ARCHITECTURE §3, GrowthLineStage from kol-atlas). kol-fxr's
|
package/README.md
CHANGED
|
@@ -11,6 +11,7 @@ const sim = new Growth({ w, h, density: densityFrom(pathCanvas(d, w / h, 0.6)),
|
|
|
11
11
|
sim.setParams({ minGap: 6, maxGap: 24, contrast: 1.4, growth: 8 })
|
|
12
12
|
sim.seed() // a small ring at the field's centre of darkness
|
|
13
13
|
sim.step() // one step: smooth, repel, resample, grow — call per frame until x.length hits cap
|
|
14
|
+
sim.setParams({ settle: 120 }) // the packed interior rests; only the growing front and the pointer move
|
|
14
15
|
sim.poke(px, py, down) // the pointer as a force: a push, or a blast when down
|
|
15
16
|
sim.draw(ctx, w, h, { ink, width: 1 }) // the one path onto a canvas
|
|
16
17
|
sim.pathD(w, h) // the same path as an SVG `d`
|
|
@@ -18,7 +19,7 @@ sim.pathD(w, h) // the same path as an SVG `d`
|
|
|
18
19
|
|
|
19
20
|
| export | does |
|
|
20
21
|
|---|---|
|
|
21
|
-
| `Growth` | the sim — `step`, `seed`, `poke`, `draw`, `pathD`, `setParams({ minGap, maxGap, contrast, growth, cap })` |
|
|
22
|
+
| `Growth` | the sim — `step`, `seed`, `poke`, `draw`, `pathD`, `setParams({ minGap, maxGap, contrast, growth, cap, bleed, settle })` — `bleed` (default 0, the framed clamp) lets the line run that far past every edge, where it freezes; `settle` (default 0, off) cools a packed node after that many steps so it stops, grows only the front, and `poke` re-heats what it touches |
|
|
22
23
|
| `densityFrom(canvas, cols?, invert?)` | 1 − luma on a grid, lightly blurred — the field the spacing reads |
|
|
23
24
|
| `SIM_W` | 1000, the sim width kol-fxr's loops use |
|
|
24
25
|
| `mulberry32(seed)` | the seeded 0..1 PRNG; pass as `rand` and the same seed grows the same line |
|
package/package.json
CHANGED
package/src/engine.js
CHANGED
|
@@ -8,8 +8,20 @@
|
|
|
8
8
|
// than a fifth merge, and `growth` random edges split per step so the line keeps lengthening until the node cap.
|
|
9
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
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
|
+
// THE LINE RESTS (plan 44 6.1, user 2026-10-30: *"it doesnt need to be so active when nothing is happening, its just the
|
|
17
|
+
// mouse interaction and the growth edges that are subject to constant change"*). A packed line never comes to rest on
|
|
18
|
+
// its own — strands two nodes apart push along it, so it always wants to lengthen. With `settle` > 0 a node COOLS: off
|
|
19
|
+
// the front (FRONT, below) it freezes after `settle` steps; on the front it stays live; the pointer (`poke`) re-heats
|
|
20
|
+
// what it touches. Growth splits only the front — so only the growing front and the pointer move.
|
|
11
21
|
|
|
12
22
|
export const SIM_W = 1000
|
|
23
|
+
/* fewer close strands than this, and a node is on the growing front */
|
|
24
|
+
const FRONT = 14
|
|
13
25
|
|
|
14
26
|
/* density from any canvas: 1 − luma, on a grid `cols` wide, lightly blurred so the spacing changes smoothly */
|
|
15
27
|
export function densityFrom(canvas, cols = 160, invert = false) {
|
|
@@ -37,16 +49,23 @@ export class Growth {
|
|
|
37
49
|
this.density = density
|
|
38
50
|
this.rand = rand
|
|
39
51
|
this.cap = cap
|
|
40
|
-
this.minGap = 5; this.maxGap = 22; this.contrast = 1.4; this.growth = 4
|
|
41
|
-
this.x = []; this.y = []
|
|
52
|
+
this.minGap = 5; this.maxGap = 22; this.contrast = 1.4; this.growth = 4; this.bleed = 0; this.settle = 0
|
|
53
|
+
this.x = []; this.y = []; this.r = []
|
|
42
54
|
}
|
|
43
55
|
|
|
44
|
-
setParams({ minGap, maxGap, contrast, growth, cap }) {
|
|
56
|
+
setParams({ minGap, maxGap, contrast, growth, cap, bleed, settle }) {
|
|
45
57
|
if (minGap != null) this.minGap = minGap
|
|
46
58
|
if (maxGap != null) this.maxGap = Math.max(maxGap, this.minGap + 0.5)
|
|
47
59
|
if (contrast != null) this.contrast = contrast
|
|
48
60
|
if (growth != null) this.growth = growth
|
|
49
61
|
if (cap != null) this.cap = cap
|
|
62
|
+
if (bleed != null) this.bleed = bleed
|
|
63
|
+
if (settle != null) this.settle = settle
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/* off the frame, with a bleed: frozen */
|
|
67
|
+
out(x, y) {
|
|
68
|
+
return this.bleed > 0 && (x < 0 || y < 0 || x > this.w || y > this.h)
|
|
50
69
|
}
|
|
51
70
|
|
|
52
71
|
/* a small ring of nodes — at (cx, cy) in 0..1, or at the field's centre of darkness */
|
|
@@ -64,6 +83,7 @@ export class Growth {
|
|
|
64
83
|
const a = (i / n) * Math.PI * 2
|
|
65
84
|
this.x.push(cx + Math.cos(a) * r); this.y.push(cy + Math.sin(a) * r)
|
|
66
85
|
}
|
|
86
|
+
this.r = new Array(n).fill(0)
|
|
67
87
|
}
|
|
68
88
|
|
|
69
89
|
dens(x, y) {
|
|
@@ -83,24 +103,29 @@ export class Growth {
|
|
|
83
103
|
const { x, y, w, h } = this
|
|
84
104
|
let n = x.length
|
|
85
105
|
if (n < 3) return
|
|
86
|
-
const R = this.maxGap
|
|
87
|
-
/* spatial hash on the largest gap */
|
|
88
|
-
const cols = Math.ceil(w / R) + 1, rows = Math.ceil(h / R) + 1
|
|
106
|
+
const R = this.maxGap, b = this.bleed
|
|
107
|
+
/* spatial hash on the largest gap, over the frame and its bleed */
|
|
108
|
+
const cols = Math.ceil((w + 2 * b) / R) + 1, rows = Math.ceil((h + 2 * b) / R) + 1
|
|
89
109
|
const head = new Int32Array(cols * rows).fill(-1)
|
|
90
110
|
const next = new Int32Array(n)
|
|
91
|
-
const cell = (px, py) => Math.min(rows - 1, Math.max(0, Math.floor(py / R))) * cols + Math.min(cols - 1, Math.max(0, Math.floor(px / R)))
|
|
111
|
+
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)))
|
|
112
|
+
/* 1 = off the frame (for good), 2 = cooled (the pointer re-heats it) */
|
|
113
|
+
const rest = this.r.length === n ? this.r : (this.r = new Array(n).fill(0)), S = this.settle
|
|
114
|
+
const frozen = new Uint8Array(n)
|
|
115
|
+
for (let i = 0; i < n; i++) frozen[i] = this.out(x[i], y[i]) ? 1 : S > 0 && rest[i] >= S ? 2 : 0
|
|
92
116
|
for (let i = 0; i < n; i++) { const c = cell(x[i], y[i]); next[i] = head[c]; head[c] = i }
|
|
93
117
|
const gaps = new Float32Array(n)
|
|
94
118
|
for (let i = 0; i < n; i++) gaps[i] = this.gap(x[i], y[i])
|
|
95
|
-
const fx = new Float32Array(n), fy = new Float32Array(n)
|
|
119
|
+
const fx = new Float32Array(n), fy = new Float32Array(n), crowd = new Uint8Array(n)
|
|
96
120
|
for (let i = 0; i < n; i++) {
|
|
121
|
+
if (frozen[i]) continue
|
|
97
122
|
const ip = (i - 1 + n) % n, inx = (i + 1) % n
|
|
98
123
|
/* smooth: toward the neighbours' midpoint */
|
|
99
124
|
fx[i] += ((x[ip] + x[inx]) / 2 - x[i]) * 0.35
|
|
100
125
|
fy[i] += ((y[ip] + y[inx]) / 2 - y[i]) * 0.35
|
|
101
126
|
/* repel: every non-adjacent node inside the local gap */
|
|
102
127
|
const gi = gaps[i]
|
|
103
|
-
const cx = Math.floor(x[i] / R), cy = Math.floor(y[i] / R)
|
|
128
|
+
const cx = Math.floor((x[i] + b) / R), cy = Math.floor((y[i] + b) / R)
|
|
104
129
|
for (let oy = -1; oy <= 1; oy++) {
|
|
105
130
|
const ry = cy + oy
|
|
106
131
|
if (ry < 0 || ry >= rows) continue
|
|
@@ -112,6 +137,8 @@ export class Growth {
|
|
|
112
137
|
const dx = x[i] - x[j], dy = y[i] - y[j]
|
|
113
138
|
const g = (gi + gaps[j]) / 2
|
|
114
139
|
const d2 = dx * dx + dy * dy
|
|
140
|
+
/* a strand of line within 1.5 gaps — packed; the front has none on its open side */
|
|
141
|
+
if (d2 < 2.25 * g * g && crowd[i] < 255) crowd[i]++
|
|
115
142
|
if (d2 >= g * g || d2 === 0) continue
|
|
116
143
|
const d = Math.sqrt(d2)
|
|
117
144
|
const push = ((g - d) / g) * 0.5
|
|
@@ -121,33 +148,52 @@ export class Growth {
|
|
|
121
148
|
}
|
|
122
149
|
}
|
|
123
150
|
}
|
|
124
|
-
const m = 2
|
|
151
|
+
const m = b > 0 ? -b : 2
|
|
125
152
|
for (let i = 0; i < n; i++) {
|
|
153
|
+
if (frozen[i]) continue
|
|
126
154
|
const lim = gaps[i] * 0.25
|
|
127
|
-
|
|
128
|
-
|
|
155
|
+
const mx = Math.max(-lim, Math.min(lim, fx[i])), my = Math.max(-lim, Math.min(lim, fy[i]))
|
|
156
|
+
x[i] = Math.min(w - m, Math.max(m, x[i] + mx))
|
|
157
|
+
y[i] = Math.min(h - m, Math.max(m, y[i] + my))
|
|
158
|
+
rest[i] = crowd[i] < FRONT ? 0 : rest[i] + 1
|
|
129
159
|
}
|
|
130
160
|
/* resample: split long edges, merge short ones, then grow */
|
|
131
|
-
const nx = [], ny = []
|
|
161
|
+
const nx = [], ny = [], nr = [], nc = []
|
|
132
162
|
let budget = this.cap - n
|
|
133
163
|
for (let i = 0; i < n; i++) {
|
|
134
164
|
const j = (i + 1) % n
|
|
135
165
|
const g = this.gap(x[i], y[i])
|
|
136
166
|
const dx = x[j] - x[i], dy = y[j] - y[i]
|
|
137
167
|
const len = Math.hypot(dx, dy)
|
|
168
|
+
/* an edge with a frozen end keeps its nodes as they are */
|
|
169
|
+
if (frozen[i] || frozen[j]) { nx.push(x[i]); ny.push(y[i]); nr.push(rest[i]); nc.push(255); continue }
|
|
138
170
|
if (len < g * 0.2 && n > 8) continue
|
|
139
|
-
nx.push(x[i]); ny.push(y[i])
|
|
140
|
-
if (len > g * 0.5 && budget > 0) { nx.push(x[i] + dx / 2); ny.push(y[i] + dy / 2); budget-- }
|
|
171
|
+
nx.push(x[i]); ny.push(y[i]); nr.push(rest[i]); nc.push(crowd[i])
|
|
172
|
+
if (len > g * 0.5 && budget > 0) { nx.push(x[i] + dx / 2); ny.push(y[i] + dy / 2); nr.push(0); nc.push(crowd[i]); budget-- }
|
|
173
|
+
}
|
|
174
|
+
/* WHERE IT GROWS: anywhere (the default), or — resting on — only the FRONT: active edges with room around them (fewer
|
|
175
|
+
* than FRONT strands within 1.5 gaps; measured, the front holds ~10, the packed interior ~20). Grown anywhere, every
|
|
176
|
+
* split shoves the whole line and nothing ever rests */
|
|
177
|
+
const front = []
|
|
178
|
+
if (S > 0) for (let i = 0; i < nx.length; i++) {
|
|
179
|
+
const j = (i + 1) % nx.length
|
|
180
|
+
if (nr[i] < S && nr[j] < S && nc[i] < FRONT && nc[j] < FRONT && !this.out(nx[i], ny[i]) && !this.out(nx[j], ny[j])) front.push(i)
|
|
141
181
|
}
|
|
142
182
|
for (let k = 0; k < this.growth && nx.length < this.cap; k++) {
|
|
143
|
-
|
|
183
|
+
if (S > 0 && !front.length) break
|
|
184
|
+
/* ponytail: front indices drift by the splits made before them this step — a neighbouring edge, harmless */
|
|
185
|
+
let i = S > 0 ? front[Math.floor(this.rand() * front.length)] : Math.floor(this.rand() * nx.length)
|
|
186
|
+
i = Math.min(i, nx.length - 1)
|
|
187
|
+
const j = (i + 1) % nx.length
|
|
188
|
+
if (S === 0 && (this.out(nx[i], ny[i]) || this.out(nx[j], ny[j]))) continue
|
|
144
189
|
/* a split nudged sideways starts a new fold */
|
|
145
190
|
const dx = nx[j] - nx[i], dy = ny[j] - ny[i]
|
|
146
191
|
const s = (this.rand() - 0.5) * 0.6
|
|
147
192
|
nx.splice(i + 1, 0, nx[i] + dx / 2 - dy * s)
|
|
148
193
|
ny.splice(i + 1, 0, ny[i] + dy / 2 + dx * s)
|
|
194
|
+
nr.splice(i + 1, 0, 0); nc.splice(i + 1, 0, nc[i])
|
|
149
195
|
}
|
|
150
|
-
this.x = nx; this.y = ny
|
|
196
|
+
this.x = nx; this.y = ny; this.r = nr; this.c = nc
|
|
151
197
|
}
|
|
152
198
|
|
|
153
199
|
/* THE POINTER AS A FORCE (sim units) — penrose/common.js's verbs on this line: a move pushes the nodes out of a ring
|
|
@@ -155,15 +201,16 @@ export class Growth {
|
|
|
155
201
|
* a teleport). The line's own smoothing and spacing refold the crater; `amount` 0 turns it off. */
|
|
156
202
|
poke(px, py, down = false, amount = 1) {
|
|
157
203
|
if (!amount) return
|
|
158
|
-
const { x, y, w, h } = this, s = Math.min(w, h), m = 2
|
|
204
|
+
const { x, y, w, h } = this, s = Math.min(w, h), m = this.bleed > 0 ? -this.bleed : 2
|
|
159
205
|
const reach = s * 0.08 * (down ? 3 : 1), r2 = reach * reach
|
|
160
206
|
const strength = Math.min(s * 0.03, s * 0.006 * amount * (down ? 6 : 1))
|
|
161
207
|
for (let i = 0; i < x.length; i++) {
|
|
162
208
|
const dx = x[i] - px, dy = y[i] - py, d2 = dx * dx + dy * dy
|
|
163
|
-
if (d2 >= r2 || d2 < 1e-6) continue
|
|
209
|
+
if (d2 >= r2 || d2 < 1e-6 || this.out(x[i], y[i])) continue
|
|
164
210
|
const d = Math.sqrt(d2), f = (1 - d / reach) * strength
|
|
165
211
|
x[i] = Math.min(w - m, Math.max(m, x[i] + (dx / d) * f))
|
|
166
212
|
y[i] = Math.min(h - m, Math.max(m, y[i] + (dy / d) * f))
|
|
213
|
+
if (this.r.length === x.length) this.r[i] = 0
|
|
167
214
|
}
|
|
168
215
|
}
|
|
169
216
|
|
package/src/selftest.mjs
CHANGED
|
@@ -17,4 +17,22 @@ const before = a.x.slice()
|
|
|
17
17
|
a.poke(a.x[0], a.y[0] + 1, true)
|
|
18
18
|
assert.notDeepEqual(a.x, before, 'poke moves the line')
|
|
19
19
|
assert.match(a.pathD(400, 300), /^M.*Z$/, 'one closed path')
|
|
20
|
+
/* resting (`settle`): the packed interior cools and stops — once grown, a step moves only the front */
|
|
21
|
+
const r = new Growth({ w: 400, h: 300, rand: mulberry32(5), cap: 1500 })
|
|
22
|
+
r.setParams({ minGap: 6, maxGap: 24, contrast: 1.4, growth: 8, bleed: 30, settle: 40 })
|
|
23
|
+
r.seed()
|
|
24
|
+
for (let i = 0; i < 3000 && r.x.length < r.cap; i++) r.step()
|
|
25
|
+
for (let i = 0; i < 60; i++) r.step()
|
|
26
|
+
const rx = r.x.slice(), ry = r.y.slice(), rn = r.x.length
|
|
27
|
+
r.step()
|
|
28
|
+
if (r.x.length === rn) {
|
|
29
|
+
const still = rx.filter((v, i) => v === r.x[i] && ry[i] === r.y[i]).length
|
|
30
|
+
assert.ok(still / rn > 0.8, `a grown line rests (${still}/${rn} still)`)
|
|
31
|
+
}
|
|
32
|
+
/* with a bleed the line may leave the frame, and stays inside the bled frame */
|
|
33
|
+
const c = new Growth({ w: 200, h: 150, rand: mulberry32(3), cap: 900 })
|
|
34
|
+
c.setParams({ minGap: 6, maxGap: 24, contrast: 1.4, growth: 8, bleed: 30 })
|
|
35
|
+
c.seed()
|
|
36
|
+
for (let i = 0; i < 600 && c.x.length < c.cap; i++) c.step()
|
|
37
|
+
assert.ok(c.x.every((v, i) => v >= -30 && v <= 230 && c.y[i] >= -30 && c.y[i] <= 180), 'held inside the bled frame')
|
|
20
38
|
console.log('kol-growth selftest ok')
|