@kolkrabbi/kol-growth 0.3.0 → 0.4.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,13 @@
1
1
  # @kolkrabbi/kol-growth
2
2
 
3
+ ## 0.4.0 — 2026-10-31
4
+
5
+ - **The line cools instead of freezing** (GrowthLineIntro, kol-fxr plan 45). With `settle` > 0 every node carries a heat 1 → 0 that fades by 1/settle a step; its motion, a split beside it, `poke`, or a warm neighbour pressing a fifth into its gap (less 0.15 a hop) raise it. Force and growth scale with heat, the last quarter tapers, the step is halved (it overshot every step), so nothing stops mid-move — 0.3.0's step-count freeze left a rubber-string rim and a hard stop. New fields: `heat`, `hot` (nodes still warm), `full`, `id` (stable node ids).
6
+ - **Front growth** — while settling, each open-front edge splits with chance `growth`/100 a step, so the rim advances at a steady pace; splits end at the cap or when the front leaves the frame. `growth` now means a percentage for settling sims (fxr's home uses 2.5); `settle` 0 is unchanged.
7
+ - **Soft pointer** — settling, a hover reaches 0.24 of the short side with a bell falloff `(1 − d²/r²)²`, capped under a third of `minGap` a frame.
8
+ - **`draw({ wave })`** — `{ a, k, p }` displaces the drawn line by two crossed sines; the sim is untouched.
9
+ - `orbCanvas(aspect, W)` · `pathCanvas(d, aspect, fit, W)` take an optional width (default 400). kol-fxr's `engine.js`, carried verbatim.
10
+
3
11
  ## 0.3.0 — 2026-10-30
4
12
 
5
13
  - **`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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-growth",
3
- "version": "0.3.0",
3
+ "version": "0.4.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,15 +13,29 @@
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
- // 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.
16
+ //
17
+ // THE LINE SETTLES (plan 45 W1, user 2026-10-30: *"it still jitters to helll at the edges, its like a rubber string...
18
+ // until a timer is done thhen its just dead. AND the mouse is like just vibratiing EVERYTHING"*). Plan 44's version froze
19
+ // nodes by flag and kept a "front" live forever — on a finished orb the whole rim was front: it sprang while growing, every
20
+ // mouse move woke the whole rim, and the only stop was a hard cap flag. With `settle` > 0 every node carries a HEAT, 1 → 0:
21
+ // it fades by 1/settle a step; its own motion, a split beside it, the pointer (`poke`), or a warm neighbour pressing a
22
+ // fifth into its gap raise it. A cold node takes no force and is not resampled; a node's last quarter of heat tapers its
23
+ // motion out instead of stopping it; growth eases to nothing over the last 15 % before the cap and ends there, and a
24
+ // split's sideways nudge is capped at 0.15 · minGap so the wide-gapped rim is not kicked. Nothing is live forever, nothing
25
+ // snaps: the line goes still as its heat fades (`hot` counts the nodes still warm). `settle` 0 is the framed, always-live
26
+ // engine the effect and the generator draw with — unchanged.
21
27
 
22
28
  export const SIM_W = 1000
23
- /* fewer close strands than this, and a node is on the growing front */
29
+ /* heat below this is cold: no force, no resample, no growth, and it does not count as hot */
30
+ const COLD = 0.02
31
+ /* fewer strands than this within 1.5 gaps, and a node is on the open front (measured: front ~10, packed interior ~20) */
24
32
  const FRONT = 14
33
+ /* THE STEP IS HALVED when settling (plan 45 1.1, measured 2026-10-31): the relaxation as written overshoots — every node
34
+ * reversed direction EVERY step (direction correlation −1.00 over the whole line, 0.9 px in the tight gaps, 2.5 px at
35
+ * the rim), a two-step oscillation that never dies and was every jitter the user saw, the mouse's included. Half the
36
+ * move lands on the balance point instead of past it. The framed engine (settle 0) keeps its full step — the effect and
37
+ * the generator are drawn with it, and their look is not this plan's to change */
38
+ const GAIN = 0.5
25
39
 
26
40
  /* density from any canvas: 1 − luma, on a grid `cols` wide, lightly blurred so the spacing changes smoothly */
27
41
  export function densityFrom(canvas, cols = 160, invert = false) {
@@ -50,7 +64,12 @@ export class Growth {
50
64
  this.rand = rand
51
65
  this.cap = cap
52
66
  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 = []
67
+ this.x = []; this.y = []; this.heat = []
68
+ /* settling: a stable id per node, kept through splits and merges, so a node can be followed frame to frame */
69
+ this.id = []; this.nid = 0
70
+ /* nodes still warm (settle > 0) — the loop can sleep at 0 */
71
+ this.hot = 0
72
+ this.acc = 0; this.full = false
54
73
  }
55
74
 
56
75
  setParams({ minGap, maxGap, contrast, growth, cap, bleed, settle }) {
@@ -83,7 +102,8 @@ export class Growth {
83
102
  const a = (i / n) * Math.PI * 2
84
103
  this.x.push(cx + Math.cos(a) * r); this.y.push(cy + Math.sin(a) * r)
85
104
  }
86
- this.r = new Array(n).fill(0)
105
+ this.heat = new Array(n).fill(1); this.hot = n; this.acc = 0; this.full = false
106
+ this.id = Array.from({ length: n }, (_, i) => i); this.nid = n
87
107
  }
88
108
 
89
109
  dens(x, y) {
@@ -104,21 +124,26 @@ export class Growth {
104
124
  let n = x.length
105
125
  if (n < 3) return
106
126
  const R = this.maxGap, b = this.bleed
127
+ const S = this.settle, K = S > 0 ? 1 - 1 / S : 1
128
+ const heat = S > 0 ? (this.heat.length === n ? this.heat : (this.heat = new Array(n).fill(1))) : null
107
129
  /* spatial hash on the largest gap, over the frame and its bleed */
108
130
  const cols = Math.ceil((w + 2 * b) / R) + 1, rows = Math.ceil((h + 2 * b) / R) + 1
109
131
  const head = new Int32Array(cols * rows).fill(-1)
110
132
  const next = new Int32Array(n)
111
133
  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
134
+ /* a node takes force when it is on the frame and (settling) warm */
135
+ const live = new Uint8Array(n)
136
+ for (let i = 0; i < n; i++) live[i] = this.out(x[i], y[i]) || (heat && heat[i] <= COLD) ? 0 : 1
116
137
  for (let i = 0; i < n; i++) { const c = cell(x[i], y[i]); next[i] = head[c]; head[c] = i }
117
138
  const gaps = new Float32Array(n)
118
139
  for (let i = 0; i < n; i++) gaps[i] = this.gap(x[i], y[i])
119
- const fx = new Float32Array(n), fy = new Float32Array(n), crowd = new Uint8Array(n)
140
+ const fx = new Float32Array(n), fy = new Float32Array(n)
141
+ /* a warm node pressing into a neighbour's gap wakes it, by how far it presses (settling only) */
142
+ const wake = heat ? new Float32Array(n) : null
143
+ /* settling: strands within 1.5 gaps of each node — the open front has few, the packed interior ~20 */
144
+ const crowd = heat ? new Uint8Array(n) : null
120
145
  for (let i = 0; i < n; i++) {
121
- if (frozen[i]) continue
146
+ if (!live[i]) continue
122
147
  const ip = (i - 1 + n) % n, inx = (i + 1) % n
123
148
  /* smooth: toward the neighbours' midpoint */
124
149
  fx[i] += ((x[ip] + x[inx]) / 2 - x[i]) * 0.35
@@ -137,88 +162,135 @@ export class Growth {
137
162
  const dx = x[i] - x[j], dy = y[i] - y[j]
138
163
  const g = (gi + gaps[j]) / 2
139
164
  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]++
165
+ if (crowd && d2 < 2.25 * g * g && crowd[i] < 255) crowd[i]++
142
166
  if (d2 >= g * g || d2 === 0) continue
143
167
  const d = Math.sqrt(d2)
144
168
  const push = ((g - d) / g) * 0.5
145
169
  fx[i] += (dx / d) * push * g * 0.5
146
170
  fy[i] += (dy / d) * push * g * 0.5
171
+ /* a press of a fifth of the gap or more carries heat across, less 0.15 a hop — so a push dies out within six
172
+ * strands instead of travelling the packed line as a pressure wave (the mouse moved nodes 511 px away, 2026-10-31) */
173
+ if (wake) { const v = Math.min(heat[i] - 0.15, heat[i] * ((g - d) / g - 0.2) / 0.8); if (v > 0.1 && v > wake[j]) wake[j] = v }
147
174
  }
148
175
  }
149
176
  }
150
177
  }
151
178
  const m = b > 0 ? -b : 2
152
179
  for (let i = 0; i < n; i++) {
153
- if (frozen[i]) continue
154
- const lim = gaps[i] * 0.25
155
- const mx = Math.max(-lim, Math.min(lim, fx[i])), my = Math.max(-lim, Math.min(lim, fy[i]))
180
+ if (!live[i]) continue
181
+ /* full force while warm; the last quarter of a node's heat tapers its motion out, so nothing stops mid-move */
182
+ const lim = gaps[i] * 0.25, k = heat ? Math.min(1, heat[i] * 4) * GAIN : 1
183
+ const mx = Math.max(-lim, Math.min(lim, fx[i])) * k, my = Math.max(-lim, Math.min(lim, fy[i])) * k
156
184
  x[i] = Math.min(w - m, Math.max(m, x[i] + mx))
157
185
  y[i] = Math.min(h - m, Math.max(m, y[i] + my))
158
- rest[i] = crowd[i] < FRONT ? 0 : rest[i] + 1
186
+ /* a node that still moves a tenth of its gap a step is still hot — heat is recent motion */
187
+ if (heat) { const v = Math.min(1, Math.hypot(mx, my) / (0.1 * gaps[i])); if (v > wake[i]) wake[i] = v }
159
188
  }
189
+ if (heat) for (let i = 0; i < n; i++) { const v = heat[i] * K; heat[i] = wake[i] > v ? wake[i] : v }
160
190
  /* resample: split long edges, merge short ones, then grow */
161
- const nx = [], ny = [], nr = [], nc = []
191
+ const nx = [], ny = [], nh = [], ni = [], nc = []
192
+ const id = heat ? (this.id.length === n ? this.id : (this.id = Array.from({ length: n }, () => this.nid++))) : null
162
193
  let budget = this.cap - n
163
194
  for (let i = 0; i < n; i++) {
164
195
  const j = (i + 1) % n
165
196
  const g = this.gap(x[i], y[i])
166
197
  const dx = x[j] - x[i], dy = y[j] - y[i]
167
198
  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 }
170
- if (len < g * 0.2 && n > 8) continue
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-- }
199
+ /* an edge with a frozen or cold end keeps its nodes as they are */
200
+ if (!live[i] || !live[j]) { nx.push(x[i]); ny.push(y[i]); if (heat) { nh.push(heat[i]); ni.push(id[i]); nc.push(255) } continue }
201
+ /* settling, a merge must not leave an edge long enough to split again — a merge/split loop at one spot was the
202
+ * last thing moving on a finished line (10 nodes, 1.5 px, forever; measured 2026-10-31) */
203
+ if (len < g * 0.2 && n > 8 && (!heat || Math.hypot(x[j] - x[(i - 1 + n) % n], y[j] - y[(i - 1 + n) % n]) < g * 0.45)) continue
204
+ nx.push(x[i]); ny.push(y[i]); if (heat) { nh.push(heat[i]); ni.push(id[i]); nc.push(crowd[i]) }
205
+ if (len > g * 0.5 && budget > 0) { nx.push(x[i] + dx / 2); ny.push(y[i] + dy / 2); if (heat) { nh.push(Math.max(heat[i], heat[j])); ni.push(this.nid++); nc.push(crowd[i]) } budget-- }
173
206
  }
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)
207
+ if (!heat) {
208
+ for (let k = 0; k < this.growth && nx.length < this.cap; k++) {
209
+ const i = Math.floor(this.rand() * nx.length), j = (i + 1) % nx.length
210
+ if (this.out(nx[i], ny[i]) || this.out(nx[j], ny[j])) continue
211
+ /* a split nudged sideways starts a new fold */
212
+ const dx = nx[j] - nx[i], dy = ny[j] - ny[i]
213
+ const s = (this.rand() - 0.5) * 0.6
214
+ nx.splice(i + 1, 0, nx[i] + dx / 2 - dy * s)
215
+ ny.splice(i + 1, 0, ny[i] + dy / 2 + dx * s)
216
+ }
217
+ this.x = nx; this.y = ny
218
+ return
181
219
  }
182
- for (let k = 0; k < this.growth && nx.length < this.cap; k++) {
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
189
- /* a split nudged sideways starts a new fold */
190
- const dx = nx[j] - nx[i], dy = ny[j] - ny[i]
191
- const s = (this.rand() - 0.5) * 0.6
192
- nx.splice(i + 1, 0, nx[i] + dx / 2 - dy * s)
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])
220
+ /* SETTLING GROWTH — the differential-growth rule (plan 45, user 2026-10-31: *"the growth starts well but plateaus early
221
+ * at a small size … then 4 minutes later it reaches the edges"*): every edge on the open FRONT (both ends warm, fewer than
222
+ * FRONT strands within 1.5 gaps) splits with chance `growth`/100 a step. Random splits anywhere wasted most of the budget
223
+ * on the cooled interior, where nothing can move — the rim crawled and the nodes ran out before the edges. The front's
224
+ * length grows with the line, so the rim advances at a steady pace instead of stalling. Once the cap is reached the
225
+ * splits stop for good (merges may drop the count under it; refilling it forever was the churn). The sideways nudge is
226
+ * capped so a wide-gapped edge (the rim) is not kicked */
227
+ if (nx.length >= this.cap) this.full = true
228
+ if (!this.full) {
229
+ const p = this.growth / 100, kick = 0.15 * this.minGap
230
+ const front = []; let open = 0
231
+ for (let i = 0; i < nx.length; i++) {
232
+ const j = (i + 1) % nx.length
233
+ if (nh[i] > COLD && nh[j] > COLD && nc[i] < FRONT && nc[j] < FRONT && !this.out(nx[i], ny[i]) && !this.out(nx[j], ny[j])) { open++; if (this.rand() < p) front.push(i) }
234
+ }
235
+ /* the front has run off the frame: the line has filled what it can reach — done, cap or not */
236
+ if (open === 0 && nx.length > 200) this.full = true
237
+ /* back to front, so an insert never shifts an index still to come */
238
+ for (let q = front.length - 1; q >= 0 && nx.length < this.cap; q--) {
239
+ const i = front[q], j = (i + 1) % nx.length
240
+ const dx = nx[j] - nx[i], dy = ny[j] - ny[i]
241
+ let s = (this.rand() - 0.5) * 0.6
242
+ const len = Math.hypot(dx, dy) || 1
243
+ if (Math.abs(s) * len > kick) s = Math.sign(s) * kick / len
244
+ nx.splice(i + 1, 0, nx[i] + dx / 2 - dy * s)
245
+ ny.splice(i + 1, 0, ny[i] + dy / 2 + dx * s)
246
+ nh.splice(i + 1, 0, 1); nh[i] = 1; nh[(i + 2) % nh.length] = 1; ni.splice(i + 1, 0, this.nid++); nc.splice(i + 1, 0, nc[i])
247
+ }
195
248
  }
196
- this.x = nx; this.y = ny; this.r = nr; this.c = nc
249
+ let hot = 0
250
+ for (let i = 0; i < nh.length; i++) if (nh[i] > COLD) hot++
251
+ this.x = nx; this.y = ny; this.heat = nh; this.hot = hot; this.id = ni
197
252
  }
198
253
 
199
254
  /* THE POINTER AS A FORCE (sim units) — penrose/common.js's verbs on this line: a move pushes the nodes out of a ring
200
255
  * 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
201
- * a teleport). The line's own smoothing and spacing refold the crater; `amount` 0 turns it off. */
256
+ * a teleport). The line's own smoothing and spacing refold the crater; `amount` 0 turns it off. Settling, a push is
257
+ * capped under half the tightest gap, so it can never carry a node across a strand, and it heats what it moves. */
202
258
  poke(px, py, down = false, amount = 1) {
203
259
  if (!amount) return
204
260
  const { x, y, w, h } = this, s = Math.min(w, h), m = this.bleed > 0 ? -this.bleed : 2
205
- const reach = s * 0.08 * (down ? 3 : 1), r2 = reach * reach
206
- const strength = Math.min(s * 0.03, s * 0.006 * amount * (down ? 6 : 1))
261
+ const heat = this.settle > 0 && this.heat.length === x.length ? this.heat : null
262
+ /* settling, a hover reaches as far as a press, three times the old brush, and fades out soft (plan 45 1.4, user
263
+ * 2026-10-31: *"the mouse radius is so small it should be bigger and more blurry"*) */
264
+ const reach = s * (heat && !down ? 0.24 : 0.08) * (down ? 3 : 1), r2 = reach * reach
265
+ let strength = Math.min(s * 0.03, s * 0.006 * amount * (down ? 6 : 1))
266
+ /* settling, a hover is a nudge (under a third of the tightest gap a frame, user 2026-10-31: *"mouse hover applies too much
267
+ * affect?"*) — at half a gap it crammed strands into each other; a press may shove up to half a gap */
268
+ if (heat) strength = Math.min(strength, (down ? 0.45 : 0.3) * this.minGap)
269
+ let hot = this.hot
207
270
  for (let i = 0; i < x.length; i++) {
208
271
  const dx = x[i] - px, dy = y[i] - py, d2 = dx * dx + dy * dy
209
272
  if (d2 >= r2 || d2 < 1e-6 || this.out(x[i], y[i])) continue
210
- const d = Math.sqrt(d2), f = (1 - d / reach) * strength
273
+ /* settling, the falloff is a bell — (1 − d²/r²)², no edge at the rim — instead of the linear cone */
274
+ const d = Math.sqrt(d2), q = 1 - d2 / r2, fall = heat ? q * q : 1 - d / reach, f = fall * strength
211
275
  x[i] = Math.min(w - m, Math.max(m, x[i] + (dx / d) * f))
212
276
  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
277
+ if (heat) { const v = fall; if (v > heat[i]) { if (heat[i] <= COLD && v > COLD) hot++; heat[i] = v } }
214
278
  }
279
+ this.hot = hot
215
280
  }
216
281
 
217
282
  /* the one path, scaled onto a w × h frame */
218
- draw(ctx, fw, fh, { ink = '#111', paper = null, width = 1.2 } = {}) {
283
+ draw(ctx, fw, fh, { ink = '#111', paper = null, width = 1.2, wave = null } = {}) {
219
284
  if (paper) { ctx.fillStyle = paper; ctx.fillRect(0, 0, fw, fh) }
220
- const { x, y } = this
285
+ let { x, y } = this
221
286
  const n = x.length
287
+ /* a slow wave (plan 45 4.3): the drawn line is displaced by two crossed sines — `a` px, `k` rad/px, phase `p` — and
288
+ * the sim itself never moves, so a resting line still drifts at no cost to the step */
289
+ if (wave && n) {
290
+ const { a, k, p } = wave, wx = new Float32Array(n), wy = new Float32Array(n)
291
+ for (let i = 0; i < n; i++) { wx[i] = x[i] + a * Math.sin(k * y[i] + p); wy[i] = y[i] + a * Math.sin(k * x[i] + p * 1.3 + 1) }
292
+ x = wx; y = wy
293
+ }
222
294
  if (n < 3) return
223
295
  const sx = fw / this.w, sy = fh / this.h
224
296
  ctx.beginPath()
package/src/selftest.mjs CHANGED
@@ -17,18 +17,19 @@ 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 */
20
+ /* settling (`settle`): every node's heat fades — a grown line goes cold and a step then moves nothing; a poke re-heats it */
21
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 })
22
+ r.setParams({ minGap: 6, maxGap: 24, contrast: 1.4, growth: 2.5, bleed: 30, settle: 40 })
23
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
24
+ for (let i = 0; i < 20000 && (!r.full || r.hot > 0); i++) r.step()
25
+ assert.ok(r.full && r.hot === 0, `a grown line goes cold (full ${r.full}, hot ${r.hot})`)
26
+ const rx = r.x.slice(), ids = r.id.slice()
27
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
- }
28
+ assert.deepEqual(r.x, rx, 'a cold line does not move')
29
+ r.poke(r.x[0], r.y[0] + 1)
30
+ assert.ok(r.hot > 0, 'a poke re-heats it')
31
+ for (let i = 0; i < 20000 && r.hot > 0; i++) r.step()
32
+ assert.ok(r.hot === 0 && r.id.filter((v) => ids.includes(v)).length > ids.length * 0.9, 'it cools again and keeps its nodes')
32
33
  /* with a bleed the line may leave the frame, and stays inside the bled frame */
33
34
  const c = new Growth({ w: 200, h: 150, rand: mulberry32(3), cap: 900 })
34
35
  c.setParams({ minGap: 6, maxGap: 24, contrast: 1.4, growth: 8, bleed: 30 })
package/src/shapes.js CHANGED
@@ -2,8 +2,8 @@
2
2
  // The one DOM in this engine (as `densityFrom` itself): a shape is a canvas, so these run in a browser.
3
3
 
4
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))
5
+ export function orbCanvas(aspect, W = 400) {
6
+ const H = Math.max(1, Math.round(W / aspect))
7
7
  const c = document.createElement('canvas')
8
8
  c.width = W; c.height = H
9
9
  const g = c.getContext('2d')
@@ -17,8 +17,8 @@ export function orbCanvas(aspect) {
17
17
 
18
18
  /* any SVG path `d`, filled solid black on white, centred and scaled to `fit` of the frame — kol-fxr's `vectorCanvas`
19
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))
20
+ export function pathCanvas(d, aspect, fit = 0.6, W = 400) {
21
+ const H = Math.max(1, Math.round(W / aspect))
22
22
  const c = document.createElement('canvas')
23
23
  c.width = W; c.height = H
24
24
  const g = c.getContext('2d')