-
Notifications
You must be signed in to change notification settings - Fork 394
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #68 from stombeur/master
example that shows how to pen-plot arcs
- Loading branch information
Showing
2 changed files
with
341 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |