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.

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;
}