block by johnburnmurdoch 60a427a44ea68e152da1771b28af9bdc

Tool for creating virtual spray paint / masking tape art

Full Screen

Painting

Masking

How it works:

Tips:

index.html

<!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
		    // //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>