@kolkrabbi/kol-growth 0.0.0-stage → 0.1.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 ADDED
@@ -0,0 +1,10 @@
1
+ # @kolkrabbi/kol-growth
2
+
3
+ ## 0.1.0 — 2026-10-30
4
+
5
+ - **First release — the engine tier's fourth package** (ARCHITECTURE §3, GrowthLineStage from kol-atlas). kol-fxr's
6
+ growing line moved in so fxr and every other app grow the same line from one engine.
7
+ - `Growth` · `densityFrom` · `SIM_W` — kol-fxr `src/loops/growth/engine.js`, carried verbatim.
8
+ - `mulberry32` — kol-fxr `src/loops/gl/rng.js`, verbatim.
9
+ - `orbCanvas` (fxr's `sourceCanvas('orb')`, verbatim) and `pathCanvas` (new: any SVG path `d` as a density source).
10
+ - `src/selftest.mjs` — grows to the cap, a seed repeats, `poke` moves the line.
package/README.md CHANGED
@@ -1,3 +1,29 @@
1
- # Temporary Holding Version
1
+ # @kolkrabbi/kol-growth
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The growing line: differential growth — one closed line that grows, folds and never crosses itself, packing tighter
4
+ where a density field is darker. Plain ESM, no React, no dependencies. Engine tier (ARCHITECTURE §3): the stage that
5
+ draws it on a page is kol-component's `GrowthLineStage`; kol-fxr's generator and effect run it in their own loops.
6
+
7
+ ```js
8
+ import { Growth, densityFrom, mulberry32, orbCanvas, pathCanvas } from '@kolkrabbi/kol-growth'
9
+
10
+ const sim = new Growth({ w, h, density: densityFrom(pathCanvas(d, w / h, 0.6)), rand: mulberry32(7), cap: 14000 })
11
+ sim.setParams({ minGap: 6, maxGap: 24, contrast: 1.4, growth: 8 })
12
+ sim.seed() // a small ring at the field's centre of darkness
13
+ sim.step() // one step: smooth, repel, resample, grow — call per frame until x.length hits cap
14
+ sim.poke(px, py, down) // the pointer as a force: a push, or a blast when down
15
+ sim.draw(ctx, w, h, { ink, width: 1 }) // the one path onto a canvas
16
+ sim.pathD(w, h) // the same path as an SVG `d`
17
+ ```
18
+
19
+ | export | does |
20
+ |---|---|
21
+ | `Growth` | the sim — `step`, `seed`, `poke`, `draw`, `pathD`, `setParams({ minGap, maxGap, contrast, growth, cap })` |
22
+ | `densityFrom(canvas, cols?, invert?)` | 1 − luma on a grid, lightly blurred — the field the spacing reads |
23
+ | `SIM_W` | 1000, the sim width kol-fxr's loops use |
24
+ | `mulberry32(seed)` | the seeded 0..1 PRNG; pass as `rand` and the same seed grows the same line |
25
+ | `orbCanvas(aspect)` | the built-in orb, dark at the centre |
26
+ | `pathCanvas(d, aspect, fit?)` | any SVG path `d`, solid black on white, centred at `fit` of the frame |
27
+
28
+ `densityFrom`, `orbCanvas` and `pathCanvas` draw on a canvas, so they need a browser; the sim itself runs anywhere
29
+ (`pnpm --filter @kolkrabbi/kol-growth test`). Proved in `apps/growth`.
package/package.json CHANGED
@@ -1,6 +1,36 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-growth",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "The growing line engine, differential growth into shapes",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./src/index.js",
8
+ "module": "./src/index.js",
9
+ "exports": {
10
+ ".": "./src/index.js"
11
+ },
12
+ "files": [
13
+ "src",
14
+ "README.md",
15
+ "CHANGELOG.md"
16
+ ],
17
+ "keywords": [
18
+ "kol",
19
+ "kolkrabbi",
20
+ "design-system",
21
+ "generative",
22
+ "differential-growth",
23
+ "engine"
24
+ ],
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/Tor-Grimsson/kol-ds.git",
28
+ "directory": "packages/growth"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "scripts": {
34
+ "test": "node src/selftest.mjs"
35
+ }
6
36
  }
