@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 +8 -0
- package/package.json +1 -1
- package/src/engine.js +126 -54
- package/src/selftest.mjs +10 -9
- package/src/shapes.js +4 -4
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
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
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
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
|
-
/*
|
|
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.
|
|
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.
|
|
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
|
-
/*
|
|
113
|
-
const
|
|
114
|
-
|
|
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)
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
154
|
-
|
|
155
|
-
const
|
|
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
|
-
|
|
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 = [],
|
|
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 (
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
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-- }
|
|
173
206
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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
|
|
206
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
/*
|
|
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:
|
|
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 <
|
|
25
|
-
|
|
26
|
-
const rx = r.x.slice(),
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
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')
|