import { describe, it, expect } from 'vitest'; import { run, densityMap, applyAutoTrim, designStats, FABRICS, QWORD_BUILTINS } from '../engine.ts'; import type { StitchEvent } from '../engine.ts'; import { toDST } from '../formats/dst.ts'; import { EXAMPLES } from '../../data.ts'; // ── helpers ──────────────────────────────────────────────────────────────── const evts = (src: string) => run(src).events; const stitches = (src: string) => evts(src).filter((e) => e.t === 'stitch'); const underlay = (src: string) => evts(src).filter((e) => e.t === 'stitch' && e.u === 1); const topping = (src: string) => evts(src).filter((e) => e.t === 'stitch' && !e.u); // ── satin buffering ───────────────────────────────────────────────────────── describe('satin column buffering', () => { it('produces the legacy emission when no underlay/pullcomp is set', () => { // 1 anchor + ceil(10 / 0.4) = 25 zigzag stitches expect(stitches('lock 0 satin 3 fd 10').length).toBe(26); expect(underlay('lock 0 satin 3 fd 10').length).toBe(0); }); it('does not disturb the turtle while the column is buffered', () => { expect(run('satin 3 fd 10 print xcor print ycor print heading').printed).toEqual([ '0', '10', '0', ]); }); it('flushes before a pen-up jump, keeping machine order', () => { const ev = evts('lock 0 satin 3 fd 10 up fd 5'); const jumpIdx = ev.findIndex((e) => e.t === 'jump'); expect(jumpIdx).toBeGreaterThan(20); // satin sewn first expect(ev.slice(0, jumpIdx).every((e) => e.t === 'stitch')).toBe(true); }); it('flushes before colour changes, trims, fills, and program end', () => { const c = evts('lock 0 satin 3 fd 10 color 1 fd 5'); expect(c.findIndex((e) => e.t === 'color')).toBeGreaterThan(20); const t = evts('lock 0 autotrim 0 satin 3 fd 10 trim'); expect(t[t.length - 1].t).toBe('trim'); // program end flush expect(stitches('lock 0 satin 3 fd 10').length).toBeGreaterThan(0); }); }); // ── satin underlay ────────────────────────────────────────────────────────── describe('satin underlay', () => { it('center underlay sews a spine under the column, before the topping', () => { const ev = evts('lock 0 underlay "center satin 3 fd 10'); const u = ev.filter((e) => e.u === 1); const top = ev.filter((e) => e.t === 'stitch' && !e.u); expect(u.length).toBeGreaterThanOrEqual(8); // out + back at ~2.5 mm // spine stays on the centre line expect(u.every((e) => Math.abs(e.x) < 0.2)).toBe(true); // all underlay comes before the topping zigzag const lastU = ev.lastIndexOf(u[u.length - 1]); const firstTopZig = ev.findIndex((e) => e.t === 'stitch' && !e.u && Math.abs(e.x) > 1); expect(lastU).toBeLessThan(firstTopZig); expect(top.length).toBeGreaterThan(20); }); it('edge underlay runs offset from the centre line', () => { const u = underlay('lock 0 underlay "edge satin 3 fd 10'); expect(u.length).toBeGreaterThan(0); const offsets = u.map((e) => Math.abs(e.x)).filter((v) => v > 0.1); expect(offsets.length).toBeGreaterThan(4); // offset ≈ 30% of width = 0.9 mm expect(Math.max(...offsets)).toBeLessThan(1.6); expect(Math.min(...offsets)).toBeGreaterThan(0.5); }); it('zigzag underlay zigzags at ~60% width and returns to the start', () => { const u = underlay('lock 0 underlay "zigzag satin 5 fd 10'); const wide = u.filter((e) => Math.abs(e.x) > 0.8); expect(wide.length).toBeGreaterThan(2); // 60% of half-width = 1.5 mm; never as wide as the topping (2.5) expect(Math.max(...u.map((e) => Math.abs(e.x)))).toBeLessThan(1.8); }); it('auto underlay scales with column width', () => { expect(underlay('lock 0 underlay "auto satin 1 fd 10').length).toBe(0); // too thin const center = underlay('lock 0 underlay "auto satin 3 fd 10'); expect(center.length).toBeGreaterThan(0); expect(center.every((e) => Math.abs(e.x) < 0.2)).toBe(true); // spine only const zig = underlay('lock 0 underlay "auto satin 6 fd 10'); expect(zig.some((e) => Math.abs(e.x) > 1)).toBe(true); // zigzag pass }); it('is off by default and switchable off again', () => { expect(underlay('lock 0 satin 4 fd 10').length).toBe(0); expect(underlay('lock 0 underlay "center underlay "off satin 4 fd 10').length).toBe(0); }); it('rejects unknown modes with suggestions', () => { expect(() => run('underlay "centre satin 3 fd 5')).toThrow(/did you mean "center"/i); expect(() => run('underlay 3')).toThrow(/underlay expects a string mode/); }); }); // ── pull compensation ─────────────────────────────────────────────────────── describe('pull compensation', () => { it('widens satin columns against fabric pull', () => { const plain = stitches('lock 0 satin 3 fd 10'); const comp = stitches('lock 0 pullcomp 0.6 satin 3 fd 10'); const w = (s: StitchEvent[]) => Math.max(...s.map((e) => Math.abs(e.x))); expect(w(plain)).toBeCloseTo(1.5, 1); expect(w(comp)).toBeCloseTo(1.8, 1); }); it('extends fill rows along the stitch axis', () => { const square = 'lock 0 beginfill repeat 4 [ fd 20 rt 90 ] endfill'; const plain = stitches(square); const comp = stitches('pullcomp 0.5 ' + square); // fillangle 0 → rows run along x; boundary spans x 0..20, y 0..20 const maxX = (s: StitchEvent[]) => Math.max(...s.map((e) => e.x)); const minX = (s: StitchEvent[]) => Math.min(...s.map((e) => e.x)); expect(maxX(plain)).toBeLessThanOrEqual(20.05); expect(minX(plain)).toBeGreaterThanOrEqual(-0.05); expect(maxX(comp)).toBeGreaterThan(20.3); expect(minX(comp)).toBeLessThan(-0.3); }); it('clamps to the safe range', () => { expect(run('pullcomp 5 satin 3 fd 5').warnings.some((w) => w.includes('clamped'))).toBe(true); }); }); // ── short-stitch on curves ────────────────────────────────────────────────── describe('short-stitch (curve physics)', () => { // satin 6 around arc r=8: inner edge is ~5 mm from the arc centre (8, 0); // short stitches sit at 8 − 0.6·3 = 6.2 mm from it. const CURVE = 'lock 0 stitchlen 1 satin 6 arc 180 8'; const fromCenter = (e: StitchEvent) => Math.hypot(e.x - 8, e.y); it('pulls alternate inner stitches inward on tight curves', () => { const shortened = stitches(CURVE).filter((e) => { const d = fromCenter(e); return d > 5.6 && d < 6.8; }); expect(shortened.length).toBeGreaterThan(3); }); it('shortstitch 0 disables it', () => { const shortened = stitches('shortstitch 0 ' + CURVE).filter((e) => { const d = fromCenter(e); return d > 5.6 && d < 6.8; }); expect(shortened.length).toBe(0); }); it('does not touch straight columns or retraced columns', () => { expect(stitches('lock 0 satin 3 fd 10 bk 10').length).toBe( stitches('shortstitch 0 lock 0 satin 3 fd 10 bk 10').length, ); const a = run('lock 0 satin 3 fd 10 bk 10').events; const b = run('shortstitch 0 lock 0 satin 3 fd 10 bk 10').events; expect(a).toEqual(b); }); it('warns when the column is wider than the curve allows', () => { const { warnings } = run('stitchlen 1 satin 8 arc 90 3'); expect(warnings.some((w) => w.includes('wider than the curve'))).toBe(true); }); }); // ── fill underlay ─────────────────────────────────────────────────────────── describe('fill underlay', () => { const SQUARE = 'beginfill repeat 4 [ fd 20 rt 90 ] endfill'; // x 0..20, y 0..20 it('tatami underlay crosses the grain and stays inside the boundary', () => { const u = underlay('lock 0 fillunderlay "tatami ' + SQUARE); expect(u.length).toBeGreaterThan(10); for (const e of u) { expect(e.x).toBeGreaterThan(-0.1); expect(e.x).toBeLessThan(20.1); // rows run along y (fillangle 0 + 90): inset 0.6 at row ends expect(e.y).toBeGreaterThan(0.3); expect(e.y).toBeLessThan(19.7); } // far sparser than the topping const top = topping('lock 0 fillunderlay "tatami ' + SQUARE); expect(u.length).toBeLessThan(top.length / 3); }); it('edge underlay traces an inset outline', () => { const u = underlay('lock 0 fillunderlay "edge ' + SQUARE); expect(u.length).toBeGreaterThan(10); for (const e of u) { // strictly inside the square… expect(e.x).toBeGreaterThan(0.05); expect(e.x).toBeLessThan(19.95); expect(e.y).toBeGreaterThan(0.05); expect(e.y).toBeLessThan(19.95); // …but hugging the boundary const edgeDist = Math.min(e.x, 20 - e.x, e.y, 20 - e.y); expect(edgeDist).toBeLessThan(1.2); } }); it('auto underlay adds edge + tatami on large areas', () => { const auto = underlay('lock 0 fillunderlay "auto ' + SQUARE); const tatami = underlay('lock 0 fillunderlay "tatami ' + SQUARE); expect(auto.length).toBeGreaterThan(tatami.length); // edge pass on top of tatami }); it('small areas get tatami only and stay inside', () => { const small = 'lock 0 fillunderlay "auto beginfill repeat 4 [ fd 6 rt 90 ] endfill'; const u = underlay(small); // square spans x 0..6, y 0..6 for (const e of u) { expect(e.x).toBeGreaterThan(-0.1); expect(e.x).toBeLessThan(6.1); expect(e.y).toBeGreaterThan(-0.1); expect(e.y).toBeLessThan(6.1); } }); it('off by default', () => { expect(underlay('lock 0 ' + SQUARE).length).toBe(0); }); }); // ── fabric presets ────────────────────────────────────────────────────────── describe('fabric presets', () => { it('knit turns on pull comp and underlay', () => { const plain = stitches('lock 0 satin 3 fd 10'); const knit = run('lock 0 fabric "knit satin 3 fd 10'); const top = knit.events.filter((e) => e.t === 'stitch' && !e.u); const w = (s: StitchEvent[]) => Math.max(...s.map((e) => Math.abs(e.x))); expect(w(top)).toBeGreaterThan(w(plain) + 0.15); // pullcomp 0.5 expect(knit.events.some((e) => e.u === 1)).toBe(true); // auto underlay }); it('knit enforces a lighter satin density (wider spacing)', () => { // default spacing 0.4 → 25 zigzags; knit floor 0.45 → 23 const plain = topping('lock 0 satin 3 fd 10'); const knit = topping('lock 0 fabric "knit satin 3 fd 10'); expect(knit.length).toBeLessThan(plain.length); }); it('explicit commands after the preset override it', () => { const overridden = topping('lock 0 fabric "knit pullcomp 0 underlay "off satin 3 fd 10'); expect(Math.max(...overridden.map((e) => Math.abs(e.x)))).toBeCloseTo(1.5, 1); }); it('fleece doubles the underlay and suggests a topping', () => { const out = run('lock 0 fabric "fleece satin 5 fd 10'); expect(out.warnings.some((w) => w.includes('topping'))).toBe(true); const woven = underlay('lock 0 fabric "woven satin 5 fd 10'); const fleece = out.events.filter((e) => e.u === 1); expect(fleece.length).toBeGreaterThan(woven.length); }); it('every preset parses and runs', () => { for (const f of QWORD_BUILTINS.fabric) { expect(FABRICS[f]).toBeDefined(); expect(() => run(`fabric "${f} satin 3 fd 10`)).not.toThrow(); } }); it('unknown fabrics fail with choices', () => { expect(() => run('fabric "silk')).toThrow(/Unknown fabric 'silk'.*expected/); }); }); // ── autotrim ──────────────────────────────────────────────────────────────── describe('autotrim', () => { it('inserts a trim before long jumps (default 7 mm)', () => { const ev = evts('lock 0 fd 5 up fd 10 down fd 5'); const trimIdx = ev.findIndex((e) => e.t === 'trim'); const jumpIdx = ev.findIndex((e) => e.t === 'jump'); expect(trimIdx).toBeGreaterThan(-1); expect(trimIdx).toBeLessThan(jumpIdx); }); it('leaves short hops alone', () => { expect(evts('lock 0 fd 5 up fd 5 down fd 5').some((e) => e.t === 'trim')).toBe(false); }); it('autotrim n changes the threshold; autotrim 0 disables', () => { expect(evts('lock 0 autotrim 3 fd 5 up fd 5 down fd 5').some((e) => e.t === 'trim')).toBe(true); expect(evts('lock 0 autotrim 0 fd 5 up fd 30 down fd 5').some((e) => e.t === 'trim')).toBe( false, ); }); it('never trims when nothing has been sewn yet', () => { expect(evts('lock 0 up setxy 20 20 down fd 5').some((e) => e.t === 'trim')).toBe(false); }); it('does not double-trim after an explicit trim', () => { const ev = evts('lock 0 fd 5 trim up fd 20 down fd 5'); expect(ev.filter((e) => e.t === 'trim').length).toBe(1); }); it('applyAutoTrim measures multi-jump travels as one', () => { const mk = (t: StitchEvent['t'], x: number, y: number): StitchEvent => ({ t, x, y, c: 0 }); const { events, trims } = applyAutoTrim( [mk('stitch', 0, 0), mk('jump', 4, 0), mk('jump', 8, 0), mk('stitch', 8, 0)], 7, ); expect(trims).toBe(1); expect(events[1].t).toBe('trim'); }); }); // ── density analysis ──────────────────────────────────────────────────────── describe('density analysis', () => { it('measures thread coverage in layers', () => { // 8 passes of a 10 mm line, stitches every 0.5 mm → ~80 mm of thread // across ~10 cells ≈ 3.2 layers const ev: StitchEvent[] = [{ t: 'stitch', x: 0, y: 0.5, c: 0, line: 4 }]; for (let pass = 0; pass < 8; pass++) for (let i = 1; i <= 20; i++) { const x = pass % 2 === 0 ? i * 0.5 : 10 - i * 0.5; ev.push({ t: 'stitch', x, y: 0.5, c: 0, line: 4 }); } const d = densityMap(ev, 1, 3); expect(d.peak).toBeGreaterThan(3); expect(d.peak).toBeLessThan(4); expect(d.hotspots.some((h) => h.kind === 'density' && h.lines.includes(4))).toBe(true); }); it('flags same-hole stacking', () => { const ev: StitchEvent[] = []; for (let i = 0; i < 6; i++) ev.push({ t: 'stitch', x: 3, y: 3, c: 0, line: 7 }); const stack = densityMap(ev, 1, 3).hotspots.find((h) => h.kind === 'stack'); expect(stack).toBeDefined(); expect(stack!.value).toBe(6); expect(stack!.lines).toEqual([7]); }); it('a single satin column reads ≈1 layer and stays quiet', () => { const out = run('satin 3 fd 20'); expect(out.warnings.filter((w) => w.includes('layers of thread')).length).toBe(0); expect(out.density.peak).toBeGreaterThan(0.5); expect(out.density.peak).toBeLessThan(2.5); }); it('three stacked satin layers do trigger it, with source lines', () => { const out = run('satin 3\nfd 20 bk 20 fd 20'); expect(out.warnings.some((w) => w.includes('layers of thread') && w.includes('line'))).toBe( true, ); }); it('maxdensity tunes the threshold; 0 silences it', () => { const strict = run('maxdensity 1 satin 3 fd 20'); expect(strict.warnings.some((w) => w.includes('layers of thread'))).toBe(true); const off = run('maxdensity 0 satin 3 fd 20 bk 20 fd 20'); expect(off.warnings.some((w) => w.includes('layers of thread'))).toBe(false); }); it('lock stitches are not counted as design density', () => { // density is analysed before the lock pass, so thread ends don't read hot const locked = run('fd 20'); const unlocked = run('lock 0 fd 20'); expect(locked.density.peak).toBeCloseTo(unlocked.density.peak, 5); }); it('every run result carries the density map', () => { const out = run('fd 10'); expect(out.density.cellMM).toBe(1); expect(out.density.cells.length).toBeGreaterThan(0); }); }); // ── exports & stats stay sane ─────────────────────────────────────────────── describe('underlay in exports and stats', () => { it('underlay stitches are real stitches in stats and DST', () => { const plain = run('lock 0 satin 4 fd 10'); const pro = run('lock 0 underlay "zigzag satin 4 fd 10'); expect(designStats(pro.events).stitches).toBeGreaterThan(designStats(plain.events).stitches); expect(toDST(pro.events).length).toBeGreaterThan(toDST(plain.events).length); }); it('underlay events carry source lines for debugging', () => { const u = underlay('lock 0\nunderlay "center\nsatin 3 fd 10'); expect(u.every((e) => e.line !== undefined)).toBe(true); }); it('reserved words include the pro commands', () => { for (const w of [ 'fabric', 'underlay', 'fillunderlay', 'pullcomp', 'shortstitch', 'autotrim', 'maxdensity', ]) expect(() => run(`to ${w} fd 1 end`)).toThrow(/can't be redefined/); }); // A handful of bundled examples are intentionally dense: motifs that radiate // from a shared centre (flower), high-symmetry designs whose arms meet (snow- // flake), and overlapping freehand strokes (turtle, spirolateral, fill). The // density / stacking advisories firing on them is correct, expected feedback // — not a defect — so they are exempt from the "sews clean" gate. The gate // still guards every other bundled example, including any added later. const DENSE_BY_DESIGN = new Set([ 'snowflake', 'fill', 'flower', 'spirolateral', 'turtle', 'functional', 'starburst', 'trace-motifs', ]); it.each(Object.keys(EXAMPLES))('"%s" sews without density or stacking warnings', (key) => { const out = run(EXAMPLES[key]); const flagged = out.warnings.filter( (w) => w.includes('layers of thread') || w.includes('same hole'), ); if (DENSE_BY_DESIGN.has(key)) return; // dense by design — see note above expect(flagged).toEqual([]); }); });