Skip to content

Commit

Permalink
Merge pull request #68 from stombeur/master
Browse files Browse the repository at this point in the history
 example that shows how to pen-plot arcs
  • Loading branch information
alvinometric authored Oct 18, 2023
2 parents 867cd18 + 1c2fd18 commit 1fc6555
Show file tree
Hide file tree
Showing 2 changed files with 341 additions and 0 deletions.
93 changes: 93 additions & 0 deletions examples/pen-plotter-circle-segments.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* A Canvas2D + SVG Pen Plotter example with circles missing a quarter segment
*
* @author Stephane Tombeur (https://github.com/stombeur)
*/

const canvasSketch = require('canvas-sketch');
const penplot = require('./util/penplotsvg');

// create an instance of SvgFile to store the svg lines and arcs
const svgFile = new penplot.SvgFile();

const settings = {
dimensions: 'A3',
orientation: 'portrait',
pixelsPerInch: 300,
scaleToView: true,
units: 'cm',
};

const getRandomInt = (max, min = 0) => min + Math.floor(Math.random() * Math.floor(max));

const sketch = (context) => {
let margin = 0.2;
let radius = 1;
let columns = 8;
let rows = 14;

let drawingWidth = (columns * (radius * 2 + margin)) - margin;
let drawingHeight = (rows * (radius * 2 + margin)) - margin;
let marginLeft = (context.width - drawingWidth) / 2;
let marginTop = (context.height - drawingHeight) / 2;

// randomize missing circle segments
let o = [];
for (let r = 0; r < rows; r++) {
o[r] = [];
for (let i = 0; i < columns; i++) {
let angle = getRandomInt(4,0) * 90; // there are four segments of 90degrees in a circle
o[r].push(angle);
}
}

return ({ context, width, height, units }) => {
// draw an arc on the canvas and also add it to the svg file
const drawArc = (cx, cy, radius, sAngle, eAngle) => {
context.beginPath();
context.arc(cx, cy, radius, (Math.PI / 180) * sAngle, (Math.PI / 180) * eAngle);
context.stroke();

svgFile.addArc(cx, cy, radius, sAngle, eAngle);
}

context.fillStyle = 'white';
context.fillRect(0, 0, width, height);
context.strokeStyle = 'black';
context.lineWidth = 0.01;

let posX = marginLeft;
let posY = marginTop;

let increments = 15; // nr of lines inside the circle
let step = radius / increments;

for (let r = 0; r < rows; r++) {
for (let c = 0; c < columns; c++) {
for (let s = 0; s < (increments); s++) {
// draw a 270degree arc, starting from a random 90degree segment
drawArc(posX + radius, posY + radius, s * step, o[r][c], o[r][c] + 270);
}
posX = posX + (radius * 2) + margin;
}
posX = marginLeft;
posY = posY + radius * 2 + margin;
}

return [
// Export PNG as first layer
context.canvas,
// Export SVG for pen plotter as second layer
{
data: svgFile.toSvg({
width,
height,
units
}),
extension: '.svg',
}
];
};
};

canvasSketch(sketch, settings);
248 changes: 248 additions & 0 deletions examples/util/penplotsvg.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
const defined = require('defined');
const convertUnits = require('convert-units');
var convert = require('convert-length');

// 96 DPI for SVG programs like Inkscape etc
const TO_PX = 35.43307;
var DEFAULT_PIXELS_PER_INCH = 90;
var DEFAULT_PEN_THICKNESS = 0.03;
var DEFAULT_PEN_THICKNESS_UNIT = 'cm';

function cm(value, unit) {
return convertUnits(value)
.from(unit)
.to('cm');
}

// create svg paths from polylines [[x,y], ...]
function polyLinesToSvgPaths(polylines, opt = {}) {
if (!opt.units || typeof opt.units !== 'string')
throw new TypeError(
'must specify { units } string as well as dimensions, such as: { units: "in" }'
);
const units = opt.units.toLowerCase();
const decimalPlaces = 5;

let commands = [];
polylines.forEach(line => {
line.forEach((point, j) => {
const type = j === 0 ? 'M' : 'L';
const x = (TO_PX * cm(point[0], units)).toFixed(decimalPlaces);
const y = (TO_PX * cm(point[1], units)).toFixed(decimalPlaces);
commands.push(`${type} ${x} ${y}`);
});
});

return commands;
}

// create svg paths from Arc objects
function arcsToSvgPaths(arcs, opt = {}) {
if (!opt.units || typeof opt.units !== 'string')
throw new TypeError(
'must specify { units } string as well as dimensions, such as: { units: "in" }'
);
const units = opt.units.toLowerCase();
if (units === 'px')
throw new Error(
'px units are not yet supported by this function, your print should be defined in "cm" or "in"'
);

let commands = [];
arcs.forEach(input => {
let arc = input.toSvgPixels(units);
commands.push(
`M${arc.startX} ${arc.startY} A${arc.radiusX},${arc.radiusY} ${
arc.rotX
} ${arc.largeArcFlag},${arc.sweepFlag} ${arc.endX},${arc.endY}`
);
});

return commands;
}

// convert paths to an svg file
// mostly formatting into svg-xml
function pathsToSvgFile(paths, opt = {}) {
opt = opt || {};

var width = opt.width;
var height = opt.height;

var computeBounds =
typeof width === 'undefined' || typeof height === 'undefined';
if (computeBounds) {
throw new Error('Must specify "width" and "height" options');
}

var units = opt.units || 'px';

var convertOptions = {
roundPixel: false,
precision: defined(opt.precision, 5),
pixelsPerInch: DEFAULT_PIXELS_PER_INCH
};
var svgPath = paths.join(' ');
var viewWidth = convert(width, units, 'px', convertOptions).toString();
var viewHeight = convert(height, units, 'px', convertOptions).toString();
var fillStyle = opt.fillStyle || 'none';
var strokeStyle = opt.strokeStyle || 'black';
var lineWidth = opt.lineWidth;

// Choose a default line width based on a relatively fine-tip pen
if (typeof lineWidth === 'undefined') {
// Convert to user units
lineWidth = convert(
DEFAULT_PEN_THICKNESS,
DEFAULT_PEN_THICKNESS_UNIT,
units,
convertOptions
).toString();
}

return [
'<?xml version="1.0" standalone="no"?>',
' <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" ',
' "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">',
' <svg width="' + width + units + '" height="' + height + units + '"',
' xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 ' + viewWidth + ' ' + viewHeight + '">',
' <g>',
' <path d="' + svgPath + '" fill="' + fillStyle + '" stroke="' + strokeStyle + '" stroke-width="' + lineWidth + units + '" />',
' </g>',
'</svg>'
].join('\n');
}

// container class for the data needed to create an svg arc path
class Arc {
constructor() {
this.startX = 0;
this.startY = 0;
this.endX = 0;
this.endY = 0;
this.radiusX = 0;
this.radiusY = 0;
this.rotX = 0;
this.largeArcFlag = 0;
this.sweepFlag = 1;
}

// transform canvas pixels to svg
toSvgPixels(units, decimalPlaces = 5) {
let a = new Arc();
a.startX = (TO_PX * cm(this.startX, units)).toFixed(decimalPlaces);
a.startY = (TO_PX * cm(this.startY, units)).toFixed(decimalPlaces);
a.endX = (TO_PX * cm(this.endX, units)).toFixed(decimalPlaces);
a.endY = (TO_PX * cm(this.endY, units)).toFixed(decimalPlaces);

a.radiusX = (TO_PX * cm(this.radiusX, units)).toFixed(decimalPlaces);
a.radiusY = (TO_PX * cm(this.radiusY, units)).toFixed(decimalPlaces);
a.rotX = this.rotX;
a.largeArcFlag = this.largeArcFlag;
a.sweepFlag = this.sweepFlag;

return a;
}
}

// this class makes it easier to handle svg output
// use in this order:
// - new SvgFile()
// - addLine or addArc or addCircle (repeat x times)
// - toSvg(options)
class SvgFile {
constructor(options = {}) {
this.lines = [];
this.arcs = [];
this.options = options;
}

addLine(line) {
this.lines.push(line);
}

addCircle(cx, cy, radius) {
this.arcs.push(...createCircle(cx, cy, radius));
}

addArc(cx, cy, radius, sAngle, eAngle) {
this.arcs.push(createArc(cx, cy, radius, sAngle, eAngle));
}

toSvg(options = null){
if (!options) { options = this.options; }
let lineCommands = polyLinesToSvgPaths(this.lines, options);
let arcCommands = arcsToSvgPaths(this.arcs, options);
return pathsToSvgFile([...lineCommands, ...arcCommands], options);
}
}

// create a circle from 2 180degree arcs
// (a single 360 degree arc cancels itself out in svg)
function createCircle(cx, cy, radius) {
let a1 = new Arc();
a1.startX = cx + radius;
a1.startY = cy;
a1.endX = cx - radius;
a1.endY = cy;
a1.radiusX = radius;
a1.radiusY = radius;

let a2 = new Arc();
a2.startX = cx - radius;
a2.startY = cy;
a2.endX = cx + radius;
a2.endY = cy;
a2.radiusX = radius;
a2.radiusY = radius;

return [a1, a2];
}

// create an Arc object
function createArc(cx, cy, radius, sAngle, eAngle) {
let zeroX = cx + radius,
zeroY = cy,
start = rotate([zeroX, zeroY], [cx, cy], sAngle),
end = rotate([zeroX, zeroY], [cx, cy], eAngle);

// zero = 3 o'clock like in canvas2D
// to calculate the x,y for the start of the arc, we rotate [zero] around [cx,cy] for [sAngle] degrees
// same for the end of the arc, but [eAngle] degrees

let a1 = new Arc();
a1.radiusX = radius;
a1.radiusY = radius;
a1.startX = start[0];
a1.startY = start[1];
a1.endX = end[0];
a1.endY = end[1];
// if the arc spans >= 180 degrees, use the large-arc-flag
a1.largeArcFlag = (eAngle - sAngle) >= 180 ? 1 : 0;

return a1;
}

// rotate a [point] over [angle] degrees around [center]
const rotate = (point, center, angle) => {
if (angle === 0) return point;

let radians = (Math.PI / 180) * angle,
x = point[0],
y = point[1],
cx = center[0],
cy = center[1],
cos = Math.cos(radians),
sin = Math.sin(radians),
nx = cos * (x - cx) - sin * (y - cy) + cx,
ny = cos * (y - cy) + sin * (x - cx) + cy;
return [nx, ny];
};

module.exports.arcsToSvgPaths = arcsToSvgPaths;
module.exports.polyLinesToSvgPaths = polyLinesToSvgPaths;
module.exports.pathsToSvgFile = pathsToSvgFile;
module.exports.Arc = Arc;
module.exports.SvgFile = SvgFile;
module.exports.createCircle = createCircle;
module.exports.createArc = createArc;

0 comments on commit 1fc6555

Please sign in to comment.