Source code of plot #033 back to plot

Download full working sketch as 033.tar.gz.
Unzip, then start a local web server and load the page in a browser.

///<reference path="../pub/lib/paper.d.ts" />
import {info, init, loadLib, setSketch} from "./utils/boilerplate.js";
import {mulberry32, rand, rand_range, rand_select, randn_bm, setRandomGenerator} from "./utils/random.js"
import {getMaskedLine, getMaskedPoly} from "./utils/geo.js";
import {CanvasMasker} from "./utils/canvas-masker.js";
import {SimplexNoise} from "./utils/simplex-noise.js";
import {genBlueNoise} from "./utils/blue-noise.js";

const pw = 1480;    // Paper width
const ph = 2100;    // Paper height
const w = 1050;     // Drawing width
const h = 1480;     // Drawing height

// Sketch parameters
let nSections = 15;
let faceH = h * 0.8;
let faceW = w * 0.6;
let sideW = faceW * 0.2;
let insetLen = 13;

// Pre-calculated from parameters
let faceLeft = (w - (faceW+sideW)) / 2;
let faceTop = (h - faceH) / 2;
let faceRight = faceLeft + faceW;
let faceBottom = faceTop + faceH;

// Toggle: draw squiggly lines (slow, so we're turning it off while tuning the geomtry)
let squiggly = true;

const rf = 5;       // Occlusion canvas is this many times larger than our area
let cm;             // Canvas masker
let segLen = 2;


let noise;
let simplex;
let seed = Math.round(Math.random() * 65535);
//seed = 7830;
let noiseSeed = seed;

setSketch(function () {
  setRandomGenerator(mulberry32(seed));
  simplex = new SimplexNoise(noiseSeed);
  info("Seed: " + seed);
  init(w, h, pw, ph);
  cm = new CanvasMasker(w, h, rf/*, document.querySelector(".mid"), "canvas-calc"*/);
  draw();
});

let Faces = {
  Front: "Front",
  Side: "Side",
  Top: "Top",
  Bottom: "Bottom",
};

function draw() {

  paper.project.currentStyle.strokeColor = "black";
  paper.project.currentStyle.strokeWidth = 2;

  noise = genBlueNoise(32, 32);
  noise.forEach((val, ix) => noise[ix] = Math.pow((val - noise.length / 2) / noise.length, 0.5) * 3);

  let wholePts = [
    new Point(faceLeft + faceW, faceTop + faceH),
    new Point(faceLeft + faceW, faceTop),
    new Point(faceLeft, faceTop),
    new Point(faceLeft, faceTop + faceH),
  ];
  let whole = new Section(wholePts);
  let sections = splitSections([whole], nSections);

  // Draw outline of the whole
  drawOutline(wholePts);

  // Draw hatch fill of face (excluding empty inside of sections)
  let sectionInners = [];
  for (const sect of sections) sectionInners.push(sect.innerPath);
  drawShapeHatchFill(whole.path, sectionInners, null, Faces.Front);

  // Draw outline of each section, plus their inner depth
  for (const sect of sections) {
    drawOutline(sect.innerPts);
    drawDepth(sect, faceRight, sideW);
  }
}

function drawOutlineSegment(pt1, pt2) {
  let squiggleLen = 10;
  let bgain = 0.5;
  let sgain = 3;
  if (squiggly) drawSquigglyLine([pt1, pt2], false, squiggleLen, noise, bgain, sgain, 0, null);
  else {
    let ln = Path.Line(pt1, pt2);
    project.activeLayer.addChild(ln);
  }
}

function drawOutline(pts) {
  for (let i = 0; i < pts.length; ++i) {
    let pt1 = pts[i];
    let pt2 = pts[(i+1)%pts.length];
    drawOutlineSegment(pt1, pt2);
  }
}

