← Natura·Docs/Modules/Source

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);