@kolkrabbi/kol-growth 0.2.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,18 @@
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
+
11
+ ## 0.3.0 — 2026-10-30
12
+
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.
14
+ - **`step(zone)` retired** — `settle` does its job everywhere, not only around the pointer. kol-fxr's `engine.js`, carried verbatim.
15
+
3
16
  ## 0.2.0 — 2026-10-30
4
17
 
5
18
  - **`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.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,10 +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
- // 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
+ //
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.
18
27
 
19
28
  export const SIM_W = 1000
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) */
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
20
39
 
21
40
  /* density from any canvas: 1 − luma, on a grid `cols` wide, lightly blurred so the spacing changes smoothly */
22
41
  export function densityFrom(canvas, cols = 160, invert = false) {
@@ -44,22 +63,27 @@ export class Growth {
44
63
  this.density = density
45
64
  this.rand = rand
46
65
  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 = []
66
+ this.minGap = 5; this.maxGap = 22; this.contrast = 1.4; this.growth = 4; this.bleed = 0; this.settle = 0
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
49
73
  }
50
74
 
51
- setParams({ minGap, maxGap, contrast, growth, cap, bleed }) {
75
+ setParams({ minGap, maxGap, contrast, growth, cap, bleed, settle }) {
52
76
  if (minGap != null) this.minGap = minGap
53
77
  if (maxGap != null) this.maxGap = Math.max(maxGap, this.minGap + 0.5)
54
78
  if (contrast != null) this.contrast = contrast
55
79
  if (growth != null) this.growth = growth
56
80
  if (cap != null) this.cap = cap
57
81
  if (bleed != null) this.bleed = bleed
82
+ if (settle != null) this.settle = settle
58
83
  }
59
84
 
60
85
  /* 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
86
+ out(x, y) {
63
87
  return this.bleed > 0 && (x < 0 || y < 0 || x > this.w || y > this.h)
64
88
  }
65
89
 
@@ -78,6 +102,8 @@ export class Growth {
78
102
  const a = (i / n) * Math.PI * 2
79
103
  this.x.push(cx + Math.cos(a) * r); this.y.push(cy + Math.sin(a) * r)
80
104
  }
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
81
107
  }
82
108
 
83
109
  dens(x, y) {
@@ -93,24 +119,31 @@ export class Growth {
93
119
  return this.maxGap + (this.minGap - this.maxGap) * d
94
120
  }
95
121
 
96
- step(zone = null) {
122
+ step() {
97
123
  const { x, y, w, h } = this
98
124
  let n = x.length
99
125
  if (n < 3) return
100
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
101
129
  /* spatial hash on the largest gap, over the frame and its bleed */
102
130
  const cols = Math.ceil((w + 2 * b) / R) + 1, rows = Math.ceil((h + 2 * b) / R) + 1
103
131
  const head = new Int32Array(cols * rows).fill(-1)
104
132
  const next = new Int32Array(n)
105
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)))
106
- const frozen = new Uint8Array(n)
107
- for (let i = 0; i < n; i++) frozen[i] = this.out(x[i], y[i], zone) ? 1 : 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
108
137
  for (let i = 0; i < n; i++) { const c = cell(x[i], y[i]); next[i] = head[c]; head[c] = i }
109
138
  const gaps = new Float32Array(n)
110
139
  for (let i = 0; i < n; i++) gaps[i] = this.gap(x[i], y[i])
111
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
112
145
  for (let i = 0; i < n; i++) {
113
- if (frozen[i]) continue
146
+ if (!live[i]) continue
114
147
  const ip = (i - 1 + n) % n, inx = (i + 1) % n
115
148
  /* smooth: toward the neighbours' midpoint */
116
149
  fx[i] += ((x[ip] + x[inx]) / 2 - x[i]) * 0.35
@@ -129,70 +162,135 @@ export class Growth {
129
162
  const dx = x[i] - x[j], dy = y[i] - y[j]
130
163
  const g = (gi + gaps[j]) / 2
131
164
  const d2 = dx * dx + dy * dy
165
+ if (crowd && d2 < 2.25 * g * g && crowd[i] < 255) crowd[i]++
132
166
  if (d2 >= g * g || d2 === 0) continue
133
167
  const d = Math.sqrt(d2)
134
168
  const push = ((g - d) / g) * 0.5
135
169
  fx[i] += (dx / d) * push * g * 0.5
136
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 }
137
174
  }
138
175
  }
139
176
  }
140
177
  }
141
178
  const m = b > 0 ? -b : 2
142
179
  for (let i = 0; i < n; i++) {
143
- if (frozen[i]) continue
144
- 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]))))
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
184
+ x[i] = Math.min(w - m, Math.max(m, x[i] + mx))
185
+ y[i] = Math.min(h - m, Math.max(m, y[i] + my))
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 }
147
188
  }
189
+ if (heat) for (let i = 0; i < n; i++) { const v = heat[i] * K; heat[i] = wake[i] > v ? wake[i] : v }
148
190
  /* resample: split long edges, merge short ones, then grow */
149
- const nx = [], ny = []
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
150
193
  let budget = this.cap - n
151
194
  for (let i = 0; i < n; i++) {
152
195
  const j = (i + 1) % n
153
196
  const g = this.gap(x[i], y[i])
154
197
  const dx = x[j] - x[i], dy = y[j] - y[i]
155
198
  const len = Math.hypot(dx, dy)
156
- /* 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 }
158
- 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-- }
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-- }
206
+ }
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
161
219
  }
162
- 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
165
- /* a split nudged sideways starts a new fold */
166
- const dx = nx[j] - nx[i], dy = ny[j] - ny[i]
167
- const s = (this.rand() - 0.5) * 0.6
168
- nx.splice(i + 1, 0, nx[i] + dx / 2 - dy * s)
169
- ny.splice(i + 1, 0, ny[i] + dy / 2 + dx * s)
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
+ }
170
248
  }
171
- this.x = nx; this.y = ny
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
172
252
  }
173
253
 
174
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
175
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
176
- * 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. */
177
258
  poke(px, py, down = false, amount = 1) {
178
259
  if (!amount) return
179
260
  const { x, y, w, h } = this, s = Math.min(w, h), m = this.bleed > 0 ? -this.bleed : 2
180
- const reach = s * 0.08 * (down ? 3 : 1), r2 = reach * reach
181
- 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
182
270
  for (let i = 0; i < x.length; i++) {
183
271
  const dx = x[i] - px, dy = y[i] - py, d2 = dx * dx + dy * dy
184
272
  if (d2 >= r2 || d2 < 1e-6 || this.out(x[i], y[i])) continue
185
- 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
186
275
  x[i] = Math.min(w - m, Math.max(m, x[i] + (dx / d) * f))
187
276
  y[i] = Math.min(h - m, Math.max(m, y[i] + (dy / d) * f))
277
+ if (heat) { const v = fall; if (v > heat[i]) { if (heat[i] <= COLD && v > COLD) hot++; heat[i] = v } }
188
278
  }
279
+ this.hot = hot
189
280
  }
190
281
 
191
282
  /* the one path, scaled onto a w × h frame */
192
- 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 } = {}) {
193
284
  if (paper) { ctx.fillStyle = paper; ctx.fillRect(0, 0, fw, fh) }
194
- const { x, y } = this
285
+ let { x, y } = this
195
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
+ }
196
294
  if (n < 3) return
197
295
  const sx = fw / this.w, sy = fh / this.h
198
296
  ctx.beginPath()
package/src/selftest.mjs CHANGED
@@ -17,11 +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
- /* 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
+ /* settling (`settle`): every node's heat fades — a grown line goes cold and a step then moves nothing; a poke re-heats it */
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: 2.5, bleed: 30, settle: 40 })
23
+ r.seed()
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
+ r.step()
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')
25
33
  /* with a bleed the line may leave the frame, and stays inside the bled frame */
26
34
  const c = new Growth({ w: 200, h: 150, rand: mulberry32(3), cap: 900 })
27
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')