function drawDepth(sect, faceRight, sideW) {

  let hvec = new Point(sideW, 0);

  // Right side of whole edifice
  if (Math.abs(sect.pts[0].x - faceRight) < 5) {
    let rectPts = [
      sect.pts[0],
      sect.pts[0].add(hvec),
      sect.pts[1].add(hvec),
      sect.pts[1],
    ];
    let rect = new Path({
      segments: rectPts,
      closed: true,
    });
    drawOutline(rectPts);
    drawShapeHatchFill(rect, [], null, Faces.Side);
  }

  // Inside section
  let innerLines = [];

  // Iterate all sides
  for (let i = 0; i < sect.innerPts.length; ++i) {

    let pt1 = sect.innerPts[i];
    let pt2 = sect.innerPts[sect.ixmod(i+1)];
    let pts = [pt1, pt1.add(hvec), pt2.add(hvec), pt2];
    let depthPoly = new Path({
      segments: pts,
      closed: true,
    });
    innerLines.push([pts[0], pts[1]]);
    innerLines.push([pts[1], pts[2]]);

    // Vertical line: side face
    if (Math.abs(pt1.x - pt2.x) < 2) {
      drawShapeHatchFill(depthPoly, [], sect.innerPath, Faces.Side);
    }
    // Sinking from right to left
    else if (pt2.x < pt1.x && pt2.y > pt1.y) {
      let angle = pt2.subtract(pt1).angle;
      console.log(angle);
      drawShapeHatchFill(depthPoly, [], sect.innerPath, Faces.Bottom, (angle - 90) / 90);
    }
  }
  cm.clear();
  cm.reqPosCount = 1;
  paintPoly(sect.innerPath, false);
  cm.takeSnapshot();
  for (const ln of innerLines) {
    let mlns = cm.getMaskedLine(ln[0], ln[1], true);
    for (const ln of mlns)
      drawOutlineSegment(...ln);
  }
}

function splitSections(sections, nSections) {
  if (sections.length == nSections) return sections;
  let largest = null;
  for (const sect of sections) {
    if (largest == null || sect.area > largest.area)
      largest = sect;
  }
  let splitArr = [];
  for (const sect of sections) {
    if (sect == largest) splitArr.push(...sect.split());
    else splitArr.push(sect);
  }
  return splitSections(splitArr, nSections);
}

class Section {
  constructor(pts) {
    this.pts = pts;
    this.path = new Path({segments: pts, closed: true});
    this.area = Math.abs(this.path.area);
    this.innerPath = null;
    this.innerPts = [];
    [this.innerPath, this.innerPts] = inset(this.path, insetLen);
    this.smallerInnerPath = inset(this.path, insetLen + 1)[0];
  }

  split() {
    // Start IX of longest and second longest side in points
    // Second longest must not be adjacent to longest
    let lix = 0, llen = this.pts[1].subtract(this.pts[0]).length;
    for (let i = 1; i < this.pts.length; ++i) {
      let len = this.pts[this.ixmod(i+1)].subtract(this.pts[i]).length;
      if (len > llen) [lix, llen] = [i, len];
    }
    let six  = 0, slen = 0;
    for (let i = 0; i < this.pts.length; ++i) {
      // Not adjacent
      if (i == this.ixmod(lix - 1) || i == lix || i == this.ixmod(lix + 1)) continue;
      let len = this.pts[this.ixmod(i + 1)].subtract(this.pts[i]).length;
      if (len > slen) [six, slen] = [i, len];
    }
    return this.splitBy(lix, six);
  }

  findSplitPoints(ixa, ixb) {
    let pta, ptb;
    while (true) {
      let pa, pb;
      pa = rand_range(0.3, 0.7);
      if (pa <= 0.5) pa -= 0.1;
      else pa += 0.1;
      pb = rand_range(0.3, 0.7);
      if (pb <= 0.5) pb -= 0.1;
      else pb += 0.1;
      pta = this.pts[ixa].add(this.pts[this.ixmod(ixa + 1)].subtract(this.pts[ixa]).multiply(pa));
      ptb = this.pts[ixb].add(this.pts[this.ixmod(ixb + 1)].subtract(this.pts[ixb]).multiply(pb));
      // We don't want splitters that are too close to horizontal
      let angle = Math.round(ptb.subtract(pta).angle);
      if (angle < -90) angle += 180;
      if (angle > 90) angle -=180;
      if (Math.abs(angle) < 10) continue;
      // Angle between split side and splitter
      // Don't want too sharp angles
      let angleA = getAngle(this.pts[this.ixmod(ixa + 1)], this.pts[ixa], pta, ptb);
      let angleB = getAngle(this.pts[this.ixmod(ixb + 1)], this.pts[ixb], ptb, pta);
      if (angleA < 30 || angleB < 30) continue;
      break;
    }
    return [pta, ptb];

    function getAngle(a1, a2, b1, b2) {
      let angleA = a2.subtract(a1).angle;
      let angleB = b2.subtract(b1).angle;
      let angle = angleB - angleA;
      while (angle < 0) angle += 180;
      while (angle > 180) angle -= 180;
      if (angle > 90) angle = 180 - angle;
      return angle;
    }
  }

