Source code of plot #059 back to plot
Download full working sketch as 059.tar.gz.
Unzip, then start a local web server and load the page in a browser.
Unless otherwise noted, code published here is © Gábor L Ugray, shared under the Creative Commons
BY-NC-SA license (Attribution, Non-Commercial, Share-Alike). Files in lib/thirdparty
, and additional
libraries in the downloadable archive, are shared under their respective open-source licenses, attributed
to their authors.
import * as E from "./lib/env.js";
import {mulberry32, rand, rand_range, setRandomGenerator, shuffle} from "./lib/own/random.js";
import * as G from "./lib/own/geo2.js"
import Clipper2ZFactory from "./lib/thirdparty/clipper2z/clipper2z.js";
// Declarations below instruct build plugin to copy static files to runtime dir
// STATIC lib/thirdparty/clipper2z/clipper2z.wasm
// STATIC lib/texture.png
const pw = 2100; // Paper width
const ph = 1480; // Paper height
const w = 2100; // Drawing width
const h = 1480; // Drawing height
const margin = 250;
let seed = Math.round(Math.random() * 65535);
// seed = 30495;
/**
* @type {MainModule}
*/
let Clipper2Z;
// Clipper uses integer coordinates.
// Sketch multiplies canvas coordinates by this amount before doing geometry in Clipper.
const clipperMul = 50;
void setup();
async function setup() {
E.initEnv(w, h, pw, ph);
console.log(`Seed: ${seed}`);
E.info("Seed: " + seed);
setRandomGenerator(mulberry32(seed));
Clipper2Z = await Clipper2ZFactory();
await draw();
}
async function draw() {
const framePts = [
new G.Vec2(margin, margin),
new G.Vec2(w - margin, margin),
new G.Vec2(w - margin, h - margin),
new G.Vec2(margin, h - margin),
]
// let frame = new Path({segments: framePts, closed: true});
// project.activeLayer.addChild(frame);
const sh = new SpaceHash(10, 7);
const plates = [];
const segs = [];
const addSegs = pts => {
for (let ptIx = 0; ptIx < pts.length; ++ptIx) {
segs.push([pts[ptIx], pts[(ptIx + 1) % pts.length]]);
}
}
// Centers of circular plating
const o1 = new G.Vec2(w * 0.7, -h * 0.1);
const o2 = new G.Vec2(w * -0.1, h * 0.2);
// Stop generating when no more free spot found after this many tries
const stopAtNFails = 250;
// Don't keep plate if visible part smaller than this
const minAblatedArea = 200;
// If plate at least this big, draw inset
const insetMinArea = 5000;
// Inset from edge
const insetBy = 8;
// Small value for approximate equality checks in merging line segments
const mergeSegmentsEpsilon = 0.005;
let failCount = 0;
while (true) {
if ((sh.shapeCount%100) == 0) await E.spin();
if (failCount > stopAtNFails) break;
const shapeCenter = new G.Vec2(w * rand(), h * rand());
const odir1 = G.sub2(o1, shapeCenter);
const odir2 = G.sub2(o2, shapeCenter);
const orig = odir2.length() < 1200 ? odir2 : odir1;
let orad = orig.angle() + Math.PI * 0.5;
orad += 0.1 * (rand() - 0.5);
let [wrange, hrange] = [[10, 150], [10, 90]];
if (orig == odir2)
[wrange, hrange] = [[10, 80], [10, 40]];
const rw = rand_range(...wrange);
const rh = rand_range(...hrange);
const plate = new Plate(shapeCenter, rw, rh, orad);
ablate(plate, sh, framePts);
let ablatedArea = 0;
plate.visibleParts.forEach(vp => ablatedArea += vp.area);
if (ablatedArea < minAblatedArea) {
++failCount;
continue;
}
failCount = 0;
plates.push(plate);
sh.addShape(plate);
for (const vp of plate.visibleParts) {
addSegs(vp.pts);
const path = E.addPath(vp.pts, true);
plate.paths.push(path);
if (vp.area > insetMinArea) {
const insetPaths = getInset(vp.pts, insetBy);
for (const pts of insetPaths) {
addSegs(pts);
const insetPath = E.addPath(pts, true);
plate.paths.push(insetPath);
}
}
}
}
console.log(`Kept ${sh.shapeCount} shapes with visible parts: ${segs.length} line segments`);
// Add frame as segments
addSegs([new G.Vec2(margin, margin), new G.Vec2(w-margin, margin),
new G.Vec2(w-margin, h-margin), new G.Vec2(margin, h-margin)]);
// Merge segments globally to eliminate multiple draws over same section
const mergedSegs = G.mergeSegments(segs, mergeSegmentsEpsilon);
const paths = [];
mergedSegs.forEach(([a, b]) => paths.push({pts: [a, b]}));
// Join segments globally into paths
const joinedPaths = G.joinPaths(paths, 0.5);
console.log(`Joined paths: ${joinedPaths.length}`);
// Order paths for smaller pen-up travel
const orderedPaths = G.optimizePathOrder(joinedPaths);
// Remove original plates; we'll be drawing the reconstructed, optimized paths
await E.spin(100);
for (const plate of plates) {
for (const path of plate.paths)
path.remove();
}
// Redraw joined paths
for (let i = 0; i < orderedPaths.length; ++i) {
// await E.spin(500); // DBG
if ((i%3) == 0) await E.spin();
const pts = orderedPaths[i];
E.addPath(pts, false);
}
}
// DBG: join paths segment by segment on key press
function keyDraw(paths) {
let i = 0, j = 0;
document.addEventListener("keydown", () => {
if (i == paths.length) return;
let pts = paths[i];
if (j >= pts.length - 1) {
++i;
if (i == paths.length) return;
j = 0;
pts = paths[i];
}
// G.logPts(pts);
const [a, b] = [pts[j], pts[j+1]];
++j;
const ln = Path.Line(a, b);
project.activeLayer.addChild(ln);
});
}
class Plate {
constructor(center, rw, rh, rot) {
this.corners = [
new G.Vec2(-0.5 * rw, -0.5 * rh),
new G.Vec2(0.5 * rw, -0.5 * rh),
new G.Vec2(0.5 * rw, 0.5 * rh),
new G.Vec2(-0.5 * rw, 0.5 * rh),
];
this.corners.forEach(pt => pt.rot(rot));
this.corners.forEach(pt => pt.add(center));
this.visibleParts = [];
this.paths = [];
this.bounds = getBounds(this.corners);
}
}
function getBounds(pts, xtra = 0) {
const bounds = {
top: Number.MAX_VALUE,
bottom: Number.MIN_VALUE,
left: Number.MAX_VALUE,
right: Number.MIN_VALUE,
};
for (const pt of pts) {
bounds.top = Math.min(bounds.top, pt.y - xtra);
bounds.bottom = Math.max(bounds.bottom, pt.y + xtra);
bounds.left = Math.min(bounds.left, pt.x - xtra);
bounds.right = Math.max(bounds.right, pt.x + xtra);
}
bounds.width = bounds.right - bounds.left;
bounds.height = bounds.bottom - bounds.top;
return bounds;
}
// Partitions canvas area into cells, to calculate fewer polygon intersections
class SpaceHash {
constructor(nHoriz, nVert) {
this.shapeCount = 0;
this.bucketW = w / nHoriz;
this.bucketH = h / nVert;
this.cols = [];
for (let i = 0; i < nHoriz; ++i) {
const row = [];
this.cols.push(row);
for (let j = 0; j < nVert; ++j) {
row.push([])
}
}
}
addShape(shape) {
++this.shapeCount;
const [firstColIx, lastColIx, firstRowIx, lastRowIx] = this.getRanges(shape.bounds);
for (let i = firstColIx; i <= lastColIx; ++i) {
const col = this.cols[i];
for (let j = firstRowIx; j <= lastRowIx; ++j) {
col[j].push(shape);
}
}
}
getShapes(bounds) {
const [firstColIx, lastColIx, firstRowIx, lastRowIx] = this.getRanges(bounds);
const shapeSet = new Set();
for (let i = firstColIx; i <= lastColIx; ++i) {
const col = this.cols[i];
for (let j = firstRowIx; j <= lastRowIx; ++j) {
col[j].forEach(shape => shapeSet.add(shape));
}
}
return [...shapeSet];
}
getRanges(bounds) {
let firstColIx = Math.floor(bounds.left / this.bucketW);
let lastColIx = Math.floor(bounds.right / this.bucketW);
let firstRowIx = Math.floor(bounds.top / this.bucketH);
let lastRowIx = Math.floor(bounds.bottom / this.bucketH);
firstColIx = Math.min(Math.max(firstColIx, 0), this.cols.length - 1);
lastColIx = Math.min(Math.max(lastColIx, 0), this.cols.length - 1);
firstRowIx = Math.min(Math.max(firstRowIx, 0), this.cols[0].length - 1);
lastRowIx = Math.min(Math.max(lastRowIx, 0), this.cols[0].length - 1);
return [firstColIx, lastColIx, firstRowIx, lastRowIx];
}
}
// Removes existing shapes from a new plate
function ablate(plate, sh, framePts) {
let clShape = toClipperPolys(plate.corners);
if (framePts) {
const clFrame = toClipperPolys(framePts);
clShape = Clipper2Z.Intersect64(clShape, clFrame, Clipper2Z.FillRule.NonZero);
}
const nearbyShapes = sh.getShapes(plate.bounds);
for (const hider of nearbyShapes) {
const clHider = toClipperPolys(hider.corners);
clShape = Clipper2Z.Difference64(clShape, clHider, Clipper2Z.FillRule.NonZero);
}
let hadNegative = false;
for (let i = 0; i < clShape.size(); ++i) {
const path64 = clShape.get(i);
if (!Clipper2Z.IsPositive64(path64)) {
hadNegative = true;
break;
}
const pts = getClipperPathPoints(path64);
plate.visibleParts.push({
pts,
area: Clipper2Z.AreaPath64(path64) / (clipperMul ** 2),
});
}
if (hadNegative) plate.visibleParts.length = 0;
}
// Calculates inset of a closed polygon
function getInset(pts, by) {
const insetPaths = [];
const clShape = toClipperPolys(pts);
const clInset = Clipper2Z.InflatePaths64(clShape, -by * clipperMul,
Clipper2Z.JoinType.Miter, Clipper2Z.EndType.Polygon, 2, 0);
for (let i = 0; i < clInset.size(); ++i) {
const path = clInset.get(i);
insetPaths.push(getClipperPathPoints(path));
}
return insetPaths;
}
// Creates Clipper Paths64 from closed polygon as Vec2 points
function toClipperPolys(pts) {
const flatPtArr = [];
for (const pt of pts) flatPtArr.push(Math.round(pt.x * clipperMul), Math.round(pt.y * clipperMul));
const polys = new Clipper2Z.Paths64();
polys.push_back(Clipper2Z.MakePath64(flatPtArr));
return polys;
}
// Converts Clipper Path64 to Vec2 points
function getClipperPathPoints(clPath) {
const pts = [];
for (let i = 0; i < clPath.size(); i++) {
const point = clPath.get(i);
pts.push(new G.Vec2(Number(point.x) / clipperMul, Number(point.y) / clipperMul));
}
return pts;
}