@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 +13 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/engine.js +134 -36
- package/src/selftest.mjs +13 -5
- package/src/shapes.js +4 -4
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.
|
|
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,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
|
-
//
|
|
17
|
-
//
|
|
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
|
|
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(
|
|
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
|
-
|
|
107
|
-
|
|
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 (
|
|
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 (
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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 (
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
if (len
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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
|
|
181
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
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
|
|
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')
|