@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-growth",
3
- "version": "0.1.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
@@ -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
- x[i] = Math.min(w - m, Math.max(m, x[i] + Math.max(-lim, Math.min(lim, fx[i]))))
128
- 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
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
- const i = Math.floor(this.rand() * nx.length), j = (i + 1) % nx.length
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')