(function (factory) {
typeof define === 'function' && define.amd ? define(factory) :
factory();
})((function () { 'use strict';
/**
* Class for writing Tajima DST embroidery files.
* @class DSTWriter
*/
class DSTWriter {
constructor() {
this.data = [];
this.currentX = 0;
this.currentY = 0;
this.minX = Infinity;
this.maxX = -Infinity;
this.minY = Infinity;
this.maxY = -Infinity;
this.stitchCount = 0;
}
static JUMP = 1;
static STITCH = 0;
static COLOR_CHANGE = 2;
static END = 3;
bit(b) {
return 1 << b;
}
encodeRecord(x, y, flag) {
{
console.log("Encoding record:", {
x,
y,
flag:
flag === DSTWriter.JUMP
? "JUMP"
: flag === DSTWriter.STITCH
? "STITCH"
: flag === DSTWriter.COLOR_CHANGE
? "COLOR_CHANGE"
: flag,
});
}
y = -y; // DST uses a different coordinate system
let b0 = 0,
b1 = 0,
b2 = 0;
switch (flag) {
case DSTWriter.JUMP:
b2 += this.bit(7);
// fallthrough
case DSTWriter.STITCH:
b2 += this.bit(0);
b2 += this.bit(1);
if (x > 40) {
b2 += this.bit(2);
x -= 81;
}
if (x < -40) {
b2 += this.bit(3);
x += 81;
}
if (x > 13) {
b1 += this.bit(2);
x -= 27;
}
if (x < -13) {
b1 += this.bit(3);
x += 27;
}
if (x > 4) {
b0 += this.bit(2);
x -= 9;
}
if (x < -4) {
b0 += this.bit(3);
x += 9;
}
if (x > 1) {
b1 += this.bit(0);
x -= 3;
}
if (x < -1) {
b1 += this.bit(1);
x += 3;
}
if (x > 0) {
b0 += this.bit(0);
x -= 1;
}
if (x < 0) {
b0 += this.bit(1);
x += 1;
}
if (y > 40) {
b2 += this.bit(5);
y -= 81;
}
if (y < -40) {
b2 += this.bit(4);
y += 81;
}
if (y > 13) {
b1 += this.bit(5);
y -= 27;
}
if (y < -13) {
b1 += this.bit(4);
y += 27;
}
if (y > 4) {
b0 += this.bit(5);
y -= 9;
}
if (y < -4) {
b0 += this.bit(4);
y += 9;
}
if (y > 1) {
b1 += this.bit(7);
y -= 3;
}
if (y < -1) {
b1 += this.bit(6);
y += 3;
}
if (y > 0) {
b0 += this.bit(7);
y -= 1;
}
if (y < 0) {
b0 += this.bit(6);
y += 1;
}
break;
case DSTWriter.COLOR_CHANGE:
b2 = 0b11000011;
break;
case DSTWriter.END:
b2 = 0b11110011;
break;
}
return [b0, b1, b2];
}
move(x, y, flag = DSTWriter.STITCH) {
if (x !== null && y !== null) {
{
console.log("Move called with:", {
targetX: x,
targetY: y,
flag: flag === DSTWriter.JUMP ? "JUMP" : flag === DSTWriter.STITCH ? "STITCH" : flag,
currentPosition: { x: this.currentX, y: this.currentY },
});
}
let dx = Math.round(x) - this.currentX;
let dy = Math.round(y) - this.currentY;
while (Math.abs(dx) > 121 || Math.abs(dy) > 121) {
let stepX = dx > 0 ? Math.min(dx, 121) : Math.max(dx, -121);
let stepY = dy > 0 ? Math.min(dy, 121) : Math.max(dy, -121);
let command = this.encodeRecord(stepX, stepY, DSTWriter.JUMP);
this.data.push(...command);
this.currentX += stepX;
this.currentY += stepY;
this.stitchCount++;
dx -= stepX;
dy -= stepY;
}
if (dx !== 0 || dy !== 0) {
let command = this.encodeRecord(dx, dy, flag);
this.data.push(...command);
this.currentX += dx;
this.currentY += dy;
this.stitchCount++;
}
this.minX = Math.min(this.minX, this.currentX);
this.maxX = Math.max(this.maxX, this.currentX);
this.minY = Math.min(this.minY, this.currentY);
this.maxY = Math.max(this.maxY, this.currentY);
{
console.log("After move:", {
newPosition: { x: this.currentX, y: this.currentY },
dx: dx,
dy: dy,
});
}
}
}
calculateBorderSize(points) {
let minX = Infinity,
maxX = -Infinity,
minY = Infinity,
maxY = -Infinity;
// Log all trim points for debugging
{
const trimPoints = points.filter((p) => p.trim);
if (trimPoints.length > 0) {
console.log("Trim points in calculateBorderSize:", trimPoints);
}
}
for (let point of points) {
minX = Math.min(minX, point.x);
maxX = Math.max(maxX, point.x);
minY = Math.min(minY, point.y);
maxY = Math.max(maxY, point.y);
}
const result = {
left: Math.abs(Math.floor(minX)),
top: Math.abs(Math.floor(minY)),
right: Math.abs(Math.ceil(maxX)),
bottom: Math.abs(Math.ceil(maxY)),
width: Math.ceil(maxX - minX),
height: Math.ceil(maxY - minY),
// Add raw bounds for debugging
bounds: {
minX,
maxX,
minY,
maxY,
},
};
{
console.log("Border calculation:", {
points: points.length,
result,
});
}
return result;
}
generateDST(points, title) {
{
console.log("=== DSTWriter generateDST ===");
console.log("Initial state:", {
currentX: this.currentX,
currentY: this.currentY,
});
}
// Reset data and counters
this.data = [];
this.currentX = 0;
this.currentY = 0;
this.stitchCount = 0;
this.colorChangeCount = 0;
this.minX = Infinity;
this.maxX = -Infinity;
this.minY = Infinity;
this.maxY = -Infinity;
// Calculate border size before transformation
let border = this.calculateBorderSize(points);
{
console.log("Original border size:", border);
}
// Transform points to center-origin coordinates
const centerX = border.width / 2;
const centerY = border.height / 2;
{
console.log("Transformation values:", {
centerX,
centerY,
left: border.left,
top: border.top,
offset: {
x: border.left + centerX,
y: border.top + centerY,
},
});
}
const transformedPoints = points.map((point) => {
// Create a new point object with all properties from the original point
const newPoint = { ...point };
// Transform coordinates to center-origin
newPoint.x = point.x - (border.left + centerX);
newPoint.y = point.y - (border.top + centerY);
// Log transformation for trim points
if (point.trim) {
console.log("Transforming trim point:", {
original: { x: point.x, y: point.y },
transformed: { x: newPoint.x, y: newPoint.y },
offset: { x: border.left + centerX, y: border.top + centerY },
});
}
return newPoint;
});
{
console.log("Coordinate transformation:", {
centerX,
centerY,
originalFirstPoint: points[0],
transformedFirstPoint: transformedPoints[0],
});
}
// Recalculate border size after transformation
border = this.calculateBorderSize(transformedPoints);
{
console.log("Transformed border size:", border);
}
// Generate stitches using transformed points
for (let i = 0; i < transformedPoints.length; i++) {
const point = transformedPoints[i];
{
console.log("Processing point:", i, point);
}
// Handle color change
if (point.colorChange) {
{
console.log("Color change at point:", i);
}
// Add a color change command at the current position
// In DST, we don't move but just insert a color change command
this.data.push(...this.encodeRecord(0, 0, DSTWriter.COLOR_CHANGE));
this.colorChangeCount++;
continue;
}
// Handle thread trim
if (point.trim) {
{
console.log("Thread trim at point:", i, {
originalPoint: point,
currentPosition: { x: this.currentX, y: this.currentY },
});
}
// In DST format, thread trimming is signaled by a specific pattern of jump stitches
// First, ensure we're at the correct position
this.move(point.x, point.y, DSTWriter.JUMP);
{
console.log("After move to trim position:", {
targetPosition: { x: point.x, y: point.y },
actualPosition: { x: this.currentX, y: this.currentY },
});
}
// Generate a zigzag pattern of 3 jumps that embroidery machines recognize as a trim command
// These are small relative movements from the current position
// First jump: up and right
this.data.push(...this.encodeRecord(3, 3, DSTWriter.JUMP));
this.currentX += 3;
this.currentY += 3;
this.stitchCount++;
// Second jump: down and right
this.data.push(...this.encodeRecord(3, -6, DSTWriter.JUMP));
this.currentX += 3;
this.currentY -= 6;
this.stitchCount++;
// Third jump: back to original position
this.data.push(...this.encodeRecord(-6, 3, DSTWriter.JUMP));
this.currentX -= 6;
this.currentY += 3;
this.stitchCount++;
continue;
}
// Handle jump or stitch
const flag = i === 0 || point.jump ? DSTWriter.JUMP : DSTWriter.STITCH;
this.move(point.x, point.y, flag);
{
console.log("After move:", {
point: i,
position: { x: this.currentX, y: this.currentY },
flag: flag === DSTWriter.JUMP ? "JUMP" : "STITCH",
});
}
}
// Add end record
this.move(0, 0, DSTWriter.END);
{
console.log("Final state:", {
stitchCount: this.stitchCount,
colorChangeCount: this.colorChangeCount,
bounds: {
minX: this.minX,
maxX: this.maxX,
minY: this.minY,
maxY: this.maxY,
},
});
}
// Create header
let header = new Array(512).fill(0x20); // Fill with spaces
let headerString =
`LA:${title.padEnd(16)}\r` +
`ST:${this.stitchCount.toString().padStart(7)}\r` +
`CO:${this.colorChangeCount.toString().padStart(3)}\r` +
`+X:${border.right.toString().padStart(5)}\r` +
`-X:${Math.abs(border.left).toString().padStart(5)}\r` +
`+Y:${border.bottom.toString().padStart(5)}\r` +
`-Y:${Math.abs(border.top).toString().padStart(5)}\r` +
`AX:+${Math.abs(this.currentX).toString().padStart(5)}\r` +
`AY:+${Math.abs(this.currentY).toString().padStart(5)}\r` +
`MX:+${(0).toString().padStart(5)}\r` +
`MY:+${(0).toString().padStart(5)}\r` +
`PD:******\r`;
// Convert header string to byte array
for (let i = 0; i < headerString.length; i++) {
header[i] = headerString.charCodeAt(i);
}
header[headerString.length] = 0x1a; // EOF character
// Combine header and data
return new Uint8Array([...header, ...this.data]);
}
saveBytes(data, filename) {
let blob = new Blob([data], { type: "application/octet-stream" });
let link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = filename;
// Prevent page refresh by handling the click event
link.onclick = function (e) {
// Let download happen, just prevent page refresh
setTimeout(() => e.preventDefault(), 10);
//restore download function
// Clean up after download starts
setTimeout(() => {
URL.revokeObjectURL(link.href);
document.body.removeChild(link);
}, 100);
};
document.body.appendChild(link);
link.click();
}
/**
* Saves embroidery data as a DST file.
* @memberof DSTWriter
* @param {Array} points - Array of stitch points
* @param {String} title - Title for the DST file header
* @param {String} filename - Output filename
*/
saveDST(points, title, filename) {
let dstData = this.generateDST(points, title);
this.saveBytes(dstData, filename);
{
console.log("DST file saved!");
}
}
}
// Add this check to support both direct browser usage and ES modules
if (typeof exports !== "undefined") {
exports.DSTWriter = DSTWriter;
} else if (typeof window !== "undefined") {
window.DSTWriter = DSTWriter;
}
// p5.js G-code Writer
class GCodeWriter {
constructor() {
this.data = [];
this.currentX = 0;
this.currentY = 0;
this.currentZ = 0;
this.minX = Infinity;
this.maxX = -Infinity;
this.minY = Infinity;
this.maxY = -Infinity;
}
addComment(comment) {
this.data.push("(" + comment + ")");
}
move(x, y, z = null) {
let command = "G0";
if (x !== null) {
command += ` X${x.toFixed(3)}`;
this.currentX = x;
this.minX = Math.min(this.minX, x);
this.maxX = Math.max(this.maxX, x);
}
if (y !== null) {
command += ` Y${y.toFixed(3)}`;
this.currentY = y;
this.minY = Math.min(this.minY, y);
this.maxY = Math.max(this.maxY, y);
}
if (z !== null) {
command += ` Z${z.toFixed(1)}`;
this.currentZ = z;
}
this.data.push(command);
}
generateGCode(points, title) {
this.addComment(`TITLE:${title}`);
this.addComment(`STITCH_COUNT:${points.length}`);
// Generate points
this.move(0.0, 0.0);
for (let i = 0; i < points.length; i++) {
let point = points[i];
this.move(point.x, point.y);
this.move(null, null, 0.0);
this.move(point.x, point.y);
this.move(null, null, 1.0);
}
// Add final moves
this.move(0.0, 0.0);
this.data.push("M30");
// Add extents information at the beginning
this.data.unshift(
`(EXTENTS_BOTTOM:${this.minY.toFixed(3)})`,
`(EXTENTS_RIGHT:${this.maxX.toFixed(3)})`,
`(EXTENTS_TOP:${this.maxY.toFixed(3)})`,
`(EXTENTS_LEFT:${this.minX.toFixed(3)})`,
`(EXTENTS_HEIGHT:${(this.maxY - this.minY).toFixed(3)})`,
`(EXTENTS_WIDTH:${(this.maxX - this.minX).toFixed(3)})`,
"G90 (use absolute coordinates)",
"G21 (coordinates will be specified in millimeters)",
);
return this.data.join("\n");
}
saveGcode(points, title, filename) {
const gcode = this.generateGCode(points, title);
const blob = new Blob([gcode], { type: "text/plain" });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = filename;
link.click();
setTimeout(() => {
URL.revokeObjectURL(link.href);
document.body.removeChild(link);
}, 100);
}
}
/**
* p5.embroider Embroidery Guide Utilities
* Reusable functions for drawing paper layouts, hoop guides, grids, and reference marks
*/
// Paper size definitions (in mm)
const PAPER_SIZES = {
A4: { width: 210, height: 297 },
A3: { width: 297, height: 420 },
A2: { width: 420, height: 594 },
A1: { width: 594, height: 841 },
};
// Comprehensive hoop size presets (in mm)
// Based on real-world embroidery hoop specifications
const HOOP_PRESETS = {
// Manual Hoops (Round bamboo/wooden hoops for hand embroidery)
"manual-4": { width: 100, height: 100, description: "4 inch bamboo hoop", shape: "round", type: "manual" },
"manual-5": { width: 130, height: 130, description: "5 inch bamboo hoop", shape: "round", type: "manual" },
"manual-6": { width: 150, height: 150, description: "6 inch bamboo hoop", shape: "round", type: "manual" },
"manual-8": { width: 200, height: 200, description: "8 inch bamboo hoop", shape: "round", type: "manual" },
"manual-10": { width: 250, height: 250, description: "10 inch bamboo hoop", shape: "round", type: "manual" },
// Bernina Machine Hoops (Square/rectangular with grids)
"bernina-small": {
width: 72, height: 50,
description: "Bernina Small Hoop - Smallest standard hoop",
brand: "bernina",
type: "machine",
shape: "rectangle"
},
"bernina-medium": {
width: 130, height: 100,
description: "Bernina Medium Hoop - Most versatile standard size",
brand: "bernina",
type: "machine",
shape: "rectangle"
},
"bernina-large-oval": {
width: 145, height: 255,
description: "Bernina Large Oval Hoop - Oval shaped for longer designs",
brand: "bernina",
type: "machine",
shape: "rectangle"
},
"bernina-mega": {
width: 150, height: 400,
description: "Bernina Mega Hoop - Longest standard hoop for borders",
brand: "bernina",
type: "machine",
shape: "rectangle"
},
// Bernina Specialty Machine Hoops (Ergonomic Twist-lock)
"bernina-midi": {
width: 265, height: 165,
description: "Bernina Midi Hoop - Medium specialty with twist-lock",
brand: "bernina",
type: "machine",
shape: "rectangle",
frameThickness: 10
},
"bernina-maxi": {
width: 210, height: 400,
description: "Bernina Maxi Hoop - Large specialty for big designs",
brand: "bernina",
type: "machine",
shape: "rectangle",
frameThickness: 10
},
"bernina-jumbo": {
width: 400, height: 260,
description: "Bernina Jumbo Hoop - Largest specialty hoop",
brand: "bernina",
type: "machine",
shape: "rectangle",
frameThickness: 10,
compatible: ["7-series", "8-series"]
},
// Brother Standard Hoops
"brother-sa431": {
width: 20, height: 60,
description: "Brother SA431 Small - For monograms, collars, cuffs",
brand: "brother",
type: "machine",
shape: "rectangle",
model: "SA431",
physicalSize: "1x2 inches"
},
"brother-sa432": {
width: 100, height: 100,
description: "Brother SA432 Medium - Most popular for left chest",
brand: "brother",
type: "machine",
shape: "rectangle",
model: "SA432",
physicalSize: "4x4 inches"
},
"brother-sa434": {
width: 170, height: 100,
description: "Brother SA434 Large - Multi-position hoop",
brand: "brother",
model: "SA434",
physicalSize: "6.7x4 inches"
},
"brother-sa444": {
width: 127, height: 178,
description: "Brother SA444 5x7 - Popular mid-size for larger designs",
brand: "brother",
model: "SA444",
physicalSize: "5x7 inches"
},
"brother-sa441": {
width: 159, height: 267,
description: "Brother SA441 Extra Large - Large hoop for bigger designs",
brand: "brother",
model: "SA441",
physicalSize: "6x10 inches"
},
// Brother Jumbo/Commercial Hoops
"brother-7x12": {
width: 178, height: 305,
description: "Brother 7x12 Jumbo - Largest Brother hoop size",
brand: "brother",
type: "jumbo",
compatible: ["Innovis 4000D", "5000D", "XV8500D", "PR series"]
},
"brother-8x12": {
width: 203, height: 305,
description: "Brother 8x12 Commercial - Commercial embroidery hoop",
brand: "brother",
type: "commercial",
compatible: ["PR series commercial machines"]
},
// Tajima Tubular Hoops (circular)
"tajima-9cm": {
width: 90, height: 90,
description: "Tajima 9cm Tubular - Small round tubular hoop",
brand: "tajima",
type: "tubular",
sewingField: "360mm (14 inch)"
},
"tajima-12cm": {
width: 120, height: 120,
description: "Tajima 12cm Tubular - Medium round tubular hoop",
brand: "tajima",
type: "tubular",
sewingField: "360mm (14 inch)"
},
"tajima-15cm": {
width: 150, height: 150,
description: "Tajima 15cm Tubular - Large round tubular hoop",
brand: "tajima",
type: "tubular",
sewingField: "360mm (14 inch)"
},
"tajima-18cm": {
width: 180, height: 180,
description: "Tajima 18cm Tubular - Extra large round tubular hoop",
brand: "tajima",
type: "tubular",
sewingField: "360mm (14 inch)"
},
"tajima-21cm": {
width: 210, height: 210,
description: "Tajima 21cm Tubular - XXL round tubular hoop",
brand: "tajima",
type: "tubular",
sewingField: "360mm (14 inch)"
},
// Tajima Rectangular Hoops
"tajima-24x24": {
width: 240, height: 240,
description: "Tajima 24x24 Square - Large square hoop",
brand: "tajima",
type: "rectangular"
},
"tajima-30x30": {
width: 300, height: 300,
description: "Tajima 30x30 Square - Extra large square hoop",
brand: "tajima",
type: "rectangular"
},
"tajima-sleeve": {
width: 360, height: 100,
description: "Tajima 36x10cm Sleeve - Specialized sleeve embroidery hoop",
brand: "tajima",
type: "specialty",
sewingField: "360mm (14 inch)"
},
"tajima-large-rect": {
width: 335, height: 329,
description: "Tajima 335x329 Large Rectangular - Large format rectangular hoop",
brand: "tajima",
type: "rectangular"
},
"tajima-jumbo": {
width: 413, height: 467,
description: "Tajima 413x467 Jumbo - Jumbo rectangular hoop for 500mm sewing field",
brand: "tajima",
type: "jumbo",
sewingField: "500mm (19.7 inch)"
},
// Dahao Commercial Hoops
"dahao-standard": {
width: 400, height: 500,
description: "Dahao Standard Commercial - Standard commercial embroidery area",
brand: "dahao",
type: "commercial"
},
"dahao-large": {
width: 450, height: 400,
description: "Dahao Large Commercial - Large format commercial hoop",
brand: "dahao",
type: "commercial"
},
"dahao-jumbo": {
width: 500, height: 1200,
description: "Dahao Jumbo Commercial - XXL format for banners and large projects",
brand: "dahao",
type: "jumbo"
},
"dahao-square": {
width: 600, height: 600,
description: "Dahao Standard Square - Large square format",
brand: "dahao",
type: "commercial"
},
// Singer Futura Series Hoops
"singer-ce-small": {
width: 100, height: 160,
description: "Singer Futura CE Small - Standard small hoop for CE-100/150/200/250/350",
brand: "singer",
type: "standard",
compatible: ["CE-100", "CE-150", "CE-200", "CE-250", "CE-350"]
},
"singer-ce-large": {
width: 114, height: 171,
description: "Singer Futura CE Large - Large hoop for CE series machines",
brand: "singer",
type: "standard",
model: "#51010",
compatible: ["CE series"]
},
"singer-xl400-small": {
width: 100, height: 100,
description: "Singer Futura XL-400 Small - Standard 4x4 hoop for XL-400",
brand: "singer",
type: "standard",
compatible: ["XL-400"]
},
"singer-xl400-large": {
width: 152, height: 254,
description: "Singer Futura XL-400 Large - Large format hoop for XL-400",
brand: "singer",
type: "standard",
model: "#416454101",
compatible: ["XL-400"]
},
"singer-se9180": {
width: 170, height: 100,
description: "Singer SE9180 Standard - Precision hoop for detailed work",
brand: "singer",
type: "standard",
compatible: ["SE9180"]
},
// Singer Multi-Hoop Systems
"singer-xl400-multi": {
width: 305, height: 508,
description: "Singer XL-400 Multi-Hoop System - Multi-hoop capability for continuous designs",
brand: "singer",
type: "multi-hoop",
compatible: ["XL-400"]
},
"singer-xl400-max": {
width: 470, height: 279,
description: "Singer XL-400 Maximum Multi-Hoop - Maximum multi-hoop design area",
brand: "singer",
type: "multi-hoop",
compatible: ["XL-400"]
}
};
/**
* Draw a grid background with specified spacing
* @param {number} spacing - Grid spacing in mm
* @param {Object} options - Grid styling options
* @param {Array} options.color - Grid color [r, g, b] or [r, g, b, a]
* @param {number} options.weight - Grid line weight in pixels
* @param {number} options.alpha - Grid transparency (0-255)
*/
function drawGrid(spacing = 10, options = {}) {
const {
color = [0, 0, 0],
weight = 1,
alpha = 20
} = options;
push();
if (color.length === 3) {
stroke(color[0], color[1], color[2], alpha);
} else {
stroke(color[0], color[1], color[2], color[3] || alpha);
}
strokeWeight(weight);
const spacingPixels = mmToPixel(spacing);
// Note: width and height should be available from p5.js global context
// Using globalThis to access p5.js globals safely
const canvasWidth = (typeof globalThis !== 'undefined' && globalThis.width) || 800;
const canvasHeight = (typeof globalThis !== 'undefined' && globalThis.height) || 600;
// Draw horizontal lines
for (let i = 0; i <= canvasHeight / spacingPixels; i++) {
const y = i * spacingPixels;
line(0, y, canvasWidth, y);
}
// Draw vertical lines
for (let j = 0; j <= canvasWidth / spacingPixels; j++) {
const x = j * spacingPixels + spacingPixels * 0.5;
line(x, 0, x, canvasHeight);
}
pop();
}
/**
* Draw embroidery hoop guides with circular outline and center marks
* @param {number} x - Center X position in mm
* @param {number} y - Center Y position in mm
* @param {Object} hoopSize - Hoop dimensions {width, height} in mm
* @param {Object} options - Guide styling options
*/
function drawHoopGuides(x, y, hoopSize, options = {}) {
const {
showOutline = true,
showCenterMarks = true,
showPunchPoints = true,
outlineColor = [102, 102, 102],
centerMarkColor = [204, 204, 204],
punchPointColor = [102, 102, 102],
outlineWeight = 0.5,
centerMarkWeight = 0.2,
numPunchPoints = 12,
alpha = 128
} = options;
const radius = Math.min(hoopSize.width, hoopSize.height) / 2;
const centerX = x;
const centerY = y;
push();
// Draw circular hoop outline
if (showOutline) {
stroke(outlineColor[0], outlineColor[1], outlineColor[2], alpha);
strokeWeight(mmToPixel(outlineWeight));
noFill();
circle(mmToPixel(centerX), mmToPixel(centerY), mmToPixel(radius * 2));
}
// Draw center cross marks
if (showCenterMarks) {
stroke(centerMarkColor[0], centerMarkColor[1], centerMarkColor[2], alpha);
strokeWeight(mmToPixel(centerMarkWeight));
// Vertical center line
line(
mmToPixel(centerX), mmToPixel(centerY - radius),
mmToPixel(centerX), mmToPixel(centerY + radius)
);
// Horizontal center line
line(
mmToPixel(centerX - radius), mmToPixel(centerY),
mmToPixel(centerX + radius), mmToPixel(centerY)
);
}
// Draw punch needle points around the circle
if (showPunchPoints) {
stroke(punchPointColor[0], punchPointColor[1], punchPointColor[2], alpha + 75);
fill(punchPointColor[0], punchPointColor[1], punchPointColor[2], alpha + 75);
for (let i = 0; i < numPunchPoints; i++) {
const angle = (i * 2 * Math.PI) / numPunchPoints;
const pointX = centerX + radius * Math.cos(angle);
const pointY = centerY + radius * Math.sin(angle);
// Draw punch needle point (small circle)
circle(mmToPixel(pointX), mmToPixel(pointY), mmToPixel(1));
// Draw small line extending outward from the point
const outerRadius = radius + 3;
const outerX = centerX + outerRadius * Math.cos(angle);
const outerY = centerY + outerRadius * Math.sin(angle);
strokeWeight(mmToPixel(0.3));
stroke(punchPointColor[0], punchPointColor[1], punchPointColor[2], alpha + 25);
line(
mmToPixel(pointX), mmToPixel(pointY),
mmToPixel(outerX), mmToPixel(outerY)
);
}
}
pop();
}
/**
* Draw machine hoop (rectangular) with grid
* @param {number} x - Center X position in mm
* @param {number} y - Center Y position in mm
* @param {Object} hoopSize - Hoop dimensions {width, height} in mm
* @param {Object} options - Hoop styling options
*/
function drawMachineHoop(x, y, hoopSize, options = {}) {
const {
frameColor = [80, 80, 80], // Dark gray frame
gridColor = [150, 150, 150], // Light gray grid
frameWeight = 2,
gridWeight = 0.5,
gridSpacing = 10, // Grid spacing in mm
alpha = 180
} = options;
const halfWidth = hoopSize.width / 2;
const halfHeight = hoopSize.height / 2;
const left = x - halfWidth;
const top = y - halfHeight;
const right = x + halfWidth;
const bottom = y + halfHeight;
push();
// Draw machine hoop frame (rectangular)
stroke(frameColor[0], frameColor[1], frameColor[2], alpha);
strokeWeight(mmToPixel(frameWeight));
noFill();
rect(mmToPixel(left), mmToPixel(top), mmToPixel(hoopSize.width), mmToPixel(hoopSize.height));
// Draw grid inside the hoop
stroke(gridColor[0], gridColor[1], gridColor[2], alpha * 0.6);
strokeWeight(mmToPixel(gridWeight));
// Vertical grid lines
for (let gridX = left + gridSpacing; gridX < right; gridX += gridSpacing) {
line(mmToPixel(gridX), mmToPixel(top), mmToPixel(gridX), mmToPixel(bottom));
}
// Horizontal grid lines
for (let gridY = top + gridSpacing; gridY < bottom; gridY += gridSpacing) {
line(mmToPixel(left), mmToPixel(gridY), mmToPixel(right), mmToPixel(gridY));
}
// Draw center crosshairs
stroke(gridColor[0] - 50, gridColor[1] - 50, gridColor[2] - 50, alpha);
strokeWeight(mmToPixel(gridWeight * 1.5));
line(mmToPixel(x - 5), mmToPixel(y), mmToPixel(x + 5), mmToPixel(y)); // Horizontal
line(mmToPixel(x), mmToPixel(y - 5), mmToPixel(x), mmToPixel(y + 5)); // Vertical
pop();
}
/**
* Draw manual hoop (round bamboo style) with realistic appearance
* @param {number} x - Center X position in mm
* @param {number} y - Center Y position in mm
* @param {Object} hoopSize - Hoop dimensions {width, height} in mm
* @param {Object} options - Hoop styling options
*/
function drawManualHoop(x, y, hoopSize, options = {}) {
const {
outerColor = [139, 69, 19], // Saddle brown bamboo
innerColor = [245, 245, 220], // Beige fabric area
ringColor = [101, 67, 33], // Dark brown
centerMarkColor = [153, 153, 153],
outerStroke = 0.5,
innerStroke = 0.3,
centerMarkWeight = 0.2,
ringThickness = 3,
showCenterMarks = true,
alpha = 204
} = options;
const centerX = x;
const centerY = y;
const outerRadius = Math.min(hoopSize.width, hoopSize.height) / 2;
const innerRadius = outerRadius - ringThickness;
push();
// Draw outer bamboo hoop ring
fill(outerColor[0], outerColor[1], outerColor[2], alpha);
stroke(ringColor[0], ringColor[1], ringColor[2], alpha);
strokeWeight(mmToPixel(outerStroke));
circle(mmToPixel(centerX), mmToPixel(centerY), mmToPixel(outerRadius * 2));
// Draw inner working area (fabric)
fill(innerColor[0], innerColor[1], innerColor[2], alpha + 25);
stroke(innerColor[0] - 25, innerColor[1] - 25, innerColor[2] - 25, alpha);
strokeWeight(mmToPixel(innerStroke));
circle(mmToPixel(centerX), mmToPixel(centerY), mmToPixel(innerRadius * 2));
// Add center marks for alignment
if (showCenterMarks) {
stroke(centerMarkColor[0], centerMarkColor[1], centerMarkColor[2], alpha - 50);
strokeWeight(mmToPixel(centerMarkWeight));
// Horizontal center mark
line(
mmToPixel(centerX - 2), mmToPixel(centerY),
mmToPixel(centerX + 2), mmToPixel(centerY)
);
// Vertical center mark
line(
mmToPixel(centerX), mmToPixel(centerY - 2),
mmToPixel(centerX), mmToPixel(centerY + 2)
);
}
pop();
}
/**
* Draw hoop based on type (automatically chooses manual or machine hoop)
* @param {number} x - Center X position in mm
* @param {number} y - Center Y position in mm
* @param {Object} hoopSize - Hoop object with dimensions and type info
* @param {Object} options - Hoop styling options
*/
function drawHoop(x, y, hoopSize, options = {}) {
if (hoopSize.type === "manual" || hoopSize.shape === "round") {
drawManualHoop(x, y, hoopSize, options);
} else {
drawMachineHoop(x, y, hoopSize, options);
}
}
/**
* Draw corner marks for rectangular reference frames
* @param {number} x - Top-left X position in mm
* @param {number} y - Top-left Y position in mm
* @param {number} w - Width in mm
* @param {number} h - Height in mm
* @param {Object} options - Corner mark styling options
*/
function drawCornerMarks(x, y, w, h, options = {}) {
const {
markSize = 5,
color = [102, 102, 102],
weight = 0.2,
alpha = 128
} = options;
push();
stroke(color[0], color[1], color[2], alpha);
strokeWeight(mmToPixel(weight));
// Top-left corner
line(mmToPixel(x), mmToPixel(y), mmToPixel(x + markSize), mmToPixel(y));
line(mmToPixel(x), mmToPixel(y), mmToPixel(x), mmToPixel(y + markSize));
// Top-right corner
line(mmToPixel(x + w), mmToPixel(y), mmToPixel(x + w - markSize), mmToPixel(y));
line(mmToPixel(x + w), mmToPixel(y), mmToPixel(x + w), mmToPixel(y + markSize));
// Bottom-left corner
line(mmToPixel(x), mmToPixel(y + h), mmToPixel(x + markSize), mmToPixel(y + h));
line(mmToPixel(x), mmToPixel(y + h), mmToPixel(x), mmToPixel(y + h - markSize));
// Bottom-right corner
line(mmToPixel(x + w), mmToPixel(y + h), mmToPixel(x + w - markSize), mmToPixel(y + h));
line(mmToPixel(x + w), mmToPixel(y + h), mmToPixel(x + w), mmToPixel(y + h - markSize));
pop();
}
/**
* Draw paper boundary guides
* @param {string} paperSize - Paper size key (A4, A3, A2, A1)
* @param {Object} margins - Margin settings {top, right, bottom, left} in mm
* @param {Object} options - Paper guide styling options
*/
function drawPaperGuides(paperSize = "A4", margins = {}, options = {}) {
const {
top = 15,
right = 15,
bottom = 15,
left = 15
} = margins;
const {
boundaryColor = [128, 128, 128],
marginColor = [192, 192, 192],
weight = 0.3,
alpha = 100,
showMargins = true
} = options;
const paper = PAPER_SIZES[paperSize];
if (!paper) {
console.warn(`Invalid paper size: ${paperSize}`);
return;
}
push();
// Draw paper boundary
stroke(boundaryColor[0], boundaryColor[1], boundaryColor[2], alpha);
strokeWeight(mmToPixel(weight));
noFill();
rect(0, 0, mmToPixel(paper.width), mmToPixel(paper.height));
// Draw margin guides
if (showMargins) {
stroke(marginColor[0], marginColor[1], marginColor[2], alpha);
strokeWeight(mmToPixel(weight * 0.7));
rect(
mmToPixel(left),
mmToPixel(top),
mmToPixel(paper.width - left - right),
mmToPixel(paper.height - top - bottom)
);
}
pop();
}
/**
* Get hoop preset by name
* @param {string} presetName - Hoop preset name (4x4, 5x7, etc.)
* @returns {Object} Hoop size object {width, height} in mm
*/
function getHoopPreset(presetName) {
const preset = HOOP_PRESETS[presetName];
if (!preset) {
console.warn(`Invalid hoop preset: ${presetName}. Available presets:`, Object.keys(HOOP_PRESETS));
return HOOP_PRESETS["4x4"]; // Default fallback
}
return preset;
}
/**
* Get hoops by brand
* @param {string} brand - Brand name (bernina, brother)
* @returns {Array} Array of hoop objects with their keys
*/
function getHoopsByBrand(brand) {
return Object.entries(HOOP_PRESETS)
.filter(([key, hoop]) => hoop.brand === brand)
.map(([key, hoop]) => ({ key, ...hoop }));
}
/**
* Get hoops by type
* @param {string} type - Hoop type (standard, specialty, jumbo, commercial)
* @returns {Array} Array of hoop objects with their keys
*/
function getHoopsByType(type) {
return Object.entries(HOOP_PRESETS)
.filter(([key, hoop]) => hoop.type === type)
.map(([key, hoop]) => ({ key, ...hoop }));
}
/**
* Get hoops by size range
* @param {number} minWidth - Minimum width in mm
* @param {number} maxWidth - Maximum width in mm
* @param {number} minHeight - Minimum height in mm
* @param {number} maxHeight - Maximum height in mm
* @returns {Array} Array of hoop objects with their keys that fit the size criteria
*/
function getHoopsBySize(minWidth = 0, maxWidth = Infinity, minHeight = 0, maxHeight = Infinity) {
return Object.entries(HOOP_PRESETS)
.filter(([key, hoop]) =>
hoop.width >= minWidth && hoop.width <= maxWidth &&
hoop.height >= minHeight && hoop.height <= maxHeight
)
.map(([key, hoop]) => ({ key, ...hoop }));
}
/**
* Find the best hoop for a given design size
* @param {number} designWidth - Design width in mm
* @param {number} designHeight - Design height in mm
* @param {Object} options - Search options
* @param {string} options.brand - Preferred brand (optional)
* @param {string} options.type - Preferred type (optional)
* @param {number} options.margin - Extra margin around design in mm (default: 5)
* @returns {Object|null} Best matching hoop or null if no suitable hoop found
*/
function findBestHoop(designWidth, designHeight, options = {}) {
const { brand, type, margin = 5 } = options;
const requiredWidth = designWidth + margin * 2;
const requiredHeight = designHeight + margin * 2;
// Get candidate hoops
let candidates = Object.entries(HOOP_PRESETS)
.filter(([key, hoop]) =>
hoop.width >= requiredWidth && hoop.height >= requiredHeight
)
.map(([key, hoop]) => ({ key, ...hoop }));
// Filter by brand if specified
if (brand) {
candidates = candidates.filter(hoop => hoop.brand === brand);
}
// Filter by type if specified
if (type) {
candidates = candidates.filter(hoop => hoop.type === type);
}
if (candidates.length === 0) {
return null;
}
// Sort by total area (smallest suitable hoop first)
candidates.sort((a, b) => (a.width * a.height) - (b.width * b.height));
return candidates[0];
}
/**
* Get all available hoop brands
* @returns {Array} Array of unique brand names
*/
function getHoopBrands() {
const brands = new Set();
Object.values(HOOP_PRESETS).forEach(hoop => {
if (hoop.brand) brands.add(hoop.brand);
});
return Array.from(brands).sort();
}
/**
* Get all available hoop types
* @returns {Array} Array of unique hoop types
*/
function getHoopTypes() {
const types = new Set();
Object.values(HOOP_PRESETS).forEach(hoop => {
if (hoop.type) types.add(hoop.type);
});
return Array.from(types).sort();
}
/**
* Get paper size by name
* @param {string} paperName - Paper size name (A4, A3, etc.)
* @returns {Object} Paper size object {width, height} in mm
*/
function getPaperSize(paperName) {
const paper = PAPER_SIZES[paperName];
if (!paper) {
console.warn(`Invalid paper size: ${paperName}. Available sizes:`, Object.keys(PAPER_SIZES));
return PAPER_SIZES["A4"]; // Default fallback
}
return paper;
}
/**
* Draw a complete embroidery workspace with hoop, grid, and guides
* @param {Object} config - Workspace configuration
*/
function drawEmbroideryWorkspace(config = {}) {
const {
hoopPreset = "4x4",
hoopPosition = null, // Auto-center if null
gridSpacing = 10,
showGrid = true,
showHoopGuides = true,
showHoop = false,
showCornerMarks = true,
paperSize = "A4",
margins = { top: 15, right: 15, bottom: 15, left: 15 }
} = config;
// Get hoop and paper sizes
const hoop = getHoopPreset(hoopPreset);
const paper = getPaperSize(paperSize);
// Calculate hoop position (center by default)
let hoopX, hoopY;
if (hoopPosition) {
hoopX = hoopPosition.x;
hoopY = hoopPosition.y;
} else {
// Center hoop in the available area
const availableWidth = paper.width - margins.left - margins.right;
const availableHeight = paper.height - margins.top - margins.bottom;
hoopX = margins.left + availableWidth / 2;
hoopY = margins.top + availableHeight / 2;
}
// Draw components in order
if (showGrid) {
drawGrid(gridSpacing);
}
if (showHoop) {
drawHoop(hoopX, hoopY, hoop);
}
if (showHoopGuides) {
drawHoopGuides(hoopX, hoopY, hoop);
}
if (showCornerMarks) {
drawCornerMarks(margins.left, margins.top,
paper.width - margins.left - margins.right,
paper.height - margins.top - margins.bottom);
}
}
// p5.js SVG Writer for Embroidery Patterns
class SVGWriter {
constructor() {
this.data = [];
this.currentX = 0;
this.currentY = 0;
this.minX = Infinity;
this.maxX = -Infinity;
this.minY = Infinity;
this.maxY = -Infinity;
this.options = {
paperSize: "A4",
dpi: 300,
hoopSize: { width: 100, height: 100 },
margins: { top: 15, right: 15, bottom: 15, left: 15 },
showGuides: false,
showHoop: false,
lifeSize: true,
centerPattern: false, // New option to control pattern centering
threads: null, // null = export all threads, array = export only specified thread indices
stitchDots: true, // Option to show/hide stitch dots
};
}
// Use imported constants
static PAPER_SIZES = PAPER_SIZES;
static HOOP_PRESETS = HOOP_PRESETS;
setOptions(options = {}) {
this.options = { ...this.options, ...options };
}
addComment(comment) {
this.data.push(`<!-- ${comment} -->`);
}
move(x, y) {
if (x !== null && y !== null) {
this.currentX = x;
this.currentY = y;
this.minX = Math.min(this.minX, x);
this.maxX = Math.max(this.maxX, x);
this.minY = Math.min(this.minY, y);
this.maxY = Math.max(this.maxY, y);
}
}
generateSVG(stitchData, title) {
const paper = SVGWriter.PAPER_SIZES[this.options.paperSize];
if (!paper) {
throw new Error(`Invalid paper size: ${this.options.paperSize}`);
}
// Use mm as the base unit for SVG (1 SVG unit = 1mm)
const paperWidth = paper.width;
const paperHeight = paper.height;
const marginLeft = this.options.margins.left;
const marginTop = this.options.margins.top;
// Start SVG with mm units
this.data = [];
this.data.push('<?xml version="1.0" encoding="UTF-8"?>');
this.data.push(`<svg xmlns="http://www.w3.org/2000/svg"
width="${paperWidth}mm" height="${paperHeight}mm"
viewBox="0 0 ${paperWidth} ${paperHeight}">`);
// Add title and metadata
this.addComment(`TITLE: ${title}`);
this.addComment(`PAPER_SIZE: ${this.options.paperSize}`);
this.addComment(`COORDINATE_SYSTEM: 1 SVG unit = 1mm`);
this.addComment(`HOOP_SIZE: ${this.options.hoopSize.width}x${this.options.hoopSize.height}mm`);
// Add coordinate system transformation (just translation, no scaling)
this.data.push(`<g transform="translate(${marginLeft}, ${marginTop})">`);
// Draw embroidery hoop if enabled
if (this.options.showHoop) {
this.drawHoop();
}
// Draw guides if enabled
if (this.options.showGuides) {
this.drawGuides();
}
// Draw embroidery patterns
this.drawEmbroideryPatterns(stitchData);
// Close groups
this.data.push("</g>");
this.data.push("</svg>");
return this.data.join("\n");
}
drawGuides() {
const hoop = this.options.hoopSize;
const centerX = hoop.width / 2;
const centerY = hoop.height / 2;
const radius = Math.min(hoop.width, hoop.height) / 2;
// Draw circular hoop outline
this.data.push(`<circle cx="${centerX}" cy="${centerY}" r="${radius}"
fill="none" stroke="#666666" stroke-width="0.5" opacity="0.5"/>`);
// Draw center cross marks
this.data.push(`<line x1="${centerX}" y1="${centerY - radius}" x2="${centerX}" y2="${centerY + radius}"
stroke="#cccccc" stroke-width="0.2" opacity="0.5"/>`);
this.data.push(`<line x1="${centerX - radius}" y1="${centerY}" x2="${centerX + radius}" y2="${centerY}"
stroke="#cccccc" stroke-width="0.2" opacity="0.5"/>`);
// Draw punch needle points around the circle
const numPoints = 12; // Number of punch needle points
for (let i = 0; i < numPoints; i++) {
const angle = (i * 2 * Math.PI) / numPoints;
const x = centerX + radius * Math.cos(angle);
const y = centerY + radius * Math.sin(angle);
// Draw punch needle point (small circle)
this.data.push(`<circle cx="${x}" cy="${y}" r="1"
fill="#666666" stroke="none" opacity="0.8"/>`);
// Draw small line extending outward from the point
const outerRadius = radius + 3;
const outerX = centerX + outerRadius * Math.cos(angle);
const outerY = centerY + outerRadius * Math.sin(angle);
this.data.push(`<line x1="${x}" y1="${y}" x2="${outerX}" y2="${outerY}"
stroke="#666666" stroke-width="0.3" opacity="0.6"/>`);
}
// Draw corner marks for rectangular reference
const markSize = 5;
this.data.push(`<line x1="0" y1="0" x2="${markSize}" y2="0" stroke="#666666" stroke-width="0.2"/>`);
this.data.push(`<line x1="0" y1="0" x2="0" y2="${markSize}" stroke="#666666" stroke-width="0.2"/>`);
this.data.push(
`<line x1="${hoop.width}" y1="0" x2="${hoop.width - markSize}" y2="0" stroke="#666666" stroke-width="0.2"/>`,
);
this.data.push(
`<line x1="${hoop.width}" y1="0" x2="${hoop.width}" y2="${markSize}" stroke="#666666" stroke-width="0.2"/>`,
);
this.data.push(
`<line x1="0" y1="${hoop.height}" x2="${markSize}" y2="${hoop.height}" stroke="#666666" stroke-width="0.2"/>`,
);
this.data.push(
`<line x1="0" y1="${hoop.height}" x2="0" y2="${hoop.height - markSize}" stroke="#666666" stroke-width="0.2"/>`,
);
this.data.push(
`<line x1="${hoop.width}" y1="${hoop.height}" x2="${hoop.width - markSize}" y2="${hoop.height}" stroke="#666666" stroke-width="0.2"/>`,
);
this.data.push(
`<line x1="${hoop.width}" y1="${hoop.height}" x2="${hoop.width}" y2="${hoop.height - markSize}" stroke="#666666" stroke-width="0.2"/>`,
);
}
drawHoop() {
const hoop = this.options.hoopSize;
const centerX = hoop.width / 2;
const centerY = hoop.height / 2;
// Draw outer hoop ring (wood/plastic)
const outerRadius = Math.min(hoop.width, hoop.height) / 2;
const innerRadius = outerRadius - 3; // 3mm thick hoop ring
// Outer ring
this.data.push(`<circle cx="${centerX}" cy="${centerY}" r="${outerRadius}"
fill="#8B4513" stroke="#654321" stroke-width="0.5" opacity="0.8"/>`);
// Inner ring (working area)
this.data.push(`<circle cx="${centerX}" cy="${centerY}" r="${innerRadius}"
fill="#F5F5DC" stroke="#D2B48C" stroke-width="0.3" opacity="0.9"/>`);
// Add center marks for alignment
this.data.push(`<line x1="${centerX - 2}" y1="${centerY}" x2="${centerX + 2}" y2="${centerY}"
stroke="#999999" stroke-width="0.2" opacity="0.6"/>`);
this.data.push(`<line x1="${centerX}" y1="${centerY - 2}" x2="${centerX}" y2="${centerY + 2}"
stroke="#999999" stroke-width="0.2" opacity="0.6"/>`);
}
drawEmbroideryPatterns(stitchData) {
if (!stitchData || !stitchData.threads) {
this.addComment("No embroidery data to draw");
return;
}
// Filter threads if threads option is set
const threadsToDraw = this.options.threads
? stitchData.threads.filter((_, index) => this.options.threads.includes(index))
: stitchData.threads;
if (threadsToDraw.length === 0) {
this.addComment("No threads to draw (filtered by threads option)");
return;
}
// Calculate offset based on centerPattern option
let offsetX = 0;
let offsetY = 0;
if (this.options.centerPattern) {
// Get pattern bounds to center it in the hoop (using filtered threads)
const filteredStitchData = {
...stitchData,
threads: threadsToDraw
};
const bounds = this.getPatternBounds(filteredStitchData);
const hoop = this.options.hoopSize;
const hoopCenterX = hoop.width / 2;
const hoopCenterY = hoop.height / 2;
const patternCenterX = bounds.x + bounds.width / 2;
const patternCenterY = bounds.y + bounds.height / 2;
// Calculate offset to center pattern in hoop
offsetX = hoopCenterX - patternCenterX;
offsetY = hoopCenterY - patternCenterY;
this.addComment(`Pattern centered in hoop: offset(${offsetX.toFixed(2)}, ${offsetY.toFixed(2)})`);
} else {
this.addComment("Pattern exported at original coordinates");
}
// Draw filtered threads
for (let i = 0; i < threadsToDraw.length; i++) {
const thread = threadsToDraw[i];
// Get original thread index for color/identification
this.options.threads
? this.options.threads[i]
: i;
// Set thread color
const color = this.getThreadColor(thread.color);
for (const run of thread.runs) {
if (run.length < 2) continue;
// Create path for stitch run with offset
let pathData = `M ${run[0].x + offsetX} ${run[0].y + offsetY}`;
for (let j = 1; j < run.length; j++) {
pathData += ` L ${run[j].x + offsetX} ${run[j].y + offsetY}`;
}
this.data.push(
`<path d="${pathData}" fill="none" stroke="${color}" stroke-width="0.1" stroke-linecap="round"/>`,
);
// Conditionally draw stitch dots based on stitchDots option
if (this.options.stitchDots) {
for (const stitch of run) {
this.data.push(`<circle cx="${stitch.x + offsetX}" cy="${stitch.y + offsetY}" r="0.3"
fill="#ff0000" stroke="none" opacity="0.8"/>`);
}
}
}
}
}
getThreadColor(threadColor) {
if (threadColor && threadColor.r !== undefined && threadColor.g !== undefined && threadColor.b !== undefined) {
return `rgb(${threadColor.r}, ${threadColor.g}, ${threadColor.b})`;
}
return "#000000"; // Default black
}
saveSVG(stitchData, title, filename) {
try {
const svgContent = this.generateSVG(stitchData, title);
const blob = new Blob([svgContent], { type: "image/svg+xml" });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = filename || "embroidery-pattern.svg";
link.click();
setTimeout(() => {
URL.revokeObjectURL(link.href);
document.body.removeChild(link);
}, 100);
console.log(`🪡 p5.embroider says: SVG exported successfully: ${filename}`);
} catch (error) {
console.error("🪡 p5.embroider says: Error exporting SVG:", error);
throw error;
}
}
// Generate PNG from SVG using canvas
async generatePNG(stitchData, title, filename) {
try {
const svgContent = this.generateSVG(stitchData, title);
// Create canvas to convert SVG to PNG
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
// Set canvas size based on paper size and DPI
const paper = SVGWriter.PAPER_SIZES[this.options.paperSize];
const mmToPixels = this.options.dpi / 25.4; // Convert mm to pixels for raster output
canvas.width = paper.width * mmToPixels;
canvas.height = paper.height * mmToPixels;
// Create image from SVG
const svgBlob = new Blob([svgContent], { type: "image/svg+xml" });
const url = URL.createObjectURL(svgBlob);
const img = new Image();
return new Promise((resolve, reject) => {
img.onload = () => {
// Fill with white background
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Draw SVG image
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
canvas.toBlob((blob) => {
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = filename || "embroidery-pattern.png";
link.click();
setTimeout(() => {
URL.revokeObjectURL(link.href);
URL.revokeObjectURL(url);
document.body.removeChild(link);
}, 100);
console.log(`🪡 p5.embroider says: PNG exported successfully: ${filename}`);
resolve();
}, "image/png");
};
img.onerror = reject;
img.src = url;
});
} catch (error) {
console.error("🪡 p5.embroider says: Error exporting PNG:", error);
throw error;
}
}
// Get pattern bounds (respects thread filtering if threads option is set)
getPatternBounds(stitchData) {
if (!stitchData || !stitchData.threads) {
return { x: 0, y: 0, width: 0, height: 0 };
}
// Filter threads if threads option is set
const threadsToCheck = this.options.threads
? stitchData.threads.filter((_, index) => this.options.threads.includes(index))
: stitchData.threads;
if (threadsToCheck.length === 0) {
return { x: 0, y: 0, width: 0, height: 0 };
}
let minX = Infinity,
minY = Infinity;
let maxX = -Infinity,
maxY = -Infinity;
for (const thread of threadsToCheck) {
for (const run of thread.runs) {
for (const stitch of run) {
minX = Math.min(minX, stitch.x);
minY = Math.min(minY, stitch.y);
maxX = Math.max(maxX, stitch.x);
maxY = Math.max(maxY, stitch.y);
}
}
}
return {
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY,
};
}
// Draw outline path only (for clean cutting/plotting exports)
drawOutlinePath(outlinePoints) {
if (!outlinePoints || outlinePoints.length === 0) {
this.addComment("No outline data to draw");
return;
}
// Calculate offset based on centerPattern option
let offsetX = 0;
let offsetY = 0;
if (this.options.centerPattern) {
// Get outline bounds to center it in the hoop
const bounds = this.getOutlineBounds(outlinePoints);
const hoop = this.options.hoopSize;
const hoopCenterX = hoop.width / 2;
const hoopCenterY = hoop.height / 2;
const outlineCenterX = bounds.x + bounds.width / 2;
const outlineCenterY = bounds.y + bounds.height / 2;
// Calculate offset to center outline in hoop
offsetX = hoopCenterX - outlineCenterX;
offsetY = hoopCenterY - outlineCenterY;
this.addComment(`Outline centered in hoop: offset(${offsetX.toFixed(2)}, ${offsetY.toFixed(2)})`);
} else {
this.addComment("Outline exported at original coordinates");
}
// Create path data for outline with offset
let pathData = `M ${outlinePoints[0].x + offsetX} ${outlinePoints[0].y + offsetY}`;
for (let i = 1; i < outlinePoints.length; i++) {
pathData += ` L ${outlinePoints[i].x + offsetX} ${outlinePoints[i].y + offsetY}`;
}
pathData += ' Z'; // Close the path
this.data.push(
`<path d="${pathData}" fill="none" stroke="#000000" stroke-width="0.1" stroke-linecap="round" stroke-linejoin="round"/>`,
);
this.addComment(`Outline path with ${outlinePoints.length} points exported`);
}
// Generate SVG for outline only
generateOutlineSVG(outlinePoints, title) {
const paper = SVGWriter.PAPER_SIZES[this.options.paperSize];
if (!paper) {
throw new Error(`Invalid paper size: ${this.options.paperSize}`);
}
// Use mm as the base unit for SVG (1 SVG unit = 1mm)
const paperWidth = paper.width;
const paperHeight = paper.height;
const marginLeft = this.options.margins.left;
const marginTop = this.options.margins.top;
// Start SVG with mm units
this.data = [];
this.data.push('<?xml version="1.0" encoding="UTF-8"?>');
this.data.push(`<svg xmlns="http://www.w3.org/2000/svg"
width="${paperWidth}mm" height="${paperHeight}mm"
viewBox="0 0 ${paperWidth} ${paperHeight}">`);
// Add title and metadata
this.addComment(`TITLE: ${title} - Clean Outline Export`);
this.addComment(`PAPER_SIZE: ${this.options.paperSize}`);
this.addComment(`COORDINATE_SYSTEM: 1 SVG unit = 1mm`);
this.addComment(`HOOP_SIZE: ${this.options.hoopSize.width}x${this.options.hoopSize.height}mm`);
// Add coordinate system transformation (just translation, no scaling)
this.data.push(`<g transform="translate(${marginLeft}, ${marginTop})">`);
// Draw embroidery hoop if enabled
if (this.options.showHoop) {
this.drawHoop();
}
// Draw guides if enabled
if (this.options.showGuides) {
this.drawGuides();
}
// Draw outline path
this.drawOutlinePath(outlinePoints);
// Close groups
this.data.push("</g>");
this.data.push("</svg>");
return this.data.join("\n");
}
// Get outline bounds (similar to getPatternBounds but for outline points)
getOutlineBounds(outlinePoints) {
if (!outlinePoints || outlinePoints.length === 0) {
return { x: 0, y: 0, width: 0, height: 0 };
}
let minX = outlinePoints[0].x,
minY = outlinePoints[0].y;
let maxX = outlinePoints[0].x,
maxY = outlinePoints[0].y;
for (const point of outlinePoints) {
minX = Math.min(minX, point.x);
minY = Math.min(minY, point.y);
maxX = Math.max(maxX, point.x);
maxY = Math.max(maxY, point.y);
}
return {
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY,
};
}
// Validate options
validateOptions() {
const paper = SVGWriter.PAPER_SIZES[this.options.paperSize];
if (!paper) {
throw new Error(`Invalid paper size: ${this.options.paperSize}`);
}
if (this.options.dpi < 72 || this.options.dpi > 600) {
throw new Error(`DPI must be between 72 and 600, got: ${this.options.dpi}`);
}
if (this.options.hoopSize.width <= 0 || this.options.hoopSize.height <= 0) {
throw new Error(`Invalid hoop size: ${this.options.hoopSize.width}x${this.options.hoopSize.height}`);
}
// Check if hoop fits on paper
const hoopArea = this.options.hoopSize.width * this.options.hoopSize.height;
const paperArea = paper.width * paper.height;
const marginArea =
(this.options.margins.top + this.options.margins.bottom) *
(this.options.margins.left + this.options.margins.right);
if (hoopArea > paperArea - marginArea) {
console.warn(`🪡 p5.embroider says: Hoop size may be too large for selected paper size`);
}
}
}
// p5.js JSON Writer for Embroidery Patterns
class JSONWriter {
constructor() {
this.options = {
includeBounds: true,
includeMetadata: true,
precision: 2,
compactOutput: false,
};
}
setOptions(options = {}) {
this.options = { ...this.options, ...options };
}
generateJSON(stitchData, title = "Untitled Pattern") {
if (!stitchData || !stitchData.threads) {
throw new Error("Invalid stitch data: threads array is required");
}
const jsonData = {
format: "p5.embroider",
version: "1.0",
title: title,
timestamp: new Date().toISOString(),
};
// Add metadata if enabled
if (this.options.includeMetadata) {
jsonData.metadata = this.generateMetadata(stitchData);
}
// Add bounds if enabled
if (this.options.includeBounds) {
jsonData.bounds = this.calculateBounds(stitchData);
}
// Process threads
jsonData.threads = this.processThreads(stitchData.threads);
// Add statistics
jsonData.statistics = this.calculateStatistics(stitchData);
return this.options.compactOutput ? JSON.stringify(jsonData) : JSON.stringify(jsonData, null, 2);
}
generateMetadata(stitchData) {
return {
totalThreads: stitchData.threads ? stitchData.threads.length : 0,
totalStitches: this.getTotalStitchCount(stitchData),
totalRuns: this.getTotalRunCount(stitchData),
createdBy: "p5.embroider",
units: "pixels",
coordinateSystem: "cartesian",
};
}
processThreads(threads) {
return threads.map((thread, threadIndex) => {
const threadData = {
id: threadIndex,
color: this.processColor(thread.color),
weight: thread.weight || 0.2,
runs: [],
statistics: {
totalStitches: 0,
totalRuns: thread.runs ? thread.runs.length : 0,
totalDistance: 0,
},
};
if (thread.runs) {
threadData.runs = thread.runs.map((run, runIndex) => {
const runData = {
id: runIndex,
stitches: this.processStitches(run),
statistics: {
stitchCount: run.length,
distance: this.calculateRunDistance(run),
},
};
threadData.statistics.totalStitches += run.length;
threadData.statistics.totalDistance += runData.statistics.distance;
return runData;
});
}
return threadData;
});
}
processColor(color) {
if (!color) {
return { r: 0, g: 0, b: 0, hex: "#000000" };
}
if (typeof color === "string") {
return { hex: color };
}
if (color.r !== undefined && color.g !== undefined && color.b !== undefined) {
return {
r: Math.round(color.r),
g: Math.round(color.g),
b: Math.round(color.b),
hex: this.rgbToHex(color.r, color.g, color.b),
};
}
return { r: 0, g: 0, b: 0, hex: "#000000" };
}
processStitches(run) {
if (!Array.isArray(run)) {
return [];
}
return run.map((stitch, index) => ({
index: index,
x: this.roundToPrecision(stitch.x),
y: this.roundToPrecision(stitch.y),
type: stitch.type || "normal",
}));
}
calculateBounds(stitchData) {
if (!stitchData.threads || stitchData.threads.length === 0) {
return { minX: 0, minY: 0, maxX: 0, maxY: 0, width: 0, height: 0 };
}
let minX = Infinity,
minY = Infinity;
let maxX = -Infinity,
maxY = -Infinity;
for (const thread of stitchData.threads) {
if (thread.runs) {
for (const run of thread.runs) {
for (const stitch of run) {
minX = Math.min(minX, stitch.x);
minY = Math.min(minY, stitch.y);
maxX = Math.max(maxX, stitch.x);
maxY = Math.max(maxY, stitch.y);
}
}
}
}
return {
minX: this.roundToPrecision(minX),
minY: this.roundToPrecision(minY),
maxX: this.roundToPrecision(maxX),
maxY: this.roundToPrecision(maxY),
width: this.roundToPrecision(maxX - minX),
height: this.roundToPrecision(maxY - minY),
};
}
calculateStatistics(stitchData) {
const stats = {
totalThreads: 0,
totalRuns: 0,
totalStitches: 0,
totalDistance: 0,
averageStitchLength: 0,
colorPalette: [],
};
if (!stitchData.threads) {
return stats;
}
stats.totalThreads = stitchData.threads.length;
const colors = new Set();
for (const thread of stitchData.threads) {
if (thread.color) {
const colorHex = this.processColor(thread.color).hex;
colors.add(colorHex);
}
if (thread.runs) {
stats.totalRuns += thread.runs.length;
for (const run of thread.runs) {
stats.totalStitches += run.length;
stats.totalDistance += this.calculateRunDistance(run);
}
}
}
stats.colorPalette = Array.from(colors);
stats.averageStitchLength =
stats.totalStitches > 1
? this.roundToPrecision(stats.totalDistance / (stats.totalStitches - stats.totalRuns))
: 0;
stats.totalDistance = this.roundToPrecision(stats.totalDistance);
return stats;
}
calculateRunDistance(run) {
if (!Array.isArray(run) || run.length < 2) {
return 0;
}
let distance = 0;
for (let i = 1; i < run.length; i++) {
const dx = run[i].x - run[i - 1].x;
const dy = run[i].y - run[i - 1].y;
distance += Math.sqrt(dx * dx + dy * dy);
}
return distance;
}
getTotalStitchCount(stitchData) {
if (!stitchData.threads) return 0;
return stitchData.threads.reduce((total, thread) => {
if (!thread.runs) return total;
return total + thread.runs.reduce((threadTotal, run) => threadTotal + run.length, 0);
}, 0);
}
getTotalRunCount(stitchData) {
if (!stitchData.threads) return 0;
return stitchData.threads.reduce((total, thread) => {
return total + (thread.runs ? thread.runs.length : 0);
}, 0);
}
roundToPrecision(value) {
const factor = Math.pow(10, this.options.precision);
return Math.round(value * factor) / factor;
}
rgbToHex(r, g, b) {
const toHex = (c) => {
const hex = Math.round(Math.max(0, Math.min(255, c))).toString(16);
return hex.length === 1 ? "0" + hex : hex;
};
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
saveJSON(stitchData, title, filename) {
try {
const jsonContent = this.generateJSON(stitchData, title);
const blob = new Blob([jsonContent], { type: "application/json" });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = filename || "embroidery-pattern.json";
document.body.appendChild(link);
link.click();
setTimeout(() => {
URL.revokeObjectURL(link.href);
document.body.removeChild(link);
}, 100);
console.log(`🪡 p5.embroider says: JSON exported successfully: ${filename}`);
return jsonContent;
} catch (error) {
console.error("🪡 p5.embroider says: Error exporting JSON:", error);
throw error;
}
}
// Parse JSON back to stitch data format
parseJSON(jsonString) {
try {
const data = JSON.parse(jsonString);
if (data.format !== "p5.embroider") {
console.warn("🪡 p5.embroider says: JSON format may not be compatible");
}
const stitchData = {
threads: [],
};
if (data.threads && Array.isArray(data.threads)) {
stitchData.threads = data.threads.map((thread) => ({
color: thread.color,
weight: thread.weight || 0.2,
runs: thread.runs
? thread.runs.map((run) =>
run.stitches
? run.stitches.map((stitch) => ({
x: stitch.x,
y: stitch.y,
type: stitch.type,
}))
: [],
)
: [],
}));
}
return stitchData;
} catch (error) {
console.error("🪡 p5.embroider says: Error parsing JSON:", error);
throw error;
}
}
// Validate JSON structure
validateJSON(jsonData) {
const errors = [];
if (typeof jsonData !== "object") {
errors.push("Root must be an object");
return errors;
}
if (!jsonData.threads || !Array.isArray(jsonData.threads)) {
errors.push("Missing or invalid threads array");
}
if (jsonData.threads) {
jsonData.threads.forEach((thread, threadIndex) => {
if (!thread.runs || !Array.isArray(thread.runs)) {
errors.push(`Thread ${threadIndex}: Missing or invalid runs array`);
}
if (thread.runs) {
thread.runs.forEach((run, runIndex) => {
if (!run.stitches || !Array.isArray(run.stitches)) {
errors.push(`Thread ${threadIndex}, Run ${runIndex}: Missing or invalid stitches array`);
}
if (run.stitches) {
run.stitches.forEach((stitch, stitchIndex) => {
if (typeof stitch.x !== "number" || typeof stitch.y !== "number") {
errors.push(`Thread ${threadIndex}, Run ${runIndex}, Stitch ${stitchIndex}: Invalid coordinates`);
}
});
}
});
}
});
}
return errors;
}
}
/**
* p5.embroider Unit Conversion Utilities
* Functions for converting between different units (mm, pixels, inches, etc.)
*/
/**
* Converts millimeters to pixels.
* @method mmToPixel
* @param {Number} mm - Millimeters
* @param {Number} dpi - Dots per inch (default: 96)
* @return {Number} Pixels
* @example
*
* function setup() {
* let pixels = mmToPixel(10); // Convert 10mm to pixels
* if(_DEBUG) console.log(pixels);
* }
*
*/
function mmToPixel$1(mm, dpi = 96) {
return (mm / 25.4) * dpi;
}
/**
* Converts pixels to millimeters.
* @method pixelToMm
* @param {Number} pixels - Pixels
* @param {Number} dpi - Dots per inch (default: 96)
* @return {Number} Millimeters
* @example
*
* function setup() {
* let mm = pixelToMm(100); // Convert 100 pixels to mm
* if(_DEBUG) console.log(mm);
* }
*
*/
function pixelToMm(pixels, dpi = 96) {
return (pixels * 25.4) / dpi;
}
/**
* Alias for pixelToMm - converts pixels to millimeters.
* @method px2mm
* @param {Number} pixels - Pixels
* @param {Number} dpi - Dots per inch (default: 96)
* @return {Number} Millimeters
*/
function px2mm(pixels, dpi = 96) {
return pixelToMm(pixels, dpi);
}
/**
* Alias for mmToPixel - converts millimeters to pixels.
* @method mm2px
* @param {Number} mm - Millimeters
* @param {Number} dpi - Dots per inch (default: 96)
* @return {Number} Pixels
*/
function mm2px(mm, dpi = 96) {
return mmToPixel$1(mm, dpi);
}
/**
* p5.embroider Preview Viewport Utilities
* Provides scrollable and scalable preview viewport for embroidery design
* Call setupPreviewViewport() before beginRecord() to enable pan/zoom preview
*/
// Global preview state
let _previewState = {
enabled: false,
scale: 1,
panX: 0,
panY: 0,
minScale: 0.1,
maxScale: 10,
isDragging: false,
isDraggingSlider: false,
lastMouseX: 0,
lastMouseY: 0,
centerX: 0,
centerY: 0
};
/**
* Initialize preview viewport system
* Call this at the start of draw() to enable pan/zoom functionality
* @param {Object} options - Viewport configuration
* @param {number} options.scale - Initial scale (default: 1)
* @param {number} options.panX - Initial pan X offset (default: 0)
* @param {number} options.panY - Initial pan Y offset (default: 0)
* @param {number} options.minScale - Minimum zoom scale (default: 0.1)
* @param {number} options.maxScale - Maximum zoom scale (default: 10)
* @param {number} options.centerX - Center X for scaling (default: width/2)
* @param {number} options.centerY - Center Y for scaling (default: height/2)
*/
function setupPreviewViewport(options = {}) {
// Update state but don't apply transformation yet
if (!_previewState.enabled) {
_previewState.enabled = true;
_previewState.scale = options.scale || _previewState.scale || 1;
_previewState.panX = options.panX !== undefined ? options.panX : _previewState.panX;
_previewState.panY = options.panY !== undefined ? options.panY : _previewState.panY;
_previewState.minScale = options.minScale || 0.1;
_previewState.maxScale = options.maxScale || 10;
_previewState.centerX = options.centerX || (typeof width !== 'undefined' ? width / 2 : 400);
_previewState.centerY = options.centerY || (typeof height !== 'undefined' ? height / 2 : 300);
}
// Apply the preview transformation
push();
translate(_previewState.centerX + _previewState.panX, _previewState.centerY + _previewState.panY);
scale(_previewState.scale);
translate(-_previewState.centerX, -_previewState.centerY);
}
/**
* End preview viewport transformation
* Call this at the end of draw() to restore normal coordinate system
*/
function endPreviewViewport() {
if (_previewState.enabled) {
pop();
}
}
/**
* Handle mouse wheel zooming (call from mouseWheel event)
* @param {Object} event - Mouse wheel event
*/
function handlePreviewZoom(event) {
if (!_previewState.enabled) return;
// Prevent default scrolling
if (event && event.preventDefault) {
event.preventDefault();
}
const zoomFactor = 1.1;
const delta = event ? event.delta : 0;
const oldScale = _previewState.scale;
// Calculate new scale
let newScale;
if (delta > 0) {
newScale = oldScale / zoomFactor;
} else {
newScale = oldScale * zoomFactor;
}
// Clamp scale to limits
newScale = Math.max(_previewState.minScale, Math.min(_previewState.maxScale, newScale));
if (newScale !== oldScale) {
// Get mouse position relative to canvas center
const mouseFromCenterX = mouseX - _previewState.centerX;
const mouseFromCenterY = mouseY - _previewState.centerY;
// Calculate world position under mouse cursor before zoom
const worldX = (mouseFromCenterX - _previewState.panX) / oldScale;
const worldY = (mouseFromCenterY - _previewState.panY) / oldScale;
// Update scale
_previewState.scale = newScale;
// Adjust pan so the same world point is still under the mouse
_previewState.panX = mouseFromCenterX - worldX * newScale;
_previewState.panY = mouseFromCenterY - worldY * newScale;
}
return false; // Prevent default
}
/**
* Handle mouse dragging for panning (call from mouseDragged event)
*/
function handlePreviewPan() {
if (!_previewState.enabled) return;
if (_previewState.isDragging) {
const dx = mouseX - _previewState.lastMouseX;
const dy = mouseY - _previewState.lastMouseY;
_previewState.panX += dx;
_previewState.panY += dy;
}
_previewState.lastMouseX = mouseX;
_previewState.lastMouseY = mouseY;
}
/**
* Start panning (call from mousePressed event)
* @param {boolean} condition - Optional condition to enable panning (e.g., specific key held)
*/
function startPreviewPan(condition = true) {
if (!_previewState.enabled || !condition) return;
_previewState.isDragging = true;
_previewState.lastMouseX = mouseX;
_previewState.lastMouseY = mouseY;
}
/**
* Stop panning (call from mouseReleased event)
*/
function stopPreviewPan() {
_previewState.isDragging = false;
}
/**
* Reset viewport to default position and scale
*/
function resetPreviewViewport() {
_previewState.scale = 1;
_previewState.panX = 0;
_previewState.panY = 0;
}
/**
* Fit content to viewport
* @param {Object} bounds - Content bounds {x, y, width, height} in mm
* @param {number} padding - Padding around content in pixels (default: 50)
*/
function fitPreviewToContent(bounds, padding = 50) {
if (!bounds) return;
const canvasWidth = typeof width !== 'undefined' ? width : 800;
const canvasHeight = typeof height !== 'undefined' ? height : 600;
// Convert bounds to pixels
const contentWidthPx = mmToPixel(bounds.width);
const contentHeightPx = mmToPixel(bounds.height);
// Calculate scale to fit content with padding
const scaleX = (canvasWidth - padding * 2) / contentWidthPx;
const scaleY = (canvasHeight - padding * 2) / contentHeightPx;
const fitScale = Math.min(scaleX, scaleY);
// Clamp to limits
_previewState.scale = Math.max(_previewState.minScale, Math.min(_previewState.maxScale, fitScale));
// Center the content
const contentCenterXPx = mmToPixel(bounds.x + bounds.width / 2);
const contentCenterYPx = mmToPixel(bounds.y + bounds.height / 2);
_previewState.panX = -contentCenterXPx * _previewState.scale + _previewState.centerX;
_previewState.panY = -contentCenterYPx * _previewState.scale + _previewState.centerY;
}
/**
* Get current preview state (for debugging or UI display)
*/
function getPreviewState() {
return { ..._previewState };
}
/**
* Convert screen coordinates to world coordinates
* @param {number} screenX - Screen X coordinate
* @param {number} screenY - Screen Y coordinate
* @returns {Object} World coordinates {x, y}
*/
function screenToWorld(screenX, screenY) {
if (!_previewState.enabled) {
return { x: screenX, y: screenY };
}
const worldX = (screenX - _previewState.centerX - _previewState.panX) / _previewState.scale + _previewState.centerX;
const worldY = (screenY - _previewState.centerY - _previewState.panY) / _previewState.scale + _previewState.centerY;
return { x: worldX, y: worldY };
}
/**
* Convert world coordinates to screen coordinates
* @param {number} worldX - World X coordinate
* @param {number} worldY - World Y coordinate
* @returns {Object} Screen coordinates {x, y}
*/
function worldToScreen(worldX, worldY) {
if (!_previewState.enabled) {
return { x: worldX, y: worldY };
}
const screenX = _previewState.centerX + (worldX - _previewState.centerX) * _previewState.scale + _previewState.panX;
const screenY = _previewState.centerY + (worldY - _previewState.centerY) * _previewState.scale + _previewState.panY;
return { x: screenX, y: screenY };
}
/**
* Draw preview controls - call AFTER endPreviewViewport()
* Styled like SVG input example with vertical slider and reset button
* @param {Object} options - UI options
*/
function drawPreviewControls(options = {}) {
const {
showSlider = true,
showResetButton = true
} = options;
if (!_previewState.enabled) return;
// Get UI layout (matching SVG input exactly)
const ui = getPreviewUIRects();
noStroke();
// Draw reset/recenter button (matching SVG input style)
if (showResetButton) {
fill(245);
rect(ui.recenter.x, ui.recenter.y, ui.recenter.w, ui.recenter.h, 4);
// Draw crosshair icon
fill(50);
const cx = ui.recenter.x + ui.recenter.w / 2;
const cy = ui.recenter.y + ui.recenter.h / 2;
rect(cx - 6, cy - 1, 12, 2, 1);
rect(cx - 1, cy - 6, 2, 12, 1);
}
// Draw vertical zoom slider (matching SVG input style)
if (showSlider) {
// Slider track
fill(235);
rect(ui.sliderTrack.x, ui.sliderTrack.y, ui.sliderTrack.w, ui.sliderTrack.h, 4);
// Calculate knob position based on scale (matching SVG input mapping)
const t = Math.max(0, Math.min(1, (_previewState.scale - _previewState.minScale) / (_previewState.maxScale - _previewState.minScale)));
const knobY = ui.sliderTrack.y + (1 - t) * (ui.sliderTrack.h - 14);
// Slider knob
fill(80);
rect(ui.sliderTrack.x - 4, knobY, ui.sliderTrack.w + 8, 14, 6);
}
return ui;
}
/**
* Get preview UI layout rectangles (matching SVG input exactly)
* @returns {Object} UI element rectangles
*/
function getPreviewUIRects() {
const canvasWidth = (typeof globalThis !== 'undefined' && globalThis.width) || 800;
const canvasHeight = (typeof globalThis !== 'undefined' && globalThis.height) || 600;
const margin = 40;
const sliderWidth = 10;
const knobHeight = 14;
const recenterSize = 24;
const recenterX = canvasWidth - margin - recenterSize;
const recenterY = margin;
const spacing = 8;
// Center slider under recenter button
const sliderX = recenterX + (recenterSize - sliderWidth) / 2;
const sliderY = recenterY + recenterSize + spacing;
const sliderHeight = Math.max(100, canvasHeight - sliderY - margin);
return {
sliderTrack: { x: sliderX, y: sliderY, w: sliderWidth, h: sliderHeight },
sliderKnob: { x: sliderX - 4, y: sliderY, w: sliderWidth + 8, h: knobHeight },
recenter: { x: recenterX, y: recenterY, w: recenterSize, h: recenterSize }
};
}
/**
* Check if point is inside rectangle
* @param {number} px - Point X
* @param {number} py - Point Y
* @param {Object} rect - Rectangle {x, y, w, h}
* @returns {boolean} True if point is inside rectangle
*/
function pointInRect(px, py, rect) {
return px >= rect.x && px <= rect.x + rect.w && py >= rect.y && py <= rect.y + rect.h;
}
/**
* Handle mouse press on preview controls
* Call this from mousePressed() after checking if mouse is over controls
* @param {number} mouseX - Mouse X coordinate
* @param {number} mouseY - Mouse Y coordinate
* @returns {boolean} True if controls handled the event
*/
function handlePreviewControlsPressed(mouseX, mouseY) {
if (!_previewState.enabled) return false;
const ui = getPreviewUIRects();
// Check recenter button
if (pointInRect(mouseX, mouseY, ui.recenter)) {
resetPreviewViewport();
return true;
}
// Check slider track
if (pointInRect(mouseX, mouseY, ui.sliderTrack)) {
updateScaleFromMouse(mouseY);
_previewState.isDraggingSlider = true;
return true;
}
return false;
}
/**
* Handle mouse drag on preview controls
* Call this from mouseDragged()
* @param {number} mouseY - Mouse Y coordinate
*/
function handlePreviewControlsDragged(mouseY) {
if (!_previewState.enabled || !_previewState.isDraggingSlider) return;
updateScaleFromMouse(mouseY);
}
/**
* Handle mouse release on preview controls
* Call this from mouseReleased()
*/
function handlePreviewControlsReleased() {
_previewState.isDraggingSlider = false;
}
/**
* Update scale from mouse Y position on slider
* @param {number} mouseY - Mouse Y coordinate
*/
function updateScaleFromMouse(mouseY) {
const ui = getPreviewUIRects();
const t = Math.max(0, Math.min(1, (mouseY - ui.sliderTrack.y) / (ui.sliderTrack.h - 14)));
const targetScale = _previewState.maxScale + t * (_previewState.minScale - _previewState.maxScale);
const oldScale = _previewState.scale;
const newScale = Math.max(_previewState.minScale, Math.min(_previewState.maxScale, targetScale));
if (newScale !== oldScale) {
// For slider zoom, keep the current view center stable
// This provides predictable behavior when using the slider
const scaleDelta = newScale / oldScale;
// Adjust pan to keep current view centered
_previewState.panX *= scaleDelta;
_previewState.panY *= scaleDelta;
_previewState.scale = newScale;
}
}
/**
* Embroidery Outline Utilities
* Functions for creating outlines around embroidery patterns
*/
// Note: getPathBounds and calculateOffsetCorner functions are defined at the bottom
// of this file as they are utilities needed by the outline functions
/**
* Creates and exports a clean outline from a specified thread index with the given offset.
* Uses embroideryOutlineFromPath internally to generate outline paths without stitch conversion.
* Exports clean paths without stitch dots for cutting/plotting applications.
*
* @param {number} threadIndex - Index of the thread to create outline from
* @param {number} offsetDistance - Distance in mm to offset the outline
* @param {string} filename - Output filename with extension (supports .png, .svg, .gcode, .dst)
* @param {string} [outlineType='convex'] - Type of outline ('convex', 'bounding', 'scale')
* @param {Object} embroideryState - Current embroidery state object
* @param {Object} [options={}] - Export options (paperSize, hoopSize, margins, dpi, centerPattern, etc.)
* @returns {Promise<boolean>} Promise that resolves to true if export was successful
*
* @example
* // Export clean SVG outline (no stitch dots) - perfect for cutting
* exportOutline(0, 5, "cut-outline.svg", "convex", getEmbroideryState());
*
* // Export bounding box outline as PNG for templates
* exportOutline(1, 10, "template.png", "bounding", getEmbroideryState());
*
* // Export scaled outline as G-code for CNC cutting
* exportOutline(0, 8, "cut-path.gcode", "scale", getEmbroideryState());
*
* // Export with custom options
* exportOutline(0, 5, "outline.svg", "convex", getEmbroideryState(), {
* paperSize: "A3",
* hoopSize: { width: 150, height: 150 },
* margins: { top: 20, right: 20, bottom: 20, left: 20 },
* centerPattern: true,
* dpi: 600
* });
*/
async function exportOutline(threadIndex, offsetDistance, filename, outlineType = "convex", embroideryState, options = {}) {
const {
_stitchData,
_DEBUG
} = embroideryState;
if (!_stitchData.threads || _stitchData.threads.length === 0) {
console.warn("🪡 p5.embroider says: No embroidery data found to create outline");
return false;
}
if (threadIndex < 0 || threadIndex >= _stitchData.threads.length) {
console.warn(`🪡 p5.embroider says: Invalid thread index ${threadIndex}. Available threads: 0-${_stitchData.threads.length - 1}`);
return false;
}
// Extract file extension to determine export format
const extension = filename.split('.').pop().toLowerCase();
const supportedFormats = ['png', 'svg', 'gcode', 'dst'];
if (!supportedFormats.includes(extension)) {
console.warn(`🪡 p5.embroider says: Unsupported format '${extension}'. Supported formats: ${supportedFormats.join(', ')}`);
return false;
}
if (_DEBUG) {
console.log(`Creating outline from thread ${threadIndex} with ${offsetDistance}mm offset`);
console.log(`Export format: ${extension}`);
console.log(`Outline type: ${outlineType}`);
}
// Collect all stitch points from the specified thread
const threadPoints = [];
const thread = _stitchData.threads[threadIndex];
if (thread.runs && Array.isArray(thread.runs)) {
for (const run of thread.runs) {
if (Array.isArray(run)) {
for (const stitch of run) {
if (stitch && typeof stitch.x === 'number' && typeof stitch.y === 'number') {
threadPoints.push({ x: stitch.x, y: stitch.y });
}
}
}
}
}
if (threadPoints.length === 0) {
console.warn(`🪡 p5.embroider says: No valid stitch points found in thread ${threadIndex}`);
return false;
}
// Use embroideryOutlineFromPath to get outline points without creating stitches
const outlinePoints = embroideryOutlineFromPath(
threadPoints,
offsetDistance,
null, // No thread index - we don't want to add to embroidery data
outlineType,
false, // Don't apply transform
0, // No corner radius for export
embroideryState
);
if (!outlinePoints || outlinePoints.length === 0) {
console.warn("🪡 p5.embroider says: Failed to create outline");
return false;
}
if (_DEBUG) {
console.log(`Generated outline with ${outlinePoints.length} points`);
}
// Export in the specified format
try {
switch (extension) {
case 'dst':
return await exportOutlinePathAsDST(outlinePoints, filename, embroideryState);
case 'gcode':
return await exportOutlinePathAsGCODE(outlinePoints, filename, embroideryState);
case 'svg':
return await exportOutlinePathAsSVG(outlinePoints, filename, embroideryState, options);
case 'png':
return await exportOutlinePathAsPNG(outlinePoints, filename, embroideryState, options);
default:
console.warn(`🪡 p5.embroider says: Unsupported export format: ${extension}`);
return false;
}
} catch (error) {
console.error(`🪡 p5.embroider says: Export failed:`, error);
return false;
}
}
/**
* Export outline path as DST format
* @private
*/
async function exportOutlinePathAsDST(outlinePoints, filename, embroideryState) {
// Create embroidery data with outline as simple path
const embroideryData = {
width: 200,
height: 200,
pixelsPerUnit: 1,
threads: [{
color: { r: 0, g: 0, b: 0 },
weight: 0.2,
runs: [outlinePoints] // Direct path points without stitch conversion
}]
};
if (typeof window !== 'undefined' && window.exportEmbroidery) {
window.exportEmbroidery(filename, embroideryData);
return true;
} else {
console.warn("🪡 p5.embroider says: DST export not available");
return false;
}
}
/**
* Export outline path as G-code format
* @private
*/
async function exportOutlinePathAsGCODE(outlinePoints, filename, embroideryState) {
// Create embroidery data with outline as simple path
const embroideryData = {
width: 200,
height: 200,
pixelsPerUnit: 1,
threads: [{
color: { r: 0, g: 0, b: 0 },
weight: 0.2,
runs: [outlinePoints] // Direct path points without stitch conversion
}]
};
if (typeof window !== 'undefined' && window.exportGcode) {
window.exportGcode(filename, embroideryData);
return true;
} else {
console.warn("🪡 p5.embroider says: G-code export not available");
return false;
}
}
/**
* Export outline path as SVG format (clean paths without stitch dots)
* Uses SVGWriter for proper coordinate system handling and professional output
* @private
*/
async function exportOutlinePathAsSVG(outlinePoints, filename, embroideryState, options = {}) {
try {
// Create SVGWriter instance with appropriate settings
const svgWriter = new SVGWriter();
// Merge user options with defaults
svgWriter.setOptions({
paperSize: options.paperSize || "A4",
hoopSize: options.hoopSize || { width: 100, height: 100 },
margins: options.margins || { top: 15, right: 15, bottom: 15, left: 15 },
showGuides: options.showGuides ?? false,
showHoop: options.showHoop ?? false,
centerPattern: options.centerPattern ?? true,
dpi: options.dpi || 300,
...options // Allow overriding any option
});
// Validate options
svgWriter.validateOptions();
// Generate SVG content using the professional SVGWriter
const title = filename.replace(/\.[^/.]+$/, "") || "Outline";
const svgContent = svgWriter.generateOutlineSVG(outlinePoints, title);
// Save the SVG file using consistent download pattern
const blob = new Blob([svgContent], { type: "image/svg+xml" });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = filename;
document.body.appendChild(link);
link.click();
setTimeout(() => {
URL.revokeObjectURL(link.href);
document.body.removeChild(link);
}, 100);
console.log(`🪡 p5.embroider says: Clean outline SVG exported successfully: ${filename}`);
return true;
} catch (error) {
console.error("🪡 p5.embroider says: SVG outline export failed:", error);
return false;
}
}
/**
* Export outline path as PNG format (clean paths without stitch dots)
* Uses SVGWriter for proper coordinate system handling and professional output
* @private
*/
async function exportOutlinePathAsPNG(outlinePoints, filename, embroideryState, options = {}) {
try {
// Create SVGWriter instance with appropriate settings
const svgWriter = new SVGWriter();
// Merge user options with defaults
svgWriter.setOptions({
paperSize: options.paperSize || "A4",
hoopSize: options.hoopSize || { width: 100, height: 100 },
margins: options.margins || { top: 15, right: 15, bottom: 15, left: 15 },
showGuides: options.showGuides ?? false,
showHoop: options.showHoop ?? false,
centerPattern: options.centerPattern ?? true,
dpi: options.dpi || 300,
...options // Allow overriding any option
});
// Validate options
svgWriter.validateOptions();
// Generate SVG content using SVGWriter
const title = filename.replace(/\.[^/.]+$/, "") || "Outline";
const svgContent = svgWriter.generateOutlineSVG(outlinePoints, title);
// Create canvas to convert SVG to PNG
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
// Set canvas size based on paper size and DPI
const paper = SVGWriter.PAPER_SIZES[svgWriter.options.paperSize];
const mmToPixels = svgWriter.options.dpi / 25.4; // Convert mm to pixels for raster output
canvas.width = paper.width * mmToPixels;
canvas.height = paper.height * mmToPixels;
// Create image from SVG
const svgBlob = new Blob([svgContent], { type: "image/svg+xml" });
const url = URL.createObjectURL(svgBlob);
const img = new Image();
return new Promise((resolve, reject) => {
img.onload = () => {
// Fill with white background
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Draw SVG image
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
canvas.toBlob((blob) => {
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = filename;
document.body.appendChild(link);
link.click();
setTimeout(() => {
URL.revokeObjectURL(link.href);
URL.revokeObjectURL(url);
document.body.removeChild(link);
}, 100);
console.log(`🪡 p5.embroider says: Clean outline PNG exported successfully: ${filename}`);
resolve(true);
}, "image/png");
};
img.onerror = reject;
img.src = url;
});
} catch (error) {
console.error("🪡 p5.embroider says: PNG outline export failed:", error);
return false;
}
}
/**
* Exports only the specified thread path as SVG without creating an outline.
* @deprecated Use exportSVG() with threads option instead
* @param {number} threadIndex - Index of the thread to export
* @param {string} filename - Output filename with .svg extension
* @param {Object} stitchData - Embroidery stitch data object
* @param {Object} [options={}] - Export options (paperSize, hoopSize, margins, dpi, centerPattern, stitchDots, etc.)
* @returns {Promise<boolean>} Promise that resolves to true if export was successful
* @example
* // Old way (deprecated):
* exportSVGFromPath(0, "thread0-path.svg", stitchData);
*
* // New way:
* exportSVG("thread0-path.svg", { threads: [0] });
*/
async function exportSVGFromPath(threadIndex, filename, stitchData, options = {}) {
// Validate stitchData
if (!stitchData || !stitchData.threads || stitchData.threads.length === 0) {
console.warn("🪡 p5.embroider says: No embroidery data found to export");
return false;
}
// Validate threadIndex
if (threadIndex < 0 || threadIndex >= stitchData.threads.length) {
console.warn(`🪡 p5.embroider says: Invalid thread index ${threadIndex}. Available threads: 0-${stitchData.threads.length - 1}`);
return false;
}
// Validate filename extension
const extension = filename.split('.').pop().toLowerCase();
if (extension !== 'svg') {
console.warn(`🪡 p5.embroider says: Invalid file extension '${extension}'. Expected '.svg'`);
return false;
}
// Get the specified thread
const thread = stitchData.threads[threadIndex];
if (!thread.runs || !Array.isArray(thread.runs) || thread.runs.length === 0) {
console.warn(`🪡 p5.embroider says: Thread ${threadIndex} has no stitch data to export`);
return false;
}
try {
// Use SVGWriter with thread filtering option
const svgWriter = new SVGWriter();
svgWriter.setOptions({
threads: [threadIndex], // Export only this thread
stitchDots: options.stitchDots ?? true, // Allow hiding dots
paperSize: options.paperSize || "A4",
hoopSize: options.hoopSize || { width: 100, height: 100 },
margins: options.margins || { top: 15, right: 15, bottom: 15, left: 15 },
showGuides: options.showGuides ?? false,
showHoop: options.showHoop ?? false,
centerPattern: options.centerPattern ?? true,
dpi: options.dpi || 300,
...options // Allow overriding any option
});
svgWriter.validateOptions();
// Generate and save SVG
const title = filename.replace(/\.[^/.]+$/, "") || `Thread ${threadIndex}`;
const svgContent = svgWriter.generateSVG(stitchData, title);
// Save file using consistent pattern
const blob = new Blob([svgContent], { type: "image/svg+xml" });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = filename;
document.body.appendChild(link);
link.click();
setTimeout(() => {
URL.revokeObjectURL(link.href);
document.body.removeChild(link);
}, 100);
console.log(`🪡 p5.embroider says: Thread ${threadIndex} path exported successfully: ${filename}`);
return true;
} catch (error) {
console.error(`🪡 p5.embroider says: SVG export failed:`, error);
return false;
}
}
/**
* Creates an outline around the embroidery at a specified offset distance.
* @param {number} offsetDistance - Distance in mm to offset the outline from the embroidery
* @param {number} [threadIndex] - Thread index to add the outline to (defaults to current stroke thread)
* @param {string} [outlineType='convex'] - Type of outline ('convex', 'bounding')
* @param {number} [cornerRadius=0] - Corner radius in mm for bounding box outlines (only applies to 'bounding' type)
* @param {Object} embroideryState - Current embroidery state object
* @returns {void}
*/
function embroideryOutline(offsetDistance, threadIndex, outlineType = "convex", cornerRadius = 0, embroideryState) {
const {
_recording,
_stitchData,
_strokeThreadIndex,
_DEBUG,
applyCurrentTransformToPoints,
convertVerticesToStitches,
_strokeSettings,
_drawMode,
drawStitches
} = embroideryState;
if (!_recording) {
console.warn("🪡 p5.embroider says: embroideryOutline() can only be called while recording");
return;
}
if (!_stitchData.threads || _stitchData.threads.length === 0) {
console.warn("🪡 p5.embroider says: No embroidery data found to create outline");
return;
}
// Use provided threadIndex or default to stroke thread
if (threadIndex === undefined) {
threadIndex = _strokeThreadIndex;
}
// Collect all stitch points from all threads
const allPoints = [];
for (const thread of _stitchData.threads) {
if (thread.runs && Array.isArray(thread.runs)) {
for (const run of thread.runs) {
if (Array.isArray(run)) {
for (const stitch of run) {
if (stitch && typeof stitch.x === 'number' && typeof stitch.y === 'number') {
allPoints.push({ x: stitch.x, y: stitch.y });
}
}
}
}
}
}
if (allPoints.length === 0) {
console.warn("🪡 p5.embroider says: No valid stitch points found to create outline");
return;
}
if (_DEBUG) {
console.log("Creating outline from", allPoints.length, "stitch points");
console.log("Offset distance:", offsetDistance, "mm");
console.log("Outline type:", outlineType);
}
let outlinePoints = [];
// Create outline based on type
switch (outlineType) {
case "bounding":
outlinePoints = createBoundingBoxOutline(allPoints, offsetDistance, cornerRadius);
break;
case "convex":
default:
outlinePoints = createConvexHullOutline(allPoints, offsetDistance);
break;
}
if (outlinePoints.length === 0) {
console.warn("🪡 p5.embroider says: Failed to create outline");
return;
}
// Apply current transformation to outline points
const transformedOutlinePoints = applyCurrentTransformToPoints(outlinePoints);
// Convert outline to stitches
const outlineStitches = convertVerticesToStitches(
transformedOutlinePoints.map((p) => ({ x: p.x, y: p.y, isVert: true })),
_strokeSettings,
);
if (outlineStitches.length > 0) {
// Ensure we have a valid thread
if (threadIndex >= _stitchData.threads.length) {
threadIndex = _strokeThreadIndex;
}
// Add outline stitches to the specified thread
_stitchData.threads[threadIndex].runs.push(outlineStitches);
// Draw outline if in visual modes
if (_drawMode === "stitch" || _drawMode === "realistic" || _drawMode === "p5") {
drawStitches(outlineStitches, threadIndex);
}
if (_DEBUG) {
console.log("Added outline with", outlineStitches.length, "stitches to thread", threadIndex);
}
}
}
/**
* Creates an outline around specified stitch data at a specified offset distance.
* @param {Array} stitchDataArray - Array of stitch data objects (each with x, y coordinates)
* @param {number} offsetDistance - Distance in mm to offset the outline from the path
* @param {number} [threadIndex] - Thread index to add the outline to (defaults to current stroke thread)
* @param {string} [outlineType='convex'] - Type of outline ('convex', 'bounding', 'scale')
* @param {boolean} [applyTransform=true] - Whether to apply current transformation to the outline
* @param {number} [cornerRadius=0] - Corner radius in mm for bounding box outlines (only applies to 'bounding' type)
* @param {Object} embroideryState - Current embroidery state object
* @returns {Array} Array of outline points {x, y}
*/
function embroideryOutlineFromPath(
stitchDataArray,
offsetDistance,
threadIndex,
outlineType = "convex",
applyTransform = true,
cornerRadius = 0,
embroideryState
) {
const {
_recording,
_strokeThreadIndex,
_DEBUG,
applyCurrentTransformToPoints,
convertVerticesToStitches,
_strokeSettings,
_stitchData,
_drawMode,
drawStitches
} = embroideryState;
if (!stitchDataArray || !Array.isArray(stitchDataArray)) {
console.warn("🪡 p5.embroider says: embroideryOutlineFromPath() requires a valid array of stitch data");
return [];
}
if (stitchDataArray.length === 0) {
console.warn("🪡 p5.embroider says: No stitch data provided to create outline");
return [];
}
// Use provided threadIndex or default to stroke thread
if (threadIndex === undefined) {
threadIndex = _strokeThreadIndex;
}
// Extract points from the provided stitch data
const allPoints = [];
for (const item of stitchDataArray) {
if (item && typeof item.x === 'number' && typeof item.y === 'number') {
allPoints.push({ x: item.x, y: item.y });
} else if (Array.isArray(item)) {
// Handle nested arrays (runs of stitches)
for (const stitch of item) {
if (stitch && typeof stitch.x === 'number' && typeof stitch.y === 'number') {
allPoints.push({ x: stitch.x, y: stitch.y });
}
}
}
}
if (allPoints.length === 0) {
console.warn("🪡 p5.embroider says: No valid stitch points found in provided data to create outline");
return [];
}
if (_DEBUG) {
console.log("Creating outline from", allPoints.length, "stitch points from provided data");
console.log("Offset distance:", offsetDistance, "mm");
console.log("Outline type:", outlineType);
}
let outlinePoints = [];
// Create outline based on type
switch (outlineType) {
case "bounding":
outlinePoints = createBoundingBoxOutline(allPoints, offsetDistance, cornerRadius);
break;
case "scale":
outlinePoints = createScaledOutline(allPoints, offsetDistance);
break;
case "convex":
default:
outlinePoints = createConvexHullOutline(allPoints, offsetDistance);
break;
}
if (outlinePoints.length === 0) {
console.warn("🪡 p5.embroider says: Failed to create outline from provided data");
return [];
}
// Apply current transformation to outline points if requested and recording
let finalOutlinePoints = outlinePoints;
if (applyTransform && _recording) {
finalOutlinePoints = applyCurrentTransformToPoints(outlinePoints);
}
// If recording and thread index provided, add to embroidery data
if (_recording && threadIndex != null) {
// Convert outline to stitches
const outlineStitches = convertVerticesToStitches(
finalOutlinePoints.map((p) => ({ x: p.x, y: p.y, isVert: true })),
_strokeSettings,
);
if (outlineStitches.length > 0) {
// Ensure we have a valid thread
if (threadIndex >= _stitchData.threads.length) {
threadIndex = _strokeThreadIndex;
}
// Add outline stitches to the specified thread
_stitchData.threads[threadIndex].runs.push(outlineStitches);
// Draw outline if in visual modes
if (_drawMode === "stitch" || _drawMode === "realistic") {
drawStitches(outlineStitches, threadIndex);
}
if (_DEBUG) {
console.log("Added outline with", outlineStitches.length, "stitches to thread", threadIndex);
}
}
}
return finalOutlinePoints;
}
/**
* Creates a convex hull outline around the given points.
* @param {Array<{x: number, y: number}>} points - Array of points
* @param {number} offsetDistance - Distance to offset the outline
* @returns {Array<{x: number, y: number}>} Array of outline points
*/
function createConvexHullOutline(points, offsetDistance) {
// Find convex hull of all points
const hullPoints = getConvexHull(points);
if (hullPoints.length < 3) {
console.warn("Insufficient points for convex hull, falling back to bounding box");
return createBoundingBoxOutline(points, offsetDistance);
}
// Reverse the hull points to ensure clockwise ordering for outward expansion
const reversedHull = [...hullPoints].reverse();
// Expand the hull outward by the offset distance
const expandedHull = expandPolygon(reversedHull, offsetDistance);
// Close the polygon
if (expandedHull.length > 0) {
expandedHull.push({ x: expandedHull[0].x, y: expandedHull[0].y });
}
return expandedHull;
}
/**
* Creates a bounding box outline around the given points.
* @param {Array<{x: number, y: number}>} points - Array of points
* @param {number} offsetDistance - Distance to offset the outline
* @param {number} [cornerRadius=0] - Corner radius in mm for rounded corners
* @returns {Array<{x: number, y: number}>} Array of outline points
*/
function createBoundingBoxOutline(points, offsetDistance, cornerRadius = 0) {
const bounds = getPathBounds(points);
// Expand bounds by offset distance
const expandedBounds = {
x: bounds.x - offsetDistance,
y: bounds.y - offsetDistance,
w: bounds.w + 2 * offsetDistance,
h: bounds.h + 2 * offsetDistance,
};
// If no corner radius, return simple rectangle
if (cornerRadius <= 0) {
return [
{ x: expandedBounds.x, y: expandedBounds.y },
{ x: expandedBounds.x + expandedBounds.w, y: expandedBounds.y },
{ x: expandedBounds.x + expandedBounds.w, y: expandedBounds.y + expandedBounds.h },
{ x: expandedBounds.x, y: expandedBounds.y + expandedBounds.h },
{ x: expandedBounds.x, y: expandedBounds.y }, // Close the rectangle
];
}
// Limit corner radius to half the smaller dimension
const maxRadius = Math.min(expandedBounds.w, expandedBounds.h) / 2;
const r = Math.min(cornerRadius, maxRadius);
// Create rounded rectangle points (clockwise)
const outlinePoints = [];
const segments = 8; // Number of segments per quarter circle
// Define corner centers
const corners = [
{ x: expandedBounds.x + r, y: expandedBounds.y + r }, // Top-left
{ x: expandedBounds.x + expandedBounds.w - r, y: expandedBounds.y + r }, // Top-right
{ x: expandedBounds.x + expandedBounds.w - r, y: expandedBounds.y + expandedBounds.h - r }, // Bottom-right
{ x: expandedBounds.x + r, y: expandedBounds.y + expandedBounds.h - r }, // Bottom-left
];
// Define start angles for each corner (clockwise from top-left)
const startAngles = [Math.PI, Math.PI * 1.5, 0, Math.PI * 0.5];
for (let cornerIndex = 0; cornerIndex < 4; cornerIndex++) {
const corner = corners[cornerIndex];
const startAngle = startAngles[cornerIndex];
// Add corner arc points
for (let i = 0; i <= segments; i++) {
const angle = startAngle + (i / segments) * (Math.PI / 2);
const x = corner.x + Math.cos(angle) * r;
const y = corner.y + Math.sin(angle) * r;
outlinePoints.push({ x, y });
}
}
// Close the path
if (outlinePoints.length > 0) {
outlinePoints.push({ x: outlinePoints[0].x, y: outlinePoints[0].y });
}
return outlinePoints;
}
/**
* Creates a scaled outline by scaling the original path outward from its centroid.
* @param {Array<{x: number, y: number}>} points - Array of points
* @param {number} offsetDistance - Distance to offset the outline
* @returns {Array<{x: number, y: number}>} Array of outline points
*/
function createScaledOutline(points, offsetDistance) {
if (points.length === 0) return [];
// Calculate the centroid of the path
let centroidX = 0,
centroidY = 0;
for (const point of points) {
centroidX += point.x;
centroidY += point.y;
}
centroidX /= points.length;
centroidY /= points.length;
// Calculate the average distance from centroid to points
let avgDistance = 0;
for (const point of points) {
const dx = point.x - centroidX;
const dy = point.y - centroidY;
avgDistance += Math.sqrt(dx * dx + dy * dy);
}
avgDistance /= points.length;
// Calculate scale factor to achieve the desired offset
// If avgDistance is 0 (all points at centroid), use a default scale
const scaleFactor = avgDistance > 0 ? (avgDistance + offsetDistance) / avgDistance : 1 + offsetDistance;
// Scale each point outward from the centroid
const scaledPoints = [];
for (const point of points) {
const dx = point.x - centroidX;
const dy = point.y - centroidY;
const scaledX = centroidX + dx * scaleFactor;
const scaledY = centroidY + dy * scaleFactor;
scaledPoints.push({ x: scaledX, y: scaledY });
}
// Close the path if it has more than 2 points
if (scaledPoints.length > 2 && scaledPoints.length > 0) {
const firstPoint = scaledPoints[0];
const lastPoint = scaledPoints[scaledPoints.length - 1];
// Only add closing point if it's not already closed
const distance = Math.sqrt(Math.pow(lastPoint.x - firstPoint.x, 2) + Math.pow(lastPoint.y - firstPoint.y, 2));
if (distance > 0.1) {
// Small threshold to avoid duplicate points
scaledPoints.push({ x: firstPoint.x, y: firstPoint.y });
}
}
return scaledPoints;
}
/**
* Computes the convex hull of a set of 2D points using Graham scan algorithm.
* @param {Array<{x: number, y: number}>} points - Array of points
* @returns {Array<{x: number, y: number}>} Array of convex hull points
*/
function getConvexHull(points) {
if (points.length < 3) return points;
// Remove duplicate points
const uniquePoints = [];
const seen = new Set();
for (const point of points) {
const key = `${Math.round(point.x * 1000)},${Math.round(point.y * 1000)}`;
if (!seen.has(key)) {
seen.add(key);
uniquePoints.push(point);
}
}
if (uniquePoints.length < 3) return uniquePoints;
// Find the bottom-most point (and left-most if tie)
let bottom = uniquePoints[0];
for (let i = 1; i < uniquePoints.length; i++) {
if (uniquePoints[i].y < bottom.y || (uniquePoints[i].y === bottom.y && uniquePoints[i].x < bottom.x)) {
bottom = uniquePoints[i];
}
}
// Sort points by polar angle with respect to bottom point
const sortedPoints = uniquePoints.filter((p) => p !== bottom);
sortedPoints.sort((a, b) => {
// Use p5.Vector for angle calculations
const vA = createVector(a.x - bottom.x, a.y - bottom.y);
const vB = createVector(b.x - bottom.x, b.y - bottom.y);
const angleA = vA.heading();
const angleB = vB.heading();
if (angleA !== angleB) {
return angleA - angleB;
}
// If angles are equal, sort by distance using p5.Vector
const distA = vA.magSq(); // magSq() is faster than mag() for comparisons
const distB = vB.magSq();
return distA - distB;
});
// Build convex hull using Graham scan
const hull = [bottom];
for (const point of sortedPoints) {
// Remove points that would create a clockwise turn
while (hull.length > 1 && crossProduct(hull[hull.length - 2], hull[hull.length - 1], point) <= 0) {
hull.pop();
}
hull.push(point);
}
return hull;
}
/**
* Computes the cross product to determine turn direction using p5.Vector.
* @param {Object} O - Origin point {x, y}
* @param {Object} A - Point A {x, y}
* @param {Object} B - Point B {x, y}
* @returns {number} Cross product z-component
*/
function crossProduct(O, A, B) {
const v1 = createVector(A.x - O.x, A.y - O.y);
const v2 = createVector(B.x - O.x, B.y - O.y);
// Use p5.Vector.cross(v1, v2).z to get the z-component of the cross product
return p5.Vector.cross(v1, v2).z;
}
/**
* Expands a polygon outward by a specified distance.
* @param {Array<{x: number, y: number}>} polygon - Array of polygon points
* @param {number} distance - Distance to expand the polygon
* @returns {Array<{x: number, y: number}>} Array of expanded polygon points
*/
function expandPolygon(polygon, distance) {
if (polygon.length < 3) return polygon;
const expandedPoints = [];
for (let i = 0; i < polygon.length; i++) {
const prev = polygon[(i - 1 + polygon.length) % polygon.length];
const curr = polygon[i];
const next = polygon[(i + 1) % polygon.length];
// Calculate the offset point - use true for isLeft to expand outward
// (works with clockwise-ordered polygons)
const offsetPoint = calculateOffsetCorner(prev, curr, next, distance);
expandedPoints.push(offsetPoint);
}
return expandedPoints;
}
/**
* Helper function to get path bounds (needs to be imported or defined)
* This is a placeholder - the actual implementation should be imported from the main file
*/
function getPathBounds(points) {
if (points.length === 0) return { x: 0, y: 0, w: 0, h: 0 };
let minX = points[0].x, maxX = points[0].x;
let minY = points[0].y, maxY = points[0].y;
for (const point of points) {
minX = Math.min(minX, point.x);
maxX = Math.max(maxX, point.x);
minY = Math.min(minY, point.y);
maxY = Math.max(maxY, point.y);
}
return {
x: minX,
y: minY,
w: maxX - minX,
h: maxY - minY
};
}
/**
* Helper function to calculate offset corner (needs to be imported or defined)
* This is a placeholder - the actual implementation should be imported from the main file
*/
function calculateOffsetCorner(p1, p2, p3, offset, isLeft) {
// Calculate direction vectors
const v1 = { x: p2.x - p1.x, y: p2.y - p1.y };
const v2 = { x: p3.x - p2.x, y: p3.y - p2.y };
// Normalize
const len1 = Math.sqrt(v1.x * v1.x + v1.y * v1.y);
const len2 = Math.sqrt(v2.x * v2.x + v2.y * v2.y);
// Handle very short segments (common in curved corners)
if (len1 < 0.1 || len2 < 0.1) {
// Use simple perpendicular offset for tiny segments
const avgVecX = (v1.x + v2.x) / 2;
const avgVecY = (v1.y + v2.y) / 2;
const avgLen = Math.sqrt(avgVecX * avgVecX + avgVecY * avgVecY);
if (avgLen > 0) {
const actualOffset = offset ;
const perpX = (-avgVecY / avgLen) * actualOffset;
const perpY = (avgVecX / avgLen) * actualOffset;
return { x: p2.x + perpX, y: p2.y + perpY };
} else {
return { x: p2.x, y: p2.y };
}
}
const n1 = { x: v1.x / len1, y: v1.y / len1 };
const n2 = { x: v2.x / len2, y: v2.y / len2 };
// Calculate perpendiculars (90-degree rotation)
const perp1 = { x: -n1.y, y: n1.x };
const perp2 = { x: -n2.y, y: n2.x };
// Apply offset direction
const actualOffset = offset ;
const offsetPerp1 = { x: perp1.x * actualOffset, y: perp1.y * actualOffset };
const offsetPerp2 = { x: perp2.x * actualOffset, y: perp2.y * actualOffset };
// Find intersection of offset lines
const line1Start = { x: p1.x + offsetPerp1.x, y: p1.y + offsetPerp1.y };
const line1End = { x: p2.x + offsetPerp1.x, y: p2.y + offsetPerp1.y };
const line2Start = { x: p2.x + offsetPerp2.x, y: p2.y + offsetPerp2.y };
const line2End = { x: p3.x + offsetPerp2.x, y: p3.y + offsetPerp2.y };
// Calculate intersection
const denom = (line1Start.x - line1End.x) * (line2Start.y - line2End.y) -
(line1Start.y - line1End.y) * (line2Start.x - line2End.x);
if (Math.abs(denom) < 1e-10) {
// Lines are parallel, use average
return {
x: p2.x + (offsetPerp1.x + offsetPerp2.x) / 2,
y: p2.y + (offsetPerp1.y + offsetPerp2.y) / 2
};
}
const t = ((line1Start.x - line2Start.x) * (line2Start.y - line2End.y) -
(line1Start.y - line2Start.y) * (line2Start.x - line2End.x)) / denom;
const intersectionX = line1Start.x + t * (line1End.x - line1Start.x);
const intersectionY = line1Start.y + t * (line1End.y - line1Start.y);
// Check if intersection is reasonable (not too far from original point)
const distanceFromOriginal = Math.sqrt(
Math.pow(intersectionX - p2.x, 2) + Math.pow(intersectionY - p2.y, 2)
);
const maxReasonableDistance = Math.abs(offset) * 10; // Allow up to 10x the offset distance
if (distanceFromOriginal > maxReasonableDistance) {
// Fall back to simple perpendicular offset
return {
x: p2.x + (offsetPerp1.x + offsetPerp2.x) / 2,
y: p2.y + (offsetPerp1.y + offsetPerp2.y) / 2
};
}
return { x: intersectionX, y: intersectionY };
}
let _DEBUG = false;
// Allow external control of debug mode
if (typeof window !== "undefined" && window._DEBUG !== undefined) {
_DEBUG = window._DEBUG;
}
// Expose debug control
function setDebugMode(enabled) {
_DEBUG = enabled;
if (typeof window !== "undefined") {
window._DEBUG = enabled;
}
}
(function (global) {
const p5embroidery = global.p5embroidery || {};
// Internal properties
let _p5Instance;
let _recording = false;
let _drawMode = "stitch"; // 'stitch', 'p5', 'realistic'
let _stitchData = {
width: 0,
height: 0,
threads: [],
pixelsPerUnit: 1,
stitchCount: 0,
};
// Vertex properties
let _shapeKind = null;
let _vertices = [];
let _contourVertices = [];
let _contours = [];
let _currentContour = [];
let _isContour = false;
let _nextVertexWidth = null; // Width for the next vertex (set by vertexWidth())
let _strokeThreadIndex = 0;
let _fillThreadIndex = 0;
// Transformation system
let _transformStack = [];
let _currentTransform = {
matrix: [1, 0, 0, 0, 1, 0, 0, 0, 1]};
// Embroidery settings
const _embroiderySettings = {
stitchLength: 3, // mm
stitchWidth: 0.2,
minStitchLength: 1, // mm
resampleNoise: 0, // 0-1 range
minimumPathLength: 0,
maximumJoinDistance: 0,
maximumStitchesPerSquareMm: 0,
jumpThreshold: 10, // mm
units: "mm",
};
// stroke mode constants
const STROKE_MODE = {
STRAIGHT: "straight",
ZIGZAG: "zigzag",
RAMP: "ramp",
SQUARE: "square",
LINES: "lines",
SASHIKO: "sashiko",
};
// Add fill mode constants
const FILL_MODE = {
TATAMI: "tatami",
SATIN: "satin",
SPIRAL: "spiral",
};
// Add stroke join constants
const STROKE_JOIN = {
ROUND: "round",
MITER: "miter",
BEVEL: "bevel",
};
let _doStroke = false; // Track if stroke is enabled
let _currentStrokeMode = STROKE_MODE.STRAIGHT;
let _currentStrokeJoin = STROKE_JOIN.ROUND; // Default to round joins
let _doFill = false; // Track if fill is enabled
let _currentFillMode = FILL_MODE.TATAMI;
let _fillSettings = {
stitchLength: 3, // mm
stitchWidth: 0.2,
minStitchLength: 0.5, // mm
resampleNoise: 0, // 0-1 range
angle: 0, // Angle in radians
rowSpacing: 0.8, // Space between rows in mm
tieDistance: 15, // Distance between tie-down stitches in mm
alternateAngle: false, // Whether to alternate angles between shapes
color: { r: 0, g: 0, b: 0 },
};
// Add a stroke settings object to match the other settings objects
let _strokeSettings = {
stitchLength: 3, // mm
stitchWidth: 0.2,
minStitchLength: 1, // mm
resampleNoise: 0, // 0-1 range
strokeWeight: 0, // Width of the embroidery line
strokeMode: STROKE_MODE.STRAIGHT,
strokeJoin: STROKE_JOIN.ROUND, // Add join setting
strokeEntry: "right", // "right","left","middle"
strokeExit: "right", // "right","left","middle"
};
/**
* Multiply two 3x3 matrices (column-major order)
* @private
*/
function multiplyMatrix(a, b) {
const result = new Array(9);
result[0] = a[0] * b[0] + a[3] * b[1] + a[6] * b[2];
result[1] = a[1] * b[0] + a[4] * b[1] + a[7] * b[2];
result[2] = a[2] * b[0] + a[5] * b[1] + a[8] * b[2];
result[3] = a[0] * b[3] + a[3] * b[4] + a[6] * b[5];
result[4] = a[1] * b[3] + a[4] * b[4] + a[7] * b[5];
result[5] = a[2] * b[3] + a[5] * b[4] + a[8] * b[5];
result[6] = a[0] * b[6] + a[3] * b[7] + a[6] * b[8];
result[7] = a[1] * b[6] + a[4] * b[7] + a[7] * b[8];
result[8] = a[2] * b[6] + a[5] * b[7] + a[8] * b[8];
return result;
}
/**
* Create a translation matrix
* @private
*/
function createTranslationMatrix(x, y) {
return [1, 0, 0, 0, 1, 0, x, y, 1];
}
/**
* Create a rotation matrix
* @private
*/
function createRotationMatrix(angle) {
const cos = Math.cos(angle);
const sin = Math.sin(angle);
return [cos, sin, 0, -sin, cos, 0, 0, 0, 1];
}
/**
* Create a scale matrix
* @private
*/
function createScaleMatrix(sx, sy = sx) {
return [sx, 0, 0, 0, sy, 0, 0, 0, 1];
}
/**
* Apply transformation matrix to a point
* @private
*/
function transformPoint(point, matrix) {
const x = point.x * matrix[0] + point.y * matrix[3] + matrix[6];
const y = point.x * matrix[1] + point.y * matrix[4] + matrix[7];
return { x, y };
}
/**
* Apply transformation matrix to an array of points
* @private
*/
function transformPoints(points, matrix) {
return points.map((point) => transformPoint(point, matrix));
}
/**
* Helper function to apply current transformation to coordinates if recording
* Only applies transformation in stitch/realistic modes, not in p5 mode
* @private
*/
function applyCurrentTransform(x, y) {
if (_recording && _drawMode !== "p5") {
return transformPoint({ x, y }, _currentTransform.matrix);
}
return { x, y };
}
/**
* Helper function to apply current transformation to an array of coordinates
* Only applies transformation in stitch/realistic modes, not in p5 mode
* @private
*/
function applyCurrentTransformToPoints(points) {
if (_recording && _drawMode !== "p5") {
return transformPoints(points, _currentTransform.matrix);
}
return points;
}
/**
* Sets the stroke mode for embroidery stitches.
* @method setStrokeMode
* @for p5
* @param {string} mode - The stroke mode to use ('zigzag', 'lines', or 'sashiko')
* @example
* function setup() {
* createCanvas(400, 400);
* beginRecord(this);
* setStrokeMode('zigzag');
* line(10, 10, 50, 50); // Will use zigzag stitch pattern
* }
*/
p5embroidery.setStrokeMode = function (mode) {
if (Object.values(STROKE_MODE).includes(mode)) {
_currentStrokeMode = mode;
_strokeSettings.strokeMode = mode;
} else {
console.warn(`Invalid stroke mode: ${mode}. Using default: ${_currentStrokeMode}`);
}
};
/**
* Sets the stroke join mode for embroidery stitches.
* @method setStrokeJoin
* @for p5
* @param {string} join - The stroke join mode to use ('round', 'miter', or 'bevel')
* @example
* function setup() {
* createCanvas(400, 400);
* beginRecord(this);
* setStrokeJoin('miter');
* beginShape();
* vertex(20, 20);
* vertex(50, 20);
* vertex(50, 50);
* endShape();
* }
*/
p5embroidery.setStrokeJoin = function (join) {
if (Object.values(STROKE_JOIN).includes(join)) {
_currentStrokeJoin = join;
_strokeSettings.strokeJoin = join;
} else {
console.warn(`Invalid stroke join: ${join}. Using default: ${_currentStrokeJoin}`);
}
};
/**
* Sets the fill mode for embroidery fills.
* @method setFillMode
* @for p5
* @param {string} mode - The fill mode to use ('tatami', 'satin', or 'spiral')
*/
p5embroidery.setFillMode = function (mode) {
if (Object.values(FILL_MODE).includes(mode)) {
_currentFillMode = mode;
} else {
console.warn(`Invalid fill mode: ${mode}. Using default: ${_currentFillMode}`);
}
};
/**
* Sets the stroke chirality for embroidery stitches.
* @method setStrokeEntryExit
* @for p5
* @param {string} entry - The entry direction to use ('right' or 'left')
* @param {string} exit - The exit direction to use ('right' or 'left')
*/
p5embroidery.setStrokeEntryExit = function (entry = "right", exit = "left") {
if (entry === "right" || entry === "left" || entry === "middle") {
_strokeSettings.strokeEntry = entry;
} else {
console.warn(`Invalid entry: ${entry}. Using default: ${_strokeSettings.strokeEntry}`);
}
if (exit === "right" || exit === "left" || exit === "middle") {
_strokeSettings.strokeExit = exit;
} else {
console.warn(`Invalid exit: ${exit}. Using default: ${_strokeSettings.strokeExit}`);
}
};
/**
* Sets the fill settings for embroidery.
* @method setFillSettings
* @for p5
* @param {Object} settings - Fill settings object
* @param {number} [settings.stitchLength] - Length of each stitch in mm
* @param {number} [settings.stitchWidth] - Width of each stitch in mm
* @param {number} [settings.minStitchLength] - Minimum stitch length in mm
* @param {number} [settings.resampleNoise] - Amount of random variation (0-1)
* @param {number} [settings.angle] - Fill angle in degrees
* @param {number} [settings.rowSpacing] - Space between rows in mm
* @param {number} [settings.tieDistance] - Distance between tie-down stitches in mm
* @param {boolean} [settings.alternateAngle] - Whether to alternate angles between shapes
*/
p5embroidery.setFillSettings = function (settings) {
if (settings.stitchLength !== undefined) {
_fillSettings.stitchLength = settings.stitchLength;
}
if (settings.stitchWidth !== undefined) {
_fillSettings.stitchWidth = settings.stitchWidth;
}
if (settings.minStitchLength !== undefined) {
_fillSettings.minStitchLength = settings.minStitchLength;
}
if (settings.resampleNoise !== undefined) {
_fillSettings.resampleNoise = settings.resampleNoise;
}
if (settings.angle !== undefined) {
_fillSettings.angle = (settings.angle * Math.PI) / 180; // Convert to radians
}
if (settings.rowSpacing !== undefined) {
_fillSettings.rowSpacing = settings.rowSpacing;
}
if (settings.tieDistance !== undefined) {
_fillSettings.tieDistance = settings.tieDistance;
}
if (settings.alternateAngle !== undefined) {
_fillSettings.alternateAngle = settings.alternateAngle;
}
};
/**
* Thread class for storing color and stitch data.
* @class Thread
* @private
*/
class Thread {
/**
* Creates a new Thread instance.
* @constructor
* @param {number} r - Red component (0-255)
* @param {number} g - Green component (0-255)
* @param {number} b - Blue component (0-255)
* @param {number} [weight=0.2] - Weight of the thread in mm
*/
constructor(r, g, b, weight = 0.2) {
this.color = { r, g, b };
this.runs = [];
this.weight = weight;
}
}
/**
* Begins recording embroidery data.
* @method beginRecord
* @for p5
* @param {p5} p5Instance - The p5.js sketch instance
* @example
* function setup() {
* createCanvas(400, 400);
* beginRecord(this);
* // Draw embroidery patterns here
* endRecord();
* }
*/
p5embroidery.beginRecord = function (p5Instance) {
if (!p5Instance) {
throw new Error("Invalid p5 instance provided to beginRecord().");
}
_p5Instance = p5Instance;
_stitchData.width = p5Instance.width;
_stitchData.height = p5Instance.height;
_stitchData.threads = [new Thread(0, 0, 0, 0.2)]; // Start with a default black thread
_recording = true;
overrideP5Functions();
};
/**
* Ends recording and prepares for export.
* @method endRecord
* @for p5
* @example
*
*
* function setup() {
* createCanvas(400, 400);
* beginRecord(this);
* // Draw embroidery patterns
* endRecord();
* }
*
*
*/
p5embroidery.endRecord = function () {
_recording = false;
restoreP5Functions();
//exportEmbroidery(format);
};
let _originalBeginShapeFunc;
function overrideBeginShapeFunction() {
_originalBeginShapeFunc = window.beginShape;
window.beginShape = function (kind) {
if (_recording) {
if (
kind === window.POINTS ||
kind === window.LINES ||
kind === window.TRIANGLES ||
kind === window.TRIANGLE_FAN ||
kind === window.TRIANGLE_STRIP ||
kind === window.QUADS ||
kind === window.QUAD_STRIP
) {
_shapeKind = kind;
} else {
_shapeKind = null;
}
_vertices = [];
_contourVertices = [];
_contours = [];
_currentContour = [];
if (_drawMode === "p5") {
_originalBeginShapeFunc.apply(this, arguments);
}
} else {
_originalBeginShapeFunc.apply(this, arguments);
}
};
}
let _originalEndShapeFunc;
function overrideEndShapeFunction() {
_originalEndShapeFunc = window.endShape;
window.endShape = function (mode, count = 1) {
if (count < 1) {
console.log("🪡 p5.embroider says: You can not have less than one instance");
count = 1;
}
if (_recording) {
if (_vertices.length === 0) {
console.log("🪡 p5.embroider says: No vertices to draw");
return this;
}
if (_DEBUG) {
console.log("endShape", _vertices, _vertices.length);
console.log("_doStroke", _doStroke);
console.log("_doFill", _doFill);
}
if (!_doStroke && !_doFill) {
console.log("🪡 p5.embroider says: _doStroke and _doFill are both false");
return this;
}
const closeShape = mode === window.CLOSE;
if (closeShape && !_isContour) {
_vertices.push(_vertices[0]);
}
if (_doFill) {
// Convert vertices to pathPoints format for the fill function
const mainPath = _vertices.map((v) => ({
x: v.x,
y: v.y,
}));
let fillStitches = [];
if (_contours.length > 0) {
// Fill with contours (currently only tatami supports contours)
if (_DEBUG) console.log("Filling shape with", _contours.length, "contours");
switch (_currentFillMode) {
case FILL_MODE.TATAMI:
fillStitches = createTatamiFillWithContours(mainPath, _contours, _fillSettings);
break;
case FILL_MODE.SATIN:
case FILL_MODE.SPIRAL:
// For now, satin and spiral don't support contours, use simple fill
console.warn(`${_currentFillMode} fill does not support contours yet, using simple fill`);
fillStitches = _currentFillMode === FILL_MODE.SATIN
? createSatinFillFromPath(mainPath, _fillSettings)
: createSpiralFillFromPath(mainPath, _fillSettings);
break;
default:
fillStitches = createTatamiFillWithContours(mainPath, _contours, _fillSettings);
}
} else {
// Simple fill without contours
switch (_currentFillMode) {
case FILL_MODE.TATAMI:
fillStitches = createTatamiFillFromPath(mainPath, _fillSettings);
break;
case FILL_MODE.SATIN:
fillStitches = createSatinFillFromPath(mainPath, _fillSettings);
break;
case FILL_MODE.SPIRAL:
fillStitches = createSpiralFillFromPath(mainPath, _fillSettings);
break;
default:
fillStitches = createTatamiFillFromPath(mainPath, _fillSettings);
}
}
if (fillStitches && fillStitches.length > 0) {
_stitchData.threads[_fillThreadIndex].runs.push(fillStitches);
// Draw fill stitches in visual modes
if (_drawMode === "stitch" || _drawMode === "realistic") {
drawStitches(fillStitches, _fillThreadIndex);
}
}
}
//convert vertices to embroidery stitches
const stitches = p5embroidery.convertVerticesToStitches(_vertices, _strokeSettings);
// Debug log
if (_DEBUG) {
console.log("endShape: Converted vertices to stitches:", {
vertices: _vertices.length,
stitches: stitches.length,
shapeKind: _shapeKind,
mode: _drawMode,
});
}
//add stitches to the embroidery data
_stitchData.threads[_strokeThreadIndex].runs.push(stitches);
if (_drawMode === "stitch" || _drawMode === "realistic") {
if (_DEBUG)
console.log("Drawing stitches:", {
count: stitches.length,
threadIndex: _strokeThreadIndex,
mode: _drawMode,
firstStitch: stitches.length > 0 ? stitches[0] : null,
lastStitch: stitches.length > 0 ? stitches[stitches.length - 1] : null,
});
drawStitches(stitches, _strokeThreadIndex);
} else if (_drawMode === "p5") {
_originalEndShapeFunc.call(_p5Instance, mode, count);
}
_isContour = false;
// Reset contour vertices for next shape
_contourVertices = [];
_contours = [];
_currentContour = [];
// If the shape is closed, the first element was added as last element.
// We must remove it again to prevent the list of vertices from growing
// over successive calls to endShape(CLOSE)
if (closeShape) {
_vertices.pop();
}
// After drawing both shapes
if (_DEBUG)
console.log(
"Thread runs:",
_stitchData.threads[_strokeThreadIndex].runs.map((run) => ({
length: run.length,
first: run.length > 0 ? { x: run[0].x, y: run[0].y } : null,
last: run.length > 0 ? { x: run[run.length - 1].x, y: run[run.length - 1].y } : null,
})),
);
} else {
_originalEndShapeFunc.apply(this, arguments);
}
return this;
};
}
let _originalVertexFunc;
function overrideVertexFunction() {
_originalVertexFunc = window.vertex;
window.vertex = function (x, y, z_or_moveTo, u, v) {
if (_recording) {
// Determine if third parameter is z (width) or moveTo
let width = null;
let moveTo = false;
if (typeof z_or_moveTo === 'number') {
// Third parameter is z-coordinate (width)
width = z_or_moveTo;
} else if (z_or_moveTo === true || z_or_moveTo === false) {
// Third parameter is moveTo boolean
moveTo = z_or_moveTo;
}
// If width not provided via z-coordinate, check if set by vertexWidth()
if (width === null && _nextVertexWidth !== null) {
width = _nextVertexWidth;
_nextVertexWidth = null; // Reset after use
}
// If still no width, use default strokeWeight
if (width === null) {
width = _strokeSettings.strokeWeight;
}
// Apply current transformation to the vertex coordinates
const transformedPoint = transformPoint({ x, y }, _currentTransform.matrix);
// Create a vertex object with named properties instead of an array
const vert = {
x: transformedPoint.x,
y: transformedPoint.y,
width: width,
u: u || 0,
v: v || 0,
isVert: true,
};
if (moveTo) {
vert.moveTo = moveTo;
}
if (_drawMode === "p5") {
_originalVertexFunc.call(_p5Instance, mmToPixel$1(x), mmToPixel$1(y), moveTo, u, v);
}
// Add to appropriate container based on contour state
if (_isContour) {
_currentContour.push({
x: transformedPoint.x,
y: transformedPoint.y,
width: width
});
if (_DEBUG) console.log("Added to contour (transformed):", { x: transformedPoint.x, y: transformedPoint.y, width: width });
} else {
_vertices.push(vert);
if (_DEBUG) console.log("Added to vertices (transformed):", vert);
}
} else {
let args = [mmToPixel$1(x), mmToPixel$1(y), z_or_moveTo, u, v];
_originalVertexFunc.apply(this, args);
}
};
}
/**
* Set width for the next vertex or create a vertex with width
* Can be called in three ways:
* 1. vertexWidth(w) - sets width for next vertex() call
* 2. vertexWidth(x, y, w) - creates a vertex at (x,y) with width w
* @param {number} arg1 - Either width (if only one arg) or x-coordinate (if three args)
* @param {number} arg2 - y-coordinate (only if three args)
* @param {number} arg3 - width (only if three args)
*/
function vertexWidth(arg1, arg2, arg3) {
if (arguments.length === 1) {
// vertexWidth(w) - set width for next vertex
_nextVertexWidth = arg1;
} else if (arguments.length === 3) {
// vertexWidth(x, y, w) - create vertex with width
window.vertex(arg1, arg2, arg3);
} else {
console.warn("vertexWidth() expects 1 or 3 arguments");
}
}
/**
* Overrides p5.js bezierVertex() function.
* @private
*/
let _originalBezierVertexFunc;
function overrideBezierVertexFunction() {
_originalBezierVertexFunc = window.bezierVertex;
window.bezierVertex = function (x2, y2, x3, y3, x4, y4) {
if (_recording) {
// Apply current transformation to control points
const cp1 = transformPoint({ x: x2, y: y2 }, _currentTransform.matrix);
const cp2 = transformPoint({ x: x3, y: y3 }, _currentTransform.matrix);
const endPoint = transformPoint({ x: x4, y: y4 }, _currentTransform.matrix);
// Get the last vertex as the starting point - check both main vertices and current contour
let lastVertex;
if (_isContour) {
if (_currentContour.length === 0) {
console.warn("bezierVertex() called without a previous vertex in contour");
return;
}
lastVertex = _currentContour[_currentContour.length - 1];
} else {
if (_vertices.length === 0) {
console.warn("bezierVertex() called without a previous vertex");
return;
}
lastVertex = _vertices[_vertices.length - 1];
}
const x1 = lastVertex.x;
const y1 = lastVertex.y;
const startWidth = lastVertex.width || _strokeSettings.strokeWeight;
// Determine end width
let endWidth = startWidth;
if (_nextVertexWidth !== null) {
endWidth = _nextVertexWidth;
_nextVertexWidth = null; // Reset after use
}
// Generate bezier curve points using transformed control points
const bezierPoints = generateBezierPoints(x1, y1, cp1.x, cp1.y, cp2.x, cp2.y, endPoint.x, endPoint.y);
// Add all points except the first one (which is the last vertex)
for (let i = 1; i < bezierPoints.length; i++) {
const point = bezierPoints[i];
const t = i / (bezierPoints.length - 1);
const interpolatedWidth = startWidth + (endWidth - startWidth) * t;
if (_isContour) {
_currentContour.push({
x: point.x,
y: point.y,
width: interpolatedWidth
});
} else {
const vert = {
x: point.x,
y: point.y,
width: interpolatedWidth,
u: 0,
v: 0,
isVert: true,
isBezier: true,
};
_vertices.push(vert);
}
if (_drawMode === "p5") {
if (i === 1) {
// Call bezierVertex with p5 for the first segment using transformed coordinates
_originalBezierVertexFunc.call(
_p5Instance,
mmToPixel$1(cp1.x),
mmToPixel$1(cp1.y),
mmToPixel$1(cp2.x),
mmToPixel$1(cp2.y),
mmToPixel$1(endPoint.x),
mmToPixel$1(endPoint.y),
);
}
}
}
if (_DEBUG) console.log("bezierVertex added points:", bezierPoints.length - 1);
} else {
let args = [mmToPixel$1(x2), mmToPixel$1(y2), mmToPixel$1(x3), mmToPixel$1(y3), mmToPixel$1(x4), mmToPixel$1(y4)];
_originalBezierVertexFunc.apply(this, args);
}
};
}
/**
* Overrides p5.js quadraticVertex() function.
* @private
*/
let _originalQuadraticVertexFunc;
function overrideQuadraticVertexFunction() {
_originalQuadraticVertexFunc = window.quadraticVertex;
window.quadraticVertex = function (cx, cy, x3, y3) {
if (_recording) {
// Apply current transformation to control points
const controlPoint = transformPoint({ x: cx, y: cy }, _currentTransform.matrix);
const endPoint = transformPoint({ x: x3, y: y3 }, _currentTransform.matrix);
// Get the last vertex as the starting point - check both main vertices and current contour
let lastVertex;
if (_isContour) {
if (_currentContour.length === 0) {
console.warn("quadraticVertex() called without a previous vertex in contour");
return;
}
lastVertex = _currentContour[_currentContour.length - 1];
} else {
if (_vertices.length === 0) {
console.warn("quadraticVertex() called without a previous vertex");
return;
}
lastVertex = _vertices[_vertices.length - 1];
}
const x1 = lastVertex.x;
const y1 = lastVertex.y;
const startWidth = lastVertex.width || _strokeSettings.strokeWeight;
// Determine end width
let endWidth = startWidth;
if (_nextVertexWidth !== null) {
endWidth = _nextVertexWidth;
_nextVertexWidth = null; // Reset after use
}
// Generate quadratic bezier curve points using transformed control points
const quadraticPoints = generateQuadraticPoints(x1, y1, controlPoint.x, controlPoint.y, endPoint.x, endPoint.y);
// Add all points except the first one (which is the last vertex)
for (let i = 1; i < quadraticPoints.length; i++) {
const point = quadraticPoints[i];
const t = i / (quadraticPoints.length - 1);
const interpolatedWidth = startWidth + (endWidth - startWidth) * t;
if (_isContour) {
_currentContour.push({
x: point.x,
y: point.y,
width: interpolatedWidth
});
} else {
const vert = {
x: point.x,
y: point.y,
width: interpolatedWidth,
u: 0,
v: 0,
isVert: true,
isQuadratic: true,
};
_vertices.push(vert);
}
if (_drawMode === "p5") {
if (i === 1) {
// Call quadraticVertex with p5 for the first segment using transformed coordinates
_originalQuadraticVertexFunc.call(
_p5Instance,
mmToPixel$1(controlPoint.x),
mmToPixel$1(controlPoint.y),
mmToPixel$1(endPoint.x),
mmToPixel$1(endPoint.y),
);
}
}
}
if (_DEBUG) console.log("quadraticVertex added points:", quadraticPoints.length - 1);
} else {
let args = [mmToPixel$1(cx), mmToPixel$1(cy), mmToPixel$1(x3), mmToPixel$1(y3)];
_originalQuadraticVertexFunc.apply(this, args);
}
};
}
/**
* Overrides p5.js curveVertex() function.
* @private
*/
let _originalCurveVertexFunc;
function overrideCurveVertexFunction() {
_originalCurveVertexFunc = window.curveVertex;
window.curveVertex = function (x, y) {
if (_recording) {
// Apply current transformation to the curve vertex
const transformedPoint = transformPoint({ x, y }, _currentTransform.matrix);
// Determine width for this curve vertex
let width = _strokeSettings.strokeWeight;
if (_nextVertexWidth !== null) {
width = _nextVertexWidth;
_nextVertexWidth = null; // Reset after use
}
// Add to contour vertices for curve calculation using transformed coordinates
_contourVertices.push({
x: transformedPoint.x,
y: transformedPoint.y,
width: width
});
// For curve vertices, we need at least 4 points to generate a curve segment
if (_contourVertices.length >= 4) {
const len = _contourVertices.length;
const p0 = _contourVertices[len - 4];
const p1 = _contourVertices[len - 3];
const p2 = _contourVertices[len - 2];
const p3 = _contourVertices[len - 1];
// Generate curve points for this segment
const curvePoints = generateCurvePoints(p0.x, p0.y, p1.x, p1.y, p2.x, p2.y, p3.x, p3.y);
// Interpolate width from p1 to p2 (the actual curve segment)
const startWidth = p1.width || _strokeSettings.strokeWeight;
const endWidth = p2.width || _strokeSettings.strokeWeight;
// If this is the first curve segment, add all points
// Otherwise, add all points except the first (to avoid duplication)
let startIdx;
if (_isContour) {
startIdx = _currentContour.length === 0 ? 0 : 1;
} else {
startIdx = _vertices.length === 0 ? 0 : 1;
}
for (let i = startIdx; i < curvePoints.length; i++) {
const point = curvePoints[i];
const t = i / (curvePoints.length - 1);
const interpolatedWidth = startWidth + (endWidth - startWidth) * t;
if (_isContour) {
_currentContour.push({
x: point.x,
y: point.y,
width: interpolatedWidth
});
} else {
const vert = {
x: point.x,
y: point.y,
width: interpolatedWidth,
u: 0,
v: 0,
isVert: true,
isCurve: true,
};
_vertices.push(vert);
}
}
}
if (_drawMode === "p5") {
_originalCurveVertexFunc.call(_p5Instance, mmToPixel$1(transformedPoint.x), mmToPixel$1(transformedPoint.y));
}
if (_DEBUG) console.log("curveVertex added, contour length:", _contourVertices.length);
} else {
_originalCurveVertexFunc.apply(this, arguments);
}
};
}
/**
* Generates points along a quadratic Bezier curve.
* @private
*/
function generateQuadraticPoints(x1, y1, cx, cy, x3, y3) {
const points = [];
const bezierDetail = _p5Instance._bezierDetail || 20;
for (let i = 0; i <= bezierDetail; i++) {
const t = i / bezierDetail;
const x = quadraticBezierPoint(x1, cx, x3, t);
const y = quadraticBezierPoint(y1, cy, y3, t);
points.push({ x, y });
}
return points;
}
/**
* Calculate a point on a quadratic Bezier curve.
* @private
*/
function quadraticBezierPoint(a, b, c, t) {
const mt = 1 - t;
const mt2 = mt * mt;
const t2 = t * t;
return a * mt2 + 2 * b * mt * t + c * t2;
}
/**
* Converts vertices to embroidery stitches.
* @method convertVerticesToStitches
* @private
* @param {Array} vertices - Array of vertex objects
* @param {Object} strokeSettings - Settings for the stroke
* @returns {Array} Array of stitch points
*/
p5embroidery.convertVerticesToStitches = function (vertices, strokeSettings) {
let stitches = [];
if (!vertices || vertices.length < 2) {
if (_DEBUG) console.log("convertVerticesToStitches: insufficient vertices", vertices);
return stitches;
}
// Extract x,y,width coordinates from vertex objects for compatibility with path functions
const pathPoints = vertices.map((v) => ({
x: v.x,
y: v.y,
width: v.width !== undefined ? v.width : strokeSettings.strokeWeight,
}));
// Check if any vertex has width specified (for variable width paths)
const hasVariableWidth = pathPoints.some(p => p.width !== undefined && p.width > 0);
const hasStrokeWeight = strokeSettings.strokeWeight > 0;
const hasWidth = hasStrokeWeight || hasVariableWidth;
if (_DEBUG) {
console.log("convertVerticesToStitches input:", {
vertexCount: vertices.length,
pathPoints: pathPoints,
strokeWeight: strokeSettings.strokeWeight,
strokeMode: strokeSettings.strokeMode,
strokeJoin: strokeSettings.strokeJoin,
hasVariableWidth: hasVariableWidth,
hasWidth: hasWidth,
});
}
// If we have a stroke weight (global or per-vertex) and multiple vertices, use join-aware stitching
if (hasWidth && vertices.length > 2) {
if (_DEBUG) console.log("Using convertPathToStitchesWithJoins");
return convertPathToStitchesWithJoins(pathPoints, strokeSettings);
}
// If we have a stroke weight, use the appropriate path-based function
else if (hasWidth) {
if (_DEBUG) console.log("Using stroke mode:", strokeSettings.strokeMode);
switch (strokeSettings.strokeMode) {
case STROKE_MODE.STRAIGHT:
return straightLineStitchFromPath(pathPoints, strokeSettings);
case STROKE_MODE.ZIGZAG:
return zigzagStitchFromPath(pathPoints, strokeSettings);
case STROKE_MODE.LINES:
return multiLineStitchFromPath(pathPoints, strokeSettings);
case STROKE_MODE.SASHIKO:
return sashikoStitchFromPath(pathPoints, strokeSettings);
default:
// For simple paths, use the convertPathToStitches function
return convertPathToStitches(pathPoints, strokeSettings);
}
} else {
if (_DEBUG) console.log("Using generic convertPathToStitches");
// For normal width lines, just use the generic path to stitches conversion
return convertPathToStitches(pathPoints, strokeSettings);
}
};
/**
* Overrides p5.js line() function to record embroidery stitches.
* @private
*/
let _originalLineFunc;
function overrideLineFunction() {
_originalLineFunc = window.line;
window.line = function (x1, y1, x2, y2) {
if (_recording) {
if (_doStroke) {
// Apply current transformation to coordinates
const p1 = applyCurrentTransform(x1, y1);
const p2 = applyCurrentTransform(x2, y2);
let stitches = convertLineToStitches(p1.x, p1.y, p2.x, p2.y, _strokeSettings);
_stitchData.threads[_strokeThreadIndex].runs.push(stitches);
if (_drawMode === "stitch" || _drawMode === "realistic") {
drawStitches(stitches, _strokeThreadIndex);
} else if (_drawMode === "p5") {
_originalStrokeWeightFunc.call(_p5Instance, mmToPixel$1(_strokeSettings.strokeWeight));
_originalLineFunc.call(_p5Instance, mmToPixel$1(x1), mmToPixel$1(y1), mmToPixel$1(x2), mmToPixel$1(y2));
}
}
} else {
_originalLineFunc.apply(this, arguments);
}
};
}
/**
* Overrides p5.js curve() function to record embroidery stitches.
* @private
*/
let _originalCurveFunc;
function overrideCurveFunction() {
_originalCurveFunc = window.curve;
window.curve = function (x1, y1, x2, y2, x3, y3, x4, y4) {
if (_recording) {
if (_doStroke) {
// Apply current transformation to control points
const p1 = applyCurrentTransform(x1, y1);
const p2 = applyCurrentTransform(x2, y2);
const p3 = applyCurrentTransform(x3, y3);
const p4 = applyCurrentTransform(x4, y4);
// Generate curve points using Catmull-Rom spline with transformed coordinates
const curvePoints = generateCurvePoints(p1.x, p1.y, p2.x, p2.y, p3.x, p3.y, p4.x, p4.y);
let stitches = p5embroidery.convertVerticesToStitches(
curvePoints.map((p) => ({ x: p.x, y: p.y, isVert: true })),
_strokeSettings,
);
_stitchData.threads[_strokeThreadIndex].runs.push(stitches);
if (_drawMode === "stitch" || _drawMode === "realistic") {
drawStitches(stitches, _strokeThreadIndex);
} else if (_drawMode === "p5") {
_originalStrokeWeightFunc.call(_p5Instance, mmToPixel$1(_strokeSettings.strokeWeight));
_originalCurveFunc.call(
_p5Instance,
mmToPixel$1(x1),
mmToPixel$1(y1),
mmToPixel$1(x2),
mmToPixel$1(y2),
mmToPixel$1(x3),
mmToPixel$1(y3),
mmToPixel$1(x4),
mmToPixel$1(y4),
);
}
}
} else {
_originalCurveFunc.apply(this, arguments);
}
};
}
/**
* Overrides p5.js bezier() function to record embroidery stitches.
* @private
*/
let _originalBezierFunc;
function overrideBezierFunction() {
_originalBezierFunc = window.bezier;
window.bezier = function (x1, y1, x2, y2, x3, y3, x4, y4) {
if (_recording) {
if (_doStroke) {
// Apply current transformation to control points
const p1 = applyCurrentTransform(x1, y1);
const p2 = applyCurrentTransform(x2, y2);
const p3 = applyCurrentTransform(x3, y3);
const p4 = applyCurrentTransform(x4, y4);
// Generate bezier curve points with transformed coordinates
const bezierPoints = generateBezierPoints(p1.x, p1.y, p2.x, p2.y, p3.x, p3.y, p4.x, p4.y);
let stitches = p5embroidery.convertVerticesToStitches(
bezierPoints.map((p) => ({ x: p.x, y: p.y, isVert: true })),
_strokeSettings,
);
_stitchData.threads[_strokeThreadIndex].runs.push(stitches);
if (_drawMode === "stitch" || _drawMode === "realistic") {
drawStitches(stitches, _strokeThreadIndex);
} else if (_drawMode === "p5") {
_originalStrokeWeightFunc.call(_p5Instance, mmToPixel$1(_strokeSettings.strokeWeight));
_originalBezierFunc.call(
_p5Instance,
mmToPixel$1(x1),
mmToPixel$1(y1),
mmToPixel$1(x2),
mmToPixel$1(y2),
mmToPixel$1(x3),
mmToPixel$1(y3),
mmToPixel$1(x4),
mmToPixel$1(y4),
);
}
}
} else {
_originalBezierFunc.apply(this, arguments);
}
};
}
/**
* Generates points along a Catmull-Rom curve.
* @private
*/
function generateCurvePoints(x1, y1, x2, y2, x3, y3, x4, y4) {
const points = [];
const curveDetail = _p5Instance._curveDetail || 20;
for (let i = 0; i <= curveDetail; i++) {
const t = i / curveDetail;
const x = curvePoint(x1, x2, x3, x4, t);
const y = curvePoint(y1, y2, y3, y4, t);
points.push({ x, y });
}
return points;
}
/**
* Generates points along a cubic Bezier curve.
* @private
*/
function generateBezierPoints(x1, y1, x2, y2, x3, y3, x4, y4) {
const points = [];
const bezierDetail = _p5Instance._bezierDetail || 20;
for (let i = 0; i <= bezierDetail; i++) {
const t = i / bezierDetail;
const x = bezierPoint(x1, x2, x3, x4, t);
const y = bezierPoint(y1, y2, y3, y4, t);
points.push({ x, y });
}
return points;
}
/**
* Calculate a point on a Catmull-Rom curve.
* @private
*/
function curvePoint(a, b, c, d, t) {
const t2 = t * t;
const t3 = t2 * t;
return 0.5 * (2 * b + (-a + c) * t + (2 * a - 5 * b + 4 * c - d) * t2 + (-a + 3 * b - 3 * c + d) * t3);
}
/**
* Calculate a point on a cubic Bezier curve.
* @private
*/
function bezierPoint(a, b, c, d, t) {
const t2 = t * t;
const t3 = t2 * t;
const mt = 1 - t;
const mt2 = mt * mt;
const mt3 = mt2 * mt;
return a * mt3 + 3 * b * mt2 * t + 3 * c * mt * t2 + d * t3;
}
/**
* Overrides p5.js stroke() function to select thread color.
* @private
*/
let _originalStrokeFunc;
function overrideStrokeFunction() {
_originalStrokeFunc = window.stroke;
window.stroke = function () {
if (_recording) {
// Get color values from arguments
let r, g, b;
if (arguments.length === 1) {
// Single value or string color
if (typeof arguments[0] === "string") {
// Parse color string (e.g., '#FF0000' or 'red')
const colorObj = _p5Instance.color(arguments[0]);
r = _p5Instance.red(colorObj);
g = _p5Instance.green(colorObj);
b = _p5Instance.blue(colorObj);
} else {
// Grayscale value
r = g = b = arguments[0];
}
} else if (arguments.length === 3) {
// RGB values
r = arguments[0];
g = arguments[1];
b = arguments[2];
} else {
// Default to black if invalid arguments
r = g = b = 0;
}
// Check if we already have a thread with this color
let threadIndex = -1;
for (let i = 0; i < _stitchData.threads.length; i++) {
const threadColor = _stitchData.threads[i].color;
if (threadColor.r === r && threadColor.g === g && threadColor.b === b) {
threadIndex = i;
break;
}
}
if (threadIndex === -1) {
// Create a new thread with this color
_stitchData.threads.push(new Thread(r, g, b));
threadIndex = _stitchData.threads.length - 1;
}
// If we're changing to a different thread and have existing stitches,
// add a thread trim command at the current position
// if (_strokeThreadIndex !== threadIndex && _stitchData.threads[_strokeThreadIndex] !== undefined) {
// trimThread();
// }
// Set the current thread index
_strokeThreadIndex = threadIndex;
_doStroke = true;
_originalStrokeFunc.apply(this, arguments);
} else {
_originalStrokeFunc.apply(this, arguments);
}
};
}
/**
* Overrides p5.js noStroke() function to disable embroidery strokes.
* @private
*/
let _originalNoStrokeFunc;
function overrideNoStrokeFunction() {
_originalNoStrokeFunc = window.noStroke;
window.noStroke = function () {
if (_recording) {
_doStroke = false;
}
_originalNoStrokeFunc.apply(this, arguments);
};
}
/**
* Overrides p5.js fill() function to handle embroidery fills.
* @private
*/
let _originalFillFunc;
function overrideFillFunction() {
_originalFillFunc = window.fill;
window.fill = function () {
if (_recording) {
// Get color values from arguments
let r, g, b;
if (arguments.length === 1) {
// Single value or string color
if (typeof arguments[0] === "string") {
// Parse color string (e.g., '#FF0000' or 'red')
const colorObj = _p5Instance.color(arguments[0]);
r = _p5Instance.red(colorObj);
g = _p5Instance.green(colorObj);
b = _p5Instance.blue(colorObj);
} else {
// Grayscale value
r = g = b = arguments[0];
}
} else if (arguments.length === 3) {
// RGB values
r = arguments[0];
g = arguments[1];
b = arguments[2];
} else {
// Default to black if invalid arguments
r = g = b = 0;
}
// Check if we already have a thread with this color
let threadIndex = -1;
for (let i = 0; i < _stitchData.threads.length; i++) {
const thread = _stitchData.threads[i];
if (thread.color.r === r && thread.color.g === g && thread.color.b === b) {
threadIndex = i;
break;
}
}
if (threadIndex === -1) {
// Create a new thread with this color
_stitchData.threads.push(new Thread(r, g, b));
threadIndex = _stitchData.threads.length - 1;
}
// Set the current thread index
_fillThreadIndex = threadIndex;
// Store the fill state and color
_doFill = true;
_fillSettings.color = { r, g, b };
_originalFillFunc.apply(this, arguments);
} else {
_originalFillFunc.apply(this, arguments);
}
};
}
/**
* Overrides p5.js noFill() function to disable embroidery fills.
* @private
*/
let _originalNoFillFunc;
function overrideNoFillFunction() {
_originalNoFillFunc = window.noFill;
window.noFill = function () {
if (_recording) {
_doFill = false;
_fillSettings.color = null;
}
_originalNoFillFunc.apply(this, arguments);
};
}
/**
* Overrides p5.js strokeWeight() function to record embroidery stitches.
* @private
*/
let _originalStrokeWeightFunc;
function overrideStrokeWeightFunction() {
_originalStrokeWeightFunc = window.strokeWeight;
window.strokeWeight = function (weight) {
if (_recording) {
// Set the stroke weight in the stroke settings
_strokeSettings.strokeWeight = weight;
//_embroiderySettings.stitchWidth = weight;
_originalStrokeWeightFunc.call(this, weight);
} else {
_originalStrokeWeightFunc.apply(this, arguments);
}
};
}
/**
* Overrides p5.js strokeJoin() function to set embroidery stroke join mode.
* @private
*/
let _originalStrokeJoinFunc;
function overrideStrokeJoinFunction() {
_originalStrokeJoinFunc = window.strokeJoin;
window.strokeJoin = function (join) {
if (_recording) {
// Map p5.js constants to our internal format
let mappedJoin;
if (join === window.ROUND || join === "round") {
mappedJoin = STROKE_JOIN.ROUND;
} else if (join === window.MITER || join === "miter") {
mappedJoin = STROKE_JOIN.MITER;
} else if (join === window.BEVEL || join === "bevel") {
mappedJoin = STROKE_JOIN.BEVEL;
} else {
console.warn(`Invalid stroke join: ${join}. Using default: ${_currentStrokeJoin}`);
mappedJoin = _currentStrokeJoin;
}
_currentStrokeJoin = mappedJoin;
_strokeSettings.strokeJoin = mappedJoin;
_originalStrokeJoinFunc.call(this, join);
} else {
_originalStrokeJoinFunc.apply(this, arguments);
}
};
}
/**
* Overrides p5.js push() function to save embroidery state.
* @private
*/
let _originalPushFunc;
function overridePushFunction() {
_originalPushFunc = window.push;
window.push = function () {
if (_recording) {
// Save current embroidery transformation state
_transformStack.push({
matrix: [..._currentTransform.matrix],
strokeSettings: { ..._strokeSettings },
fillSettings: { ..._fillSettings },
strokeThreadIndex: _strokeThreadIndex,
fillThreadIndex: _fillThreadIndex,
doStroke: _doStroke,
doFill: _doFill,
drawMode: _drawMode,
strokeMode: _currentStrokeMode,
fillMode: _currentFillMode,
});
if (_DEBUG) {
console.log("Embroidery push - saved state", {
stackSize: _transformStack.length,
matrix: _currentTransform.matrix,
});
}
}
// Always call original p5.js push for visual modes
_originalPushFunc.apply(this, arguments);
};
}
/**
* Overrides p5.js pop() function to restore embroidery state.
* @private
*/
let _originalPopFunc;
function overridePopFunction() {
_originalPopFunc = window.pop;
window.pop = function () {
if (_recording) {
if (_transformStack.length === 0) {
console.warn("🪡 p5.embroider says: pop() called without matching push()");
return;
}
// Restore embroidery transformation state
const state = _transformStack.pop();
_currentTransform.matrix = state.matrix;
_strokeSettings = state.strokeSettings;
_fillSettings = state.fillSettings;
_strokeThreadIndex = state.strokeThreadIndex;
_fillThreadIndex = state.fillThreadIndex;
_doStroke = state.doStroke;
_doFill = state.doFill;
_drawMode = state.drawMode;
_currentStrokeMode = state.strokeMode;
_currentFillMode = state.fillMode;
if (_DEBUG) {
console.log("Embroidery pop - restored state", {
stackSize: _transformStack.length,
matrix: _currentTransform.matrix,
});
}
}
// Always call original p5.js pop for visual modes
_originalPopFunc.apply(this, arguments);
};
}
/**
* Overrides p5.js translate() function to apply embroidery transformations.
* @private
*/
let _originalTranslateFunc;
function overrideTranslateFunction() {
_originalTranslateFunc = window.translate;
window.translate = function (x, y, z) {
if (_recording) {
// Apply translation to current transformation matrix
const translationMatrix = createTranslationMatrix(x, y || 0);
_currentTransform.matrix = multiplyMatrix(_currentTransform.matrix, translationMatrix);
if (_DEBUG) {
console.log("Embroidery translate", { x, y, matrix: _currentTransform.matrix });
}
if (_drawMode == "p5") {
_originalTranslateFunc.call(this, mmToPixel$1(x), mmToPixel$1(y));
//_originalTranslateFunc.call(this, x, y);
}
} else {
_originalTranslateFunc.apply(this, arguments);
}
};
}
/**
* Overrides p5.js rotate() function to apply embroidery transformations.
* @private
*/
let _originalRotateFunc;
function overrideRotateFunction() {
_originalRotateFunc = window.rotate;
window.rotate = function (angle, axis) {
if (_recording) {
// Convert angle to radians if needed (p5.js handles this internally)
const radians = _p5Instance._angleMode === _p5Instance.DEGREES ? angle * (Math.PI / 180) : angle;
// Apply rotation to current transformation matrix
const rotationMatrix = createRotationMatrix(radians);
_currentTransform.matrix = multiplyMatrix(_currentTransform.matrix, rotationMatrix);
if (_DEBUG) {
console.log("Embroidery rotate", { angle, radians, matrix: _currentTransform.matrix });
}
}
// Call original p5.js rotate for visual modes
if (_drawMode === "p5" || !_recording) {
_originalRotateFunc.apply(this, arguments);
}
};
}
/**
* Overrides p5.js scale() function to apply embroidery transformations.
* @private
*/
let _originalScaleFunc;
function overrideScaleFunction() {
_originalScaleFunc = window.scale;
window.scale = function (x, y, z) {
if (_recording) {
// Handle different parameter formats like p5.js
let sx = x,
sy = y;
if (x instanceof p5.Vector) {
sx = x.x;
sy = x.y;
} else if (Array.isArray(x)) {
sx = x[0];
sy = x[1] || x[0];
} else {
if (y === undefined) sy = sx;
}
// Apply scale to current transformation matrix
const scaleMatrix = createScaleMatrix(sx, sy);
_currentTransform.matrix = multiplyMatrix(_currentTransform.matrix, scaleMatrix);
if (_DEBUG) {
console.log("Embroidery scale", { sx, sy, matrix: _currentTransform.matrix });
}
}
// Call original p5.js scale for visual modes
if (_drawMode === "p5" || !_recording) {
_originalScaleFunc.apply(this, arguments);
}
};
}
/**
* Overrides p5.js ellipse() function to record embroidery stitches.
* @private
*/
let _originalEllipseFunc;
function overrideEllipseFunction() {
_originalEllipseFunc = window.ellipse;
window.ellipse = function (x, y, w, h) {
if (_recording) {
// Calculate radius values
let radiusX = w / 2;
let radiusY = h / 2;
// Generate path points for the ellipse
let pathPoints = [];
let numSteps = Math.max(Math.ceil((Math.PI * (radiusX + radiusY)) / _embroiderySettings.stitchLength), 12);
// Generate points along the ellipse, starting at 0 degrees (right side of ellipse)
for (let i = 0; i <= numSteps; i++) {
let angle = (i / numSteps) * Math.PI * 2;
let pointX = x + Math.cos(angle) * radiusX;
let pointY = y + Math.sin(angle) * radiusY;
// Store in mm (internal format)
pathPoints.push({
x: pointX,
y: pointY,
});
}
// Close the path by adding the first point again
pathPoints.push({
x: pathPoints[0].x,
y: pathPoints[0].y,
});
// Apply current transformation to all pathPoints
const transformedPathPoints = applyCurrentTransformToPoints(pathPoints);
// Record the stitches if we're recording
if (_recording) {
if (_doFill) {
// Convert vertices to pathPoints format for the fill function
let fillStitches = [];
switch (_currentFillMode) {
case FILL_MODE.TATAMI:
fillStitches = createTatamiFillFromPath(transformedPathPoints, _fillSettings);
break;
case FILL_MODE.SATIN:
fillStitches = createSatinFillFromPath(transformedPathPoints, _fillSettings);
break;
case FILL_MODE.SPIRAL:
fillStitches = createSpiralFillFromPath(transformedPathPoints, _fillSettings);
break;
default:
fillStitches = createTatamiFillFromPath(transformedPathPoints, _fillSettings);
}
if (fillStitches && fillStitches.length > 0) {
_stitchData.threads[_fillThreadIndex].runs.push(fillStitches);
// Draw fill stitches in visual modes
if (_drawMode === "stitch" || _drawMode === "realistic") {
drawStitches(fillStitches, _fillThreadIndex);
}
}
}
// Get the current position (in mm)
let currentX, currentY;
if (
_stitchData.threads[_strokeThreadIndex].runs.length === 0 ||
_stitchData.threads[_strokeThreadIndex].runs[_stitchData.threads[_strokeThreadIndex].runs.length - 1]
.length === 0
) {
// If there are no runs or the last run is empty, use the first point on the ellipse
// (at 0 degrees) as the starting point, not the center
currentX = transformedPathPoints[0].x;
currentY = transformedPathPoints[0].y;
} else {
// Otherwise, use the last stitch position (already in mm)
let lastRun =
_stitchData.threads[_strokeThreadIndex].runs[_stitchData.threads[_strokeThreadIndex].runs.length - 1];
let lastStitch = lastRun[lastRun.length - 1];
currentX = lastStitch.x;
currentY = lastStitch.y;
}
// Add a jump stitch to the first point of the ellipse if needed
if (
Math.sqrt(
Math.pow(transformedPathPoints[0].x - currentX, 2) + Math.pow(transformedPathPoints[0].y - currentY, 2),
) > _embroiderySettings.jumpThreshold
) {
_stitchData.threads[_strokeThreadIndex].runs.push([
{
x: currentX,
y: currentY,
command: "jump",
},
{
x: transformedPathPoints[0].x,
y: transformedPathPoints[0].y,
},
]);
}
// Convert path points to stitches based on current stroke mode
let stitches;
if (_strokeSettings.strokeWeight > 0) {
switch (_strokeSettings.strokeMode) {
case STROKE_MODE.ZIGZAG:
stitches = zigzagStitchFromPath(transformedPathPoints, _strokeSettings);
break;
case STROKE_MODE.LINES:
stitches = multiLineStitchFromPath(transformedPathPoints, _strokeSettings);
break;
case STROKE_MODE.SASHIKO:
stitches = sashikoStitchFromPath(transformedPathPoints, _strokeSettings);
break;
default:
stitches = straightLineStitchFromPath(transformedPathPoints, _strokeSettings);
}
} else {
// If no stroke weight specified, use straight line stitching
stitches = straightLineStitchFromPath(transformedPathPoints, _strokeSettings);
}
// Add the ellipse stitches
_stitchData.threads[_strokeThreadIndex].runs.push(stitches);
// Draw the stitches
if (_drawMode === "p5") {
_originalStrokeWeightFunc.call(this, mmToPixel$1(_strokeSettings.strokeWeight));
_originalEllipseFunc.call(this, mmToPixel$1(x), mmToPixel$1(y), mmToPixel$1(w), mmToPixel$1(h));
} else {
drawStitches(stitches, _strokeThreadIndex);
}
}
} else {
_originalEllipseFunc.apply(this, arguments);
}
};
}
/**
* Overrides p5.js circle() function to record embroidery stitches.
* @private
*/
let _originalCircleFunc;
function overrideCircleFunction() {
_originalCircleFunc = window.circle;
window.circle = function (x, y, r) {
if (_recording) {
window.ellipse.call(this, x, y, r, r);
} else {
_originalCircleFunc.apply(this, arguments);
}
};
}
/**
* Overrides p5.js point() function to record embroidery stitches.
* @private
*/
let _originalPointFunc;
function overridePointFunction() {
_originalPointFunc = window.point;
window.point = function (x, y) {
if (_recording) {
// Apply current transformation to coordinates
const p = applyCurrentTransform(x, y);
// For point, we just add a single stitch
let stitches = [
{
x: p.x,
y: p.y,
},
];
_stitchData.threads[_strokeThreadIndex].runs.push(stitches);
if (_drawMode === "stitch" || _drawMode === "realistic" || _drawMode === "p5") {
_p5Instance.push();
_originalStrokeFunc.call(_p5Instance, 255, 0, 0); // Red for stitch points
_originalStrokeWeightFunc.call(_p5Instance, 3);
_originalPointFunc.call(_p5Instance, mmToPixel$1(x), mmToPixel$1(y));
_p5Instance.pop();
}
} else {
_originalStrokeWeightFunc.call(this, mmToPixel$1(_strokeSettings.strokeWeight));
_originalPointFunc.apply(this, arguments);
}
};
}
/**
* Overrides p5.js rect() function to handle embroidery fills.
* @private
*/
let _originalRectFunc;
function overrideRectFunction() {
_originalRectFunc = window.rect;
window.rect = function (x, y, w, h, ...cornerRs) {
if (_recording) {
const rectMode = _p5Instance._rectMode ?? _p5Instance._renderer?._rectMode;
let x1, y1;
if (rectMode === _p5Instance.CENTER) {
x1 = x - w / 2;
y1 = y - h / 2;
} else if (rectMode === _p5Instance.CORNERS) {
// In CORNERS mode, w is x2 and h is y2. Re-calculate w and h to be width and height.
w = w - x;
h = h - y;
x1 = x;
y1 = y;
} else {
// CORNER mode (default)
x1 = x;
y1 = y;
}
const x2 = x1 + w;
const y2 = y1 + h;
let tl = 0,
tr = 0,
br = 0,
bl = 0;
if (cornerRs.length === 1 && cornerRs[0] !== undefined) {
tl = tr = br = bl = cornerRs[0];
} else if (cornerRs.length >= 4) {
tl = cornerRs[0] || 0;
tr = cornerRs[1] || 0;
br = cornerRs[2] || 0;
bl = cornerRs[3] || 0;
}
const halfW = Math.abs(w) / 2;
const halfH = Math.abs(h) / 2;
tl = Math.min(tl, halfW, halfH);
tr = Math.min(tr, halfW, halfH);
br = Math.min(br, halfW, halfH);
bl = Math.min(bl, halfW, halfH);
const pathPoints = [];
// Check if we have any corner radii
const hasCorners = tl > 0 || tr > 0 || br > 0 || bl > 0;
if (!hasCorners) {
// Simple rectangle - use 4 corners like triangle/quad for consistent handling
pathPoints.push({ x: x1, y: y1 });
pathPoints.push({ x: x2, y: y1 });
pathPoints.push({ x: x2, y: y2 });
pathPoints.push({ x: x1, y: y2 });
pathPoints.push({ x: x1, y: y1 }); // close
if (_DEBUG) {
console.log("Simple rectangle path:", pathPoints);
}
} else {
// Complex rectangle with corner radii - use fewer points for better zigzag handling
const arcDetail = Math.max(3, Math.min(8, Math.ceil(Math.max(tl, tr, br, bl) / 3))); // Limit arc detail
pathPoints.push({ x: x1 + tl, y: y1 });
pathPoints.push({ x: x2 - tr, y: y1 });
if (tr > 0) {
for (let i = 1; i <= arcDetail; i++) {
const angle = -Math.PI / 2 + (i / arcDetail) * (Math.PI / 2);
pathPoints.push({ x: x2 - tr + Math.cos(angle) * tr, y: y1 + tr + Math.sin(angle) * tr });
}
}
pathPoints.push({ x: x2, y: y1 + tr });
pathPoints.push({ x: x2, y: y2 - br });
if (br > 0) {
for (let i = 1; i <= arcDetail; i++) {
const angle = (i / arcDetail) * (Math.PI / 2);
pathPoints.push({ x: x2 - br + Math.cos(angle) * br, y: y2 - br + Math.sin(angle) * br });
}
}
pathPoints.push({ x: x2 - br, y: y2 });
pathPoints.push({ x: x1 + bl, y: y2 });
if (bl > 0) {
for (let i = 1; i <= arcDetail; i++) {
const angle = Math.PI / 2 + (i / arcDetail) * (Math.PI / 2);
pathPoints.push({ x: x1 + bl + Math.cos(angle) * bl, y: y2 - bl + Math.sin(angle) * bl });
}
}
pathPoints.push({ x: x1, y: y2 - bl });
pathPoints.push({ x: x1, y: y1 + tl });
if (tl > 0) {
for (let i = 1; i <= arcDetail; i++) {
const angle = Math.PI + (i / arcDetail) * (Math.PI / 2);
pathPoints.push({ x: x1 + tl + Math.cos(angle) * tl, y: y1 + tl + Math.sin(angle) * tl });
}
}
pathPoints.push({ x: x1 + tl, y: y1 });
if (_DEBUG) {
console.log("Complex rectangle path with corners:", pathPoints.length, "points, arcDetail:", arcDetail);
}
}
// Apply current transformation to all pathPoints
const transformedPathPoints = applyCurrentTransformToPoints(pathPoints);
if (_doFill) {
let fillStitches = [];
switch (_currentFillMode) {
case FILL_MODE.TATAMI:
fillStitches = createTatamiFillFromPath(transformedPathPoints, _fillSettings);
break;
case FILL_MODE.SATIN:
fillStitches = createSatinFillFromPath(transformedPathPoints, _fillSettings);
break;
case FILL_MODE.SPIRAL:
fillStitches = createSpiralFillFromPath(transformedPathPoints, _fillSettings);
break;
default:
fillStitches = createTatamiFillFromPath(transformedPathPoints, _fillSettings);
}
if (fillStitches && fillStitches.length > 0) {
// Add the stitches to the current thread
_stitchData.threads[_fillThreadIndex].runs.push(fillStitches);
// Draw fill stitches if in appropriate mode
if (_drawMode === "stitch" || _drawMode === "realistic") {
drawStitches(fillStitches, _fillThreadIndex);
}
}
}
if (_doStroke) {
// Use the path-based stroke approach for consistency
const strokeStitches = p5embroidery.convertVerticesToStitches(
transformedPathPoints.map((p) => ({ x: p.x, y: p.y, isVert: true })),
_strokeSettings,
);
if (strokeStitches && strokeStitches.length > 0) {
_stitchData.threads[_strokeThreadIndex].runs.push(strokeStitches);
// Draw stroke stitches if in appropriate mode
if (_drawMode === "stitch" || _drawMode === "realistic") {
drawStitches(strokeStitches, _strokeThreadIndex);
}
}
}
// Handle p5 drawing mode
if (_drawMode === "p5") {
_originalStrokeWeightFunc.call(_p5Instance, mmToPixel$1(_strokeSettings.strokeWeight));
_originalRectFunc.call(
_p5Instance,
mmToPixel$1(x),
mmToPixel$1(y),
mmToPixel$1(w),
mmToPixel$1(h),
...cornerRs.map((r) => mmToPixel$1(r)),
);
}
} else {
_originalRectFunc.apply(this, arguments);
}
};
}
/**
* Overrides p5.js square() function to record embroidery stitches.
* @private
*/
let _originalSquareFunc;
function overrideSquareFunction() {
_originalSquareFunc = window.square;
window.square = function (x, y, w) {
if (_recording) {
window.rect.call(this, x, y, w, w);
} else {
_originalSquareFunc.apply(this, arguments);
}
};
}
/**
* Overrides p5.js triangle() function to record embroidery stitches.
* @private
*/
let _originalTriangleFunc;
function overrideTriangleFunction() {
_originalTriangleFunc = window.triangle;
window.triangle = function (x1, y1, x2, y2, x3, y3) {
if (_recording) {
// Build path points for triangle
const pathPoints = [
{ x: x1, y: y1 },
{ x: x2, y: y2 },
{ x: x3, y: y3 },
{ x: x1, y: y1 }, // close
];
// Apply current transformation to all pathPoints
const transformedPathPoints = applyCurrentTransformToPoints(pathPoints);
// Fill
if (_doFill) {
let fillStitches = [];
switch (_currentFillMode) {
case FILL_MODE.TATAMI:
fillStitches = createTatamiFillFromPath(transformedPathPoints, _fillSettings);
break;
case FILL_MODE.SATIN:
fillStitches = createSatinFillFromPath(transformedPathPoints, _fillSettings);
break;
case FILL_MODE.SPIRAL:
fillStitches = createSpiralFillFromPath(transformedPathPoints, _fillSettings);
break;
default:
fillStitches = createTatamiFillFromPath(transformedPathPoints, _fillSettings);
}
if (fillStitches && fillStitches.length > 0) {
_stitchData.threads[_fillThreadIndex].runs.push(fillStitches);
if (_drawMode === "stitch" || _drawMode === "realistic") {
drawStitches(fillStitches, _fillThreadIndex);
}
}
}
// Stroke
if (_doStroke) {
const strokeStitches = p5embroidery.convertVerticesToStitches(
transformedPathPoints.map((p) => ({ x: p.x, y: p.y, isVert: true })),
_strokeSettings,
);
if (strokeStitches && strokeStitches.length > 0) {
_stitchData.threads[_strokeThreadIndex].runs.push(strokeStitches);
if (_drawMode === "stitch" || _drawMode === "realistic") {
drawStitches(strokeStitches, _strokeThreadIndex);
}
}
}
if (_drawMode === "p5") {
_originalStrokeWeightFunc.call(_p5Instance, mmToPixel$1(_strokeSettings.strokeWeight));
_originalTriangleFunc.call(
_p5Instance,
mmToPixel$1(x1),
mmToPixel$1(y1),
mmToPixel$1(x2),
mmToPixel$1(y2),
mmToPixel$1(x3),
mmToPixel$1(y3),
);
}
} else {
_originalTriangleFunc.apply(this, arguments);
}
};
}
/**
* Overrides p5.js quad() function to record embroidery stitches.
* @private
*/
let _originalQuadFunc;
function overrideQuadFunction() {
_originalQuadFunc = window.quad;
window.quad = function (x1, y1, x2, y2, x3, y3, x4, y4) {
if (_recording) {
const pathPoints = [
{ x: x1, y: y1 },
{ x: x2, y: y2 },
{ x: x3, y: y3 },
{ x: x4, y: y4 },
{ x: x1, y: y1 }, // close
];
// Apply current transformation to all pathPoints
const transformedPathPoints = applyCurrentTransformToPoints(pathPoints);
// Fill
if (_doFill) {
let fillStitches = [];
switch (_currentFillMode) {
case FILL_MODE.TATAMI:
fillStitches = createTatamiFillFromPath(transformedPathPoints, _fillSettings);
break;
case FILL_MODE.SATIN:
fillStitches = createSatinFillFromPath(transformedPathPoints, _fillSettings);
break;
case FILL_MODE.SPIRAL:
fillStitches = createSpiralFillFromPath(transformedPathPoints, _fillSettings);
break;
default:
fillStitches = createTatamiFillFromPath(transformedPathPoints, _fillSettings);
}
if (fillStitches && fillStitches.length > 0) {
_stitchData.threads[_fillThreadIndex].runs.push(fillStitches);
if (_drawMode === "stitch" || _drawMode === "realistic") {
drawStitches(fillStitches, _fillThreadIndex);
}
}
}
// Stroke
if (_doStroke) {
const strokeStitches = p5embroidery.convertVerticesToStitches(
transformedPathPoints.map((p) => ({ x: p.x, y: p.y, isVert: true })),
_strokeSettings,
);
if (strokeStitches && strokeStitches.length > 0) {
_stitchData.threads[_strokeThreadIndex].runs.push(strokeStitches);
if (_drawMode === "stitch" || _drawMode === "realistic") {
drawStitches(strokeStitches, _strokeThreadIndex);
}
}
}
if (_drawMode === "p5") {
_originalStrokeWeightFunc.call(_p5Instance, mmToPixel$1(_strokeSettings.strokeWeight));
_originalQuadFunc.call(
_p5Instance,
mmToPixel$1(x1),
mmToPixel$1(y1),
mmToPixel$1(x2),
mmToPixel$1(y2),
mmToPixel$1(x3),
mmToPixel$1(y3),
mmToPixel$1(x4),
mmToPixel$1(y4),
);
}
} else {
_originalQuadFunc.apply(this, arguments);
}
};
}
/**
* Overrides p5.js arc() function to record embroidery stitches.
* @private
*/
let _originalArcFunc;
function overrideArcFunction() {
_originalArcFunc = window.arc;
window.arc = function (x, y, w, h, start, stop, mode) {
if (_recording) {
if (_DEBUG) {
console.log("Arc called with:", { x, y, w, h, start, stop, mode, _doFill, _doStroke });
}
// Default mode to OPEN if not specified
if (mode === undefined) {
mode = window.OPEN || "open";
}
// Approximate arc as polyline
const numSteps = Math.max(
12,
Math.ceil((Math.abs(stop - start) * Math.max(w, h)) / (_embroiderySettings.stitchLength * 2)),
);
const pathPoints = [];
// Generate arc points
for (let i = 0; i <= numSteps; i++) {
const theta = start + (i / numSteps) * (stop - start);
pathPoints.push({
x: x + (Math.cos(theta) * w) / 2,
y: y + (Math.sin(theta) * h) / 2,
});
}
if (_DEBUG) {
console.log("Generated arc points:", pathPoints.length);
}
// Apply current transformation to all pathPoints
const transformedPathPoints = applyCurrentTransformToPoints(pathPoints);
// Also transform the center point
const transformedCenter = applyCurrentTransform(x, y);
// Fill - handle all modes, not just PIE and CHORD
if (_doFill) {
let fillPathPoints = [...transformedPathPoints];
if (mode === window.PIE || mode === "pie") {
// PIE mode: close to center and back to start
fillPathPoints.push({ x: transformedCenter.x, y: transformedCenter.y });
fillPathPoints.push(transformedPathPoints[0]);
} else if (mode === window.CHORD || mode === "chord") {
// CHORD mode: close with straight line from end to start
if (transformedPathPoints.length > 1) {
fillPathPoints.push(transformedPathPoints[0]); // Close the path
}
} else {
// For OPEN mode or undefined, create a pie-like fill (common expectation)
fillPathPoints.push({ x: transformedCenter.x, y: transformedCenter.y });
fillPathPoints.push(transformedPathPoints[0]);
}
if (_DEBUG) {
console.log("Fill path points:", fillPathPoints.length, "Mode:", mode);
}
let fillStitches = [];
switch (_currentFillMode) {
case FILL_MODE.TATAMI:
fillStitches = createTatamiFillFromPath(fillPathPoints, _fillSettings);
break;
case FILL_MODE.SATIN:
fillStitches = createSatinFillFromPath(fillPathPoints, _fillSettings);
break;
case FILL_MODE.SPIRAL:
fillStitches = createSpiralFillFromPath(fillPathPoints, _fillSettings);
break;
default:
fillStitches = createTatamiFillFromPath(fillPathPoints, _fillSettings);
}
if (_DEBUG) {
console.log("Fill stitches generated:", fillStitches.length);
}
if (fillStitches && fillStitches.length > 0) {
_stitchData.threads[_fillThreadIndex].runs.push(fillStitches);
if (_drawMode === "stitch" || _drawMode === "realistic") {
drawStitches(fillStitches, _fillThreadIndex);
}
}
}
// Stroke (uses transformed arc points, not closed fill path)
if (_doStroke) {
let strokePathPoints = transformedPathPoints;
// For PIE mode, include lines to center for stroke
if (mode === window.PIE || mode === "pie") {
strokePathPoints = [
{ x: transformedCenter.x, y: transformedCenter.y }, // Start at center
...transformedPathPoints, // Arc points
{ x: transformedCenter.x, y: transformedCenter.y }, // Back to center
];
} else if (mode === window.CHORD || mode === "chord") {
// For CHORD mode, add the chord line
strokePathPoints = [
...transformedPathPoints,
transformedPathPoints[0], // Close with chord
];
}
// For OPEN mode, use transformedPathPoints as-is
const strokeStitches = p5embroidery.convertVerticesToStitches(
strokePathPoints.map((p) => ({ x: p.x, y: p.y, isVert: true })),
_strokeSettings,
);
if (strokeStitches && strokeStitches.length > 0) {
_stitchData.threads[_strokeThreadIndex].runs.push(strokeStitches);
if (_drawMode === "stitch" || _drawMode === "realistic") {
drawStitches(strokeStitches, _strokeThreadIndex);
}
}
}
if (_drawMode === "p5") {
_originalStrokeWeightFunc.call(_p5Instance, mmToPixel$1(_strokeSettings.strokeWeight));
_originalArcFunc.call(_p5Instance, mmToPixel$1(x), mmToPixel$1(y), mmToPixel$1(w), mmToPixel$1(h), start, stop, mode);
}
} else {
_originalArcFunc.apply(this, arguments);
}
};
}
/**
* Overrides p5.js beginContour() function.
* @private
*/
let _originalBeginContourFunc;
function overrideBeginContourFunction() {
_originalBeginContourFunc = window.beginContour;
window.beginContour = function () {
if (_recording) {
if (_DEBUG) console.log("beginContour called");
_isContour = true;
_currentContour = []; // Start a new contour
if (_drawMode === "p5") {
_originalBeginContourFunc.call(_p5Instance);
}
} else {
_originalBeginContourFunc.apply(this, arguments);
}
};
}
/**
* Overrides p5.js endContour() function.
* @private
*/
let _originalEndContourFunc;
function overrideEndContourFunction() {
_originalEndContourFunc = window.endContour;
window.endContour = function () {
if (_recording) {
if (_DEBUG) console.log("endContour called, current contour length:", _currentContour.length);
if (_currentContour.length > 0) {
// Close the contour if it's not already closed
const firstPoint = _currentContour[0];
const lastPoint = _currentContour[_currentContour.length - 1];
const distance = Math.sqrt(Math.pow(lastPoint.x - firstPoint.x, 2) + Math.pow(lastPoint.y - firstPoint.y, 2));
// If the contour isn't closed, close it
if (distance > 0.1) {
_currentContour.push({ x: firstPoint.x, y: firstPoint.y });
}
// Add the completed contour to the contours array
_contours.push([..._currentContour]);
if (_DEBUG) console.log("Added contour with", _currentContour.length, "points");
}
_currentContour = [];
_isContour = false;
if (_drawMode === "p5") {
_originalEndContourFunc.call(_p5Instance);
}
} else {
_originalEndContourFunc.apply(this, arguments);
}
};
}
/**
* Overrides necessary p5.js functions for embroidery recording.
* @private
*/
function overrideP5Functions() {
// Transformation functions
overridePushFunction();
overridePopFunction();
overrideTranslateFunction();
overrideRotateFunction();
overrideScaleFunction();
// Drawing functions
overrideLineFunction();
overrideCurveFunction();
overrideBezierFunction();
overrideEllipseFunction();
overrideCircleFunction();
overrideStrokeWeightFunction();
overrideStrokeJoinFunction();
overridePointFunction();
overrideStrokeFunction();
overrideNoStrokeFunction();
overrideFillFunction();
overrideNoFillFunction();
overrideRectFunction();
overrideSquareFunction();
overrideTriangleFunction();
overrideQuadFunction();
overrideArcFunction();
// Shape vertex functions
overrideVertexFunction();
overrideBezierVertexFunction();
overrideQuadraticVertexFunction();
overrideCurveVertexFunction();
overrideBeginShapeFunction();
overrideEndShapeFunction();
overrideBeginContourFunction();
overrideEndContourFunction();
// Add vertexWidth function to window
window.vertexWidth = vertexWidth;
}
/**
* Restores original p5.js functions.
* @private
*/
function restoreP5Functions() {
// Restore transformation functions
window.push = _originalPushFunc;
window.pop = _originalPopFunc;
window.translate = _originalTranslateFunc;
window.rotate = _originalRotateFunc;
window.scale = _originalScaleFunc;
// Restore drawing functions
window.line = _originalLineFunc;
window.curve = _originalCurveFunc;
window.bezier = _originalBezierFunc;
window.ellipse = _originalEllipseFunc;
window.circle = _originalCircleFunc;
window.strokeWeight = _originalStrokeWeightFunc;
window.strokeJoin = _originalStrokeJoinFunc;
window.point = _originalPointFunc;
window.stroke = _originalStrokeFunc;
window.noStroke = _originalNoStrokeFunc;
window.fill = _originalFillFunc;
window.noFill = _originalNoFillFunc;
window.rect = _originalRectFunc;
window.square = _originalSquareFunc;
window.triangle = _originalTriangleFunc;
window.quad = _originalQuadFunc;
window.arc = _originalArcFunc;
// Restore shape vertex functions
window.vertex = _originalVertexFunc;
window.bezierVertex = _originalBezierVertexFunc;
window.quadraticVertex = _originalQuadraticVertexFunc;
window.curveVertex = _originalCurveVertexFunc;
window.beginShape = _originalBeginShapeFunc;
window.endShape = _originalEndShapeFunc;
window.beginContour = _originalBeginContourFunc;
window.endContour = _originalEndContourFunc;
}
/**
* Sets the stitch parameters for embroidery.
* @method setStitch
* @for p5
* @param {Number} minLength - Minimum stitch length in millimeters
* @param {Number} desiredLength - Desired stitch length in millimeters
* @param {Number} noise - Amount of random variation in stitch length (0-1)
* @example
*
*
* function setup() {
* createCanvas(400, 400);
* beginRecord(this);
* setStitch(1, 3, 0.2); // min 1mm, desired 3mm, 20% noise
* // Draw embroidery patterns
* }
*
*
*/
p5embroidery.setStitch = function (minLength, desiredLength, noise) {
_embroiderySettings.minStitchLength = Math.max(0, minLength);
_embroiderySettings.stitchLength = Math.max(0.1, desiredLength);
_embroiderySettings.resampleNoise = Math.min(1, Math.max(0, noise));
_strokeSettings.minStitchLength = _embroiderySettings.minStitchLength;
_strokeSettings.stitchLength = _embroiderySettings.stitchLength;
_strokeSettings.resampleNoise = _embroiderySettings.resampleNoise;
};
/**
* Sets the stitch width parameters for embroidery.
* @method setStitchWidth
* @for p5
* @param {Number} width - Width of the stitch in millimeters
* @example
*
*
* function setup() {
* createCanvas(400, 400);
* beginRecord(this);
* setStitchWidth(0.2); // width 0.2mm
* // Draw embroidery patterns
* }
*
*
*/
p5embroidery.setStitchWidth = function (width) {
_embroiderySettings.stitchWidth = Math.max(0, width);
_strokeSettings.stitchWidth = _embroiderySettings.stitchWidth;
_fillSettings.stitchWidth = _embroiderySettings.stitchWidth;
};
/**
* Sets the stroke settings for embroidery.
* @method setStrokeSettings
* @for p5
* @param {Object} settings - The settings for the stroke
*/
p5embroidery.setStrokeSettings = function (settings) {
// Merge default settings with provided settings
Object.assign(_strokeSettings, settings);
};
/**
* Sets the fill settings for embroidery.
* @method setFillSettings
* @for p5
* @param {Object} settings - The settings for the fill
*/
p5embroidery.setFillSettings = function (settings) {
// Merge default settings with provided settings
Object.assign(_fillSettings, settings);
};
/**
* Sets the draw mode for embroidery.
* @method setDrawMode
* @for p5
* @param {String} mode - The draw mode to set ('stitch', 'p5', 'realistic')
* @example
*
*
* function setup() {
* createCanvas(400, 400);
* beginRecord(this);
* setDrawMode('stitch'); // Show stitch points and lines
* // Draw embroidery patterns
* }
*
*
*/
p5embroidery.setDrawMode = function (mode) {
_drawMode = mode;
};
/**
* Converts a line segment into a series of stitches.
* @private
* @param {number} x1 - Starting x-coordinate in mm
* @param {number} y1 - Starting y-coordinate in mm
* @param {number} x2 - Ending x-coordinate in mm
* @param {number} y2 - Ending y-coordinate in mm
* @param {Object} stitchSettings - Settings for the stitches
* @returns {Array<{x: number, y: number}>} Array of stitch points in mm
*/
function convertLineToStitches(x1, y1, x2, y2, stitchSettings = _embroiderySettings) {
if (_DEBUG)
console.log("Converting line to stitches (before offset):", {
from: { x: x1, y: y1 },
to: { x: x2, y: y2 },
});
if (_DEBUG)
console.log("Converting line to stitches", {
from: { x: x1, y: y1 },
to: { x: x2, y: y2 },
});
let dx = x2 - x1;
let dy = y2 - y1;
let distance = Math.sqrt(dx * dx + dy * dy);
if (_DEBUG)
console.log("Line properties:", {
dx,
dy,
distance,
minStitchLength: stitchSettings.minStitchLength,
stitchLength: stitchSettings.stitchLength,
strokeWeight: stitchSettings.strokeWeight,
});
if (stitchSettings.strokeWeight > 0) {
switch (_currentStrokeMode) {
case STROKE_MODE.STRAIGHT:
return straightLineStitch(x1, y1, x2, y2, stitchSettings);
case STROKE_MODE.ZIGZAG:
return zigzagStitch(x1, y1, x2, y2, stitchSettings);
case STROKE_MODE.RAMP:
return rampStitch(x1, y1, x2, y2, stitchSettings);
case STROKE_MODE.SQUARE:
return squareStitch(x1, y1, x2, y2, stitchSettings);
case STROKE_MODE.LINES:
return multiLineStitch(x1, y1, x2, y2, stitchSettings);
case STROKE_MODE.SASHIKO:
return sashikoStitch(x1, y1, x2, y2, stitchSettings);
default:
return straightLineStitch(x1, y1, x2, y2, stitchSettings);
}
} else {
return straightLineStitch(x1, y1, x2, y2, stitchSettings);
}
}
/**
* Converts a path into a series of stitches with proper corner joins.
* @private
* @param {Array<{x: number, y: number}>} pathPoints - Array of path points in mm
* @param {Object} stitchSettings - Settings for the stitches
* @returns {Array<{x: number, y: number}>} Array of stitch points in mm
*/
function convertPathToStitchesWithJoins(pathPoints, stitchSettings = _embroiderySettings) {
if (!pathPoints || pathPoints.length < 2) {
console.warn("Cannot convert path to stitches from insufficient path points");
return [];
}
const strokeWeight = stitchSettings.strokeWeight;
const joinType = stitchSettings.strokeJoin || _currentStrokeJoin;
if (_DEBUG) {
console.log("convertPathToStitchesWithJoins:", {
pathPointsCount: pathPoints.length,
strokeWeight: strokeWeight,
strokeMode: stitchSettings.strokeMode,
joinType: joinType,
});
}
// For zigzag and other wide stroke modes, use a different approach
switch (stitchSettings.strokeMode) {
case STROKE_MODE.ZIGZAG:
if (_DEBUG) console.log("Creating zigzag with joins...");
const zigzagResult = createZigzagWithJoins(pathPoints, stitchSettings);
if (_DEBUG) console.log("Zigzag result:", zigzagResult.length, "stitches");
return zigzagResult;
case STROKE_MODE.LINES:
return multiLineStitchFromPath(pathPoints, stitchSettings);
case STROKE_MODE.SASHIKO:
return sashikoStitchFromPath(pathPoints, stitchSettings);
default:
return straightLineStitchFromPath(pathPoints, stitchSettings);
}
}
/**
* Creates a continuous zigzag pattern with proper corner joins.
* @private
* @param {Array<{x: number, y: number}>} pathPoints - Array of path points in mm
* @param {Object} stitchSettings - Settings for the stitches
* @returns {Array<{x: number, y: number}>} Array of stitch points in mm
*/
function createZigzagWithJoins(pathPoints, stitchSettings) {
if (!pathPoints || pathPoints.length < 2) {
return [];
}
const width = stitchSettings.strokeWeight;
const density = stitchSettings.stitchLength;
const result = [];
// Create parallel paths offset to each side
const leftPath = createOffsetPath(pathPoints, width / 2, true);
const rightPath = createOffsetPath(pathPoints, width / 2, false);
if (leftPath.length === 0 || rightPath.length === 0) {
// Fall back to simple zigzag if offset calculation fails
return zigzagStitchFromPath(pathPoints, stitchSettings);
}
if (_DEBUG) {
console.log("Original path:", pathPoints);
console.log("Left path:", leftPath);
console.log("Right path:", rightPath);
}
// Calculate total path length for zigzag spacing
let totalLength = 0;
for (let i = 0; i < pathPoints.length - 1; i++) {
const dx = pathPoints[i + 1].x - pathPoints[i].x;
const dy = pathPoints[i + 1].y - pathPoints[i].y;
totalLength += Math.sqrt(dx * dx + dy * dy);
}
const numZigzags = Math.max(2, Math.floor(totalLength / density));
// Start from left side and ensure we always get valid points
let currentSide = leftPath;
let isOnLeftSide = true;
// Interpolate between left and right paths to create zigzag
for (let i = 0; i <= numZigzags; i++) {
const t = i / numZigzags;
// Alternate between sides for each stitch
currentSide = isOnLeftSide ? leftPath : rightPath;
const point = getPointAtRatio(currentSide, t);
if (point) {
result.push(point);
if (_DEBUG && i < 5) {
console.log(`Zigzag point ${i}: side=${isOnLeftSide ? "left" : "right"}, t=${t}, point=`, point);
}
} else {
// If we can't get a point from one side, try the other
const alternateSide = isOnLeftSide ? rightPath : leftPath;
const alternatePoint = getPointAtRatio(alternateSide, t);
if (alternatePoint) {
result.push(alternatePoint);
}
}
// Toggle sides for next iteration
isOnLeftSide = !isOnLeftSide;
}
if (_DEBUG) {
console.log("Zigzag result:", result.slice(0, 10));
}
return result;
}
/**
* Simplifies a path by removing redundant points that are too close together.
* @private
*/
function simplifyPath(pathPoints, tolerance = 0.1) {
if (pathPoints.length <= 2) return pathPoints;
const simplified = [pathPoints[0]]; // Always keep first point
for (let i = 1; i < pathPoints.length - 1; i++) {
const prev = simplified[simplified.length - 1];
const curr = pathPoints[i];
pathPoints[i + 1];
// Calculate distance from current point to previous
const distToPrev = Math.sqrt((curr.x - prev.x) * (curr.x - prev.x) + (curr.y - prev.y) * (curr.y - prev.y));
// Keep point if it's far enough from previous point
if (distToPrev >= tolerance) {
simplified.push(curr);
}
}
// Always keep last point
simplified.push(pathPoints[pathPoints.length - 1]);
if (_DEBUG && simplified.length !== pathPoints.length) {
console.log(`Path simplified: ${pathPoints.length} -> ${simplified.length} points`);
}
return simplified;
}
/**
* Creates an offset path parallel to the original path.
* @private
* @param {Array<{x: number, y: number}>} pathPoints - Original path points
* @param {number} offset - Offset distance (positive = left, negative = right)
* @param {boolean} isLeft - Whether this is the left side offset
* @returns {Array<{x: number, y: number}>} Offset path points
*/
function createOffsetPath(pathPoints, offset, isLeft) {
const offsetPath = [];
// Simplify the path first to remove closely spaced points
const simplifiedPath = simplifyPath(pathPoints, 0.2);
// Check if this is a closed path (first and last points are the same or very close)
const isClosedPath =
simplifiedPath.length > 2 &&
Math.abs(simplifiedPath[0].x - simplifiedPath[simplifiedPath.length - 1].x) < 0.1 &&
Math.abs(simplifiedPath[0].y - simplifiedPath[simplifiedPath.length - 1].y) < 0.1;
for (let i = 0; i < simplifiedPath.length; i++) {
const curr = simplifiedPath[i];
// Use point's width if available, otherwise use default offset
const pointOffset = curr.width !== undefined ? curr.width / 2 : offset;
let prev, next;
if (isClosedPath) {
// For closed paths, wrap around for prev/next calculation
prev = i > 0 ? simplifiedPath[i - 1] : simplifiedPath[simplifiedPath.length - 2]; // Skip duplicate end point
next = i < simplifiedPath.length - 1 ? simplifiedPath[i + 1] : simplifiedPath[1]; // Skip duplicate start point
} else {
// For open paths, use normal indexing
prev = i > 0 ? simplifiedPath[i - 1] : null;
next = i < simplifiedPath.length - 1 ? simplifiedPath[i + 1] : null;
}
let offsetPoint;
if (prev === null) {
// First point of open path - use direction to next point
const dx = next.x - curr.x;
const dy = next.y - curr.y;
const len = Math.sqrt(dx * dx + dy * dy);
if (len > 0) {
const perpX = (-dy / len) * (isLeft ? pointOffset : -pointOffset);
const perpY = (dx / len) * (isLeft ? pointOffset : -pointOffset);
offsetPoint = { x: curr.x + perpX, y: curr.y + perpY };
} else {
offsetPoint = { x: curr.x, y: curr.y };
}
} else if (next === null) {
// Last point of open path - use direction from previous point
const dx = curr.x - prev.x;
const dy = curr.y - prev.y;
const len = Math.sqrt(dx * dx + dy * dy);
if (len > 0) {
const perpX = (-dy / len) * (isLeft ? pointOffset : -pointOffset);
const perpY = (dx / len) * (isLeft ? pointOffset : -pointOffset);
offsetPoint = { x: curr.x + perpX, y: curr.y + perpY };
} else {
offsetPoint = { x: curr.x, y: curr.y };
}
} else {
// Middle point or closed path vertex - calculate proper join
offsetPoint = calculateOffsetCorner(prev, curr, next, pointOffset, isLeft);
}
offsetPath.push(offsetPoint);
}
return offsetPath;
}
/**
* Calculates the offset corner point with proper join handling.
* @private
*/
function calculateOffsetCorner(p1, p2, p3, offset, isLeft) {
// Calculate direction vectors
const v1 = { x: p2.x - p1.x, y: p2.y - p1.y };
const v2 = { x: p3.x - p2.x, y: p3.y - p2.y };
// Normalize
const len1 = Math.sqrt(v1.x * v1.x + v1.y * v1.y);
const len2 = Math.sqrt(v2.x * v2.x + v2.y * v2.y);
// Handle very short segments (common in curved corners)
if (len1 < 0.1 || len2 < 0.1) {
// Use simple perpendicular offset for tiny segments
const avgVecX = (v1.x + v2.x) / 2;
const avgVecY = (v1.y + v2.y) / 2;
const avgLen = Math.sqrt(avgVecX * avgVecX + avgVecY * avgVecY);
if (avgLen > 0) {
const actualOffset = isLeft ? offset : -offset;
const perpX = (-avgVecY / avgLen) * actualOffset;
const perpY = (avgVecX / avgLen) * actualOffset;
return { x: p2.x + perpX, y: p2.y + perpY };
}
return { x: p2.x, y: p2.y };
}
const n1 = { x: v1.x / len1, y: v1.y / len1 };
const n2 = { x: v2.x / len2, y: v2.y / len2 };
// Check angle between vectors to handle sharp turns
const dot = n1.x * n2.x + n1.y * n2.y;
const angle = Math.acos(Math.max(-1, Math.min(1, dot)));
// Calculate perpendiculars
const actualOffset = isLeft ? offset : -offset;
const perp1 = { x: -n1.y * actualOffset, y: n1.x * actualOffset };
const perp2 = { x: -n2.y * actualOffset, y: n2.x * actualOffset };
// For very small angles (nearly straight line), use simple averaging
if (angle < 0.1) {
const avgPerpX = (perp1.x + perp2.x) / 2;
const avgPerpY = (perp1.y + perp2.y) / 2;
return { x: p2.x + avgPerpX, y: p2.y + avgPerpY };
}
// For sharp angles (< 30 degrees), limit the miter to prevent extreme spikes
if (angle < Math.PI / 6) {
const avgPerpX = (perp1.x + perp2.x) / 2;
const avgPerpY = (perp1.y + perp2.y) / 2;
return { x: p2.x + avgPerpX, y: p2.y + avgPerpY };
}
// Calculate intersection of offset lines
const line1Start = { x: p1.x + perp1.x, y: p1.y + perp1.y };
const line1End = { x: p2.x + perp1.x, y: p2.y + perp1.y };
const line2Start = { x: p2.x + perp2.x, y: p2.y + perp2.y };
const line2End = { x: p3.x + perp2.x, y: p3.y + perp2.y };
const intersection = lineLineIntersection(line1Start, line1End, line2Start, line2End);
if (intersection) {
// Limit miter length to prevent extreme spikes
const miterDistance = Math.sqrt(
(intersection.x - p2.x) * (intersection.x - p2.x) + (intersection.y - p2.y) * (intersection.y - p2.y),
);
const maxMiterDistance = Math.abs(offset) * 3; // Reduced miter limit for stability
if (miterDistance <= maxMiterDistance) {
return intersection;
}
}
// Fall back to averaged perpendicular for failed intersections or extreme miters
const avgPerpX = (perp1.x + perp2.x) / 2;
const avgPerpY = (perp1.y + perp2.y) / 2;
const avgLen = Math.sqrt(avgPerpX * avgPerpX + avgPerpY * avgPerpY);
if (avgLen > 0) {
// Normalize and scale to proper offset
const scale = Math.abs(offset) / avgLen;
return {
x: p2.x + avgPerpX * scale,
y: p2.y + avgPerpY * scale,
};
} else {
return { x: p2.x, y: p2.y };
}
}
/**
* Gets a point at a specific ratio along a path.
* @private
*/
function getPointAtRatio(pathPoints, ratio) {
if (!pathPoints || pathPoints.length === 0) return null;
if (pathPoints.length === 1) return pathPoints[0];
// Calculate total path length
let totalLength = 0;
const segments = [];
for (let i = 0; i < pathPoints.length - 1; i++) {
const dx = pathPoints[i + 1].x - pathPoints[i].x;
const dy = pathPoints[i + 1].y - pathPoints[i].y;
const length = Math.sqrt(dx * dx + dy * dy);
segments.push(length);
totalLength += length;
}
if (totalLength === 0) return pathPoints[0];
// Find target distance along path
const targetDistance = ratio * totalLength;
let currentDistance = 0;
// Find which segment contains the target point
for (let i = 0; i < segments.length; i++) {
if (currentDistance + segments[i] >= targetDistance) {
// Interpolate within this segment
const segmentRatio = (targetDistance - currentDistance) / segments[i];
const p1 = pathPoints[i];
const p2 = pathPoints[i + 1];
return {
x: p1.x + (p2.x - p1.x) * segmentRatio,
y: p1.y + (p2.y - p1.y) * segmentRatio,
};
}
currentDistance += segments[i];
}
// If we get here, return the last point
return pathPoints[pathPoints.length - 1];
}
/**
* Converts a path into a series of stitches.
* @private
* @param {Array<{x: number, y: number}>} pathPoints - Array of path points in mm
* @param {Object} stitchSettings - Settings for the stitches
* @returns {Array<{x: number, y: number}>} Array of stitch points in mm
*/
function convertPathToStitches(pathPoints, stitchSettings = _embroiderySettings) {
if (!pathPoints || pathPoints.length < 2) {
console.warn("Cannot convert path to stitches from insufficient path points");
return [];
}
if (stitchSettings.strokeWeight > 0) {
switch (_currentStrokeMode) {
case STROKE_MODE.STRAIGHT:
return straightLineStitchFromPath(pathPoints, stitchSettings);
case STROKE_MODE.ZIGZAG:
return zigzagStitchFromPath(pathPoints, stitchSettings);
case STROKE_MODE.RAMP:
return rampStitchFromPath(pathPoints, stitchSettings);
case STROKE_MODE.SQUARE:
return squareStitchFromPath(pathPoints, stitchSettings);
case STROKE_MODE.LINES:
return multiLineStitchFromPath(pathPoints, stitchSettings);
case STROKE_MODE.SASHIKO:
return sashikoStitchFromPath(pathPoints, stitchSettings);
default:
// For simple straight stitches, we'll need to break this down segment by segment
const result = [];
for (let i = 0; i < pathPoints.length - 1; i++) {
const p1 = pathPoints[i];
const p2 = pathPoints[i + 1];
const segmentStitches = straightLineStitch(p1.x, p1.y, p2.x, p2.y, stitchSettings);
result.push(...segmentStitches);
}
return result;
}
} else {
// For simple straight stitches, we'll need to break this down segment by segment
const result = [];
for (let i = 0; i < pathPoints.length - 1; i++) {
const p1 = pathPoints[i];
const p2 = pathPoints[i + 1];
const segmentStitches = straightLineStitch(p1.x, p1.y, p2.x, p2.y, stitchSettings);
result.push(...segmentStitches);
}
return result;
}
}
/**
* Creates zigzag stitches.
* @method zigzagStitch
* @private
* @param {number} x1 - Starting x-coordinate in mm
* @param {number} y1 - Starting y-coordinate in mm
* @param {number} x2 - Ending x-coordinate in mm
* @param {number} y2 - Ending y-coordinate in mm
* @param {Object} stitchSettings - Settings for the stitches
* @returns {Array<{x: number, y: number}>} Array of stitch points in mm
*/
function zigzagStitch(x1, y1, x2, y2, stitchSettings) {
// For a simple straight line, we can implement the zigzag directly
// instead of calling the path-based version
let stitches = [];
let dx = x2 - x1;
let dy = y2 - y1;
let distance = Math.sqrt(dx * dx + dy * dy);
// Check for zero distance to prevent division by zero
if (distance === 0 || distance < 0.001) {
// If points are the same or very close, just return the start point
if (_DEBUG) console.log("Zero distance detected in zigzagStitch, returning single point");
return [
{
x: x1,
y: y1,
},
];
}
// Calculate perpendicular vector for zigzag
let perpX = -dy / distance;
let perpY = dx / distance;
// Use strokeWeight for the width of the zigzag
let width = stitchSettings.strokeWeight > 0 ? stitchSettings.strokeWeight : 2;
// Calculate number of zigzag segments
let zigzagDistance = stitchSettings.stitchLength;
let numZigzags = Math.max(2, Math.floor(distance / zigzagDistance));
// Create zigzag pattern
let halfWidth = width / 2;
let entry = stitchSettings.strokeEntry;
let exit = stitchSettings.strokeExit;
let side = 1;
if (entry === "right") {
side = -1;
} else if (entry === "left") {
side = 1;
} else if (entry === "middle") {
side = 0;
}
// Add first point
stitches.push({
x: x1 + perpX * halfWidth * side,
y: y1 + perpY * halfWidth * side,
});
// Add zigzag points
for (let i = 1; i <= numZigzags; i++) {
let t = i / numZigzags;
if (side == 0) {
side = 1;
} else {
side = -side; // Alternate sides
}
if (i == numZigzags) {
if (exit == "right") {
side = -1;
} else if (exit == "left") {
side = 1;
} else if (exit == "middle") {
side = 0;
}
}
let pointX = x1 + dx * t + perpX * halfWidth * side;
let pointY = y1 + dy * t + perpY * halfWidth * side;
stitches.push({
x: pointX,
y: pointY,
});
}
if (_DEBUG) console.log("Generated zigzag stitches:", stitches);
return stitches;
}
/**
* Creates ramp stitches (sawtooth wave pattern).
* @method rampStitch
* @private
* @param {number} x1 - Starting x-coordinate in mm
* @param {number} y1 - Starting y-coordinate in mm
* @param {number} x2 - Ending x-coordinate in mm
* @param {number} y2 - Ending y-coordinate in mm
* @param {Object} stitchSettings - Settings for the stitches
* @returns {Array<{x: number, y: number}>} Array of stitch points in mm
*/
function rampStitch(x1, y1, x2, y2, stitchSettings) {
let stitches = [];
let dx = x2 - x1;
let dy = y2 - y1;
let distance = Math.sqrt(dx * dx + dy * dy);
// Check for zero distance to prevent division by zero
if (distance === 0 || distance < 0.001) {
if (_DEBUG) console.log("Zero distance detected in rampStitch, returning single point");
return [{ x: x1, y: y1 }];
}
// Calculate perpendicular vector for ramp
let perpX = -dy / distance;
let perpY = dx / distance;
// Use strokeWeight for the width of the ramp
let width = stitchSettings.strokeWeight > 0 ? stitchSettings.strokeWeight : 2;
let halfWidth = width / 2;
// Calculate number of ramp segments
let rampDistance = stitchSettings.stitchLength;
let numRamps = Math.max(2, Math.floor(distance / rampDistance));
let entry = stitchSettings.strokeEntry;
stitchSettings.strokeExit;
let currentSide = 1;
if (entry === "right") {
currentSide = 1;
} else if (entry === "left") {
currentSide = -1;
}
// Add first point
stitches.push({
x: x1 + perpX * halfWidth * currentSide,
y: y1 + perpY * halfWidth * currentSide,
});
// Create ramp pattern - sawtooth wave (gradual rise, sharp drop)
for (let i = 1; i <= numRamps; i++) {
let t = i / numRamps;
if (i % 1 === 0) {
currentSide = -currentSide;
}
let pointX = x1 + dx * t + perpX * halfWidth * currentSide;
let pointY = y1 + dy * t + perpY * halfWidth * currentSide;
let pointX1 = x1 + dx * t + perpX * halfWidth * -currentSide;
let pointY1 = y1 + dy * t + perpY * halfWidth * -currentSide;
stitches.push({
x: pointX,
y: pointY,
});
if (entry === "right") {
if (currentSide === -1) {
stitches.push({
x: pointX1,
y: pointY1,
});
}
} else if (entry === "left") {
if (currentSide === 1) {
stitches.push({
x: pointX1,
y: pointY1,
});
//ellipse(pointX1, pointY1, 5, 5)
}
}
}
if (_DEBUG) console.log("Generated ramp stitches:", stitches);
return stitches;
}
/**
* Creates square stitches (square wave pattern).
* @method squareStitch
* @private
* @param {number} x1 - Starting x-coordinate in mm
* @param {number} y1 - Starting y-coordinate in mm
* @param {number} x2 - Ending x-coordinate in mm
* @param {number} y2 - Ending y-coordinate in mm
* @param {Object} stitchSettings - Settings for the stitches
* @returns {Array<{x: number, y: number}>} Array of stitch points in mm
*/
function squareStitch(x1, y1, x2, y2, stitchSettings) {
let stitches = [];
let dx = x2 - x1;
let dy = y2 - y1;
let distance = Math.sqrt(dx * dx + dy * dy);
// Check for zero distance to prevent division by zero
if (distance === 0 || distance < 0.001) {
if (_DEBUG) console.log("Zero distance detected in squareStitch, returning single point");
return [{ x: x1, y: y1 }];
}
// Calculate perpendicular vector for square wave
let perpX = -dy / distance;
let perpY = dx / distance;
// Use strokeWeight for the width of the square wave
let width = stitchSettings.strokeWeight > 0 ? stitchSettings.strokeWeight : 2;
let halfWidth = width / 2;
// Calculate number of square wave segments
let squareDistance = stitchSettings.stitchLength;
let numSquares = Math.max(2, Math.floor(distance / squareDistance));
let entry = stitchSettings.strokeEntry;
let exit = stitchSettings.strokeExit;
let currentSide = 1;
if (entry === "right") {
currentSide = -1;
} else if (entry === "left") {
currentSide = 1;
} else if (entry === "middle") {
currentSide = 0;
}
// Add first point
if (entry === "middle") {
stitches.push({
x: x1 + perpX * halfWidth * currentSide,
y: y1 + perpY * halfWidth * currentSide,
});
} else {
stitches.push({
x: x1 + perpX * halfWidth * currentSide,
y: y1 + perpY * halfWidth * currentSide,
});
stitches.push({
x: x1 + perpX * halfWidth * -currentSide,
y: y1 + perpY * halfWidth * -currentSide,
});
}
// Create square wave pattern - abrupt transitions between high and low states
for (let i = 1; i <= numSquares - 1; i++) {
let t = i / numSquares;
let pointX;
let pointY;
let pointX1;
let pointY1;
// Square wave: stay at current level for half the period, then jump to opposite
if (i % 1 === 0) {
currentSide = -currentSide; // Abrupt transition
}
if (entry === "middle") {
if (i === 1) currentSide = 0;
if (i === 2) currentSide = -1;
}
pointX = x1 + dx * t + perpX * halfWidth * currentSide;
pointY = y1 + dy * t + perpY * halfWidth * currentSide;
pointX1 = x1 + dx * t + perpX * halfWidth * -currentSide;
pointY1 = y1 + dy * t + perpY * halfWidth * -currentSide;
if (exit === "middle") {
if (i == 1) {
currentSide = -1;
pointX = x1 + dx * t;
pointY = y1 + dy * t;
pointX1 = x1 + dx * t + perpX * halfWidth * currentSide;
pointY1 = y1 + dy * t + perpY * halfWidth * currentSide;
} else if (i == numSquares - 1) {
pointX = x1 + dx * t + perpX * halfWidth * currentSide;
pointY = y1 + dy * t + perpY * halfWidth * currentSide;
pointX1 = x1 + dx * t;
pointY1 = y1 + dy * t;
}
stitches.push({
x: pointX,
y: pointY,
});
stitches.push({
x: pointX1,
y: pointY1,
});
} else {
stitches.push({
x: pointX,
y: pointY,
});
stitches.push({
x: pointX1,
y: pointY1,
});
}
}
// Add last point
if (exit === "middle") {
stitches.push({
x: x2,
y: y2,
});
} else {
currentSide = -currentSide;
stitches.push({
x: x2 + perpX * halfWidth * currentSide,
y: y2 + perpY * halfWidth * currentSide,
});
stitches.push({
x: x2 + perpX * halfWidth * -currentSide,
y: y2 + perpY * halfWidth * -currentSide,
});
}
if (_DEBUG) console.log("Generated square stitches:", stitches);
return stitches;
}
/**
* Creates straight line stitches.
* @method straightLineStitch
* @private
* @param {number} x1 - Starting x-coordinate in mm
* @param {number} y1 - Starting y-coordinate in mm
* @param {number} x2 - Ending x-coordinate in mm
* @param {number} y2 - Ending y-coordinate in mm
* @param {Object} [stitchSettings=_embroiderySettings] - Settings for the stitches
* @returns {Array<{x: number, y: number}>} Array of stitch points in mm
*/
function straightLineStitch(x1, y1, x2, y2, stitchSettings = _embroiderySettings) {
let stitches = [];
let dx = x2 - x1;
let dy = y2 - y1;
const distance = Math.sqrt(dx * dx + dy * dy);
// Add first stitch at starting point
stitches.push({
x: x1,
y: y1,
});
// If distance is less than minimum stitch length, we're done
if (distance < stitchSettings.minStitchLength) {
return stitches;
}
let baseStitchLength = stitchSettings.stitchLength;
let numStitches = Math.floor(distance / baseStitchLength);
let currentDistance = 0;
//console.log("numStitches",numStitches)
// Handle full-length stitches
for (let i = 0; i < numStitches; i++) {
// Add noise to stitch length if specified
let stitchLength = baseStitchLength;
if (stitchSettings.resampleNoise > 0) {
let noise = (Math.random() * 2 - 1) * stitchSettings.resampleNoise;
stitchLength *= 1 + noise;
}
// update cumulative distance
currentDistance += stitchLength;
let t = Math.min(currentDistance / distance, 1);
//console.log("t",t)
stitches.push({
x: x1 + dx * t,
y: y1 + dy * t,
});
}
// Add final stitch at end point if needed
let remainingDistance = distance - currentDistance;
if (remainingDistance > stitchSettings.minStitchLength || numStitches === 0) {
stitches.push({
x: x2,
y: y2,
});
}
if (_DEBUG) console.log("Generated straight line stitches:", stitches);
return stitches;
}
/**
* Creates line zigzag stitches that takes an array of path points
* @private
* @param {Array<{x: number, y: number}>} pathPoints - Array of path points in mm
* @param {Object} stitchSettings - Settings for the stitches
* @returns {Array<{x: number, y: number}>} Array of stitch points in mm
*/
function zigzagStitchFromPath(pathPoints, stitchSettings) {
if (!pathPoints || pathPoints.length < 2) {
console.warn("Cannot create zigzag stitching from insufficient path points");
return [];
}
const result = [];
stitchSettings.strokeWeight > 0 ? stitchSettings.strokeWeight : 2;
// Process each segment between consecutive points
for (let i = 0; i < pathPoints.length - 1; i++) {
const p1 = pathPoints[i];
const p2 = pathPoints[i + 1];
// Get zigzag stitches for this segment
const segmentStitches = zigzagStitch(p1.x, p1.y, p2.x, p2.y, stitchSettings);
result.push(...segmentStitches);
}
return result;
}
/**
* Creates ramp stitches from a path (sawtooth wave pattern).
* @private
* @param {Array<{x: number, y: number}>} pathPoints - Array of path points in mm
* @param {Object} stitchSettings - Settings for the stitches
* @returns {Array<{x: number, y: number}>} Array of stitch points in mm
*/
function rampStitchFromPath(pathPoints, stitchSettings) {
if (!pathPoints || pathPoints.length < 2) {
console.warn("Cannot create ramp stitching from insufficient path points");
return [];
}
const result = [];
// Process each segment between consecutive points
for (let i = 0; i < pathPoints.length - 1; i++) {
const p1 = pathPoints[i];
const p2 = pathPoints[i + 1];
// Get ramp stitches for this segment
const segmentStitches = rampStitch(p1.x, p1.y, p2.x, p2.y, stitchSettings);
result.push(...segmentStitches);
}
return result;
}
/**
* Creates square stitches from a path (square wave pattern).
* @private
* @param {Array<{x: number, y: number}>} pathPoints - Array of path points in mm
* @param {Object} stitchSettings - Settings for the stitches
* @returns {Array<{x: number, y: number}>} Array of stitch points in mm
*/
function squareStitchFromPath(pathPoints, stitchSettings) {
if (!pathPoints || pathPoints.length < 2) {
console.warn("Cannot create square stitching from insufficient path points");
return [];
}
const result = [];
// Process each segment between consecutive points
for (let i = 0; i < pathPoints.length - 1; i++) {
const p1 = pathPoints[i];
const p2 = pathPoints[i + 1];
// Get square stitches for this segment
const segmentStitches = squareStitch(p1.x, p1.y, p2.x, p2.y, stitchSettings);
result.push(...segmentStitches);
}
return result;
}
function multiLineStitch(x1, y1, x2, y2, stitchSettings) {
// This is now a wrapper function that calls the path-based implementation
const pathPoints = [
{ x: x1, y: y1 },
{ x: x2, y: y2 },
];
return multiLineStitchFromPath(pathPoints, stitchSettings);
}
/**
* Creates multi-line stitches from an array of stitch points
* @private
* @param {Array<{x: number, y: number}>} pathPoints - Array of path points in mm
* @param {Object} stitchSettings - Settings for the stitches
* @returns {Array<{x: number, y: number}>} Array of stitch points in mm
*/
function multiLineStitchFromPath(pathPoints, stitchSettings) {
if (!pathPoints || pathPoints.length < 2) {
console.warn("Cannot create multi-line stitching from insufficient path points");
return [];
}
const threadWeight = stitchSettings.stitchWidth || 0.2;
const width = stitchSettings.strokeWeight || 2;
const numLines = Math.max(2, Math.floor(width / threadWeight));
const result = [];
// Calculate the spacing between lines
const spacing = width / (numLines - 1);
// Generate multiple parallel paths
for (let i = 0; i < numLines; i++) {
// Calculate offset from center
const offset = i * spacing - width / 2;
const offsetPath = [];
// Calculate perpendicular vectors for each segment and apply offset
for (let j = 0; j < pathPoints.length; j++) {
// For first point or when calculating new perpendicular
if (j === 0 || j === pathPoints.length - 1) {
let perpX, perpY;
if (j === 0) {
// For first point, use direction to next point
const dx = pathPoints[1].x - pathPoints[0].x;
const dy = pathPoints[1].y - pathPoints[0].y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance === 0 || distance < 0.001) {
// Skip this point if distance is zero
continue;
}
perpX = -dy / distance;
perpY = dx / distance;
} else {
// For last point, use direction from previous point
const dx = pathPoints[j].x - pathPoints[j - 1].x;
const dy = pathPoints[j].y - pathPoints[j - 1].y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance === 0 || distance < 0.001) {
// Skip this point if distance is zero
continue;
}
perpX = -dy / distance;
perpY = dx / distance;
}
offsetPath.push({
x: pathPoints[j].x + perpX * offset,
y: pathPoints[j].y + perpY * offset,
});
} else {
// For interior points, average the perpendiculars of adjacent segments
const prevDx = pathPoints[j].x - pathPoints[j - 1].x;
const prevDy = pathPoints[j].y - pathPoints[j - 1].y;
const prevDistance = Math.sqrt(prevDx * prevDx + prevDy * prevDy);
const nextDx = pathPoints[j + 1].x - pathPoints[j].x;
const nextDy = pathPoints[j + 1].y - pathPoints[j].y;
const nextDistance = Math.sqrt(nextDx * nextDx + nextDy * nextDy);
// Calculate perpendicular vectors
const prevPerpX = -prevDy / prevDistance;
const prevPerpY = prevDx / prevDistance;
const nextPerpX = -nextDy / nextDistance;
const nextPerpY = nextDx / nextDistance;
// Average the perpendicular vectors
const perpX = (prevPerpX + nextPerpX) / 2;
const perpY = (prevPerpY + nextPerpY) / 2;
// Normalize the averaged vector
const length = Math.sqrt(perpX * perpX + perpY * perpY);
offsetPath.push({
x: pathPoints[j].x + (perpX / length) * offset,
y: pathPoints[j].y + (perpY / length) * offset,
});
}
}
// For even lines, go from start to end
// For odd lines, go from end to start (back and forth pattern)
if (i % 2 === 0) {
for (let j = 0; j < offsetPath.length - 1; j++) {
const start = offsetPath[j];
const end = offsetPath[j + 1];
const lineStitches = straightLineStitch(start.x, start.y, end.x, end.y, stitchSettings);
result.push(...lineStitches);
}
} else {
for (let j = offsetPath.length - 1; j > 0; j--) {
const start = offsetPath[j];
const end = offsetPath[j - 1];
const lineStitches = straightLineStitch(start.x, start.y, end.x, end.y, stitchSettings);
result.push(...lineStitches);
}
}
}
return result;
}
function sashikoStitch(x1, y1, x2, y2, stitchSettings) {
// This is now a wrapper function that calls the path-based implementation
const pathPoints = [
{ x: x1, y: y1 },
{ x: x2, y: y2 },
];
return sashikoStitchFromPath(pathPoints, stitchSettings);
}
/**
* Creates sashiko stitches from an array of path points
* @private
* @param {Array<{x: number, y: number}>} pathPoints - Array of path points in mm
* @param {Object} stitchSettings - Settings for the stitches
* @returns {Array<{x: number, y: number}>} Array of stitch points in mm
*/
function sashikoStitchFromPath(pathPoints, stitchSettings) {
if (!pathPoints || pathPoints.length < 2) {
console.warn("Cannot create sashiko stitching from insufficient path points");
return [];
}
const result = [];
// Process each segment between consecutive points
for (let i = 0; i < pathPoints.length - 1; i++) {
const p1 = pathPoints[i];
const p2 = pathPoints[i + 1];
// Calculate direction and distance
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const distance = Math.sqrt(dx * dx + dy * dy);
// Normalize direction vector
const dirX = dx / distance;
const dirY = dy / distance;
// Sashiko stitch length (longer than regular stitches)
const sashikoStitchLength = stitchSettings.stitchLength * 2;
const multilineLength = sashikoStitchLength * 0.5;
const straightLength = sashikoStitchLength * 0.5;
let currentDist = 0;
let isMultiline = true;
// Create segments along this path section
while (currentDist < distance) {
const segmentLength = isMultiline ? multilineLength : straightLength;
const endDist = Math.min(currentDist + segmentLength, distance);
// Calculate segment start and end points
const segStartX = p1.x + dirX * currentDist;
const segStartY = p1.y + dirY * currentDist;
const segEndX = p1.x + dirX * endDist;
const segEndY = p1.y + dirY * endDist;
// Create segment pathPoints
const segmentPoints = [
{ x: segStartX, y: segStartY },
{ x: segEndX, y: segEndY },
];
if (isMultiline) {
// Use the pathPoints version of multiLine stitching
const lineStitches = multiLineStitchFromPath(segmentPoints, stitchSettings);
result.push(...lineStitches);
} else {
// Create single straight line for this segment
const lineStitches = straightLineStitch(segStartX, segStartY, segEndX, segEndY, stitchSettings);
result.push(...lineStitches);
}
// Move to next segment
currentDist = endDist;
isMultiline = !isMultiline; // Toggle between multiline and straight line
}
}
return result;
}
/**
* Exports the recorded embroidery data as a file.
* Supported formats: DST (.dst), SVG (.svg), PNG (.png), JSON (.json)
* @method exportEmbroidery
* @for p5
* @param {String} filename - Output filename with extension (dst, svg, png, or json)
* @example
*
*
* function setup() {
* createCanvas(400, 400);
* beginRecord(this);
* // Draw embroidery patterns here
* circle(50, 50, 20);
* line(10, 10, 90, 90);
* endRecord();
* exportEmbroidery('pattern.dst'); // or .svg, .png, .json
* }
*
*
*/
p5embroidery.exportEmbroidery = function (filename) {
const extension = filename.split(".").pop().toLowerCase();
switch (extension) {
case "dst":
p5embroidery.exportDST(filename);
break;
case "svg":
p5embroidery.exportSVG(filename);
break;
case "png":
p5embroidery.exportPNG(filename);
break;
case "json":
p5embroidery.exportJSON(filename);
break;
default:
console.error(`Unsupported embroidery format: ${extension}`);
break;
}
};
/**
* Exports the recorded embroidery data as a G-code file.
* @method exportGcode
* @for p5
* @param {String} filename - Output filename
* @example
*
*
* function setup() {
* createCanvas(400, 400);
* beginRecord(this);
* // Draw embroidery patterns
* endRecord();
* exportGcode('pattern.gcode');
* }
*
*
*/
p5embroidery.exportGcode = function (filename) {
const points = [];
for (const thread of _stitchData.threads) {
for (const run of thread.runs) {
for (const stitch of run) {
points.push({
x: stitch.x,
y: stitch.y,
command: stitch.command,
});
}
}
}
const gcodeWriter = new GCodeWriter();
gcodeWriter.addComment("Embroidery Pattern");
if (points.length > 0) {
gcodeWriter.move(points[0].x, points[0].y);
for (const point of points) {
gcodeWriter.move(point.x, point.y);
}
}
gcodeWriter.saveGcode(points, "EmbroideryPattern", filename);
};
/**
* Exports embroidery pattern as SVG for printing templates.
* @method exportSVG
* @for p5
* @param {string} filename - Output filename
* @param {Object} [options={}] - Export options
* @param {string} [options.paperSize='A4'] - Paper size (A4, A3, A2, A1)
* @param {number} [options.dpi=300] - Print resolution in DPI
* @param {Object} [options.hoopSize] - Hoop size in mm {width, height}
* @param {Object} [options.margins] - Margins in mm {top, right, bottom, left}
* @param {boolean} [options.showGuides=true] - Show hoop guides and center marks
* @param {boolean} [options.centerPattern=false] - Center pattern in hoop (false = use original coordinates)
* @param {boolean} [options.lifeSize=true] - Export at life-size scale
* @example
* function setup() {
* createCanvas(400, 400);
* beginRecord(this);
* // Draw embroidery patterns at specific coordinates
* translate(100, 25); // Position at 100mm, 25mm
* circle(0, 0, 20);
* endRecord();
*
* // Export at original coordinates (recommended)
* exportSVG('my-pattern.svg', {
* paperSize: 'A4',
* hoopSize: {width: 200, height: 200},
* centerPattern: false // Preserves your coordinate positioning
* });
* }
*/
//TODO Add bounding box to SVG so that SVG can be previewed in browser and not cropped
p5embroidery.exportSVG = function (filename = "embroidery-pattern.svg", options = {}) {
if (!_stitchData || !_stitchData.threads) {
console.warn("🪡 p5.embroider says: No embroidery data to export");
return;
}
try {
// Create SVG writer instance
const svgWriter = new SVGWriter();
svgWriter.setOptions(options);
svgWriter.validateOptions();
// Generate title from filename or use default
const title = filename ? filename.replace(/\.[^/.]+$/, "") : "Embroidery Pattern";
// Save SVG using the writer
svgWriter.saveSVG(_stitchData, title, filename);
} catch (error) {
console.error("🪡 p5.embroider says: Error exporting SVG:", error);
}
};
/**
* Exports embroidery pattern as PNG for printing templates.
* @method exportPNG
* @for p5
* @param {string} filename - Output filename
* @param {Object} [options={}] - Export options
* @param {string} [options.paperSize='A4'] - Paper size (A4, A3, A2, A1)
* @param {number} [options.dpi=300] - Print resolution in DPI
* @param {Object} [options.hoopSize] - Hoop size in mm {width, height}
* @param {Object} [options.margins] - Margins in mm {top, right, bottom, left}
* @param {boolean} [options.showGuides=true] - Show hoop guides and center marks
* @param {boolean} [options.lifeSize=true] - Export at life-size scale
* @example
* function setup() {
* createCanvas(400, 400);
* beginRecord(this);
* // Draw embroidery patterns
* circle(50, 50, 20);
* endRecord();
* exportPNG('my-pattern.png', {
* paperSize: 'A4',
* hoopSize: {width: 100, height: 100}
* });
* }
*/
p5embroidery.exportPNG = function (filename = "embroidery-pattern.png", options = {}) {
if (!_stitchData || !_stitchData.threads) {
console.warn("🪡 p5.embroider says: No embroidery data to export");
return;
}
try {
// Create SVG writer instance
const svgWriter = new SVGWriter();
svgWriter.setOptions(options);
svgWriter.validateOptions();
// Generate title from filename or use default
const title = filename ? filename.replace(/\.[^/.]+$/, "") : "Embroidery Pattern";
// Generate PNG using the writer
svgWriter.generatePNG(_stitchData, title, filename);
} catch (error) {
console.error("🪡 p5.embroider says: Error exporting PNG:", error);
}
};
/**
* Exports the recorded embroidery data as a DST file.
* @method exportDST
* @for p5
* @param {String} [filename='embroideryPattern.dst'] - Output filename
* @example
*
*
* function setup() {
* createCanvas(400, 400);
* beginRecord(this);
* // Draw embroidery patterns
* endRecord();
* exportDST('pattern.dst');
* }
*
*
*/
p5embroidery.exportDST = function (filename = "embroideryPattern.dst") {
const points = [];
const dstWriter = new DSTWriter();
if (_DEBUG) console.log("=== Starting DST Export ===");
if (_DEBUG) console.log("Canvas size:", _stitchData.width, _stitchData.height);
if (_DEBUG) console.log("Stitch data:", _stitchData);
let currentThreadIndex = -1;
for (let threadIndex = 0; threadIndex < _stitchData.threads.length; threadIndex++) {
const thread = _stitchData.threads[threadIndex];
// Skip threads with no stitches
if (thread.runs.length === 0 || !thread.runs.some((run) => run.length > 0)) {
continue;
}
// If we're changing threads and have previous stitches, add a color change command
if (currentThreadIndex !== -1 && threadIndex !== currentThreadIndex && points.length > 0) {
// Get the last stitch position
const lastPoint = points[points.length - 1];
// Add a color change command at the same position
points.push({
x: lastPoint.x,
y: lastPoint.y,
colorChange: true,
});
if (_DEBUG) console.log("Color change at:", lastPoint.x, lastPoint.y);
}
currentThreadIndex = threadIndex;
for (const run of thread.runs) {
// Check if this is a thread trim command
if (run.length === 1 && run[0].command === "trim") {
if (_DEBUG) {
console.log("Trim command at:", run[0].x, run[0].y);
console.log("Canvas size:", _stitchData.width, _stitchData.height);
}
// Validate trim command coordinates
if (run[0].x == null || run[0].y == null || !isFinite(run[0].x) || !isFinite(run[0].y)) {
if (_DEBUG) console.warn("Skipping invalid trim command with null/NaN coordinates:", run[0]);
continue;
}
// Convert from mm to 0.1mm for DST format
points.push({
x: run[0].x * 10, // Convert from mm to 0.1mm for DST format
y: run[0].y * 10, // Convert from mm to 0.1mm for DST format
jump: true,
trim: true,
});
continue;
}
// Normal stitches
if (_DEBUG) console.log("=== New Stitch Run ===");
if (_DEBUG) console.log("Run:", run);
for (const stitch of run) {
// Validate stitch coordinates before processing
if (stitch.x == null || stitch.y == null || !isFinite(stitch.x) || !isFinite(stitch.y)) {
if (_DEBUG) console.warn("Skipping invalid stitch with null/NaN coordinates:", stitch);
continue;
}
// if (_DEBUG)
// console.log("Stitch point:", {
// mm: { x: stitch.x, y: stitch.y },
// dst: { x: stitch.x * 10, y: stitch.y * 10 }, // Convert to DST units (0.1mm) for logging
// });
// Convert from mm to 0.1mm for DST format
points.push({
x: stitch.x * 10, // Convert to DST units (0.1mm)
y: stitch.y * 10, // Convert to DST units (0.1mm)
command: stitch.command,
jump: stitch.command === "jump",
});
}
}
}
// Skip export if no points
if (points.length === 0) {
console.warn("No embroidery points to export");
return;
}
if (_DEBUG) {
console.log("=== Final Points Array ===");
console.log("Total points:", points.length);
console.log("First point:", points[0]);
console.log("Last point:", points[points.length - 1]);
// Log bounding box
let minX = Infinity,
maxX = -Infinity,
minY = Infinity,
maxY = -Infinity;
for (const point of points) {
minX = Math.min(minX, point.x);
maxX = Math.max(maxX, point.x);
minY = Math.min(minY, point.y);
maxY = Math.max(maxY, point.y);
}
console.log("Bounding box (0.1mm):", {
minX,
maxX,
minY,
maxY,
width: maxX - minX,
height: maxY - minY,
});
}
dstWriter.saveDST(points, "EmbroideryPattern", filename);
};
/**
* Exports the recorded embroidery data as a JSON file with detailed stitch information organized by thread ID.
* @method exportJSON
* @for p5
* @param {string} [filename='embroidery-pattern.json'] - Output filename
* @param {Object} [options={}] - Export options
* @param {boolean} [options.includeBounds=true] - Include pattern bounds information
* @param {boolean} [options.includeMetadata=true] - Include metadata and statistics
* @param {number} [options.precision=2] - Decimal precision for coordinates
* @param {boolean} [options.compactOutput=false] - Export in compact JSON format
* @example
* function setup() {
* createCanvas(400, 400);
* beginRecord(this);
* // Draw embroidery patterns
* circle(50, 50, 20);
* line(10, 10, 90, 90);
* endRecord();
* exportJSON('my-pattern.json', {
* precision: 3,
* includeMetadata: true
* });
* }
*/
p5embroidery.exportJSON = function (filename = "embroidery-pattern.json", options = {}) {
if (!_stitchData || !_stitchData.threads) {
console.warn("🪡 p5.embroider says: No embroidery data to export");
return null;
}
try {
// Create JSON writer instance
const jsonWriter = new JSONWriter();
jsonWriter.setOptions(options);
// Generate title from filename or use default
const title = filename ? filename.replace(/\.[^/.]+$/, "") : "Embroidery Pattern";
// Save JSON using the writer and return the content
const jsonContent = jsonWriter.saveJSON(_stitchData, title, filename);
return JSON.parse(jsonContent); // Return parsed JSON object for potential use
} catch (error) {
console.error("🪡 p5.embroider says: Error exporting JSON:", error);
return null;
}
};
/**
* Inserts a thread trim command at the current position.
* @method trimThread
* @for p5
* @example
*
*
* function setup() {
* createCanvas(400, 400);
* beginRecord(this);
* line(10, 10, 50, 50);
* trimThread(); // Cut thread at current position
* line(60, 60, 100, 100);
* }
*
*
*/
p5embroidery.trimThread = function (threadIndex = _strokeThreadIndex) {
if (_recording) {
// Get the current thread
const currentThread = _stitchData.threads[threadIndex];
// // Check if there are any runs in the current thread
// if (!currentThread || currentThread.runs.length === 0) {
// console.warn("trimThread: No runs found for thread", threadIndex);
// return; // Nothing to trim
// }
// Get the last run in the current thread
const lastRun = currentThread.runs[currentThread.runs.length - 1];
// Check if the last run has any stitches
if (!lastRun || lastRun.length === 0) {
console.warn("trimThread: No stitches to trim for thread", threadIndex);
console.trace("Call stack for trimThread:"); // Add this line
return; // No stitches to trim
}
// Get the last stitch position from the last run (in mm)
let lastStitchIndex = lastRun.length - 1;
let currentX = lastRun[lastStitchIndex].x;
let currentY = lastRun[lastStitchIndex].y;
if (_DEBUG) console.log("Adding trim at position:", currentX, currentY);
// Add a special point to indicate thread trim (in mm)
_stitchData.threads[threadIndex].runs.push([
{
x: currentX,
y: currentY,
command: "trim",
},
]);
if (_drawMode === "stitch") {
// draw a scissors emoji at the trim point
_p5Instance.push();
_originalFillFunc.call(_p5Instance, 0);
let lineLength = 10;
let endX = mmToPixel$1(currentX) + lineLength;
let endY = mmToPixel$1(currentY) - lineLength;
_originalStrokeFunc.call(_p5Instance, 255, 0, 0); // red for line
_originalStrokeWeightFunc.call(_p5Instance, 0.5);
_originalLineFunc.call(_p5Instance, mmToPixel$1(currentX), mmToPixel$1(currentY), endX, endY);
// Place translucent white circle at the center of the scissors
_p5Instance.push();
_originalNoStrokeFunc.call(_p5Instance);
_originalFillFunc.call(_p5Instance, 255, 255, 255, 150);
_p5Instance.ellipseMode(CENTER);
_originalEllipseFunc.call(_p5Instance, endX + 6, endY - 5, 20, 20);
_p5Instance.pop();
// Place scissors at end of line
_p5Instance.text("✂️", endX, endY);
_p5Instance.pop();
}
}
};
/**
* Draws stitches according to the current draw mode.
* @method drawStitches
* @private
* @param {Array} stitches - Array of stitch objects with x and y coordinates in mm
* @param {number} threadIndex - Index of the current thread
*/
function drawStitches(stitches, threadIndex) {
// Check for empty stitches array
if (!stitches || stitches.length === 0) {
console.warn("drawStitches: Empty stitches array");
return;
}
let prevX = mmToPixel$1(stitches[0].x);
let prevY = mmToPixel$1(stitches[0].y);
if (_drawMode === "stitch") {
// Draw stitch lines
_p5Instance.push();
for (let i = 1; i < stitches.length; i++) {
let currentX = mmToPixel$1(stitches[i].x);
let currentY = mmToPixel$1(stitches[i].y);
if (i === 1) {
// Draw small dots at stitch points
_originalStrokeFunc.call(_p5Instance, 255, 0, 0); // Red for stitch points
_originalStrokeWeightFunc.call(_p5Instance, 3);
_originalPointFunc.call(_p5Instance, prevX, prevY);
}
// Use the current thread color if defined, otherwise black
_originalStrokeFunc.call(
_p5Instance,
_stitchData.threads[threadIndex].color.r,
_stitchData.threads[threadIndex].color.g,
_stitchData.threads[threadIndex].color.b,
);
_originalStrokeWeightFunc.call(_p5Instance, 1);
_originalLineFunc.call(_p5Instance, prevX, prevY, currentX, currentY);
// Draw small dots at stitch points
_originalStrokeFunc.call(_p5Instance, 255, 0, 0); // Red for stitch points
_originalStrokeWeightFunc.call(_p5Instance, 3);
_originalPointFunc.call(_p5Instance, currentX, currentY);
prevX = currentX;
prevY = currentY;
}
_p5Instance.pop();
} else if (_drawMode === "realistic") {
_p5Instance.push();
_p5Instance.strokeCap(ROUND);
// Draw background dots for thread ends
for (let i = 1; i < stitches.length; i++) {
let currentX = mmToPixel$1(stitches[i].x);
let currentY = mmToPixel$1(stitches[i].y);
_originalNoStrokeFunc.call(_p5Instance);
_originalFillFunc.call(_p5Instance, 15); // White background dots
_originalEllipseFunc.call(_p5Instance, currentX, currentY, 3); // Small white dots at stitch points
// Draw three layers of lines with different weights and colors
// Dark bottom layer - darkened thread color
_originalStrokeFunc.call(
_p5Instance,
_stitchData.threads[threadIndex].color.r * 0.4,
_stitchData.threads[threadIndex].color.g * 0.4,
_stitchData.threads[threadIndex].color.b * 0.4,
);
_originalStrokeWeightFunc.call(_p5Instance, 2.5);
_originalLineFunc.call(_p5Instance, prevX, prevY, currentX, currentY);
// Middle layer - thread color
_originalStrokeFunc.call(
_p5Instance,
_stitchData.threads[threadIndex].color.r,
_stitchData.threads[threadIndex].color.g,
_stitchData.threads[threadIndex].color.b,
);
_originalStrokeWeightFunc.call(_p5Instance, 1.8);
_originalLineFunc.call(_p5Instance, prevX, prevY, currentX, currentY);
// Top highlight layer - lightened thread color
_originalStrokeFunc.call(
_p5Instance,
_stitchData.threads[threadIndex].color.r * 1.8,
_stitchData.threads[threadIndex].color.g * 1.8,
_stitchData.threads[threadIndex].color.b * 1.8,
);
_originalStrokeWeightFunc.call(_p5Instance, 1);
_originalLineFunc.call(_p5Instance, prevX, prevY, currentX, currentY);
prevX = currentX;
prevY = currentY;
}
_p5Instance.strokeCap(SQUARE);
_p5Instance.pop();
}else if (_drawMode === "p5") {
_p5Instance.push();
_p5Instance.strokeCap(ROUND);
// Draw background dots for thread ends
for (let i = 1; i < stitches.length; i++) {
let currentX = mmToPixel$1(stitches[i].x);
let currentY = mmToPixel$1(stitches[i].y);
_originalNoStrokeFunc.call(_p5Instance);
_originalFillFunc.call(_p5Instance, 15); // White background dots
// Middle layer - thread color
_originalStrokeFunc.call(
_p5Instance,
_stitchData.threads[threadIndex].color.r,
_stitchData.threads[threadIndex].color.g,
_stitchData.threads[threadIndex].color.b,
);
_originalStrokeWeightFunc.call(_p5Instance, 1.8);
_originalLineFunc.call(_p5Instance, prevX, prevY, currentX, currentY);
prevX = currentX;
prevY = currentY;
}
_p5Instance.strokeCap(SQUARE);
_p5Instance.pop();
}
// Return the last stitch position for chaining
return stitches.length > 0
? {
x: stitches[stitches.length - 1].x,
y: stitches[stitches.length - 1].y,
}
: { x: startX, y: startY };
}
function getPathBounds(points) {
const xs = points.map((p) => p.x);
const ys = points.map((p) => p.y);
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minY = Math.min(...ys);
const maxY = Math.max(...ys);
return {
x: minX,
y: minY,
w: maxX - minX,
h: maxY - minY,
};
}
function createTatamiFillFromPath(pathPoints, settings) {
// Default settings
const angle = settings.angle || 0;
const spacing = settings.rowSpacing || 0.8;
settings.stitchLength || 3;
// Calculate bounds of the polygon
const bounds = getPathBounds(pathPoints);
// Calculate the center of the path
const centerX = bounds.x + bounds.w / 2;
const centerY = bounds.y + bounds.h / 2;
// Expand bounds to ensure we cover rotated shape
const diagonal = Math.sqrt(bounds.w * bounds.w + bounds.h * bounds.h) * 1.2;
// First pass: collect all valid segments organized by scan line
const scanLineSegments = [];
let forward = true;
// Generate scan lines at the specified angle
for (let d = -diagonal / 2; d <= diagonal / 2; d += spacing) {
// Calculate start and end points for the scan line
const startX = centerX - (diagonal / 2) * Math.cos(angle) - d * Math.sin(angle);
const startY = centerY - (diagonal / 2) * Math.sin(angle) + d * Math.cos(angle);
const endX = centerX + (diagonal / 2) * Math.cos(angle) - d * Math.sin(angle);
const endY = centerY + (diagonal / 2) * Math.sin(angle) + d * Math.cos(angle);
// Find intersections with the polygon
const intersections = segmentIntersectPolygon({ x: startX, y: startY }, { x: endX, y: endY }, pathPoints);
// Sort intersections by distance from start
intersections.sort((a, b) => {
const distA = Math.sqrt((a.x - startX) * (a.x - startX) + (a.y - startY) * (a.y - startY));
const distB = Math.sqrt((b.x - startX) * (b.x - startX) + (b.y - startY) * (b.y - startY));
return distA - distB;
});
// Find valid segments for this scan line
const validSegments = findValidSegments(
intersections,
pathPoints);
// Store segments with their scan line info
for (const segment of validSegments) {
scanLineSegments.push({
start: segment.start,
end: segment.end,
scanLineIndex: scanLineSegments.length,
forward: forward,
});
}
// Alternate direction for next row
forward = !forward;
}
// Second pass: group segments by proximity and create optimized stitch paths
const stitches = createOptimizedStitchPaths(scanLineSegments, settings);
return stitches;
}
// Function to create optimized stitch paths by grouping nearby segments
function createOptimizedStitchPaths(segments, settings = {}) {
if (segments.length === 0) return [];
const stitches = [];
const used = new Array(segments.length).fill(false);
const jumpThreshold = 10; // mm - threshold for inserting trim commands
// Process segments in groups to minimize jumps
for (let i = 0; i < segments.length; i++) {
if (used[i]) continue;
// Start a new region from this segment
const currentRegion = [];
const stack = [i];
// Find all segments connected to this region
while (stack.length > 0) {
const currentIndex = stack.pop();
if (used[currentIndex]) continue;
used[currentIndex] = true;
currentRegion.push(segments[currentIndex]);
// Find nearby segments (within reasonable distance)
for (let j = 0; j < segments.length; j++) {
if (used[j]) continue;
const currentSeg = segments[currentIndex];
const testSeg = segments[j];
// Check if segments are close enough to be in same region
const minDist = Math.min(
Math.sqrt(
(currentSeg.start.x - testSeg.start.x) * (currentSeg.start.x - testSeg.start.x) +
(currentSeg.start.y - testSeg.start.y) * (currentSeg.start.y - testSeg.start.y),
),
Math.sqrt(
(currentSeg.start.x - testSeg.end.x) * (currentSeg.start.x - testSeg.end.x) +
(currentSeg.start.y - testSeg.end.y) * (currentSeg.start.y - testSeg.end.y),
),
Math.sqrt(
(currentSeg.end.x - testSeg.start.x) * (currentSeg.end.x - testSeg.start.x) +
(currentSeg.end.y - testSeg.start.y) * (currentSeg.end.y - testSeg.start.y),
),
Math.sqrt(
(currentSeg.end.x - testSeg.end.x) * (currentSeg.end.x - testSeg.end.x) +
(currentSeg.end.y - testSeg.end.y) * (currentSeg.end.y - testSeg.end.y),
),
);
// If segments are close (within 2 scan line spacings), add to region
if (minDist < 20) {
// Adjust this threshold as needed
stack.push(j);
}
}
}
// Sort segments in current region for optimal stitching order
const optimizedRegion = optimizeRegionStitchOrder(currentRegion, settings);
// Add trim command before this region if it's not the first region and there are existing stitches
if (stitches.length > 0 && optimizedRegion.length > 0) {
// Get the last stitch position
const lastStitch = stitches[stitches.length - 1];
const firstStitch = optimizedRegion[0];
// Calculate distance to first stitch of new region
const jumpDistance = Math.sqrt(
(firstStitch.x - lastStitch.x) * (firstStitch.x - lastStitch.x) +
(firstStitch.y - lastStitch.y) * (firstStitch.y - lastStitch.y),
);
// Insert trim command if jump is too long
if (jumpDistance > jumpThreshold) {
stitches.push({
x: lastStitch.x,
y: lastStitch.y,
command: "trim",
});
}
}
// Add region stitches to final array
stitches.push(...optimizedRegion);
}
return stitches;
}
// Function to optimize stitch order within a region
function optimizeRegionStitchOrder(regionSegments, settings = {}) {
const stitchSettings = {
stitchLength: settings.stitchLength || 2,
minStitchLength: settings.minStitchLength || 0.5,
resampleNoise: settings.resampleNoise || 0,
};
if (regionSegments.length === 0) return [];
if (regionSegments.length === 1) {
const seg = regionSegments[0];
const segmentPath = [
{ x: seg.forward ? seg.start.x : seg.end.x, y: seg.forward ? seg.start.y : seg.end.y },
{ x: seg.forward ? seg.end.x : seg.start.x, y: seg.forward ? seg.end.y : seg.start.y },
];
// Convert path to individual stitches
return convertPathToStitches(segmentPath, stitchSettings);
}
const stitches = [];
const used = new Array(regionSegments.length).fill(false);
used[0] = true;
let currentSeg = regionSegments[0];
const firstSegmentPath = [
{
x: currentSeg.forward ? currentSeg.start.x : currentSeg.end.x,
y: currentSeg.forward ? currentSeg.start.y : currentSeg.end.y,
},
{
x: currentSeg.forward ? currentSeg.end.x : currentSeg.start.x,
y: currentSeg.forward ? currentSeg.end.y : currentSeg.start.y,
},
];
// Convert first segment to stitches
const firstSegmentStitches = convertPathToStitches(firstSegmentPath, stitchSettings);
stitches.push(...firstSegmentStitches);
// Find the nearest unused segment for each subsequent stitch
for (let i = 1; i < regionSegments.length; i++) {
let nearestIndex = -1;
let nearestDist = Infinity;
const lastStitch = stitches[stitches.length - 1];
for (let j = 0; j < regionSegments.length; j++) {
if (used[j]) continue;
const testSeg = regionSegments[j];
// Calculate distance from end of last stitch to start of test segment
const distToStart = Math.sqrt(
(lastStitch.x - testSeg.start.x) * (lastStitch.x - testSeg.start.x) +
(lastStitch.y - testSeg.start.y) * (lastStitch.y - testSeg.start.y),
);
const distToEnd = Math.sqrt(
(lastStitch.x - testSeg.end.x) * (lastStitch.x - testSeg.end.x) +
(lastStitch.y - testSeg.end.y) * (lastStitch.y - testSeg.end.y),
);
const minDist = Math.min(distToStart, distToEnd);
if (minDist < nearestDist) {
nearestDist = minDist;
nearestIndex = j;
}
}
if (nearestIndex !== -1) {
used[nearestIndex] = true;
const nextSeg = regionSegments[nearestIndex];
// // Check if we need a trim command for a long jump within the region
// if (nearestDist > intraRegionJumpThreshold) {
// stitches.push({
// x: lastStitch.x,
// y: lastStitch.y,
// command: "trim"
// });
// }
// Determine orientation based on which end is closer
const distToStart = Math.sqrt(
(lastStitch.x - nextSeg.start.x) * (lastStitch.x - nextSeg.start.x) +
(lastStitch.y - nextSeg.start.y) * (lastStitch.y - nextSeg.start.y),
);
const distToEnd = Math.sqrt(
(lastStitch.x - nextSeg.end.x) * (lastStitch.x - nextSeg.end.x) +
(lastStitch.y - nextSeg.end.y) * (lastStitch.y - nextSeg.end.y),
);
const useForwardDirection = distToStart <= distToEnd;
// Create path for this segment
const segmentPath = [
{
x: useForwardDirection ? nextSeg.start.x : nextSeg.end.x,
y: useForwardDirection ? nextSeg.start.y : nextSeg.end.y,
},
{
x: useForwardDirection ? nextSeg.end.x : nextSeg.start.x,
y: useForwardDirection ? nextSeg.end.y : nextSeg.start.y,
},
];
// Convert segment to individual stitches
const segmentStitches = convertPathToStitches(segmentPath, stitchSettings);
stitches.push(...segmentStitches);
}
}
return stitches;
}
// Function to find valid segments that are inside the polygon
function findValidSegments(intersections, polygon, lineStart, lineEnd) {
const validSegments = [];
// For each pair of intersections, check if the segment between them is inside the polygon
for (let i = 0; i < intersections.length - 1; i += 2) {
if (i + 1 >= intersections.length) break;
const segStart = intersections[i];
const segEnd = intersections[i + 1];
// Check if the midpoint of this segment is inside the polygon
const midX = (segStart.x + segEnd.x) / 2;
const midY = (segStart.y + segEnd.y) / 2;
if (pointInPolygon({ x: midX, y: midY }, polygon)) {
validSegments.push({
start: segStart,
end: segEnd,
});
}
}
return validSegments;
}
// Point-in-polygon test using ray casting algorithm
function pointInPolygon(point, polygon) {
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const xi = polygon[i].x;
const yi = polygon[i].y;
const xj = polygon[j].x;
const yj = polygon[j].y;
if (yi > point.y !== yj > point.y && point.x < ((xj - xi) * (point.y - yi)) / (yj - yi) + xi) {
inside = !inside;
}
}
return inside;
}
// Function to find intersections between a line segment and a polygon
function segmentIntersectPolygon(p1, p2, polygon) {
const intersections = [];
// Check each edge of the polygon
for (let i = 0; i < polygon.length - 1; i++) {
const p3 = polygon[i];
const p4 = polygon[i + 1];
// Check if the line segments intersect
const intersection = lineLineIntersection(p1, p2, p3, p4);
if (intersection) {
intersections.push(intersection);
}
}
// Check the last edge (connecting the last point to the first)
if (polygon.length > 0) {
const p3 = polygon[polygon.length - 1];
const p4 = polygon[0];
const intersection = lineLineIntersection(p1, p2, p3, p4);
if (intersection) {
intersections.push(intersection);
}
}
return intersections;
}
// Function to calculate the intersection point of two line segments
function lineLineIntersection(p1, p2, p3, p4) {
// Line segment 1: p1 to p2
// Line segment 2: p3 to p4
const denominator = (p4.y - p3.y) * (p2.x - p1.x) - (p4.x - p3.x) * (p2.y - p1.y);
// Lines are parallel or coincident
if (denominator === 0) {
return null;
}
const ua = ((p4.x - p3.x) * (p1.y - p3.y) - (p4.y - p3.y) * (p1.x - p3.x)) / denominator;
const ub = ((p2.x - p1.x) * (p1.y - p3.y) - (p2.y - p1.y) * (p1.x - p3.x)) / denominator;
// Check if intersection is within both line segments
if (ua >= 0 && ua <= 1 && ub >= 0 && ub <= 1) {
const intersectionX = p1.x + ua * (p2.x - p1.x);
const intersectionY = p1.y + ua * (p2.y - p1.y);
return { x: intersectionX, y: intersectionY };
}
// No intersection within the line segments
return null;
}
/**
* Creates satin fill stitches from a path.
* @private
* @param {Array<{x: number, y: number}>} pathPoints - Array of path points in mm
* @param {Object} settings - Fill settings
* @returns {Array<{x: number, y: number}>} Array of stitch points in mm
*/
function createSatinFillFromPath(pathPoints, settings) {
if (!pathPoints || pathPoints.length < 3) {
if (_DEBUG) console.log("createSatinFillFromPath: insufficient pathPoints", pathPoints?.length);
return [];
}
// Satin fill uses perpendicular stitches (like columns)
// Stitches run perpendicular to the specified angle
const angle = (settings.angle || 0) * (Math.PI / 180); // Convert to radians
const threadWidth = settings.stitchWidth || 0.2;
const spacing = threadWidth * 0.8; // Slight overlap for complete coverage
const maxStitchLength = settings.stitchLength || 10;
const minStitchLength = settings.minStitchLength || 0.5;
// Calculate bounds of the polygon
const bounds = getPathBounds(pathPoints);
const centerX = bounds.x + bounds.w / 2;
const centerY = bounds.y + bounds.h / 2;
// Expand bounds to ensure we cover rotated shape
const diagonal = Math.sqrt(bounds.w * bounds.w + bounds.h * bounds.h) * 1.2;
// Calculate perpendicular angle (90 degrees offset from fill angle)
// If angle is 0° (horizontal), stitches should be vertical (90°)
const perpAngle = angle + Math.PI / 2;
const stitches = [];
let forward = true; // Track direction for alternating
if (_DEBUG) {
console.log("Satin fill params:", {
angle: settings.angle,
perpAngle: perpAngle * (180 / Math.PI),
spacing,
threadWidth,
bounds,
});
}
// Collect all scan line segments first
const allSegments = [];
// Generate scan lines perpendicular to the fill angle
for (let d = -diagonal / 2; d <= diagonal / 2; d += spacing) {
// Calculate start and end points for the scan line (perpendicular to angle)
const startX = centerX - (diagonal / 2) * Math.cos(perpAngle) - d * Math.sin(perpAngle);
const startY = centerY - (diagonal / 2) * Math.sin(perpAngle) + d * Math.cos(perpAngle);
const endX = centerX + (diagonal / 2) * Math.cos(perpAngle) - d * Math.sin(perpAngle);
const endY = centerY + (diagonal / 2) * Math.sin(perpAngle) + d * Math.cos(perpAngle);
// Find intersections with the polygon
const intersections = segmentIntersectPolygon({ x: startX, y: startY }, { x: endX, y: endY }, pathPoints);
// Sort intersections by distance from start
intersections.sort((a, b) => {
const distA = Math.sqrt((a.x - startX) * (a.x - startX) + (a.y - startY) * (a.y - startY));
const distB = Math.sqrt((b.x - startX) * (b.x - startX) + (b.y - startY) * (b.y - startY));
return distA - distB;
});
// Find valid segments for this scan line
const validSegments = findValidSegments(
intersections,
pathPoints);
// Store segments with direction info
if (validSegments.length > 0) {
allSegments.push({
segments: validSegments,
forward: forward,
});
forward = !forward; // Alternate direction for next line
}
}
// Now create stitches, alternating direction
for (const lineData of allSegments) {
const segments = lineData.segments;
const isForward = lineData.forward;
// Process segments in order (or reverse order)
const segmentsToProcess = isForward ? segments : segments.slice().reverse();
for (const segment of segmentsToProcess) {
let segStart = segment.start;
let segEnd = segment.end;
// If going backward, swap start and end
if (!isForward) {
[segStart, segEnd] = [segEnd, segStart];
}
// Calculate segment length
const segLength = Math.sqrt(
Math.pow(segEnd.x - segStart.x, 2) + Math.pow(segEnd.y - segStart.y, 2),
);
// If segment is very short, skip it
if (segLength < minStitchLength) {
continue;
}
// If segment is longer than max stitch length, subdivide it
if (segLength > maxStitchLength) {
const numSubStitches = Math.ceil(segLength / maxStitchLength);
const dx = (segEnd.x - segStart.x) / numSubStitches;
const dy = (segEnd.y - segStart.y) / numSubStitches;
// Add subdivided stitches
for (let i = 0; i <= numSubStitches; i++) {
stitches.push({
x: segStart.x + dx * i,
y: segStart.y + dy * i,
});
}
} else {
// Add the segment as a single stitch
stitches.push(segStart);
stitches.push(segEnd);
}
}
}
if (_DEBUG) {
console.log("Satin fill generated:", stitches.length, "stitch points (alternating direction)");
}
return stitches;
}
/**
* Creates spiral fill stitches from a path.
* @private
* @param {Array<{x: number, y: number}>} pathPoints - Array of path points in mm
* @param {Object} settings - Fill settings
* @returns {Array<{x: number, y: number}>} Array of stitch points in mm
*/
function createSpiralFillFromPath(pathPoints, settings) {
if (!pathPoints || pathPoints.length < 3) {
if (_DEBUG) console.log("createSpiralFillFromPath: insufficient pathPoints", pathPoints?.length);
return [];
}
const stitches = [];
const bounds = getPathBounds(pathPoints);
const centerX = bounds.x + bounds.w / 2;
const centerY = bounds.y + bounds.h / 2;
// Calculate maximum radius to ensure we fill the entire shape
const maxRadius = (Math.max(bounds.w, bounds.h) / 2) * 1.2;
// Spiral parameters
const spiralSpacing = settings.rowSpacing || 0.8;
const stitchLength = settings.stitchLength || 2;
let currentRadius = 0;
let currentAngle = 0;
let lastPoint = null;
if (_DEBUG) {
console.log("Spiral fill params:", { bounds, centerX, centerY, maxRadius, spiralSpacing, stitchLength });
}
// Generate spiral points from center outward
while (currentRadius <= maxRadius) {
const x = centerX + Math.cos(currentAngle) * currentRadius;
const y = centerY + Math.sin(currentAngle) * currentRadius;
// Check if point is inside the polygon
if (pointInPolygon({ x, y }, pathPoints)) {
if (lastPoint) {
// Add stitches along the path from last point to current point
const segmentStitches = straightLineStitch(lastPoint.x, lastPoint.y, x, y, {
stitchLength: stitchLength,
minStitchLength: settings.minStitchLength || 0.5,
resampleNoise: settings.resampleNoise || 0,
});
stitches.push(...segmentStitches);
} else {
// First point
stitches.push({ x, y });
}
lastPoint = { x, y };
}
// Update angle and radius for next iteration
const angleIncrement = stitchLength / Math.max(currentRadius, 1); // Prevent division by zero
currentAngle += angleIncrement;
currentRadius += (spiralSpacing * angleIncrement) / (2 * Math.PI);
// Safety break to prevent infinite loops
if (currentAngle > 100 * Math.PI) {
if (_DEBUG) console.log("Spiral fill: breaking due to too many iterations");
break;
}
}
if (_DEBUG) {
console.log("Spiral fill generated stitches:", stitches.length);
}
return stitches;
}
/**
* Creates tatami fill with contours (holes).
* @private
* @param {Array<{x: number, y: number}>} mainPath - Main outline path
* @param {Array<Array<{x: number, y: number}>>} contours - Array of contour paths (holes)
* @param {Object} settings - Fill settings
* @returns {Array<{x: number, y: number}>} Array of stitch points in mm
*/
function createTatamiFillWithContours(mainPath, contours, settings) {
if (!mainPath || mainPath.length < 3) {
if (_DEBUG) console.log("createTatamiFillWithContours: insufficient mainPath points");
return [];
}
// Default settings
const angle = settings.angle || 0;
const spacing = settings.rowSpacing || 0.8;
settings.stitchLength || 3;
// Calculate bounds of the main polygon
const bounds = getPathBounds(mainPath);
// Calculate the center of the path
const centerX = bounds.x + bounds.w / 2;
const centerY = bounds.y + bounds.h / 2;
// Expand bounds to ensure we cover rotated shape
const diagonal = Math.sqrt(bounds.w * bounds.w + bounds.h * bounds.h) * 1.2;
// First pass: collect all valid segments organized by scan line
const scanLineSegments = [];
let forward = true;
// Generate scan lines at the specified angle
for (let d = -diagonal / 2; d <= diagonal / 2; d += spacing) {
// Calculate start and end points for the scan line
const startX = centerX - (diagonal / 2) * Math.cos(angle) - d * Math.sin(angle);
const startY = centerY - (diagonal / 2) * Math.sin(angle) + d * Math.cos(angle);
const endX = centerX + (diagonal / 2) * Math.cos(angle) - d * Math.sin(angle);
const endY = centerY + (diagonal / 2) * Math.sin(angle) + d * Math.cos(angle);
const scanLine = { x: startX, y: startY };
const scanLineEnd = { x: endX, y: endY };
// Find intersections with the main polygon
const mainIntersections = segmentIntersectPolygon(scanLine, scanLineEnd, mainPath);
// Find intersections with all contour polygons
let allContourIntersections = [];
for (const contour of contours) {
const contourIntersections = segmentIntersectPolygon(scanLine, scanLineEnd, contour);
allContourIntersections = allContourIntersections.concat(contourIntersections);
}
// Combine and sort all intersections by distance from start
const allIntersections = mainIntersections.concat(allContourIntersections);
allIntersections.sort((a, b) => {
const distA = Math.sqrt((a.x - startX) * (a.x - startX) + (a.y - startY) * (a.y - startY));
const distB = Math.sqrt((b.x - startX) * (b.x - startX) + (b.y - startY) * (b.y - startY));
return distA - distB;
});
// Find valid segments considering contours
const validSegments = findValidSegmentsWithContours(allIntersections, mainPath, contours);
// Store segments with their scan line info
for (const segment of validSegments) {
scanLineSegments.push({
start: segment.start,
end: segment.end,
scanLineIndex: scanLineSegments.length,
forward: forward,
});
}
// Alternate direction for next row
forward = !forward;
}
// Second pass: group segments by proximity and create optimized stitch paths
const stitches = createOptimizedStitchPaths(scanLineSegments, settings);
return stitches;
}
/**
* Find valid segments that are inside the main polygon but outside any contours.
* @private
*/
function findValidSegmentsWithContours(intersections, mainPolygon, contours, lineStart, lineEnd) {
const validSegments = [];
// For each pair of intersections, check if the segment between them is valid
for (let i = 0; i < intersections.length - 1; i += 2) {
if (i + 1 >= intersections.length) break;
const segStart = intersections[i];
const segEnd = intersections[i + 1];
// Check if the midpoint of this segment is inside the main polygon
const midX = (segStart.x + segEnd.x) / 2;
const midY = (segStart.y + segEnd.y) / 2;
const midpoint = { x: midX, y: midY };
// Must be inside main polygon
if (!pointInPolygon(midpoint, mainPolygon)) {
continue;
}
// Must not be inside any contour
let insideContour = false;
for (const contour of contours) {
if (pointInPolygon(midpoint, contour)) {
insideContour = true;
break;
}
}
if (!insideContour) {
validSegments.push({
start: segStart,
end: segEnd,
});
}
}
return validSegments;
}
// Add exportOutline to p5embroidery object
p5embroidery.exportOutline = async function (threadIndex, offsetDistance, filename, outlineType = "convex") {
return await exportOutline(threadIndex, offsetDistance, filename, outlineType, getEmbroideryState());
};
// Add embroideryOutline to p5embroidery object
p5embroidery.embroideryOutline = function (offsetDistance, threadIndex = _strokeThreadIndex, outlineType = "convex", cornerRadius = 0) {
return embroideryOutline(offsetDistance, threadIndex, outlineType, cornerRadius, getEmbroideryState());
};
// Add embroideryOutlineFromPath to p5embroidery object
p5embroidery.embroideryOutlineFromPath = function (
stitchDataArray,
offsetDistance,
threadIndex = _strokeThreadIndex,
outlineType = "convex",
applyTransform = true,
cornerRadius = 0,
) {
return embroideryOutlineFromPath(
stitchDataArray,
offsetDistance,
threadIndex,
outlineType,
applyTransform,
cornerRadius,
getEmbroideryState()
);
};
// Add exportSVGFromPath to p5embroidery object (deprecated - use exportSVG with threads option)
p5embroidery.exportSVGFromPath = async function (threadIndex, filename, options = {}) {
if (!_stitchData || !_stitchData.threads) {
console.warn("🪡 p5.embroider says: No embroidery data to export");
return false;
}
return await exportSVGFromPath(threadIndex, filename, _stitchData, options);
};
// Expose public functions
global.p5embroidery = p5embroidery;
global.beginRecord = p5embroidery.beginRecord;
global.endRecord = p5embroidery.endRecord;
global.exportEmbroidery = p5embroidery.exportEmbroidery;
global.exportDST = p5embroidery.exportDST;
global.exportGcode = p5embroidery.exportGcode;
global.exportSVG = p5embroidery.exportSVG;
global.exportPNG = p5embroidery.exportPNG;
global.trimThread = p5embroidery.trimThread; // Renamed from cutThread
global.embroideryOutline = p5embroidery.embroideryOutline;
global.exportOutline = p5embroidery.exportOutline;
global.exportSVGFromPath = p5embroidery.exportSVGFromPath;
global.setStitch = p5embroidery.setStitch;
global.setStitchWidth = p5embroidery.setStitchWidth;
global.setDrawMode = p5embroidery.setDrawMode;
global.drawStitches = p5embroidery.drawStitches;
global.mmToPixel = mmToPixel$1;
global.pixelToMm = pixelToMm;
global.px2mm = px2mm;
global.mm2px = mm2px;
global.setStrokeMode = p5embroidery.setStrokeMode;
global.setStrokeJoin = p5embroidery.setStrokeJoin;
global.STROKE_MODE = STROKE_MODE;
global.STROKE_JOIN = STROKE_JOIN;
global.FILL_MODE = FILL_MODE;
global.setFillMode = p5embroidery.setFillMode;
global.setFillSettings = p5embroidery.setFillSettings;
global.setStrokeSettings = p5embroidery.setStrokeSettings;
global.setStrokeEntryExit = p5embroidery.setStrokeEntryExit;
// Expose new path-based functions
global.convertPathToStitches = convertPathToStitches;
global.multiLineStitchingFromPath = multiLineStitchFromPath;
global.sashikoStitchingFromPath = sashikoStitchFromPath;
// Expose embroidery guide utilities
global.drawGrid = drawGrid;
global.drawHoopGuides = drawHoopGuides;
global.drawHoop = drawHoop;
global.drawManualHoop = drawManualHoop;
global.drawMachineHoop = drawMachineHoop;
global.drawCornerMarks = drawCornerMarks;
global.drawPaperGuides = drawPaperGuides;
global.drawEmbroideryWorkspace = drawEmbroideryWorkspace;
global.PAPER_SIZES = PAPER_SIZES;
global.HOOP_PRESETS = HOOP_PRESETS;
global.getHoopPreset = getHoopPreset;
global.getPaperSize = getPaperSize;
global.getHoopsByBrand = getHoopsByBrand;
global.getHoopsByType = getHoopsByType;
global.getHoopsBySize = getHoopsBySize;
global.findBestHoop = findBestHoop;
global.getHoopBrands = getHoopBrands;
global.getHoopTypes = getHoopTypes;
// Expose preview viewport utilities
global.setupPreviewViewport = setupPreviewViewport;
global.endPreviewViewport = endPreviewViewport;
global.handlePreviewZoom = handlePreviewZoom;
global.handlePreviewPan = handlePreviewPan;
global.startPreviewPan = startPreviewPan;
global.stopPreviewPan = stopPreviewPan;
global.resetPreviewViewport = resetPreviewViewport;
global.fitPreviewToContent = fitPreviewToContent;
global.getPreviewState = getPreviewState;
global.screenToWorld = screenToWorld;
global.worldToScreen = worldToScreen;
global.drawPreviewControls = drawPreviewControls;
global.handlePreviewControlsPressed = handlePreviewControlsPressed;
global.handlePreviewControlsDragged = handlePreviewControlsDragged;
global.handlePreviewControlsReleased = handlePreviewControlsReleased;
global.zigzagStitchFromPath = zigzagStitchFromPath;
global.rampStitchFromPath = rampStitchFromPath;
global.squareStitchFromPath = squareStitchFromPath;
// Expose debug function
global.setDebugMode = setDebugMode;
// Expose contour functions
global.beginContour =
p5embroidery.beginContour ||
function () {
if (window.beginContour && typeof window.beginContour === "function") {
return window.beginContour.apply(this, arguments);
}
};
global.endContour =
p5embroidery.endContour ||
function () {
if (window.endContour && typeof window.endContour === "function") {
return window.endContour.apply(this, arguments);
}
};
// Create embroidery state object for outline functions
const getEmbroideryState = () => ({
_recording,
_stitchData,
_strokeThreadIndex,
_DEBUG,
applyCurrentTransformToPoints,
convertVerticesToStitches: p5embroidery.convertVerticesToStitches,
_strokeSettings,
_drawMode,
drawStitches
});
/**
* Adds an outline around the embroidery at a specified offset distance.
* @method embroideryOutline
* @for p5
* @param {number} offsetDistance - Distance in mm to offset the outline from the embroidery
* @param {number} [threadIndex] - Thread index to add the outline to (defaults to current stroke thread)
* @param {string} [outlineType='convex'] - Type of outline ('convex', 'bounding')
* @param {number} [cornerRadius=0] - Corner radius in mm for bounding box outlines (only applies to 'bounding' type)
* @example
* function setup() {
* createCanvas(400, 400);
* beginRecord(this);
* // Draw embroidery patterns
* circle(50, 50, 20);
* embroideryOutline(5); // Add 5mm outline around the embroidery
* embroideryOutline(10, 1, "bounding", 5); // Add 10mm bounding box outline with 5mm rounded corners
* endRecord();
* }
*/
global.embroideryOutline = function (offsetDistance, threadIndex = _strokeThreadIndex, outlineType = "convex", cornerRadius = 0) {
return embroideryOutline(offsetDistance, threadIndex, outlineType, cornerRadius, getEmbroideryState());
};
/**
* Creates an outline around specified stitch data at a specified offset distance.
* @method embroideryOutlineFromPath
* @for p5
* @param {Array} stitchDataArray - Array of stitch data objects (each with x, y coordinates)
* @param {number} offsetDistance - Distance in mm to offset the outline from the path
* @param {number} [threadIndex] - Thread index to add the outline to (defaults to current stroke thread)
* @param {string} [outlineType='convex'] - Type of outline ('convex', 'bounding', 'scale')
* @param {boolean} [applyTransform=true] - Whether to apply current transformation to the outline
* @param {number} [cornerRadius=0] - Corner radius in mm for bounding box outlines (only applies to 'bounding' type)
* @returns {Array} Array of outline points {x, y}
* @example
* function setup() {
* createCanvas(400, 400);
* beginRecord(this);
* // Create some stitch data
* let pathData = [{x: 50, y: 50}, {x: 100, y: 50}, {x: 100, y: 100}];
* let outlinePoints = embroideryOutlineFromPath(pathData, 5); // Add 5mm outline
* let roundedOutline = embroideryOutlineFromPath(pathData, 8, 0, "bounding", true, 3); // 8mm bounding box with 3mm corners
* // Use the outline points for further processing
* endRecord();
* }
*/
global.embroideryOutlineFromPath = function (
stitchDataArray,
offsetDistance,
threadIndex = _strokeThreadIndex,
outlineType = "convex",
applyTransform = true,
cornerRadius = 0,
) {
return embroideryOutlineFromPath(
stitchDataArray,
offsetDistance,
threadIndex,
outlineType,
applyTransform,
cornerRadius,
getEmbroideryState()
);
};
/**
* Creates and exports an outline from a specified thread index with the given offset.
* @method exportOutline
* @for p5
* @param {number} threadIndex - Index of the thread to create outline from
* @param {number} offsetDistance - Distance in mm to offset the outline
* @param {string} filename - Output filename with extension (supports .png, .svg, .gcode, .dst)
* @param {string} [outlineType='convex'] - Type of outline ('convex', 'bounding', 'scale')
* @returns {Promise<boolean>} Promise that resolves to true if export was successful
* @example
* function setup() {
* createCanvas(400, 400);
* beginRecord(this);
* // Draw embroidery patterns
* circle(50, 50, 20);
* endRecord();
*
* // Export outline of thread 0 with 5mm offset as SVG
* exportOutline(0, 5, "outline.svg");
*
* // Export outline with bounding box type as G-code
* exportOutline(0, 10, "cut-outline.gcode", "bounding");
* }
*/
global.exportOutline = async function (threadIndex, offsetDistance, filename, outlineType = "convex") {
return await exportOutline(threadIndex, offsetDistance, filename, outlineType, getEmbroideryState());
};
/**
* Exports only the specified thread path as SVG without creating an outline.
* @method exportSVGFromPath
* @for p5
* @deprecated Use exportSVG() with threads option instead
* @param {number} threadIndex - Index of the thread to export
* @param {string} filename - Output filename with .svg extension
* @param {Object} [options={}] - Export options
* @returns {Promise<boolean>} Promise that resolves to true if export was successful
* @example
* function setup() {
* createCanvas(400, 400);
* beginRecord(this);
*
* // Thread 0 - Red circle
* stroke(255, 0, 0);
* circle(50, 50, 20);
*
* // Thread 1 - Blue square
* stroke(0, 0, 255);
* rect(30, 30, 40, 40);
*
* endRecord();
*
* // Old way (deprecated):
* exportSVGFromPath(0, "thread0-path.svg");
*
* // New way (recommended):
* exportSVG("thread0-path.svg", { threads: [0] });
* }
*/
global.exportSVGFromPath = async function (threadIndex, filename, options = {}) {
if (!_stitchData || !_stitchData.threads) {
console.warn("🪡 p5.embroider says: No embroidery data to export");
return false;
}
return await exportSVGFromPath(threadIndex, filename, _stitchData, options);
};
})(typeof globalThis !== "undefined" ? globalThis : window);
/**
* Creates straight line stitches from an array of path points
* @private
* @param {Array<{x: number, y: number}>} pathPoints - Array of path points in mm
* @param {Object} stitchSettings - Settings for the stitches
* @returns {Array<{x: number, y: number}>} Array of stitch points in mm
*/
function straightLineStitchFromPath(pathPoints, stitchSettings = _embroiderySettings) {
if (!pathPoints || pathPoints.length < 2) {
console.warn("Cannot create straight stitching from insufficient path points");
return [];
}
const result = [];
// Process each segment between consecutive points
for (let i = 0; i < pathPoints.length - 1; i++) {
const p1 = pathPoints[i];
const p2 = pathPoints[i + 1];
// For the first segment, include the starting point
if (i === 0) {
result.push({
x: p1.x,
y: p1.y,
});
}
// Calculate segment properties
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const distance = Math.sqrt(dx * dx + dy * dy);
// Skip if the segment is too short
if (distance < stitchSettings.minStitchLength) {
// Still include the endpoint
result.push({
x: p2.x,
y: p2.y,
});
continue;
}
// Calculate number of stitches for this segment
let baseStitchLength = stitchSettings.stitchLength;
let numStitches = Math.floor(distance / baseStitchLength);
let currentDistance = 0;
// Create intermediate stitches along this segment
for (let j = 0; j < numStitches; j++) {
// Add noise to stitch length if specified
let stitchLength = baseStitchLength;
if (stitchSettings.resampleNoise > 0) {
let noise = (Math.random() * 2 - 1) * stitchSettings.resampleNoise;
stitchLength *= 1 + noise;
}
// Update cumulative distance
currentDistance += stitchLength;
let t = Math.min(currentDistance / distance, 1);
// Add the stitch point
result.push({
x: p1.x + dx * t,
y: p1.y + dy * t,
});
}
// Add endpoint of this segment
let remainingDistance = distance - currentDistance;
if (remainingDistance > stitchSettings.minStitchLength || numStitches === 0) {
result.push({
x: p2.x,
y: p2.y,
});
}
}
if (_DEBUG) console.log("Generated straight line path stitches:", result);
return result;
}
}));