@kolkrabbi/kol-growth 0.2.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 +5 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/engine.js +45 -19
- package/src/selftest.mjs +12 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
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
|
+
|
|
3
8
|
## 0.2.0 — 2026-10-30
|
|
4
9
|
|
|
5
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.
|
package/README.md
CHANGED
|
@@ -11,7 +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.
|
|
14
|
+
sim.setParams({ settle: 120 }) // the packed interior rests; only the growing front and the pointer move
|
|
15
15
|
sim.poke(px, py, down) // the pointer as a force: a push, or a blast when down
|
|
16
16
|
sim.draw(ctx, w, h, { ink, width: 1 }) // the one path onto a canvas
|
|
17
17
|
sim.pathD(w, h) // the same path as an SVG `d`
|
|
@@ -19,7 +19,7 @@ sim.pathD(w, h) // the same path as an SVG `d`
|
|
|
19
19
|
|
|
20
20
|
| export | does |
|
|
21
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 |
|
|
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 |
|
|
23
23
|
| `densityFrom(canvas, cols?, invert?)` | 1 − luma on a grid, lightly blurred — the field the spacing reads |
|
|
24
24
|
| `SIM_W` | 1000, the sim width kol-fxr's loops use |
|
|
25
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
|
@@ -13,10 +13,15 @@
|
|
|
13
13
|
// sits still, not this jittering"*). By default the line is held 2 units inside the frame — the effect and the generator
|
|
14
14
|
// draw on framed paper. `bleed` lets it run that far past every edge instead, and a node outside the frame FREEZES: no
|
|
15
15
|
// force, no split, no merge, no growth. The wall the line piled into is gone; what leaves the frame sits still.
|
|
16
|
-
//
|
|
17
|
-
//
|
|
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.
|
|
18
21
|
|
|
19
22
|
export const SIM_W = 1000
|
|
23
|
+
/* fewer close strands than this, and a node is on the growing front */
|
|
24
|
+
const FRONT = 14
|
|
20
25
|
|
|
21
26
|
/* density from any canvas: 1 − luma, on a grid `cols` wide, lightly blurred so the spacing changes smoothly */
|
|
22
27
|
export function densityFrom(canvas, cols = 160, invert = false) {
|
|
@@ -44,22 +49,22 @@ export class Growth {
|
|
|
44
49
|
this.density = density
|
|
45
50
|
this.rand = rand
|
|
46
51
|
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 = []
|
|
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 = []
|
|
49
54
|
}
|
|
50
55
|
|
|
51
|
-
setParams({ minGap, maxGap, contrast, growth, cap, bleed }) {
|
|
56
|
+
setParams({ minGap, maxGap, contrast, growth, cap, bleed, settle }) {
|
|
52
57
|
if (minGap != null) this.minGap = minGap
|
|
53
58
|
if (maxGap != null) this.maxGap = Math.max(maxGap, this.minGap + 0.5)
|
|
54
59
|
if (contrast != null) this.contrast = contrast
|
|
55
60
|
if (growth != null) this.growth = growth
|
|
56
61
|
if (cap != null) this.cap = cap
|
|
57
62
|
if (bleed != null) this.bleed = bleed
|
|
63
|
+
if (settle != null) this.settle = settle
|
|
58
64
|
}
|
|
59
65
|
|
|
60
66
|
/* off the frame, with a bleed: frozen */
|
|
61
|
-
out(x, y
|
|
62
|
-
if (zone && (x - zone.x) ** 2 + (y - zone.y) ** 2 > zone.r * zone.r) return true
|
|
67
|
+
out(x, y) {
|
|
63
68
|
return this.bleed > 0 && (x < 0 || y < 0 || x > this.w || y > this.h)
|
|
64
69
|
}
|
|
65
70
|
|
|
@@ -78,6 +83,7 @@ export class Growth {
|
|
|
78
83
|
const a = (i / n) * Math.PI * 2
|
|
79
84
|
this.x.push(cx + Math.cos(a) * r); this.y.push(cy + Math.sin(a) * r)
|
|
80
85
|
}
|
|
86
|
+
this.r = new Array(n).fill(0)
|
|
81
87
|
}
|
|
82
88
|
|
|
83
89
|
dens(x, y) {
|
|
@@ -93,7 +99,7 @@ export class Growth {
|
|
|
93
99
|
return this.maxGap + (this.minGap - this.maxGap) * d
|
|
94
100
|
}
|
|
95
101
|
|
|
96
|
-
step(
|
|
102
|
+
step() {
|
|
97
103
|
const { x, y, w, h } = this
|
|
98
104
|
let n = x.length
|
|
99
105
|
if (n < 3) return
|
|
@@ -103,12 +109,14 @@ export class Growth {
|
|
|
103
109
|
const head = new Int32Array(cols * rows).fill(-1)
|
|
104
110
|
const next = new Int32Array(n)
|
|
105
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
|
|
106
114
|
const frozen = new Uint8Array(n)
|
|
107
|
-
for (let i = 0; i < n; i++) frozen[i] = this.out(x[i], y[i]
|
|
115
|
+
for (let i = 0; i < n; i++) frozen[i] = this.out(x[i], y[i]) ? 1 : S > 0 && rest[i] >= S ? 2 : 0
|
|
108
116
|
for (let i = 0; i < n; i++) { const c = cell(x[i], y[i]); next[i] = head[c]; head[c] = i }
|
|
109
117
|
const gaps = new Float32Array(n)
|
|
110
118
|
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)
|
|
119
|
+
const fx = new Float32Array(n), fy = new Float32Array(n), crowd = new Uint8Array(n)
|
|
112
120
|
for (let i = 0; i < n; i++) {
|
|
113
121
|
if (frozen[i]) continue
|
|
114
122
|
const ip = (i - 1 + n) % n, inx = (i + 1) % n
|
|
@@ -129,6 +137,8 @@ export class Growth {
|
|
|
129
137
|
const dx = x[i] - x[j], dy = y[i] - y[j]
|
|
130
138
|
const g = (gi + gaps[j]) / 2
|
|
131
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]++
|
|
132
142
|
if (d2 >= g * g || d2 === 0) continue
|
|
133
143
|
const d = Math.sqrt(d2)
|
|
134
144
|
const push = ((g - d) / g) * 0.5
|
|
@@ -142,11 +152,13 @@ export class Growth {
|
|
|
142
152
|
for (let i = 0; i < n; i++) {
|
|
143
153
|
if (frozen[i]) continue
|
|
144
154
|
const lim = gaps[i] * 0.25
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
147
159
|
}
|
|
148
160
|
/* resample: split long edges, merge short ones, then grow */
|
|
149
|
-
const nx = [], ny = []
|
|
161
|
+
const nx = [], ny = [], nr = [], nc = []
|
|
150
162
|
let budget = this.cap - n
|
|
151
163
|
for (let i = 0; i < n; i++) {
|
|
152
164
|
const j = (i + 1) % n
|
|
@@ -154,21 +166,34 @@ export class Growth {
|
|
|
154
166
|
const dx = x[j] - x[i], dy = y[j] - y[i]
|
|
155
167
|
const len = Math.hypot(dx, dy)
|
|
156
168
|
/* 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 }
|
|
169
|
+
if (frozen[i] || frozen[j]) { nx.push(x[i]); ny.push(y[i]); nr.push(rest[i]); nc.push(255); continue }
|
|
158
170
|
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-- }
|
|
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)
|
|
161
181
|
}
|
|
162
182
|
for (let k = 0; k < this.growth && nx.length < this.cap; k++) {
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
165
189
|
/* a split nudged sideways starts a new fold */
|
|
166
190
|
const dx = nx[j] - nx[i], dy = ny[j] - ny[i]
|
|
167
191
|
const s = (this.rand() - 0.5) * 0.6
|
|
168
192
|
nx.splice(i + 1, 0, nx[i] + dx / 2 - dy * s)
|
|
169
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])
|
|
170
195
|
}
|
|
171
|
-
this.x = nx; this.y = ny
|
|
196
|
+
this.x = nx; this.y = ny; this.r = nr; this.c = nc
|
|
172
197
|
}
|
|
173
198
|
|
|
174
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
|
|
@@ -185,6 +210,7 @@ export class Growth {
|
|
|
185
210
|
const d = Math.sqrt(d2), f = (1 - d / reach) * strength
|
|
186
211
|
x[i] = Math.min(w - m, Math.max(m, x[i] + (dx / d) * f))
|
|
187
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
|
|
188
214
|
}
|
|
189
215
|
}
|
|
190
216
|
|
package/src/selftest.mjs
CHANGED
|
@@ -17,11 +17,18 @@ 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
|
-
/* a
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
+
}
|
|
25
32
|
/* with a bleed the line may leave the frame, and stays inside the bled frame */
|
|
26
33
|
const c = new Growth({ w: 200, h: 150, rand: mulberry32(3), cap: 900 })
|
|
27
34
|
c.setParams({ minGap: 6, maxGap: 24, contrast: 1.4, growth: 8, bleed: 30 })
|