@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 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.step({ x, y, r }) // a finished line: only nodes inside the zone move, the rest stay frozen
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-growth",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "The growing line engine, differential growth into shapes",
5
5
  "license": "MIT",
6
6
  "type": "module",
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
- // 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.
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, zone) {
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(zone = null) {
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], zone) ? 1 : 0
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
- 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]))))
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
- 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
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 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')
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 })