package/src/engine.js ADDED
@@ -0,0 +1,200 @@
1
+ // The growing line (plan 43 W4) — differential growth: ONE closed line that grows, folds and never crosses itself,
2
+ // packing tighter where a density field is darker. The reference is algorigraph's plotted portraits (user 2026-10-28:
3
+ // *"a single elastic loop morphs into the shapes of a…"*). The generator (growthLine.js) feeds it a built-in image;
4
+ // the effect (filters/fxGrowth.js) feeds it the layer's own photo. Both draw it the same way: one path, ink on paper.
5
+ //
6
+ // A step: every node is pulled toward its neighbours' midpoint (smooth), pushed off every non-adjacent node closer than
7
+ // the local spacing (no crossings, even gaps), held inside the frame; edges longer than half the spacing split, shorter
8
+ // than a fifth merge, and `growth` random edges split per step so the line keeps lengthening until the node cap.
9
+ // Spacing is local: `minGap` where the density is 1, `maxGap` where it is 0. Units are sim px — the sim is 1000 wide
10
+ // and as tall as the frame's aspect, scaled to the frame at draw.
11
+
12
+ export const SIM_W = 1000
13
+
14
+ /* density from any canvas: 1 − luma, on a grid `cols` wide, lightly blurred so the spacing changes smoothly */
15
+ export function densityFrom(canvas, cols = 160, invert = false) {
16
+ const rows = Math.max(1, Math.round(cols * canvas.height / canvas.width))
17
+ const c = document.createElement('canvas')
18
+ c.width = cols; c.height = rows
19
+ const g = c.getContext('2d', { willReadFrequently: true })
20
+ g.filter = 'blur(1px)'
21
+ g.drawImage(canvas, 0, 0, cols, rows)
22
+ const d = g.getImageData(0, 0, cols, rows).data
23
+ const out = new Float32Array(cols * rows)
24
+ for (let i = 0; i < out.length; i++) {
25
+ const j = i * 4
26
+ const a = d[j + 3] / 255
27
+ /* transparent reads as paper */
28
+ const lum = ((0.2126 * d[j] + 0.7152 * d[j + 1] + 0.0722 * d[j + 2]) / 255) * a + (1 - a)
29
+ out[i] = invert ? lum : 1 - lum
30
+ }
31
+ return { cols, rows, data: out }
32
+ }
33
+
34
+ export class Growth {
35
+ constructor({ w = SIM_W, h = SIM_W, density = null, rand = Math.random, cap = 6000 }) {
36
+ this.w = w; this.h = h
37
+ this.density = density
38
+ this.rand = rand
39
+ this.cap = cap
40
+ this.minGap = 5; this.maxGap = 22; this.contrast = 1.4; this.growth = 4
41
+ this.x = []; this.y = []
42
+ }
43
+
44
+ setParams({ minGap, maxGap, contrast, growth, cap }) {
45
+ if (minGap != null) this.minGap = minGap
46
+ if (maxGap != null) this.maxGap = Math.max(maxGap, this.minGap + 0.5)
47
+ if (contrast != null) this.contrast = contrast
48
+ if (growth != null) this.growth = growth
49
+ if (cap != null) this.cap = cap
50
+ }
51
+
52
+ /* a small ring of nodes — at (cx, cy) in 0..1, or at the field's centre of darkness */
53
+ seed(cx, cy, r = 14) {
54
+ if (cx == null && this.density) {
55
+ const { cols, rows, data } = this.density
56
+ let sx = 0, sy = 0, sw = 0
57
+ for (let i = 0; i < data.length; i++) { const v = data[i] * data[i]; sx += v * (i % cols); sy += v * Math.floor(i / cols); sw += v }
58
+ if (sw > 0) { cx = (sx / sw + 0.5) / cols; cy = (sy / sw + 0.5) / rows }
59
+ }
60
+ cx = (cx ?? 0.5) * this.w; cy = (cy ?? 0.5) * this.h
61
+ const n = 24
62
+ this.x = []; this.y = []
63
+ for (let i = 0; i < n; i++) {
64
+ const a = (i / n) * Math.PI * 2
65
+ this.x.push(cx + Math.cos(a) * r); this.y.push(cy + Math.sin(a) * r)
66
+ }
67
+ }
68
+
69
+ dens(x, y) {
70
+ const D = this.density
71
+ if (!D) return 0.5
72
+ const c = Math.min(D.cols - 1, Math.max(0, Math.floor((x / this.w) * D.cols)))
73
+ const r = Math.min(D.rows - 1, Math.max(0, Math.floor((y / this.h) * D.rows)))
74
+ return D.data[r * D.cols + c]
75
+ }
76
+
77
+ gap(x, y) {
78
+ const d = Math.pow(Math.min(1, Math.max(0, this.dens(x, y))), 1 / this.contrast)
79
+ return this.maxGap + (this.minGap - this.maxGap) * d
80
+ }
81
+
82
+ step() {
83
+ const { x, y, w, h } = this
84
+ let n = x.length
85
+ if (n < 3) return
86
+ const R = this.maxGap
87
+ /* spatial hash on the largest gap */
88
+ const cols = Math.ceil(w / R) + 1, rows = Math.ceil(h / R) + 1
89
+ const head = new Int32Array(cols * rows).fill(-1)
90
+ const next = new Int32Array(n)
91
+ const cell = (px, py) => Math.min(rows - 1, Math.max(0, Math.floor(py / R))) * cols + Math.min(cols - 1, Math.max(0, Math.floor(px / R)))
92
+ for (let i = 0; i < n; i++) { const c = cell(x[i], y[i]); next[i] = head[c]; head[c] = i }
93
+ const gaps = new Float32Array(n)
94
+ for (let i = 0; i < n; i++) gaps[i] = this.gap(x[i], y[i])
95
+ const fx = new Float32Array(n), fy = new Float32Array(n)
96
+ for (let i = 0; i < n; i++) {
97
+ const ip = (i - 1 + n) % n, inx = (i + 1) % n
98
+ /* smooth: toward the neighbours' midpoint */
99
+ fx[i] += ((x[ip] + x[inx]) / 2 - x[i]) * 0.35
100
+ fy[i] += ((y[ip] + y[inx]) / 2 - y[i]) * 0.35
101
+ /* repel: every non-adjacent node inside the local gap */
102
+ const gi = gaps[i]
103
+ const cx = Math.floor(x[i] / R), cy = Math.floor(y[i] / R)
104
+ for (let oy = -1; oy <= 1; oy++) {
105
+ const ry = cy + oy
106
+ if (ry < 0 || ry >= rows) continue
107
+ for (let ox = -1; ox <= 1; ox++) {
108
+ const rx = cx + ox
109
+ if (rx < 0 || rx >= cols) continue
110
+ for (let j = head[ry * cols + rx]; j !== -1; j = next[j]) {
111
+ if (j === i || j === ip || j === inx) continue
112
+ const dx = x[i] - x[j], dy = y[i] - y[j]
113
+ const g = (gi + gaps[j]) / 2
114
+ const d2 = dx * dx + dy * dy
115
+ if (d2 >= g * g || d2 === 0) continue
116
+ const d = Math.sqrt(d2)
117
+ const push = ((g - d) / g) * 0.5
118
+ fx[i] += (dx / d) * push * g * 0.5
119
+ fy[i] += (dy / d) * push * g * 0.5
120
+ }
121
+ }
122
+ }
123
+ }
124
+ const m = 2
125
+ for (let i = 0; i < n; i++) {
126
+ const lim = gaps[i] * 0.25
127
+ x[i] = Math.min(w - m, Math.max(m, x[i] + Math.max(-lim, Math.min(lim, fx[i]))))
128
+ y[i] = Math.min(h - m, Math.max(m, y[i] + Math.max(-lim, Math.min(lim, fy[i]))))
129
+ }
130
+ /* resample: split long edges, merge short ones, then grow */
131
+ const nx = [], ny = []
132
+ let budget = this.cap - n
133
+ for (let i = 0; i < n; i++) {
134
+ const j = (i + 1) % n
135
+ const g = this.gap(x[i], y[i])
136
+ const dx = x[j] - x[i], dy = y[j] - y[i]
137
+ const len = Math.hypot(dx, dy)
138
+ if (len < g * 0.2 && n > 8) continue
139
+ nx.push(x[i]); ny.push(y[i])
140
+ if (len > g * 0.5 && budget > 0) { nx.push(x[i] + dx / 2); ny.push(y[i] + dy / 2); budget-- }
141
+ }
142
+ for (let k = 0; k < this.growth && nx.length < this.cap; k++) {
143
+ const i = Math.floor(this.rand() * nx.length), j = (i + 1) % nx.length
144
+ /* a split nudged sideways starts a new fold */
145
+ const dx = nx[j] - nx[i], dy = ny[j] - ny[i]
146
+ const s = (this.rand() - 0.5) * 0.6
147
+ nx.splice(i + 1, 0, nx[i] + dx / 2 - dy * s)
148
+ ny.splice(i + 1, 0, ny[i] + dy / 2 + dx * s)
149
+ }
150
+ this.x = nx; this.y = ny
151
+ }
152
+
153
+ /* THE POINTER AS A FORCE (sim units) — penrose/common.js's verbs on this line: a move pushes the nodes out of a ring
154
+ * 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
155
+ * a teleport). The line's own smoothing and spacing refold the crater; `amount` 0 turns it off. */
156
+ poke(px, py, down = false, amount = 1) {
157
+ if (!amount) return
158
+ const { x, y, w, h } = this, s = Math.min(w, h), m = 2
159
+ const reach = s * 0.08 * (down ? 3 : 1), r2 = reach * reach
160
+ const strength = Math.min(s * 0.03, s * 0.006 * amount * (down ? 6 : 1))
161
+ for (let i = 0; i < x.length; i++) {
162
+ const dx = x[i] - px, dy = y[i] - py, d2 = dx * dx + dy * dy
163
+ if (d2 >= r2 || d2 < 1e-6) continue
164
+ const d = Math.sqrt(d2), f = (1 - d / reach) * strength
165
+ x[i] = Math.min(w - m, Math.max(m, x[i] + (dx / d) * f))
166
+ y[i] = Math.min(h - m, Math.max(m, y[i] + (dy / d) * f))
167
+ }
168
+ }
169
+
170
+ /* the one path, scaled onto a w × h frame */
171
+ draw(ctx, fw, fh, { ink = '#111', paper = null, width = 1.2 } = {}) {
172
+ if (paper) { ctx.fillStyle = paper; ctx.fillRect(0, 0, fw, fh) }
173
+ const { x, y } = this
174
+ const n = x.length
175
+ if (n < 3) return
176
+ const sx = fw / this.w, sy = fh / this.h
177
+ ctx.beginPath()
178
+ /* through the edge midpoints with the nodes as control points — a smooth closed curve */
179
+ const mx = (i) => ((x[i] + x[(i + 1) % n]) / 2) * sx, my = (i) => ((y[i] + y[(i + 1) % n]) / 2) * sy
180
+ ctx.moveTo(mx(n - 1), my(n - 1))
181
+ for (let i = 0; i < n; i++) ctx.quadraticCurveTo(x[i] * sx, y[i] * sy, mx(i), my(i))
182
+ ctx.closePath()
183
+ ctx.strokeStyle = ink
184
+ ctx.lineWidth = width
185
+ ctx.lineJoin = 'round'
186
+ ctx.stroke()
187
+ }
188
+
189
+ /* the same path as one SVG `d`, in frame units */
190
+ pathD(fw, fh) {
191
+ const { x, y } = this
192
+ const n = x.length
193
+ if (n < 3) return ''
194
+ const sx = fw / this.w, sy = fh / this.h, f = (v) => v.toFixed(2)
195
+ const mx = (i) => f(((x[i] + x[(i + 1) % n]) / 2) * sx), my = (i) => f(((y[i] + y[(i + 1) % n]) / 2) * sy)
196
+ let d = `M${mx(n - 1)} ${my(n - 1)}`
197
+ for (let i = 0; i < n; i++) d += `Q${f(x[i] * sx)} ${f(y[i] * sy)} ${mx(i)} ${my(i)}`
198
+ return d + 'Z'
199
+ }
200
+ }
package/src/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { Growth, densityFrom, SIM_W } from './engine.js'
2
+ export { mulberry32 } from './rng.js'
3
+ export { orbCanvas, pathCanvas } from './shapes.js'
package/src/rng.js ADDED
@@ -0,0 +1,13 @@
1
+ // rng — the seeded PRNG the growing line takes as `rand`, carried verbatim from kol-fxr (src/loops/gl/rng.js).
2
+
3
+ /** Deterministic 0..1 PRNG seeded by an integer. Same seed → same sequence. */
4
+ export function mulberry32(seed) {
5
+ let a = seed >>> 0
6
+ return function () {
7
+ a |= 0
8
+ a = (a + 0x6D2B79F5) | 0
9
+ let t = Math.imul(a ^ (a >>> 15), 1 | a)
10
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
11
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
12
+ }
13
+ }
@@ -0,0 +1,20 @@
1
+ // node src/selftest.mjs — the engine with no density field (no DOM): it grows to the cap, a seed repeats, poke moves nodes.
2
+ import assert from 'node:assert/strict'
3
+ import { Growth, mulberry32 } from './index.js'
4
+
5
+ const grow = (seed) => {
6
+ const g = new Growth({ w: 400, h: 300, rand: mulberry32(seed), cap: 600 })
7
+ g.setParams({ minGap: 6, maxGap: 24, contrast: 1.4, growth: 8 })
8
+ g.seed()
9
+ for (let i = 0; i < 400 && g.x.length < g.cap; i++) g.step()
10
+ return g
11
+ }
12
+ const a = grow(7), b = grow(7)
13
+ assert.ok(a.x.length >= a.cap - 8, `grows to the cap (${a.x.length}/${a.cap})`)
14
+ assert.deepEqual(a.x, b.x, 'same seed, same line')
15
+ assert.ok(a.x.every((v, i) => v >= 2 && v <= 398 && a.y[i] >= 2 && a.y[i] <= 298), 'held inside the frame')
16
+ const before = a.x.slice()
17
+ a.poke(a.x[0], a.y[0] + 1, true)
18
+ assert.notDeepEqual(a.x, before, 'poke moves the line')
19
+ assert.match(a.pathD(400, 300), /^M.*Z$/, 'one closed path')
20
+ console.log('kol-growth selftest ok')
package/src/shapes.js ADDED
@@ -0,0 +1,40 @@
1
+ // Shapes — the density sources the growing line reads with `densityFrom`: dark on light, at the frame's aspect.
2
+ // The one DOM in this engine (as `densityFrom` itself): a shape is a canvas, so these run in a browser.
3
+
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))
7
+ const c = document.createElement('canvas')
8
+ c.width = W; c.height = H
9
+ const g = c.getContext('2d')
10
+ g.fillStyle = '#fff'; g.fillRect(0, 0, W, H)
11
+ const r = Math.min(W, H) * 0.46
12
+ const grd = g.createRadialGradient(W / 2, H / 2, 0, W / 2, H / 2, r)
13
+ grd.addColorStop(0, '#000'); grd.addColorStop(1, '#fff')
14
+ g.fillStyle = grd; g.fillRect(0, 0, W, H)
15
+ return c
16
+ }
17
+
18
+ /* any SVG path `d`, filled solid black on white, centred and scaled to `fit` of the frame — kol-fxr's `vectorCanvas`
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))
22
+ const c = document.createElement('canvas')
23
+ c.width = W; c.height = H
24
+ const g = c.getContext('2d')
25
+ g.fillStyle = '#fff'; g.fillRect(0, 0, W, H)
26
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
27
+ svg.setAttribute('style', 'position:absolute;width:0;height:0;overflow:hidden')
28
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
29
+ path.setAttribute('d', d)
30
+ svg.appendChild(path)
31
+ document.body.appendChild(svg)
32
+ let b
33
+ try { b = path.getBBox() } finally { svg.remove() }
34
+ if (!b.width || !b.height) return c
35
+ const k = Math.min(W / b.width, H / b.height) * fit
36
+ g.setTransform(k, 0, 0, k, (W - b.width * k) / 2 - b.x * k, (H - b.height * k) / 2 - b.y * k)
37
+ g.fillStyle = '#000'
38
+ g.fill(new Path2D(d))
39
+ return c
40
+ }