  splitBy(ixa, ixb) {
    let [pta, ptb] = this.findSplitPoints(ixa, ixb);
    let ptsa = [pta];
    let i = ixa + 1;
    while (true) {
      ptsa.push(this.pts[this.ixmod(i)]);
      if (this.ixmod(i) == ixb) break;
      ++i;
    }
    ptsa.push(ptb);
    let ptsb = [ptb];
    i = ixb + 1;
    while (true) {
      ptsb.push(this.pts[this.ixmod(i)]);
      if (this.ixmod(i) == ixa) break;
      ++i;
    }
    ptsb.push(pta);
    let res = [new Section(ptsa), new Section(ptsb)];
    res[0].pts = orderSectPts(res[0].pts);
    res[1].pts = orderSectPts(res[1].pts);
    return res;
  }

  ixmod(ix) {
    while (ix < 0) ix += this.pts.length;
    return ix % this.pts.length;
  }
}

// Points go counterclockwise, starting from rightmost lower corner
function orderSectPts(pts) {
  // Find lowest rightmost point
  let startIx = 0, startPt = pts[0];
  for (let i = 1; i < pts.length; ++i) {
    let pt = pts[i];
    if (pt.x > startPt.x || Math.abs(pt.x - startPt.x) < 2 && pt.y > startPt.y)
      [startIx, startPt] = [i, pt];
  }
  let orderedPts = [];
  for (let i = startIx; i < startIx + pts.length; ++i)
    orderedPts.push(pts[i%pts.length]);
  return orderedPts;
}

// Insets the sides of the polygon by the specified value
// If a side is on the face's perimiter, it is inset by twice the length
function inset(poly, len) {
  let cutters = [];
  for (let i = 0; i < poly.segments.length; ++i) {
    let pt1 = poly.segments[i].point;
    let pt2 = poly.segments[(i+1)%poly.segments.length].point;
    let vec = pt2.subtract(pt1);
    let orto = vec.rotate(90);
    orto.length = len;
    if (isPerimiter(pt1, pt2)) orto.length = len * 2;
    cutters.push(new Path({
      segments: [
        pt1.subtract(vec).subtract(orto),
        pt1.subtract(vec).add(orto),
        pt2.add(vec).add(orto),
        pt2.add(vec).subtract(orto),
      ],
      closed: true
    }));
  }
  let innerPath = poly;
  for (const c of cutters) innerPath = innerPath.subtract(c);
  let pts = [];
  for (const s of innerPath.segments) pts.push(s.point);
  pts.reverse();
  let innerPts = orderSectPts(pts);
  return [innerPath, innerPts];
}

// Checks if a line segment (side of a section) is on the perimiter of the entire face
function isPerimiter(pt1, pt2) {
  // Horizontal?
  if (Math.abs(pt1.y - pt2.y) < 2) {
    // Top or bottom
    if (Math.abs(pt1.y - faceTop) < 5) return true;
    if (Math.abs(pt1.y - faceBottom) < 5) return true;
  }
  // Vertical?
  if (Math.abs(pt1.x - pt2.x) < 2) {
    // Left or right
    if (Math.abs(pt1.x - faceLeft) < 5) return true;
    if (Math.abs(pt1.x - faceRight) < 5) return true;
  }
  // Not on perimiter
   return false;
}


function drawShapeHatchFill(path, blockers, posMask, face, shade = 1) {

  let gap, squiggleLen, bgain, sgain;
  let gapper;

  if (face == Faces.Side) {
    gap = 10;
    squiggleLen = 10;
    bgain = 0.5;
    sgain = 3;
    gapper = {
      interruptProb: 0.05,
      skips: [2, 2, 2, 2, 3, 4],
    };
  }
  else if (face == Faces.Front) {
    gap = 6;
    squiggleLen = 10;
    bgain = 0.5;
    sgain = 3;
  }
  else if (face == Faces.Bottom) {
    gap = 10 - 7 * (shade);
    squiggleLen = 10;
    bgain = 0.5;
    sgain = 3;
  }
  else return;


  let bounds = path.bounds;
  let hatchRect, nHatches, lines;
  if (face == Faces.Bottom) {
    hatchRect = Path.Rectangle(bounds.x, bounds.y - 1, bounds.width, bounds.height - 1);
    nHatches = Math.round(bounds.height / gap);
    lines = genHatchLines(hatchRect, 90, bounds.height / nHatches);
  }
  else {
    hatchRect = Path.Rectangle(bounds.x - 1, bounds.y, bounds.width - 1, bounds.height);
    nHatches = Math.round(bounds.width / gap);
    lines = genHatchLines(hatchRect, 0, bounds.width / nHatches);
  }

  cm.clear();
  cm.reqPosCount = posMask ? 2 : 1;
  paintPoly(path, false);
  if (posMask) paintPoly(posMask, false);
  for (const b of blockers) paintPoly(b, true);
  cm.takeSnapshot();

  for (const [pt1, pt2] of lines) {
    let mlns = cm.getMaskedLine(pt1, pt2, true, segLen);
    for (let i = 0; i < mlns.length; ++i) {
      const [pta, ptb] = mlns[i];
      const reverse = i % 2 == 0;
      let sofs;
      if (face == Faces.Front) sofs = pta.x;
      else if (face == Faces.Side) sofs = pta.x * 0.5;
      if (squiggly) {
        drawSquigglyLine([pta, ptb], reverse, squiggleLen, noise, bgain, sgain, sofs, gapper);
      }
      else {
        let ln = Path.Line(pta, ptb);
        project.activeLayer.addChild(ln);
      }
    }
  }
}

