Skip to content

Instantly share code, notes, and snippets.

@johnburnmurdoch
Last active December 21, 2020 00:18
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save johnburnmurdoch/60a427a44ea68e152da1771b28af9bdc to your computer and use it in GitHub Desktop.
Save johnburnmurdoch/60a427a44ea68e152da1771b28af9bdc to your computer and use it in GitHub Desktop.
Tool for creating virtual spray paint / masking tape art
height: 700

Painting

  • Click and drag to spray paint
  • Use hue, saturation and lightness sliders to choose paint colour (alpha is fixed at 0.3)

Masking

  • Disk: click for disk centre, drag outward for radius. To adjust position after placing, click and drag to move.
  • Tape: click for start of tape, drag to lay it down. As you drag, a narrow ray shows where the full edge-to-edge tape will end up.

How it works:

  • Spray paint will cover the canvas everywhere except "behind" any masked areas. Remove a mask (by double-clicking), and the areas it was covering is exposed to the paint once more.

Tips:

  • Spray a nice, bright, multicoloured backdrop; then add some masks to create striking patterns, then spray the whole thing in a darker colour, then remove the masks to reveal Pretty Patterns(TM)
<!DOCTYPE html>
<html lang="en-GB">
<head>
<title>HTML5 canvas spray-paint/masking-tape art</title>
<script src="https://unpkg.com/d3"></script>
<script src="https://unpkg.com/d3-jetpack-module"></script>
<meta charset=utf-8>
<style>
body{background: #fff; margin: 20px;}
html, text{font-family: Avenir; font-size: 18px; fill:#43423e; color:#43423e;}
canvas{display: inline-block; cursor: crosshair;}
#canvas{border:1px solid #000; position: absolute;}
#svg{position: absolute; pointer-events: none; overflow:visible;}
#svg.active{pointer-events: all; cursor: crosshair;}
.mask{pointer-events: all; cursor: pointer;}
span{vertical-align: top;}
#diskMask, #tapeMask, #radius{cursor: pointer;}
line,.mask{clip-path:url("#clip");}
</style>
</head>
<body>
<div id=colorPicker>
<span>Hue</span> <canvas id=h></canvas>  
<span>Sat.</span> <canvas id=s></canvas>  
<span>Lightness</span> <canvas id=l></canvas>  
<span style=color:#aaa;>Alpha</span> <canvas id=a></canvas>  
</div>
<div id=color>
<span>Paint colour:</span> <canvas id=c></canvas>       
<span>Spray radius:</span> <span id=radiusText>100</span> <input id=radius type=range min=10 max=100 step=5 value=100></input>       
<span id=diskMask>✚ disk mask</span>       
<span id=tapeMask>✚ tape mask</span>
</div>
<div id=radiusDiv>
</div>
<div id=frame>
<canvas id=canvas></canvas>
<svg id=svg></svg>
</div>
<script type="text/javascript" charset="utf-8">
let width = 970,
height = 600,
pixRatio = window.devicePixelRatio || 1,
scaledWidth = width*pixRatio,
scaledHeight = height*pixRatio,
radius = 100,
paintColour = "hsla(350, 100%, 58%, 0.3)",
h=350,s=100,l=58,a=1,
masks = [],
polygonData = [];
function pointInCircle(p, c, r){
return Math.pow(Math.pow(p.x - c.x,2) + Math.pow(p.y - c.y,2), 0.5) < r;
};
function pointInPolygon(px, py, vs) {
// ray-casting algorithm based on
// http://www.ecse.rpi.edu/Homepages/wrf/Research/Short_Notes/pnpoly.html
let inside = false;
for (let i = 0, j = vs.length - 1; i < vs.length; j = i++) {
let xi = vs[i][0], yi = vs[i][1];
let xj = vs[j][0], yj = vs[j][1];
let intersect = ((yi > py) != (yj > py))
&& (px < (xj - xi) * (py - yi) / (yj - yi) + xi);
if (intersect) inside = !inside;
}
return inside;
};
function getRGBA(x,y){
return "rgba(" + context.getImageData(x*2, y*2, 1, 1).data.join(",") + ")";
}
const path = d3.line()
.x(d => d[0])
.y(d => d[1]);
let svg = d3.select("#svg")
.at({
width: width,
height: height
})
.st({
width: width + "px",
height: height + "px"
});
let defs = svg.append("defs");
defs.append("clipPath#clip")
.append("rect")
.at({
x: 0,
y: 0,
width: width,
height: height
});
function unMask(){
svg.selectAll(".mask")
.on("mousedown", function(){
d3.event.stopPropagation();
})
.on("dblclick", function(){
d3.event.stopPropagation();
let thisMask = d3.select(this);
thisMask.remove();
masks = masks.filter(d => d.id != thisMask.attr("id"));
});
}
function circularMask(cx,cy,r){
svg.append("circle.mask")
// .translate([cx, cy])
.at({
cx: cx,
cy: cy,
id: "_" + (masks.length+1),
r: r,
fill: "#43423e",
stroke: "#43423e",
"fill-opacity": 0.9
});
masks.push({
shape: "circle",
id: "_" + (masks.length+1),
x: cx,
y: cy,
r: r
});
unMask();
}
function polygonMask(points){
svg.append("path.mask")
.at({
d: path(points),
id: "_" + masks.length+1,
fill: "#43423e",
stroke: "#43423e",
"stroke-width": 4,
opacity: 0.9
});
masks.push({
shape: "polygon",
id: "_" + masks.length+1,
points: points
});
unMask();
}
function moveDisk(){
let oldCentre,
newCx,
newCy;
svg.selectAll("circle.mask")
.call(
d3.drag()
.on("start", function(){
let thisMask = d3.select(this);
oldCentre = [+thisMask.attr("cx"), +thisMask.attr("cy")];
oldMouse = d3.mouse(svg.node());
})
.on("drag", function(){
let thisMask = d3.select(this);
let newMouse = d3.mouse(svg.node());
let xMove = newMouse[0]-oldMouse[0],
yMove = newMouse[1]-oldMouse[1];
newCx = oldCentre[0]+xMove;
newCy = oldCentre[1]+yMove;
d3.select(this)
.at({
cx: newCx,
cy: newCy
});
})
.on("end", function(){
let thisMask = d3.select(this);
masks.filter(d => d.id == thisMask.attr("id"))[0].x = newCx;
masks.filter(d => d.id == thisMask.attr("id"))[0].y = newCy;
})
);
}
function addDisk(){
let diskRadius = 5,
diskCentre,
diskCx,
diskCy,
diskDragCentre,
diskDragCx,
diskDragCy,
activeCircle;
svg.classed("active", 1).call(
d3.drag()
.on("start", function(){
diskCentre = d3.mouse(svg.node());
diskCx = diskCentre[0];
diskCy = diskCentre[1];
})
.on("drag", function(){
activeCircle = svg.selectAll("circle#active")
.data([diskCentre])
.enter()
.append("circle#active")
// .translate([diskCx, diskCy])
.at({
cx: diskCx,
cy: diskCy,
r: diskRadius
})
diskDragCentre = d3.mouse(svg.node()),
diskDragCx = diskDragCentre[0],
diskDragCy = diskDragCentre[1];
diskRadius = Math.pow(Math.pow(diskCx-diskDragCx, 2) + Math.pow(diskCy-diskDragCy, 2), 0.5);
svg.selectAll("circle#active").at({
r: diskRadius,
fill: "#43423e",
stroke: "#43423e",
"fill-opacity": 0.9
});
})
.on("end", function(){
svg.selectAll("circle#active").remove()
circularMask(diskCx, diskCy, diskRadius);
svg.classed("active",0);
moveDisk();
})
)
}
function addTape(){
let tape1,
tapeX1,
tapeY1,
tape2,
tapeX2,
tapeY2,
slope,
intercept,
rayX1,
rayY1,
rayX2,
rayY2,
h,
v;
svg.classed("active", 1).call(
d3.drag()
.on("start", function(){
tape1 = d3.mouse(svg.node());
tapeX1 = tape1[0];
tapeY1 = tape1[1];
tape2 = d3.mouse(svg.node());
tapeX2 = tape2[0]+1;
tapeY2 = tape2[1]+1;
svg.selectAll("line#active")
.data([tape1])
.enter()
.append("line#active")
.at({
x1: tapeX1,
y1: tapeY1,
x2: tapeX2,
y2: tapeY2,
stroke: "#43423e",
"stroke-width":24
});
svg.selectAll("line#ray")
.data([tape1])
.enter()
.append("line#ray")
.at({
x1: tapeX1,
y1: tapeY1,
x2: tapeX2,
y2: tapeY2,
stroke: "#000",
"stroke-width":2
});
})
.on("drag", function(){
tape2 = d3.mouse(svg.node());
tapeX2 = tape2[0];
tapeY2 = tape2[1];
slope = (tapeY1-tapeY2)/(tapeX1-tapeX2);
intercept = (tapeY2-(tapeX2*slope));
if(intercept < 0){
rayY1 = 0;
rayX1 = -intercept/slope;
if(slope*width+intercept > height){
rayY2 = height;
rayX2 = (rayY2-intercept)/slope;
}else{
rayX2 = width;
rayY2 = (slope*rayX2)+intercept;
}
}else if(intercept > height){
rayY1 = height;
rayX1 = (rayY1-intercept)/slope;
if(slope*width+intercept < 0){
rayY2 = 0;
rayX2 = -intercept/slope;
}else{
rayX2 = width;
rayY2 = (slope*rayX2)+intercept;
}
}else{
rayX1 = 0;
rayY1 = intercept;
if(slope*width+intercept < 0){
rayY2 = 0;
rayX2 = -intercept/slope;
}else if(slope*width+intercept > height){
rayY2 = height;
rayX2 = (rayY2-intercept)/slope;
}else{
rayX2 = width;
rayY2 = (slope*rayX2)+intercept;
}
}
svg.selectAll("line#active")
.at({
x1: tapeX1,
y1: tapeY1,
x2: tapeX2,
y2: tapeY2
});
svg.selectAll("line#ray")
.at({
x1: rayX1,
y1: rayY1,
x2: rayX2,
y2: rayY2
});
})
.on("end", function(){
svg.selectAll("line").remove();
h = Math.abs(12 / Math.cos((Math.PI/180) * (90/(Math.abs(slope)+1))));
v = Math.abs(12 / Math.cos((Math.PI/180) * (90-90/(Math.abs(slope)+1))));
if(slope > 0){
polygonMask([ [rayX1, rayY1-v], [rayX2+h, rayY2], [rayX2+h, rayY2+2*v], [rayX1-2*h, rayY1-v] ]);
}else{
polygonMask([ [rayX1, rayY1+v], [rayX2+2*h, rayY2-v], [rayX2, rayY2-v], [rayX1-h, rayY1] ]);
}
svg.classed("active",0);
})
)
}
function addPoint(){
let pointIndex,
pointCentre,
pointCx,
pointCy,
activePoint;
svg.classed("active", 1).call(
d3.drag()
.on("start", function(){
pointIndex = polygonData.length;
pointCentre = d3.mouse(svg.node());
pointCentre.push(pointIndex);
polygonData[pointIndex] = pointCentre;
})
.on("drag", function(){
activePoint = svg.selectAll("circle#active")
.data(polygonData)
.enter()
.append("circle#active")
.translate(d => [d[0], d[1]])
.at({
r: 5,
fill: "red"
});
pointCentre = d3.mouse(svg.node());
pointCentre.push(pointIndex);
polygonData[pointIndex] = pointCentre;
svg.selectAll("circle#active")
.translate(d => [d[0], d[1]]);
})
// .on("end", function(){
// svg.selectAll("circle#active").remove();
// polygonMask(polygonData);
// svg.classed("active",0);
// })
)
}
d3.select("#diskMask").on("click", addDisk);
d3.select("#tapeMask").on("click", addTape);
function pickColor(){
colContext.clearRect(0,0,100,40);
// paintColour = `hsla(${h},${s}%,${l}%,${a})`;
paintColour = `hsla(${h},${s}%,${l}%,0.3)`;
colContext.fillStyle = paintColour;
colContext.beginPath();
colContext.rect(0,0,100,40);
colContext.fill();
colContext.closePath();
hueContext.clearRect(0,0,180,40);
d3.range(0,360).forEach(d => {
hueContext.fillStyle = `hsla(${d},${s}%,${l}%,${a})`;
hueContext.beginPath();
hueContext.rect(d/2,0,1,40);
hueContext.fill();
hueContext.closePath();
});
satContext.clearRect(0,0,180,40);
d3.range(0,100).forEach(d => {
satContext.fillStyle = `hsla(${h},${d}%,${l}%,${a})`;
satContext.beginPath();
satContext.rect(d*4,0,4,40);
satContext.fill();
satContext.closePath();
});
ligContext.clearRect(0,0,180,40);
d3.range(0,100).forEach(d => {
ligContext.fillStyle = `hsla(${h},${s}%,${d}%,${a})`;
ligContext.beginPath();
ligContext.rect(d*4,0,4,40);
ligContext.fill();
ligContext.closePath();
});
alpContext.clearRect(0,0,180,40);
d3.range(0,1,0.01).forEach(d => {
alpContext.fillStyle = `hsla(${h},${s}%,${l}%,${d})`;
alpContext.beginPath();
alpContext.rect(d*400,0,4,40);
alpContext.fill();
alpContext.closePath();
});
}
d3.select("#radius")
.on("change", function(){
radius = +d3.select(this).node().value;
d3.select("#radiusText").html(radius);
outerCircle = d3.range(0, radius*radius*16);
outerCircle.forEach(function(i){
outerCircle[i] = {x: i % (radius*4)/2, y: Math.ceil(i/(radius*4))/2}
});
outerCircle = outerCircle
.filter(d => innerCircle.indexOf(d) <= 0)
.filter(d => pointInCircle(d, {x:radius, y:radius}, radius));
});
const hue = d3.select("#h")
.at({
width: 360,
height: 40,
})
.st({
width: "180px",
height: "20px"
});
const hueContext = hue.node().getContext("2d");
hueContext.scale(pixRatio, pixRatio);
hue.call(
d3.drag()
.on("drag", function(){
h = d3.mouse(hue.node())[0]*2;
pickColor();
})
)
.on("click", function(){
h = d3.mouse(hue.node())[0]*2;
pickColor();
});
d3.range(0,360).forEach(d => {
hueContext.fillStyle = `hsla(${d},${s}%,${l}%,${a})`;
hueContext.beginPath();
hueContext.rect(d/2,0,1,40);
hueContext.fill();
hueContext.closePath();
});
const sat = d3.select("#s")
.at({
width: 360,
height: 40,
})
.st({
width: "180px",
height: "20px"
});
const satContext = sat.node().getContext("2d");
satContext.scale(pixRatio, pixRatio);
sat.call(
d3.drag()
.on("drag", function(){
s = d3.mouse(sat.node())[0]/1.8;
pickColor();
})
)
.on("click", function(){
s = d3.mouse(sat.node())[0]/1.8;
pickColor();
});
d3.range(0,100).forEach(d => {
satContext.fillStyle = `hsla(${h},${d}%,${l}%,${a})`;
satContext.beginPath();
satContext.rect(d*4,0,4,40);
satContext.fill();
satContext.closePath();
});
const lig = d3.select("#l")
.at({
width: 360,
height: 40,
})
.st({
width: "180px",
height: "20px"
});
const ligContext = lig.node().getContext("2d");
ligContext.scale(pixRatio, pixRatio);
lig.call(
d3.drag()
.on("drag", function(){
l = d3.mouse(lig.node())[0]/1.8;
pickColor();
})
)
.on("click", function(){
l = d3.mouse(lig.node())[0]/1.8;
pickColor();
});
d3.range(0,100).forEach(d => {
ligContext.fillStyle = `hsla(${h},${s}%,${d}%,${a})`;
ligContext.beginPath();
ligContext.rect(d*4,0,4,40);
ligContext.fill();
ligContext.closePath();
});
const alp = d3.select("#a")
.at({
width: 360,
height: 40,
})
.st({
width: "180px",
height: "20px"
});
const alpContext = alp.node().getContext("2d");
alpContext.scale(pixRatio, pixRatio);
alp.call(
d3.drag()
.on("drag", function(){
a = d3.mouse(alp.node())[0]/180;
pickColor();
})
)
.on("click", function(){
a = d3.mouse(alp.node())[0]/180;
pickColor();
});
d3.range(0,1,0.01).forEach(d => {
alpContext.fillStyle = `hsla(${h},${s}%,${l}%,${d})`;
alpContext.beginPath();
alpContext.rect(d*400,0,4,40);
alpContext.fill();
alpContext.closePath();
});
const col = d3.select("#c")
.at({
width: 100,
height: 40,
})
.st({
width: "50px",
height: "20px"
});
const colContext = col.node().getContext("2d");
colContext.scale(pixRatio, pixRatio);
colContext.fillStyle = "hsla(350, 100%, 58%, 1)";
colContext.beginPath();
colContext.rect(0,0,100,40);
colContext.fill();
colContext.closePath();
let canvas = d3.select("#canvas")
.at({
width: scaledWidth,
height: scaledHeight
})
.st({
width: width + "px",
height: height + "px"
});
let context = canvas.node().getContext("2d");
context.scale(pixRatio, pixRatio);
context.save();
context.fillStyle = "rgba(255,255,255,1)";
// context.fillStyle = "rgba(0,0,0,1)";
context.beginPath();
context.rect(0,0,scaledWidth,scaledHeight);
context.fill();
context.closePath();
let innerCircle = d3.range(0, 0);
innerCircle.forEach(function(i){
innerCircle[i] = {x: i % (radius) + radius/2, y: Math.ceil(i/(radius)) + radius/2}
});
innerCircle = innerCircle.filter(d => pointInCircle(d, {x:radius, y:radius}, radius/2));
let outerCircle = d3.range(0, radius*radius*16);
outerCircle.forEach(function(i){
outerCircle[i] = {x: i % (radius*4)/2, y: Math.ceil(i/(radius*4))/2}
});
outerCircle = outerCircle
.filter(d => innerCircle.indexOf(d) <= 0)
.filter(d => pointInCircle(d, {x:radius, y:radius}, radius));
context.fillStyle = paintColour;
function spray(xy){
let pointsInCircle = innerCircle
.filter(function(){return Math.random() > 0.75})
.concat(outerCircle.filter(function(){return Math.random() > 0.97}));
masks.forEach(function(m){
if(m.shape == "circle"){
pointsInCircle = pointsInCircle
.filter(function(d){
let circle = {x:xy.x + d.x - radius, y: xy.y + d.y - radius};
return !pointInCircle(circle, {x:m.x, y:m.y}, m.r)
});
}else if(m.shape == "polygon"){
pointsInCircle = pointsInCircle
.filter(function(d){
let circle = {x:xy.x + d.x - radius, y: xy.y + d.y - radius};
return !pointInPolygon(circle.x, circle.y, m.points)
});
}
})
pointsInCircle.forEach(d => {
context.fillStyle = paintColour;
context.fillRect(xy.x + d.x - radius,xy.y + d.y - radius,0.5,0.5);
});
context.restore();
};
canvas.call(
d3.drag()
.on("drag", function(){
let xy = d3.mouse(canvas.node()),
_x = xy[0],
_y = xy[1];
spray({x: _x, y: _y});
})
);
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment