growth-test.js
221 lines · in natura-src-1.0.0.zip as natura/growth-test.js · a test
// growth-test.js -- assertions for the branching-growth generator (topology only, render-free).
const { rngFrom } = require('./natura.js');
const { branch, FORMS } = require('./growth.js');
let pass = 0, fail = 0;
const ok = (c, m) => { if (c) pass++; else { fail++; console.log(' FAIL: ' + m); } };
const len = s => Math.hypot(s.x1 - s.x0, s.y1 - s.y0);
const mean = a => a.reduce((s, v) => s + v, 0) / a.length;
const varc = a => { const m = mean(a); return mean(a.map(v => (v - m) * (v - m))); };
console.log('growth assertions:');
// 1. DETERMINISTIC: same seed -> identical form
{
const a = branch(rngFrom(7), { depthMax: 6 }), b = branch(rngFrom(7), { depthMax: 6 });
ok(JSON.stringify(a.segments) === JSON.stringify(b.segments), 'same seed -> identical branch structure');
const c = branch(rngFrom(8), { depthMax: 6 });
ok(JSON.stringify(a.segments) !== JSON.stringify(c.segments), 'a different seed -> a different tree');
}
// 2. BINARY fork by default: exactly 2^(d+1)-1 segments, all leaves at depthMax, none deeper
{
const d = 6, t = branch(rngFrom(3), { depthMax: d });
ok(t.segments.length === Math.pow(2, d + 1) - 1, 'binary fork -> 2^(d+1)-1 segments (' + t.segments.length + ' for d=' + d + ')');
ok(t.segments.every(s => s.depth <= d), 'no segment exceeds depthMax');
ok(t.leaves.length === Math.pow(2, d) && t.leaves.every(l => l.depth === d), 'every tip sits at depthMax (' + t.leaves.length + ' leaves)');
}
// 3. a custom SPLITS strategy changes the child count (ternary tree)
{
const d = 3, t = branch(rngFrom(3), { depthMax: d, splits: () => 3 });
const expect = (Math.pow(3, d + 1) - 1) / 2; // 1+3+9+27
ok(t.segments.length === expect, 'splits:()=>3 -> a ternary tree (' + t.segments.length + ' == ' + expect + ')');
}
// 4. DECAY: children shrink -- the deepest segments are much shorter than the trunk
{
const t = branch(rngFrom(5), { depthMax: 6, length0: 100, decay: [0.7, 0.1] });
const trunk = len(t.segments[0]);
const deepest = t.segments.filter(s => s.depth === 6).map(len);
ok(Math.abs(trunk - 100) < 1e-9, 'the trunk is length0 (' + trunk.toFixed(1) + ')');
ok(mean(deepest) < trunk * 0.3, 'deep segments are far shorter than the trunk (mean ' + mean(deepest).toFixed(1) + ' << ' + trunk + ')');
ok(t.segments.every((s, i) => i === 0 || true) && deepest.every(l => l < trunk), 'no tip segment is longer than the trunk');
}
// 5. maxSegments CAP: a deep tree can never explode past the cap
{
const t = branch(rngFrom(1), { depthMax: 40, maxSegments: 500 });
ok(t.segments.length <= 500, 'recursion is bounded by maxSegments (' + t.segments.length + ' <= 500)');
}
// 6. minLength stop: branching halts once a segment gets short, so leaves can appear before depthMax
{
const t = branch(rngFrom(2), { depthMax: 30, length0: 10, minLength: 1, decay: [0.6, 0.0] });
ok(t.leaves.some(l => l.depth < 30), 'minLength stops some branches before depthMax');
ok(t.segments.length < Math.pow(2, 31), 'the tree terminates by length, not by blowing depth');
}
// 7. CONNECTED graph: every segment starts at the seed or at another segment's end (a real tree, no floating twigs)
{
const t = branch(rngFrom(9), { depthMax: 5, x0: 3, y0: 4 });
const ends = new Set(t.segments.map(s => s.x1.toFixed(6) + ',' + s.y1.toFixed(6)));
const seed = (3).toFixed(6) + ',' + (4).toFixed(6);
const orphans = t.segments.filter(s => { const k = s.x0.toFixed(6) + ',' + s.y0.toFixed(6); return k !== seed && !ends.has(k); });
ok(orphans.length === 0, 'no floating twigs -- every segment joins the seed or a parent tip (' + orphans.length + ' orphans)');
ok(Math.abs(t.segments[0].x0 - 3) < 1e-9 && Math.abs(t.segments[0].y0 - 4) < 1e-9, 'the trunk grows from the seed position');
}
// 8. FORM bias -- spire CONVERGES toward vertical (low tip-angle spread), willow DROOPS (tips pull downward)
{
const deepAngles = form => { const t = branch(rngFrom(11), { depthMax: 7, bias: form }); return t.nodes.filter(n => n.depth === 7).map(n => n.ang); };
const open = deepAngles(FORMS.open());
const spire = deepAngles(FORMS.spire());
const willow = deepAngles(FORMS.willow());
ok(varc(spire) < varc(open), 'spire tips CONVERGE toward vertical (spread ' + varc(spire).toFixed(3) + ' < open ' + varc(open).toFixed(3) + ')');
ok(mean(willow) > mean(open), 'willow tips DROOP downward (mean angle ' + mean(willow).toFixed(2) + ' > open ' + mean(open).toFixed(2) + ')');
}
// 9. angle0 aims the trunk
{
const t = branch(rngFrom(4), { depthMax: 1, angle0: 0, length0: 5, x0: 0, y0: 0 });
ok(Math.abs(t.segments[0].x1 - 5) < 1e-9 && Math.abs(t.segments[0].y1) < 1e-9, 'angle0=0 grows the trunk along +x');
}
// 10. PURE: does not mutate opts, and bad rng throws
{
const opts = { depthMax: 4, decay: [0.7, 0.1] };
branch(rngFrom(1), opts);
ok(opts.depthMax === 4 && opts.decay[0] === 0.7 && Object.keys(opts).length === 2, 'branch does not mutate its opts');
let threw = false; try { branch(null, {}); } catch (e) { threw = true; }
ok(threw, 'a missing seeded rng throws (deterministic-by-contract)');
}
// ---- SPACE-COLONIZATION (grow toward a shape) + cloud shapes ----
const { colonize, cloud } = require('./growth.js');
// 11. cloud.disk scatters n points inside radius r
{
const pts = cloud.disk(rngFrom(3), { cx: 0, cy: 0, r: 40, n: 150 });
ok(pts.length === 150, 'cloud.disk returns n points');
ok(pts.every(p => Math.hypot(p.x, p.y) <= 40 + 1e-9), 'every disk point sits within the radius');
}
// 12. colonize grows a connected network UP INTO the attractor cloud
{
const pts = cloud.disk(rngFrom(3), { cx: 0, cy: -50, r: 40, n: 200 });
const g = colonize(rngFrom(9), { attractors: pts, x0: 0, y0: 0, step: 3 });
ok(g.segments.length > 40, 'colonize grows a real network toward the cloud (' + g.segments.length + ' segments)');
ok(Math.min(...g.nodes.map(n => n.y)) < -50, 'the network reaches UP past the crown centre (into the shape it fills)');
ok(g.nodes[0].parent === -1 && g.nodes.every((n, i) => i === 0 || n.parent < i), 'a valid rooted tree (every node joins an earlier parent -- no floating pieces)');
}
// 13. THICKNESS by feed: the trunk (near the root) is far thicker than a twig
{
const pts = cloud.disk(rngFrom(3), { cx: 0, cy: -50, r: 40, n: 200 });
const g = colonize(rngFrom(9), { attractors: pts, x0: 0, y0: 0, step: 3 });
const w = g.segments.map(s => s.width);
ok(g.segments[0].width === Math.max(...w), 'the root segment carries the most flow -> is the thickest');
ok(Math.max(...w) > 3 * Math.min(...w), 'a trunk-to-twig taper emerges from subtree feed (' + Math.max(...w).toFixed(1) + ' vs ' + Math.min(...w).toFixed(1) + ')');
}
// 14. deterministic, empty-safe, rng-by-contract
{
const mk = () => colonize(rngFrom(9), { attractors: cloud.disk(rngFrom(3), { cx: 0, cy: -50, r: 40, n: 120 }), x0: 0, y0: 0, step: 3 });
ok(JSON.stringify(mk().segments) === JSON.stringify(mk().segments), 'colonize is deterministic (same seed + cloud -> identical)');
ok(colonize(rngFrom(1), { attractors: [] }).segments.length === 0, 'no attractors -> an empty (safe) result');
let threw = false; try { colonize(null, { attractors: [{ x: 1, y: 1 }] }); } catch (e) { threw = true; }
ok(threw, 'colonize also requires a seeded rng');
}
// ---- DIFFERENTIAL GROWTH (a folding curve) ----
const { differentiate } = require('./growth.js');
function pathLen(pts, closed) { let s = 0; for (let i = 0; i < pts.length - (closed ? 0 : 1); i++) { const j = (i + 1) % pts.length; s += Math.hypot(pts[j].x - pts[i].x, pts[j].y - pts[i].y); } return s; }
function bboxDiag(pts) { let a = Infinity, b = Infinity, c = -Infinity, d = -Infinity; for (const p of pts) { a = Math.min(a, p.x); b = Math.min(b, p.y); c = Math.max(c, p.x); d = Math.max(d, p.y); } return Math.hypot(c - a, d - b) || 1; }
// 15. it GROWS (the chain lengthens) and FOLDS (perimeter outgrows its bounding box, far past a circle's ~2.2)
{
const g = differentiate(rngFrom(5), { n: 26, radius: 20, iterations: 140 });
ok(g.points.length > 300, 'the chain grows many-fold (' + g.points.length + ' points from 26)');
const ratio = pathLen(g.points, true) / bboxDiag(g.points);
ok(ratio > 6, 'the curve FOLDS -- perimeter/bbox ratio climbs to ' + ratio.toFixed(1) + ' (a circle is ~2.2)');
}
// 16. SELF-AVOIDANCE: folds stay apart, never collapse to a point
{
const g = differentiate(rngFrom(5), { n: 26, radius: 20, iterations: 120 });
let minNon = Infinity, P = g.points, N = P.length;
for (let i = 0; i < N; i++) for (let j = i + 2; j < N; j++) { if (i === 0 && j === N - 1) continue; const d = Math.hypot(P[i].x - P[j].x, P[i].y - P[j].y); if (d < minNon) minNon = d; }
ok(minNon > 0.3, 'non-adjacent folds keep a real gap (min ' + minNon.toFixed(2) + ' > 0), not self-intersecting to zero');
}
// 17. deterministic; open strand vs closed loop; rng-by-contract
{
const mk = () => differentiate(rngFrom(5), { n: 24, radius: 18, iterations: 90 });
ok(JSON.stringify(mk().points) === JSON.stringify(mk().points), 'differentiate is deterministic');
const open = differentiate(rngFrom(2), { n: 24, radius: 18, iterations: 40, closed: false });
ok(open.closed === false && open.segments.length === open.points.length - 1, 'an OPEN strand has one fewer segment than points (endpoints do not wrap)');
const closed = differentiate(rngFrom(2), { n: 24, radius: 18, iterations: 40, closed: true });
ok(closed.segments.length === closed.points.length, 'a CLOSED loop wraps (segments == points)');
let threw = false; try { differentiate(null, {}); } catch (e) { threw = true; }
ok(threw, 'differentiate requires a seeded rng');
}
// ---- TROPISM (growth bends toward a field) + flow-thickness for branch ----
// 18. a tropism field curves the branching form toward it
{
const meanX = t => t.leaves.reduce((s, l) => s + l.x, 0) / t.leaves.length;
const meanSin = t => { const d = t.nodes.filter(n => n.depth === 7); return d.reduce((s, n) => s + Math.sin(n.ang), 0) / d.length; };
const base = branch(rngFrom(4), { depthMax: 7, length0: 10 });
const right = branch(rngFrom(4), { depthMax: 7, length0: 10, tropism: { x: 1, y: 0, strength: 0.35 } });
const up = branch(rngFrom(4), { depthMax: 7, length0: 10, tropism: { x: 0, y: -1, strength: 0.4 } });
ok(meanX(right) > meanX(base) + 1, 'a rightward tropism (wind) leans the crown right (' + meanX(right).toFixed(1) + ' > ' + meanX(base).toFixed(1) + ')');
ok(meanSin(up) < meanSin(base) - 0.05, 'an upward tropism (light) angles the tips skyward (mean sin ' + meanSin(up).toFixed(2) + ' < ' + meanSin(base).toFixed(2) + ')');
ok(JSON.stringify(base.segments) !== JSON.stringify(right.segments), 'tropism actually changes the form (not a no-op)');
}
// 19. thickenByFlow: a real Murray taper (trunk feeds the crown -> thickest), and OFF leaves the depth-based path intact
{
const g = branch(rngFrom(3), { depthMax: 6, thickenByFlow: true });
const w = g.segments.map(s => s.width);
ok(g.segments[0].width === Math.max(...w), 'thickenByFlow: the root segment (feeds the whole crown) is the thickest');
ok(Math.max(...w) > 4 * Math.min(...w), 'trunk >> twig by subtree feed (' + Math.max(...w).toFixed(1) + ' vs ' + Math.min(...w).toFixed(1) + ')');
const d = branch(rngFrom(3), { depthMax: 6 });
ok(d.segments[0].width === Math.max(0.7, 6 * 0.85 + 0.7), 'default (depth-based) width path is unchanged when thickenByFlow is off (back-compatible)');
}
// 20. colonize takes a tropism field too (reach the cloud AND sink/rise). Attractors CENTRED on the root so the
// tropism bias is isolated from where the cloud happens to sit.
{
const segDy = g => g.segments.reduce((s, e) => s + (e.y1 - e.y0), 0) / g.segments.length; // mean vertical of each GROWTH STEP
const mk = trop => colonize(rngFrom(9), { attractors: cloud.disk(rngFrom(3), { cx: 0, cy: 0, r: 30, n: 200 }), x0: 0, y0: 0, step: 3, tropism: trop });
const down = mk({ x: 0, y: 1, strength: 0.9 }), up = mk({ x: 0, y: -1, strength: 0.9 });
ok(JSON.stringify(down.segments) !== JSON.stringify(up.segments), 'a tropism field changes the colonize network');
ok(segDy(down) > segDy(up) + 0.1, 'a downward field tilts growth steps down, an upward field tilts them up (mean step dy ' + segDy(down).toFixed(2) + ' vs ' + segDy(up).toFixed(2) + ')');
}
// ---- arbitrary-shape clouds: polygon + leaf (colonize can now fill ANY silhouette) ----
// 21. polygon: every sampled point lands INSIDE the outline; a non-convex shape is respected
{
const square = [{ x: 0, y: 0 }, { x: 10, y: 0 }, { x: 10, y: 10 }, { x: 0, y: 10 }];
const p = cloud.polygon(rngFrom(3), { points: square, n: 120 });
ok(p.length === 120, 'polygon fills to the requested count');
ok(p.every(q => q.x >= 0 && q.x <= 10 && q.y >= 0 && q.y <= 10), 'every point is inside the square outline');
// an L-shape (non-convex): points must avoid the notch
const L = [{ x: 0, y: 0 }, { x: 10, y: 0 }, { x: 10, y: 4 }, { x: 4, y: 4 }, { x: 4, y: 10 }, { x: 0, y: 10 }];
const inNotch = cloud.polygon(rngFrom(4), { points: L, n: 150 }).filter(q => q.x > 4 && q.y > 4);
ok(inNotch.length === 0, 'a non-convex (L) outline is respected -- nothing falls in the notch');
ok(cloud.polygon(rngFrom(1), { points: [{ x: 0, y: 0 }] }).length === 0, 'a degenerate (<3 pt) polygon safely yields no points');
}
// 22. leaf: points sit within the leaf profile, and colonize grows a VENATION network filling it
{
const lf = cloud.leaf(rngFrom(3), { cx: 0, cy: -30, w: 15, h: 60, n: 200 });
ok(lf.length === 200, 'leaf returns n points');
ok(lf.every(q => q.y >= -60 - 1e-6 && q.y <= 0 + 1e-6 && Math.abs(q.x) <= 15 + 1e-6), 'leaf points sit within the leaf outline');
const g = colonize(rngFrom(9), { attractors: lf, x0: 0, y0: 0, step: 2 });
ok(g.segments.length > 30, 'colonize grows a vein network to FILL the leaf (' + g.segments.length + ' veins)');
ok(Math.min(...g.nodes.map(n => n.y)) < -30, 'the veins reach up into the blade');
}
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);