function paintPoly(path, block) {
  let separatePaths = [];
  let pathPts = [];
  if (!path.children) separatePaths.push(path);
  else {
    for (const c of path.children) separatePaths.push(c);
  }
  for (const sp of separatePaths) {
    sp.segments.forEach(seg => pathPts.push(seg.point));
    if (block) cm.blockPoly(pathPts);
    else cm.includePoly(pathPts);
  }
}

function drawSquigglyLine(ln, reverse, squiggleLen, bnoise, bgain, sgain, sofs, gapper) {

  let subDiv = Math.round(squiggleLen / 5);
  if (subDiv < 1) subDiv = 1;

  let vec = ln[1].subtract(ln[0]);
  let len = vec.length;
  let nsegs = Math.max(2, Math.round(len / squiggleLen));
  nsegs *= subDiv;

  let orto = vec.rotate(90);
  orto.length = 1;
  let noiseOfs = Math.floor(rand() * bnoise.length);

  let getSimplex = pt => {
    let dist = pt.subtract(ln[1]).length;
    let test = new Point(sofs, dist);
    return simplex.noise2D(test.x / w * 40, test.y / h * 4) * sgain;
  }
  let getPt = i => ln[0].add(vec.multiply(i/nsegs));

  // First, get simplex bias along path. We'll offset to get 0 average.
  let sbias = 0;
  for (let i = 0; i <= nsegs; ++i) sbias += getSimplex(getPt(i));
  sbias /= (nsegs + 1);

  let polys = [];
  let pts = [];
  for (let i = 0; i < nsegs + subDiv; i += subDiv) {

    if (i > nsegs) i = nsegs;

    if (gapper && pts.length > 1 && rand() < gapper.interruptProb) {
      polys.push(pts);
      pts = [];
      i -= subDiv;
      i += rand_select(gapper.skips);
    }

    let d = bnoise[(i + noiseOfs) % bnoise.length] * bgain;
    let pt = getPt(i);

    // Add simplex noise for large-ish deviation
    let sn = getSimplex(pt);
    pt = pt.add(orto.multiply(sn - sbias));

    // Add blue noise for squiggles
    pt = pt.add(orto.multiply(d));

    pts.push(pt);
  }
  if (pts.length > 1) polys.push(pts);

  if (reverse) polys.reverse();
  for (const pts of polys) {
    if (reverse) pts.reverse();
    let path = new Path({segments: pts});
    project.activeLayer.addChild(path);
  }
}

function genHatchLines(rect, angle, gap) {
  let arad = angle / 180 * Math.PI;
  if (angle < -90 || angle > 90) throw "angle must be between -90 and 90";
  let lines = [];
  // Going left from right
  if (Math.abs(angle) < 45) {
    let v = rect.bounds.height * Math.tan(arad);
    let len = Math.sqrt(v ** 2 + rect.bounds.height ** 2);
    let adv = gap / Math.cos(arad);
    let vec = new Point(0, -len).rotate(angle);
    let start = angle > 0 ? rect.bounds.left - v : rect.bounds.left;
    let end = angle > 0 ? rect.bounds.right : rect.bounds.right - v;
    for (let x = start; x < end; x += adv) {
      let pt = new Point(x, rect.bounds.bottom);
      lines.push([pt, pt.add(vec)]);
    }
    return lines;
  }
  // Going top to bottom
  angle = angle - 90;
  if (angle < -90) angle += 180;
  arad = angle / 180 * Math.PI;
  let v = rect.bounds.width * Math.tan(-arad);
  let len = Math.sqrt(v ** 2 + rect.bounds.width ** 2);
  let adv = gap / Math.cos(arad);
  let vec = new Point(len, 0).rotate(angle);
  let start = angle > 0 ? rect.bounds.top + v : rect.bounds.top;
  let end = angle > 0 ? rect.bounds.bottom : rect.bounds.bottom + v;
  for (let y = start; y < end; y += adv) {
    let pt = new Point(rect.bounds.left, y);
    lines.push([pt, pt.add(vec)]);
    if (lines.length > 200) break;
  }
  return lines;
}