import { describe, it, expect } from 'vitest';
import {
signedArea,
orientationOf,
pointInPolygon,
computeHoleMap,
netFillArea,
perimeterToAreaRatio,
selfIntersects,
normalizedFillGroups,
} from '../formats/svg-import/geometry.ts';
import {
STRATEGIES,
eligibleStrategies,
isClosedGeom,
autoSuggest,
} from '../formats/svg-import/strategies.ts';
import { emit, resampleRing } from '../formats/svg-import/emit.ts';
import { emitAppend, inventoryProgram, mergeAppend } from '../formats/svg-import/merge.ts';
import { orderOperations } from '../formats/svg-import/ordering.ts';
import { parseSvgToModel } from '../../svg-import/parse-svg-dom.ts';
import { pathToCurveSpecs } from '../formats/svg-import/svg-path.ts';
import {
defaultStrategy,
type StagedDocument,
type ElementModel,
} from '../formats/svg-import/model.ts';
import { FILL_UNDERLAY_MODES, SATIN_UNDERLAY_MODES } from '../embroidery/embroidery-registry.ts';
import { SATIN_CAP_MODES, SATIN_JOIN_MODES } from '../embroidery/satin-profile.ts';
import { run } from '../runtime/index.ts';
import {
canCreateMotifAlong,
canCreateRailPair,
createMotifAlong,
createRailPair,
} from '../formats/svg-import/relationships.ts';
import fillAndStrokeFixture from './fixtures/svg/fill-and-stroke.svg?raw';
import compoundNonzeroFixture from './fixtures/svg/compound-nonzero.svg?raw';
import nestedGroupsFixture from './fixtures/svg/nested-groups.svg?raw';
import railPairFixture from './fixtures/svg/rail-pair.svg?raw';
import linearGradientFixture from './fixtures/svg/linear-gradient.svg?raw';
const PALETTE = ['#C8472F', '#31604F', '#3A4E8C', '#D9A441', '#8C4A6B', '#2B2B2B'];
// a 20mm CCW square ring
const SQUARE: [number, number][] = [
[-10, -10],
[10, -10],
[10, 10],
[-10, 10],
[-10, -10],
];
// a 6mm hole inside it (CW)
const HOLE: [number, number][] = [
[-3, -3],
[-3, 3],
[3, 3],
[3, -3],
[-3, -3],
];
describe('svg geometry', () => {
it('signed area sign encodes orientation', () => {
expect(signedArea(SQUARE)).toBeGreaterThan(0);
expect(orientationOf(SQUARE)).toBe('ccw');
expect(orientationOf(SQUARE.slice().reverse())).toBe('cw');
});
it('point-in-polygon', () => {
expect(pointInPolygon([0, 0], SQUARE)).toBe(true);
expect(pointInPolygon([99, 99], SQUARE)).toBe(false);
});
it('computeHoleMap marks a contained ring as a hole (odd depth)', () => {
const hm = computeHoleMap([SQUARE, HOLE]);
expect(hm[0].hole).toBe(false);
expect(hm[0].depth).toBe(0);
expect(hm[1].hole).toBe(true);
expect(hm[1].depth).toBe(1);
});
it('netFillArea subtracts holes', () => {
const hm = computeHoleMap([SQUARE, HOLE]);
// 20*20 - 6*6 = 400 - 36 = 364
expect(netFillArea([SQUARE, HOLE], hm)).toBeCloseTo(364, 0);
});
it('honors nonzero winding and lowers solid nested rings without a false hole', () => {
const sameWinding = [SQUARE, HOLE.slice().reverse()];
const same = computeHoleMap(sameWinding, 'nonzero');
expect(same[1].hole).toBe(false);
expect(normalizedFillGroups(same)).toEqual([[0]]);
expect(netFillArea(sameWinding, same)).toBeCloseTo(400, 0);
const opposite = computeHoleMap([SQUARE, HOLE], 'nonzero');
expect(opposite[1].hole).toBe(true);
expect(normalizedFillGroups(opposite)).toEqual([[0, 1]]);
});
it('perimeterToAreaRatio is high for slivers', () => {
const blob = perimeterToAreaRatio([SQUARE], true);
const sliver: [number, number][] = [
[0, 0],
[40, 0],
[40, 0.5],
[0, 0.5],
[0, 0],
];
expect(perimeterToAreaRatio([sliver], true)).toBeGreaterThan(blob);
});
it('detects self-intersection (bowtie)', () => {
const bowtie: [number, number][] = [
[0, 0],
[10, 10],
[10, 0],
[0, 10],
[0, 0],
];
expect(selfIntersects(bowtie)).toBe(true);
expect(selfIntersects(SQUARE)).toBe(false);
});
});
describe('strategy catalogue', () => {
it('exposes every registered high-level underlay, cap, and join policy', () => {
const options = (strategy: keyof typeof STRATEGIES, key: string) => {
const control = STRATEGIES[strategy].controls.find((candidate) => candidate.key === key);
return control?.kind === 'select' ? control.options.map((option) => option.value) : [];
};
expect(options('satinBorder', 'underlay')).toEqual(SATIN_UNDERLAY_MODES);
expect(options('railPair', 'underlay')).toEqual(SATIN_UNDERLAY_MODES);
expect(options('tatamiFill', 'fillunderlay')).toEqual(FILL_UNDERLAY_MODES);
expect(options('directionalFill', 'fillunderlay')).toEqual(FILL_UNDERLAY_MODES);
expect(options('satinBorder', 'cap')).toEqual(SATIN_CAP_MODES);
expect(options('railPair', 'cap')).toEqual(SATIN_CAP_MODES);
expect(options('satinBorder', 'join')).toEqual(SATIN_JOIN_MODES);
expect(options('railPair', 'join')).toEqual(SATIN_JOIN_MODES);
});
it('eligibility by geom type', () => {
expect(isClosedGeom('closedPath')).toBe(true);
expect(isClosedGeom('openPath')).toBe(false);
expect(eligibleStrategies('openPath')).toContain('runningMotif');
expect(eligibleStrategies('openPath')).not.toContain('tatamiFill');
expect(eligibleStrategies('rect')).toContain('tatamiFill');
});
it('auto-suggest: filled closed → tatami', () => {
const s = autoSuggest('rect', [SQUARE], '#ff0000', null, null);
expect(s.kind).toBe('tatamiFill');
});
it('auto-suggest: safe physical stroke → satin, seeded from stroke width', () => {
const s = autoSuggest('openPath', [SQUARE], null, '#000000', 2.4);
expect(s.kind).toBe('satinBorder');
if (s.kind === 'satinBorder') expect(s.params.width).toBeCloseTo(2.4, 1);
});
it('tatami emit wraps rings in beginfill/endfill with hole', () => {
const el = mkElement({
rings: [SQUARE, HOLE],
strategy: defaultStrategy('tatamiFill'),
});
const ctx = {
ringNames: ['crest_outer', 'crest_hole0'],
holeMap: el.holeMap,
fillGroups: normalizedFillGroups(el.holeMap),
scaffoldName: 'crest_grain',
};
const lines = STRATEGIES.tatamiFill.emit(el, ctx);
const code = lines.join('\n');
expect(code).toContain('beginfill');
expect(code).toContain('endfill');
expect(code).toContain('sewpath(crest_outer)');
expect(code).toContain('sewpath(crest_hole0)');
});
it('satin emit brackets the path with satin width / 0', () => {
const el = mkElement({ rings: [SQUARE], strategy: defaultStrategy('satinBorder') });
const lines = STRATEGIES.satinBorder.emit(el, {
ringNames: ['edge'],
holeMap: el.holeMap,
fillGroups: [[0]],
scaffoldName: 'edge_grain',
});
const code = lines.join('\n');
expect(code).toMatch(/^\s+satin \d/m);
expect(code).toContain('satin 0');
});
it('directional fill with no field emits an active scaffold', () => {
const el = mkElement({
rings: [SQUARE],
strategy: {
kind: 'directionalFill',
params: { field: null, fillspacing: 0.73, fillunderlay: 'edge' },
},
});
const lines = STRATEGIES.directionalFill.emit(el, {
ringNames: ['petal'],
holeMap: el.holeMap,
fillGroups: [[0]],
scaffoldName: 'petal_grain',
});
expect(lines).toContain('def petal_grain(p) [ return 45 ]');
expect(lines).toContain(' fillunderlay "edge"');
expect(lines).toContain(' fillspacing 0.73');
expect(lines).toContain(' fill dir @petal_grain');
expect(run(emit(mkDoc([el]), { date: '2026-01-01' }).code).events.length).toBeGreaterThan(0);
});
it('observes every non-default strategy control in emitted code', () => {
const context = {
ringNames: ['shape'],
holeMap: computeHoleMap([SQUARE]),
fillGroups: [[0]],
scaffoldName: 'shape_grain',
};
const outline = mkElement({
rings: [SQUARE],
strategy: { kind: 'outline', params: { stitchlen: 3.2, bean: true, beanCount: 5 } },
});
const outlineCode = STRATEGIES.outline.emit(outline, context).join('\n');
expect(outlineCode).toContain('stitchlen 3.2');
expect(outlineCode).toContain('bean 5');
expect(outlineCode).not.toContain('bean 0');
const satin = mkElement({
rings: [SQUARE],
strategy: {
kind: 'satinBorder',
params: {
width: 3.4,
density: 0.55,
underlay: 'edge',
shortstitch: false,
cap: 'taper',
join: 'fan',
},
},
});
const satinCode = STRATEGIES.satinBorder.emit(satin, context).join('\n');
for (const command of [
'underlay "edge"',
"satincap 'taper'",
"satinjoin 'fan'",
'shortstitch 0',
'density 0.55',
'satin 3.4',
]) {
expect(satinCode).toContain(command);
}
const tatami = mkElement({
rings: [SQUARE],
strategy: {
kind: 'tatamiFill',
params: {
fillangle: 30,
fillspacing: 0.65,
filllen: 3.3,
fillunderlay: 'off',
fillinset: 0.6,
},
},
});
const tatamiCode = STRATEGIES.tatamiFill.emit(tatami, context).join('\n');
for (const command of [
'fillunderlay "off"',
'fillinset 0.6',
'fillangle 30',
'fillspacing 0.65',
'filllen 3.3',
]) {
expect(tatamiCode).toContain(command);
}
const running = mkElement({
rings: [SQUARE],
strategy: {
kind: 'runningMotif',
params: { stitchlen: 2.8, bean: false, estitch: true, estitchLen: 4.2 },
},
});
const runningCode = STRATEGIES.runningMotif.emit(running, context).join('\n');
expect(runningCode).toContain('stitchlen 2.8');
expect(runningCode).toContain('estitch 4.2');
expect(runningCode).not.toContain('estitch 0');
for (const operation of [outline, satin, tatami, running]) {
expect(() => run(emit(mkDoc([operation]), { date: '2026-01-01' }).code)).not.toThrow();
}
});
});
describe('explicit SVG relationships', () => {
it('creates rail-pair satin only after two open paths are explicitly selected', () => {
const { doc } = parseSvgToModel(railPairFixture, {
palette: PALETTE,
name: 'rail-pair.svg',
fitMM: 70,
});
const sourceIds = doc.operations.map((operation) => operation.id);
expect(doc.operations).toHaveLength(2);
expect(doc.operations.some((operation) => operation.role === 'relation')).toBe(false);
expect(canCreateRailPair(doc, sourceIds)).toBe(true);
const paired = createRailPair(doc, sourceIds);
const relation = paired.operations.find((operation) => operation.role === 'relation')!;
expect(relation).toMatchObject({
geometryIds: [doc.geometries[0].id, doc.geometries[1].id],
pathIndices: [0, 0],
strategy: { kind: 'railPair' },
});
expect(
paired.operations
.filter((operation) => sourceIds.includes(operation.id))
.every((operation) => !operation.include),
).toBe(true);
const code = emit(paired, { date: '2026-01-01' }).code;
expect(code).toContain('let left_rail =');
expect(code).toContain('let right_rail =');
expect(code).toContain('satinbetween(left_rail, right_rail)');
expect(code).not.toMatch(/^satin \d/m);
expect(run(code).events.length).toBeGreaterThan(0);
});
it('rejects implicit, duplicate, multi-path, and closed-path rail pairing', () => {
const { doc } = parseSvgToModel(
``,
{ palette: PALETTE, fitMM: 70 },
);
const ids = doc.operations.map((operation) => operation.id);
expect(canCreateRailPair(doc, ids)).toBe(false);
expect(createRailPair(doc, ids)).toBe(doc);
});
it('emits every rail-pair control without replacing satinbetween with stitches', () => {
const { doc } = parseSvgToModel(railPairFixture, { palette: PALETTE, fitMM: 70 });
const paired = createRailPair(
doc,
doc.operations.map((operation) => operation.id),
);
const relation = paired.operations.find((operation) => operation.role === 'relation')!;
const edited: StagedDocument = {
...paired,
operations: paired.operations.map((operation) =>
operation.id === relation.id
? {
...operation,
strategy: {
kind: 'railPair',
params: {
density: 0.65,
underlay: 'edge',
shortstitch: false,
cap: 'round',
join: 'miter',
},
},
}
: operation,
),
};
const code = emit(edited, { date: '2026-01-01' }).code;
expect(code).toContain('underlay "edge"');
expect(code).toContain("satincap 'round'");
expect(code).toContain("satinjoin 'miter'");
expect(code).toContain('shortstitch 0');
expect(code).toContain('density 0.65');
expect(code).toContain('satinbetween(');
expect(code).toContain('stitchscope [');
expect(() => run(code)).not.toThrow();
});
it('keeps motif-along-path as authored path, layout, and procedure composition', () => {
const { doc } = parseSvgToModel(
``,
{ palette: PALETTE, name: 'motifs.svg', fitMM: 70 },
);
const ids = doc.operations.map((operation) => operation.id);
expect(canCreateMotifAlong(doc, ids)).toBe(true);
const related = createMotifAlong(doc, ids);
const relation = related.operations.find((operation) => operation.role === 'relation')!;
const edited: StagedDocument = {
...related,
operations: related.operations.map((operation) =>
operation.id === relation.id
? {
...operation,
strategy: {
kind: 'motifAlong',
params: { count: 3, scale: 0.5, stitchlen: 1.7, align: false },
},
}
: operation,
),
};
const code = emit(edited, { date: '2026-01-01' }).code;
expect(code).toContain('import std.layout.alongpath as svg_alongpath');
expect(code).toContain('let route =');
expect(code).toContain('let motif =');
expect(code).toMatch(/def route_place_motif\(motif, placement\)/);
expect(code).toContain('xscale(motif, 0.5), 0)');
expect(code).toContain('sewpath(resample(placed, 1.7))');
expect(code).toContain('svg_alongpath(route, 3)');
expect(() => run(code)).not.toThrow();
const appended = emitAppend(edited, 'import std.layout.alongpath as placealong\nseed 9\nfd 1', {
date: '2026-01-01',
});
expect(appended.code.match(/^import std\.layout\.alongpath as /gm)).toHaveLength(1);
expect(appended.code).toContain('placealong(route, 3)');
expect(() => run(appended.code)).not.toThrow();
});
});
describe('resampleRing', () => {
it('keeps endpoints and spaces points ~evenly', () => {
const line: [number, number][] = [
[0, 0],
[10, 0],
];
const r = resampleRing(line, 2);
expect(r[0]).toEqual([0, 0]);
expect(r[r.length - 1]).toEqual([10, 0]);
// ~10/2 = 5 segments → 6 points
expect(r.length).toBeGreaterThanOrEqual(5);
});
});
describe('emit → run integration', () => {
it('emits a header, bindings, and runnable sew blocks', () => {
const doc = mkDoc([
mkElement({
id: 'a',
name: 'crest',
rings: [SQUARE, HOLE],
strategy: defaultStrategy('tatamiFill'),
threadIndex: 2,
}),
mkElement({
id: 'b',
name: 'badge',
rings: [SQUARE],
strategy: defaultStrategy('satinBorder'),
threadIndex: 1,
}),
]);
const { code, sewSpans } = emit(doc, { date: '2026-01-01' });
expect(code).toContain('// imported from logo.svg — 2026-01-01');
expect(code).toContain('seed 1');
expect(code).toContain('fabric "woven"');
expect(code).toContain('let crest_path1 =');
expect(code).toContain('let crest_path2 =');
expect(code).toContain('color 2');
expect(code).toContain('color 1');
expect(code).toContain('trim');
expect(Object.keys(sewSpans)).toEqual(['a', 'b']);
// the real engine must accept it and produce stitches
const result = run(code);
expect(result.events.length).toBeGreaterThan(0);
});
it('emits explicit construction, material intent, and group-preserving planner metadata', () => {
const fill = mkElement({
id: 'badge-fill',
name: 'badge fill',
rings: [SQUARE],
groupPath: ['badge'],
atomic: true,
strategy: {
kind: 'tatamiFill',
params: {
fillangle: 30,
fillspacing: 0.5,
filllen: 3.5,
fillunderlay: 'edge',
fillinset: 0.7,
},
},
});
const border = mkElement({
id: 'badge-border',
name: 'badge border',
role: 'stroke',
rings: [SQUARE],
groupPath: ['badge'],
sourceFill: null,
sourceStroke: '#ff0000',
atomic: true,
strategy: {
kind: 'satinBorder',
params: {
width: 3,
density: 0.45,
underlay: 'edge',
shortstitch: true,
cap: 'round',
join: 'fan',
},
},
});
const detail = mkElement({
id: 'detail',
name: 'detail',
rings: [SQUARE.map(([x, y]) => [x / 2, y / 2])],
groupPath: ['detail'],
planBarrierBefore: true,
strategy: defaultStrategy('outline'),
});
const doc = {
...mkDoc([fill, border, detail]),
threadProfile: 'rayon-60wt' as const,
planMode: 'reversing-nearest' as const,
};
const code = emit(doc, { date: '2026-01-01' }).code;
expect(code).toContain("threadprofile 'rayon-60wt'");
expect(code).toContain("plan 'reversing-nearest'");
expect(code.match(/^routegroup \[/gm)).toHaveLength(2);
expect(code).toContain('planbarrier');
expect(code.match(/^\s*atomic \[/gm)).toHaveLength(2);
expect(code).toContain('fillinset 0.7');
expect(code).toContain('fillunderlay "edge"');
expect(code).toContain("satincap 'round'");
expect(code).toContain("satinjoin 'fan'");
expect(code).not.toContain('fabricgrain');
expect(code).not.toContain('fabricstretch');
expect(code).not.toContain('stabilizer');
expect(() => run(code)).not.toThrow();
});
it('inherits append-mode setup while retaining authored planner barriers', () => {
const first = mkElement({
id: 'first',
rings: [SQUARE],
groupPath: ['first-group'],
atomic: true,
});
const second = mkElement({
id: 'second',
rings: [SQUARE.map(([x, y]) => [x / 2, y / 2])],
groupPath: ['second-group'],
});
const doc = {
...mkDoc([first, second]),
fabric: 'fleece' as const,
threadProfile: 'rayon-60wt' as const,
planMode: 'nearest' as const,
};
const base = "threadprofile 'polyester-60wt'\nplan 'nearest'\nfd 2";
const appended = emitAppend(doc, base, { date: '2026-01-01' });
expect(appended.fragmentCode).not.toMatch(/^fabric /m);
expect(appended.fragmentCode).not.toMatch(/^threadprofile /m);
expect(appended.fragmentCode).not.toMatch(/^plan /m);
expect(appended.fragmentCode).not.toContain('routegroup [');
expect(appended.fragmentCode).toContain('planbarrier');
expect(appended.fragmentCode).toContain('atomic [');
expect(appended.code.match(/^threadprofile /gm)).toHaveLength(1);
expect(appended.code.match(/^plan /gm)).toHaveLength(1);
expect(() => run(appended.code)).not.toThrow();
});
it('skips excluded and skip-strategy elements', () => {
const doc = mkDoc([
mkElement({
id: 'a',
name: 'keep',
rings: [SQUARE],
strategy: defaultStrategy('tatamiFill'),
}),
mkElement({ id: 'b', name: 'drop', rings: [SQUARE], strategy: defaultStrategy('skip') }),
mkElement({
id: 'c',
name: 'off',
rings: [SQUARE],
strategy: defaultStrategy('tatamiFill'),
include: false,
}),
]);
const { code } = emit(doc);
expect(code).toContain('let keep =');
expect(code).not.toContain('let drop =');
expect(code).not.toContain('let off =');
});
it('allocates collision-free names for generated subpaths', () => {
const code = emit(
mkDoc([
mkElement({
id: 'compound',
name: 'crest',
rings: [SQUARE, HOLE],
strategy: defaultStrategy('tatamiFill'),
}),
mkElement({
id: 'named-like-subpath',
name: 'crest_path1',
rings: [SQUARE],
strategy: defaultStrategy('outline'),
}),
]),
{ date: '2026-01-01' },
).code;
expect(code).toContain('let crest_path1 =');
expect(code).toContain('let crest_path1_2 =');
expect(() => run(code)).not.toThrow();
});
it('manual Solid/Hole edits change the emitted fill topology', () => {
const operation = mkElement({
id: 'crest',
name: 'crest',
rings: [SQUARE, HOLE],
strategy: defaultStrategy('tatamiFill'),
});
const withHole = emit(mkDoc([operation]), { date: '2026-01-01' }).code;
expect(withHole).toContain('sewpath(crest_path2)');
const solidMap = operation.holeMap.map((entry, index) =>
index === 1 ? { ...entry, hole: false } : entry,
);
const solid = emit(mkDoc([{ ...operation, holeMap: solidMap }]), {
date: '2026-01-01',
}).code;
expect(solid).not.toContain('sewpath(crest_path2)');
});
it('emits an append fragment without once-only setup and with a color literal', () => {
const operation = mkElement({
id: 'append',
rings: [SQUARE],
sourceFill: '#ff0000',
strategy: defaultStrategy('tatamiFill'),
});
const fragment = emit(mkDoc([operation]), { mode: 'append', date: '2026-01-01' }).code;
expect(fragment).not.toMatch(/^seed /m);
expect(fragment).not.toMatch(/^fabric /m);
expect(fragment).toContain("color '#ff0000'");
expect(run(fragment).events.length).toBeGreaterThan(0);
});
it('merges append output with the base program and avoids declared names', () => {
const operation = mkElement({
id: 'append',
name: 'crest',
rings: [SQUARE],
sourceFill: '#ff0000',
strategy: defaultStrategy('tatamiFill'),
});
const base = `seed 17\nfabric "knit"\nlet crest = 42\nfd 2`;
const merged = emitAppend(mkDoc([operation]), base, { date: '2026-01-01' });
expect(merged.code).toContain(base);
expect(merged.fragmentCode).not.toMatch(/^seed /m);
expect(merged.fragmentCode).not.toMatch(/^fabric /m);
expect(merged.code).toContain('let crest_2 =');
expect(merged.code.match(/^seed /gm)).toHaveLength(1);
expect(merged.code.match(/^fabric /gm)).toHaveLength(1);
expect(run(merged.code).events.length).toBeGreaterThan(run(base).events.length);
const span = merged.sewSpans.append;
expect(
merged.code
.split('\n')
.slice(span.start - 1, span.end)
.join('\n'),
).toContain('beginfill');
});
it('inventories imports, variables, and procedures for append collision checks', () => {
const inventory = inventoryProgram(
`import std.shapes.rectpath as rectangle\nlet boundary = []\ndef grain(p) [ return 45 ]`,
);
expect(inventory.imports).toEqual([{ specifier: 'std.shapes.rectpath', alias: 'rectangle' }]);
expect(inventory.usedNames.has('rectangle')).toBe(true);
expect(inventory.usedNames.has('boundary')).toBe(true);
expect(inventory.usedNames.has('grain')).toBe(true);
});
it('deduplicates compatible append imports and rejects alias conflicts', () => {
const fragment = {
code: '',
imports: ['import std.shapes.rectpath as shapehelper'],
preamble: ['// fragment'],
body: ['', 'fd 1'],
sewSpans: {},
};
const compatible = mergeAppend('import std.shapes.rectpath as shapehelper\nfd 1', fragment);
expect(compatible.code.match(/^import .* as shapehelper$/gm)).toHaveLength(1);
expect(() =>
mergeAppend('import std.shapes.ellipsepath as shapehelper\nfd 1', fragment),
).toThrow(/already refers to/);
});
});
describe('operation ordering', () => {
it('keeps top-level SVG groups contiguous when requested', () => {
const a = mkElement({
id: 'a',
rings: [SQUARE],
threadIndex: 0,
sourceOrder: 0,
groupPath: ['group-a'],
});
const b = mkElement({
id: 'b',
rings: [SQUARE],
threadIndex: 2,
sourceOrder: 1,
groupPath: ['group-a'],
});
const c = mkElement({
id: 'c',
rings: [SQUARE],
threadIndex: 1,
sourceOrder: 2,
groupPath: ['group-b'],
});
expect(orderOperations([a, b, c], 'color', true).map((operation) => operation.id)).toEqual([
'a',
'b',
'c',
]);
expect(orderOperations([a, b, c], 'color', false).map((operation) => operation.id)).toEqual([
'a',
'c',
'b',
]);
});
});
describe('parseSvgToModel', () => {
const SVG = ``;
it('builds an element model in hoop mm and auto-suggests strategies', () => {
const { doc } = parseSvgToModel(SVG, { palette: PALETTE, name: 'logo.svg', fitMM: 50 });
expect(doc.name).toBe('logo');
expect(doc.threadProfile).toBe('polyester-40wt');
expect(doc.planMode).toBe('off');
expect(doc.operations.length).toBe(2);
expect(doc.operations.every((operation) => !operation.atomic)).toBe(true);
expect(doc.operations.every((operation) => !operation.planBarrierBefore)).toBe(true);
const rect = doc.operations.find((e) => e.geomType === 'rect')!;
expect(rect.strategy.kind).toBe('tatamiFill');
const circ = doc.operations.find((e) => e.geomType === 'circle')!;
expect(circ.sourceFill).toBeNull();
expect(circ.strategy.kind).toBe('outline');
});
it('derives adjacent fill and stroke operations that share one geometry', () => {
const { doc } = parseSvgToModel(fillAndStrokeFixture, { palette: PALETTE, fitMM: 50 });
expect(doc.sourceObjects).toHaveLength(1);
expect(doc.geometries).toHaveLength(1);
expect(doc.operations.map((operation) => operation.role)).toEqual(['fill', 'stroke']);
expect(doc.operations[0].geometryIds).toEqual(doc.operations[1].geometryIds);
const code = emit(doc, { date: '2026-01-01' }).code;
expect(code.match(/let badge =/g)).toHaveLength(1);
expect(code).toContain('beginfill');
expect(code).toContain('satin ');
});
it('resolves inherited paint, nested group paths, and fitted physical stroke width', () => {
const { doc } = parseSvgToModel(nestedGroupsFixture, {
palette: PALETTE,
fitMM: 60,
});
expect(doc.operations.map((operation) => operation.role)).toEqual(['fill', 'stroke']);
expect(doc.operations[0].groupPath).toEqual(['logo', 'details']);
const stroke = doc.operations[1];
expect(stroke.sourceStrokeWidth).toBeCloseTo(60 / 11, 4);
expect(stroke.strategy.kind).toBe('satinBorder');
expect(stroke.bbox.maxX - stroke.bbox.minX + stroke.sourceStrokeWidth!).toBeCloseTo(60, 4);
});
it('retains physical dash, cap, join, and offset metadata', () => {
const svg = ``;
const { doc } = parseSvgToModel(svg, { palette: PALETTE, fitMM: 50 });
expect(doc.sourceObjects[0].paint).toMatchObject({
strokeWidthMM: 2,
lineCap: 'round',
lineJoin: 'bevel',
dashArrayMM: [5, 2.5],
dashOffsetMM: 1,
});
});
it('normalizes same- and opposite-winding nonzero compound paths', () => {
const { doc } = parseSvgToModel(compoundNonzeroFixture, { palette: PALETTE, fitMM: 70 });
const same = doc.operations.find((operation) => operation.name.startsWith('same-winding'))!;
const opposite = doc.operations.find((operation) =>
operation.name.startsWith('opposite-winding'),
)!;
expect(same.holeMap.map((entry) => entry.hole)).toEqual([false, false]);
expect(opposite.holeMap.map((entry) => entry.hole)).toEqual([false, true]);
expect(normalizedFillGroups(same.holeMap)).toEqual([[0]]);
expect(normalizedFillGroups(opposite.holeMap)).toEqual([[0, 1]]);
});
it('keeps mixed open/closed subpaths on shared geometry but fills only closed paths', () => {
const svg = ``;
const { doc } = parseSvgToModel(svg, { palette: PALETTE, fitMM: 70 });
const fill = doc.operations.find((operation) => operation.role === 'fill')!;
const stroke = doc.operations.find((operation) => operation.role === 'stroke')!;
expect(fill.pathIndices).toEqual([0]);
expect(stroke.pathIndices).toEqual([0, 1]);
expect(stroke.geomType).toBe('openPath');
});
it('fits and validates against an oval sewable field', () => {
const svg = ``;
const { doc } = parseSvgToModel(svg, {
palette: PALETTE,
fitMM: 90,
field: { shape: 'oval', widthMM: 114, heightMM: 69 },
});
expect(doc.operations[0].flags.outsideHoop).toBeUndefined();
expect(doc.operations[0].bbox.maxY - doc.operations[0].bbox.minY).toBeLessThanOrEqual(69.01);
});
it('includes an auto-suggested satin outline width in the fit envelope', () => {
const svg = ``;
const { doc } = parseSvgToModel(svg, {
palette: PALETTE,
fitMM: 90,
field: { shape: 'rectangle', widthMM: 94, heightMM: 94 },
});
const operation = doc.operations[0];
expect(operation.strategy.kind).toBe('satinBorder');
const satinWidth = operation.sourceStrokeWidth!;
expect(operation.bbox.maxX - operation.bbox.minX + satinWidth).toBeCloseTo(90, 4);
const stitches = run(emit(doc, { date: '2026-01-01' }).code).events.filter(
(event) => event.t === 'stitch',
);
expect(Math.max(...stitches.map((event) => Math.abs(event.x)))).toBeLessThan(47);
});
it('reports unsupported paints instead of silently substituting gray', () => {
const svg = ``;
const { doc } = parseSvgToModel(svg, { palette: PALETTE });
expect(doc.operations).toHaveLength(0);
expect(doc.sourceObjects[0].findings).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: 'unsupported-paint', severity: 'error' }),
]),
);
expect(doc.threadMap).not.toHaveProperty('#808080');
});
it('parses an opaque 2–8 stop linear gradient in hoop space', () => {
const { doc } = parseSvgToModel(linearGradientFixture, {
palette: PALETTE,
fitMM: 70,
field: { shape: 'rectangle', widthMM: 94, heightMM: 94 },
});
expect(doc.operations).toHaveLength(1);
const operation = doc.operations[0];
expect(operation.strategy).toEqual({
kind: 'gradientFill',
params: { pitch: 0.5, stitchlen: 2.5 },
});
expect(operation.sourceGradient).toMatchObject({
kind: 'linear',
id: 'sunset',
stops: [
{ offset: 0, color: '#c8472f' },
{ offset: 0.5, color: '#d9a441' },
{ offset: 1, color: '#3a4e8c' },
],
});
expect(operation.sourceGradient!.start).toEqual([-35, 35]);
expect(operation.sourceGradient!.end).toEqual([35, 35]);
expect(operation.holeMap.map((ring) => ring.hole)).toEqual([false, true]);
expect(Object.keys(doc.threadMap)).toEqual(
expect.arrayContaining(['#c8472f', '#d9a441', '#3a4e8c']),
);
});
it('emits density-neutral SVG gradient rows and runs them through the real engine', () => {
const { doc: parsed } = parseSvgToModel(linearGradientFixture, {
palette: PALETTE,
fitMM: 50,
});
const doc: StagedDocument = {
...parsed,
operations: parsed.operations.map((operation) => ({
...operation,
strategy: { kind: 'gradientFill', params: { pitch: 0.75, stitchlen: 3.1 } },
})),
};
const code = emit(doc, { date: '2026-01-01' }).code;
expect(code).toContain('import std.stitchcraft.gradientrowsn as svg_gradientrowsn');
expect(code).toContain('import std.stitchcraft.serpentinerows as svg_serpentinerows');
expect(code).toContain('def gradient_badge_gradient_weights(v)');
expect(code).toContain('svg_gradientrowsn([gradient_badge_path1, gradient_badge_path2]');
expect(code).toContain(', 0.75, @gradient_badge_gradient_weights)');
expect(code).toContain('gradient_badge_gradient_groups[channel] = concat(');
expect(code).toContain('color gradient_badge_gradient_colors[channel]');
expect(code).toContain('sewpath(resample(row, 3.1))');
const result = run(code);
expect(result.events.filter((event) => event.t === 'stitch').length).toBeGreaterThan(0);
expect(result.events.filter((event) => event.t === 'color')).toHaveLength(2);
});
it('inherits stops, honors user-space vectors and transforms, and preserves append colors', () => {
const svg = ``;
const { doc } = parseSvgToModel(svg, { palette: PALETTE, fitMM: 40 });
const gradient = doc.operations[0].sourceGradient!;
expect(gradient.start[0]).toBeCloseTo(0, 6);
expect(gradient.start[1]).toBeCloseTo(25, 6);
expect(gradient.end[0]).toBeCloseTo(0, 6);
expect(gradient.end[1]).toBeCloseTo(-25, 6);
const appended = emitAppend(
doc,
`import std.stitchcraft.gradientrowsn as blendrows
import std.stitchcraft.serpentinerows as routerows
seed 9
fd 1`,
{ date: '2026-01-01' },
);
expect(appended.code.match(/^import std\.stitchcraft\.gradientrowsn as /gm)).toHaveLength(1);
expect(appended.code.match(/^import std\.stitchcraft\.serpentinerows as /gm)).toHaveLength(1);
expect(appended.fragmentCode).toContain("let rect_1_gradient_colors = ['#c8472f', '#3a4e8c']");
expect(appended.fragmentCode).toContain('blendrows(');
expect(appended.fragmentCode).toContain('routerows(');
expect(() => run(appended.code)).not.toThrow();
});
it('keeps radial, repeating, transparent, and oversized gradients as explicit findings', () => {
const cases = [
'',
'',
'',
`${Array.from({ length: 9 }, (_, index) => ``).join('')}`,
];
for (const definition of cases) {
const { doc } = parseSvgToModel(
``,
{ palette: PALETTE },
);
expect(doc.operations).toHaveLength(0);
expect(doc.sourceObjects[0].findings).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: 'unsupported-paint', severity: 'error' }),
]),
);
}
});
it('parse → emit → run round-trips to real stitches', () => {
const { doc } = parseSvgToModel(SVG, { palette: PALETTE, name: 'logo.svg', fitMM: 80 });
const { code } = emit(doc);
const result = run(code);
expect(result.events.length).toBeGreaterThan(0);
});
it('flags as unsupported and skipped', () => {
const svg = ``;
const { doc } = parseSvgToModel(svg, { palette: PALETTE, name: 'x.svg' });
const unsupported = doc.sourceObjects.find((object) =>
object.findings.some((finding) => finding.code === 'unsupported-element'),
);
expect(unsupported?.name).toBe('text #1');
});
it('keeps a text-only SVG openable as findings without fabricating an operation', () => {
const svg = ``;
const { doc } = parseSvgToModel(svg, { palette: PALETTE });
expect(doc.operations).toHaveLength(0);
expect(doc.sourceObjects[0]).toMatchObject({ name: 'caption', geometryId: null });
expect(doc.sourceObjects[0].findings[0].code).toBe('unsupported-element');
});
it('retains C/Q/A/Z geometry as editable cubic specs and emits it opt-in', () => {
const svg = ``;
const { doc } = parseSvgToModel(svg, { palette: PALETTE, name: 'curves.svg', fitMM: 60 });
expect(doc.geometries[0].curveSpecs?.[0].closed).toBe(true);
expect(doc.geometries[0].curveSpecs?.[0].anchors.length).toBeGreaterThan(3);
const legacy = emit(doc, { date: '2026-01-01' }).code;
expect(legacy).not.toContain('// [curve');
const editable = emit({ ...doc, editableCurves: true }, { date: '2026-01-01' }).code;
expect(editable).toContain('// [curve: closed]');
expect(editable).toContain('curveflat(');
expect(run(editable).events.length).toBeGreaterThan(0);
});
});
describe('pathToCurveSpecs', () => {
it('degree-elevates quadratics and resolves smooth cubic reflection', () => {
const [spec] = pathToCurveSpecs('M0 0 Q3 6 6 0 T12 0 C14 4 16 4 18 0 S22 -4 24 0');
expect(spec.anchors).toHaveLength(5);
expect(spec.anchors[1][1]).toEqual([-2, 4]);
expect(spec.anchors[2][1]).toEqual([-2, -4]);
expect(spec.anchors[4][1]).toEqual([-2, -4]);
});
});
// ---------- helpers ----------
function mkElement(partial: Partial & { rings: [number, number][][] }): ElementModel {
const rings = partial.rings;
const holeMap = computeHoleMap(rings, partial.fillRule ?? 'nonzero');
const id = partial.id ?? 'el';
return {
id,
sourceObjectId: `source-${id}`,
geometryIds: [`geometry-${id}`],
pathIndices: rings.map((_, index) => index),
name: 'el',
role: 'fill',
geomType: 'closedPath',
bbox: { minX: -10, minY: -10, maxX: 10, maxY: 10 },
areaMm2: netFillArea(rings, holeMap),
sourceFill: '#ff0000',
sourceGradient: null,
sourceStroke: null,
sourceStrokeWidth: null,
fillRule: 'nonzero',
strategy: defaultStrategy('tatamiFill'),
threadIndex: 0,
holeMap,
sourceOrder: 0,
order: 0,
include: true,
atomic: false,
planBarrierBefore: false,
flags: {},
findings: [],
groupPath: [],
groupId: null,
...partial,
};
}
function mkDoc(operations: ElementModel[]): StagedDocument {
operations.forEach((operation, index) => {
operation.order = index;
operation.sourceOrder = index;
});
return {
name: 'logo',
fabric: 'woven',
threadProfile: 'polyester-40wt',
planMode: 'off',
sewOrderKey: 'depth',
keepGroups: true,
geometryToleranceMM: 0.2,
scaleFactor: 1,
seed: 1,
palette: PALETTE,
threadMap: {},
activeField: { shape: 'circle', widthMM: 94, heightMM: 94 },
sourceObjects: operations.map((operation) => ({
id: operation.sourceObjectId,
name: operation.name,
geometryId: operation.geometryIds[0],
groupPath: operation.groupPath,
sourceIndex: operation.sourceOrder,
paint: {
fill: operation.sourceFill,
fillGradient: operation.sourceGradient,
stroke: operation.sourceStroke,
strokeWidthMM: operation.sourceStrokeWidth,
fillRule: operation.fillRule,
lineCap: 'butt',
lineJoin: 'miter',
dashArrayMM: null,
dashOffsetMM: 0,
visible: true,
},
findings: [],
})),
geometries: operations.map((operation) => ({
id: operation.geometryIds[0],
sourceObjectId: operation.sourceObjectId,
name: operation.name,
kind: 'path',
groupPath: operation.groupPath,
paths: operation.rings,
sourcePaths: operation.rings,
curveSpecs: operation.curveSpecs,
closed: operation.rings.map(() => true),
bbox: operation.bbox,
outputMode: 'compact',
flags: {},
})),
operations,
};
}