Source code of plot #032 back to plot

Download full working sketch as 032.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, dbgRedraw} from "./utils/boilerplate.js";
import {mulberry32, rand, rand_range, randn_bm, setRandomGenerator} from "./utils/random.js"
import {shuffle} from "./utils/shuffle.js"
import {CanvasMasker} from "./utils/canvas-masker.js";
import {SvgFont} from "./utils/svg-font.js";

const pw = 2100;    // Paper width
const ph = 1480;    // Paper height
const w = 1480;     // Drawing width
const h = 1050;     // Drawing height
const margin = 50;  // Margin (within drawing)
const rf = 5;       // Occlusion canvas is this many times larger than our area

const segLen = 2;

const title = "KEPLER'S JOURNAL";
const year = 1604;
const day = Math.floor(1 + 365 * Math.random());
let seed = hashCode(getRomanStr(year, day));
//seed = 44642;

let font;

setSketch(function () {
  setRandomGenerator(mulberry32(seed));
  info("Seed: " + seed);
  init(w, h, pw, ph);

  let reqFont = new XMLHttpRequest();
  reqFont.open("GET", "data/fonts_BubblerOne.svg", true);
  // reqFont.open("GET", "data/fonts_EMS_EMSReadability.svg", true);
  reqFont.send();
  reqFont.onload = () => {
    font = new SvgFont(reqFont.responseText);
    draw();
  }
});

const ArcTypes = {
  RadialRandom: "RadialRandom",
  RadialUniform: "RadialUniform",
  RadialFM: "RadialFM",
  CrossHatchMoire: "CrossHatchMoire",
  ArcSine: "ArcSine",
  Cogwheels: "Cogwheels",
};

const ComplementTypes = {
  Notches: "Notches",
  Circles: "Circles",
  Spiral: "Spiral",
};

const ComplementDirection = {
  Left: "Left",
  Right: "Right",
};

const NotchPosition = {
  In: "In",
  Mid: "Mid",
  Out: "Out",
};

function draw() {
  paper.project.currentStyle.strokeColor = "black";
  paper.project.currentStyle.strokeWidth = 2;

  let center = new Point(w / 2, h / 2);

  // Meta parameters for run
  const params = {
    nArcs: Math.floor(rand_range(4, 8)),
    nComplements: Math.floor(2 + 2 * rand()),
    radialUniformGapRange: [8, 20],
    radialRandomAvgGapRange: [6, 20],
    spiralProb: 0.25, // Probability that that the sketch has a spiraling circles complement
    notchMaxGap: 10,
    fontSize: 38,
  }

  // The arcs we'll be building.
  let arcs = [];

  // Dividers on boths sides. These will define from/to angles for arcs.
  let rDivs = calcSideDivs(params.nArcs);
  let lDivs = calcSideDivs(params.nArcs);

  // Get start and end angles from dividers
  // Random pair for each band, but pairs cannot be next to each other
  let divAngles = [];
  for (const yDiv of rDivs) {
    let vec = new Point(w - 2.5 * margin - w / 2, h / 2 - yDiv);
    divAngles.push(Math.PI * (90 - vec.angle) / 180);
  }
  for (const yDiv of lDivs) {
    let vec = new Point(2.5 * margin - w / 2, h / 2 - yDiv);
    divAngles.push(Math.PI * (90 - vec.angle) / 180);
  }

  // Generate arcs: for now, only the outline geometry, which is determined by the side divs
  let arcWidth = (h / 2 - margin) / (params.nArcs + 1);
  for (let ix = 0; ix < params.nArcs; ++ix) {
    let [startAngle, endAngle] = getAnglePair(divAngles);
    let arcLen = endAngle - startAngle;
    let radIn = (ix + 1) * arcWidth;
    let radOut = (ix + 2) * arcWidth;
    let arc = new Arc(center, radIn, radOut, startAngle, startAngle + arcLen);
    arcs.push(arc);
  }

  // Disturb arc widths
  for (let ix = 0; ix < arcs.length - 1; ++ix) {
    let diff = randn_bm(-arcWidth, arcWidth);
    if (arcs.length < 5 && Math.abs(diff) < arcWidth / 3) diff *= 1.5;
    // Change this arc's width; distribute difference equally among remaining arcs outwards
    let n = arcs.length - 1 - ix;
    for (let j = ix; j < arcs.length - 1; ++j) {
      let d = arcs.length - 1 - j;
      let valHere = diff * d / n;
      arcs[j].radOut += valHere;
      arcs[j+1].radIn += valHere;
    }
  }
  // Generate outlines
  arcs.forEach(arc => arc.genOutline());

  // Decide what patterns and complements to use
  calcPatternsComplements(arcs, params);

  let cm = new CanvasMasker(w, h, rf/*, document.querySelector(".mid"), "canvas-calc"*/);

  // If we have a spiral complement, draw it now.
  // This will block stuff left and right.
  let spiralIx = arcs.findIndex(a => a.complement == ComplementTypes.Spiral);
  let spiralQuadrant = -1; // 0: TL, 1: TR, 2: BR, 3: BL
  if (spiralIx != -1) {
    let arc = arcs[spiralIx];
    // let arcAngleLen = arc.endAngle - arc.startAngle;
    // let circStartAngle = arc.endAngle - arcAngleLen / 3;
    let arcCircles = genCircleSpiral(arc);
    for (const ac of arcCircles) {
      project.activeLayer.addChild(ac.path);
      cm.blockCircle(ac.center, ac.radius);
    }
    // Which quadrant does spiral end? Might have to move title to upper half.
    let lc = arcCircles[arcCircles.length-1].center;
    if (lc.x < w / 2) spiralQuadrant = lc.y < h / 2 ? 0 : 3;
    else spiralQuadrant = lc.y < h / 2 ? 1 : 2;
  }

  // Draw arcs: outline, fillers, complements
  for (let i = 0; i < arcs.length; ++i) {

    let arc = arcs[i];

    if (arc.type == ArcTypes.RadialRandom) {
      arc.genRadialFillersRandom();
    }
    else if (arc.type == ArcTypes.RadialUniform) {
      arc.genRadialFillers();
    }
    else if (arc.type == ArcTypes.RadialFM) {
      arc.genRadialFillersFM(8, 5, Math.floor(arc.radOut * (arc.endAngle - arc.startAngle) / 200) + 1);
    }
    else if (arc.type == ArcTypes.CrossHatchMoire) {
      arc.genCrossHatchFillers();
    }
    else if (arc.type == ArcTypes.Cogwheels) {
      arc.genCogwheelFillers();
    }
    else if (arc.type == ArcTypes.ArcSine) {
      arc.genArcFillersSine(12, 20, 4);
    }

    if (arc.complement == ComplementTypes.Circles) {
      let arcAngleLen = arc.endAngle - arc.startAngle;
      let arcCircles = genCircleArc(arc);
      for (let i = 0; i < arcCircles.length; ++i) {
        let ac = arcCircles[i];
        let eclipsePhase = 0;
        if (arc.complementParams.eclipse) {
          eclipsePhase = arcCircles.length - i - 1;
          if (eclipsePhase > 6) eclipsePhase = 0;
        }
        let pts = genCirclePts(ac.center, ac.radius, eclipsePhase);
        drawMaskedPath(pts, cm);
        cm.blockPoly(pts);
        //cm.blockCircle(ac.center, ac.radius);
      }
    }
    else if (arc.complement == ComplementTypes.Notches) {
      let angleFrom = drawNotchComplement(arc, cm);
      // Create an "arc" matching the notched decoration
      // The outline path of this is used to block division rays
      let notchesArc = new Arc(center, arc.radIn + 2, arc.radOut - 2, angleFrom + 0.01, angleFrom + arc.complementParams.angleLen - 0.01);
      notchesArc.genOutline();
      cm.blockPoly(notchesArc.outlinePts);
    }

    if (arc.type != ArcTypes.ArcSine && arc.type != ArcTypes.Cogwheels)
      drawMaskedPath(arc.outlinePts, cm);


    for (const pts of arc.fillers){
      drawMaskedPath(pts, cm);
    }

    if (arc.type == ArcTypes.Cogwheels) {
      // First two polylines are the cogs on the arc itself
      // Those are open paths, adding them would block entire arc area
      for (let fIx = 2; fIx < arc.fillers.length; ++fIx) {
        cm.blockPoly(arc.fillers[fIx]);
      }
    }

    // For blocking, we create a slightly smaller outline
    // Otherwise an artifact appears and neighboring outline gets sliced into small chunks
    if (arc.type != ArcTypes.Cogwheels) {
      let arcBlocker = new Arc(arc.center, arc.radIn + 1, arc.radOut - 1, arc.startAngle + 0.01, arc.endAngle - 0.01);
      arcBlocker.genOutline();
      cm.blockPoly(arcBlocker.outlinePts);
    }
  }


  let blAngle;
  const sideCircleSizes = [15, 30];
  for (let i = 0; i < rDivs.length; ++i) {
    let yDiv = rDivs[i];
    let rSideCircle = rand_range(...sideCircleSizes);
    if (i > 0 && (yDiv - rDivs[i-1]) / 2 < rSideCircle) rSideCircle = (yDiv - rDivs[i-1]) / 2;
    if (i < rDivs.length - 1 && (rDivs[i+1] - yDiv) / 2 < rSideCircle) rSideCircle = (rDivs[i+1] - yDiv) / 2;
    let ln = Path.Line(w - 2.5 * margin, yDiv, w - 1.5 * margin - rSideCircle, yDiv);
    project.activeLayer.addChild(ln);
    let linePts = [new Point(w / 2, h / 2), new Point(w - 2.5 * margin, yDiv)];
    drawMaskedLine(linePts[0], linePts[1], cm);
    drawSideCircle(yDiv, rSideCircle, true);
  }
  for (let i = 0; i < lDivs.length; ++i) {
    let yDiv = lDivs[i];
    let rSideCircle = rand_range(...sideCircleSizes);
    if (i > 0 && (yDiv - lDivs[i-1]) / 2 < rSideCircle) rSideCircle = (yDiv - lDivs[i-1]) / 2;
    if (i < lDivs.length - 1 && (lDivs[i+1] - yDiv) / 2 < rSideCircle) rSideCircle = (lDivs[i+1] - yDiv) / 2;
    let ln = Path.Line(1.5 * margin + rSideCircle, yDiv, 2.5 * margin, yDiv);
    project.activeLayer.addChild(ln);
    let linePts = [new Point(w / 2, h / 2), new Point(2.5 * margin, yDiv)];
    if (i == rDivs.length - 1) blAngle = (linePts[1].subtract(linePts[0])).angle;
    drawMaskedLine(linePts[0], linePts[1], cm);
    drawSideCircle(yDiv, rSideCircle, false);
  }

  drawTitle(params, spiralQuadrant, blAngle, arcs[arcs.length-1].radOut);
}

function drawTitle(params, spiralQuadrant, lAngle, rad) {

  // Bottom left or top left
  if (spiralQuadrant != 3) {
    let [gTitle1, angleLen1] = font.writeOnArc(params.fontSize, rad + params.fontSize + 10, title, true);
    gTitle1.rotate(lAngle - 92, new Point(0, 0));
    gTitle1.translate(new Point(w / 2, h / 2));
    project.activeLayer.addChild(gTitle1);
  }
  else  {
    let [gTitle1, angleLen1] = font.writeOnArc(params.fontSize, rad + 10, title, false);
    gTitle1.rotate(lAngle + 162, new Point(0, 0));
    gTitle1.translate(new Point(w / 2, h / 2));
    project.activeLayer.addChild(gTitle1);
  }

  // Bottom right or top right
  if (spiralQuadrant != 2) {
    let [gTitle2, angleLen2] = font.writeOnArc(params.fontSize, rad + params.fontSize + 10, getRomanStr(year, day), true);
    gTitle2.rotate(92 - lAngle + angleLen2, new Point(0, 0));
    gTitle2.translate(new Point(w / 2, h / 2));
    project.activeLayer.addChild(gTitle2);
  }
  else {
    let [gTitle2, angleLen2] = font.writeOnArc(params.fontSize, rad + 10, getRomanStr(year, day), false);
    gTitle2.rotate(199 - lAngle - angleLen2, new Point(0, 0));
    gTitle2.translate(new Point(w / 2, h / 2));
    project.activeLayer.addChild(gTitle2);
  }

}

function calcSideDivs(nDivs) {
  while (true) {
    let ys = [];
    let usefulH = h - 4 * margin;
    for (let i = 0; i < nDivs; ++i) {
      let y = 2 * margin + i / (nDivs - 1) * usefulH;
      if (i > 0 && i < nDivs - 1) y += rand_range(-usefulH / nDivs, usefulH / nDivs);
      ys.push(y);
    }
    let minDist = Number.MAX_VALUE;
    for (let i = 1; i < nDivs; ++i) if (ys[i] - ys[i - 1] < minDist) minDist = ys[i] - ys[i - 1];
    if (minDist < usefulH / nDivs / 4) continue;
    return ys;
  }
}

function calcPatternsComplements(arcs, params) {

  // Random-pick type for each arc
  // Verify that constraints are met
  while (true) {
    fillArcTypes();
    if (checkArcTypeConstraints()) break;
  }

  // Fill in type-specific params
  for (const arc of arcs) {
    if (arc.type == ArcTypes.RadialUniform) {
      arc.typeParams.midGap = Math.floor(rand_range(...params.radialUniformGapRange));
    }
    else if (arc.type == ArcTypes.RadialRandom) {
      arc.typeParams.avgMidGap = Math.floor(rand_range(...params.radialRandomAvgGapRange));
    }
    else if (arc.type == ArcTypes.CrossHatchMoire) {
      arc.typeParams.gap = 9;
      arc.typeParams.dualAngle = rand_range(3, 8);
      arc.typeParams.parallelMoire = rand() < 0.5;
    }
  }

  // Fill complements, enforcing constraints
  while (true) {
    arcs.forEach(arc => {
      arc.complement = null;
      arc.complementParams = {};
    });
    fillComplements();
    if (checkComplementConstraints()) break;
  }

  function checkComplementConstraints() {
    if (arcs.length >= 5) return true;
    let hasNotches = false;
    let hasEclipse = false;
    for (const arc of arcs) {
      if (arc.complement == ComplementTypes.Notches) hasNotches = true;
      if (arc.complement == ComplementTypes.Circles && arc.complementParams.eclipse) hasEclipse = true;
    }
    return hasNotches || hasEclipse;
  }

  function fillComplements() {

    // Decide if we have a spiral complement
    let gotSpiral = rand() < params.spiralProb;
    if (gotSpiral) {
      while (true) {
        // Must start within inner 2/3s of arcs
        // Must not be next to cogwheels
        let arcIx = rand_range(0, Math.floor(2 * arcs.length / 3));
        arcIx = Math.floor(arcIx);
        if (arcs[arcIx].type == ArcTypes.Cogwheels) continue;
        arcs[arcIx].complement = ComplementTypes.Spiral;
        break;
      }
    }

    // Decide where remaining complements will go, and what kinds
    let complementCount = gotSpiral ? 1 : 0;
    let nTries = 42;
    while (complementCount < params.nComplements) {
      let type = complementCount % 2 == 0 ? ComplementTypes.Circles : ComplementTypes.Notches;
      let arcIx = -1;
      let tries = 0;
      while (tries < nTries) {
        ++tries;
        arcIx = Math.floor((arcs.length) * rand());
        // Already got a complement
        if (arcs[arcIx].complement != null) continue;
        // We add complements in the outer rings, not two innermost ones
        if (arcIx < arcs.length / 3) continue;
        // Outermost arc should not get notches
        if (type == ComplementTypes.Notches && arcIx == arcs.length - 1) continue;
        // Cogwheels should not get a complement
        if (arcs[arcIx].type == ArcTypes.Cogwheels) continue;
        // We good here
        break;
      }
      if (tries == nTries) break;
      arcs[arcIx].complement = type;
      ++complementCount;
    }

    // Fill in complement-specific params
    let outermostEclipseArc = null;
    for (const arc of arcs) {

      if (arc.complement == ComplementTypes.Circles) {
        let cw = rand() < 0.5;
        let radius = rand_range(13, (arc.radOut - arc.radIn) / 3);
        if (radius < 13) radius = 13;
        let arcAngleLen = arc.endAngle - arc.startAngle;
        let fromAngle, toAngle;
        if (cw) {
          fromAngle = rand_range(arc.startAngle + 0.5 * arcAngleLen, arc.startAngle + 0.8 * arcAngleLen);
          toAngle = arc.endAngle + Math.PI * (0.5 + 0.5 * rand());
        } else {
          fromAngle = rand_range(arc.startAngle + 0.5 * arcAngleLen, arc.startAngle + 0.2 * arcAngleLen);
          toAngle = arc.startAngle - Math.PI * (0.5 + 0.5 * rand());
        }
        let travel = Math.abs(toAngle - fromAngle) * (arc.radIn + arc.radOut) / 2;
        let dist = radius * rand_range(4, 6);
        let nCircles = Math.round(travel / dist);
        arc.complementParams.nCircles = nCircles;
        arc.complementParams.radius = radius;
        arc.complementParams.fromAngle = fromAngle;
        arc.complementParams.toAngle = toAngle;
        arc.complementParams.eclipse = nCircles > 7 && radius >= 16;
        if (arc.complementParams.eclipse) outermostEclipseArc = arc;
      } else if (arc.complement == ComplementTypes.Spiral) {
        let cw = rand() < 0.5;
        let arcAngleLen = arc.endAngle - arc.startAngle;
        let circStartAngle = cw ? arc.endAngle - arcAngleLen / 3 : arc.startAngle + arcAngleLen / 3;
        arc.complementParams.rArcStart = (arc.radIn + arc.radOut) * 0.5;
        arc.complementParams.fromAngle = circStartAngle;
        arc.complementParams.toAngle = cw ? circStartAngle + Math.PI * 0.8 : circStartAngle - Math.PI * 0.8;
        arc.complementParams.nCirclesInArcLen = 15;
        arc.complementParams.rCircleMid = margin / 4;
      } else if (arc.complement == ComplementTypes.Notches) {

        let posVal = Math.floor(3 * rand());
        if (posVal == 0) arc.complementParams.notchPos = NotchPosition.In;
        else if (posVal == 1) arc.complementParams.notchPos = NotchPosition.Mid;
        else arc.complementParams.notchPos = NotchPosition.Out;

        arc.complementParams.startAngle = arc.endAngle;
        let angleLen = 2 * Math.PI - (arc.endAngle - arc.startAngle);
        while (angleLen < 0) angleLen += 2 * Math.PI;
        while (angleLen > Math.PI * 2) angleLen -= 2 * Math.PI;
        // Adjacent to main arc pattern
        if (angleLen < Math.PI * 1.1) arc.complementParams.angleLen = angleLen;
        // Complements arc pattern symmetrically: same swipe as arc itself
        else {
          angleLen = arc.endAngle - arc.startAngle;
          angleLen = Math.min(angleLen, Math.PI * 0.4);
          arc.complementParams.angleLen = angleLen;
        }
        // Aim for a small notch gap of 0.7
        let r = (arc.radIn + arc.radOut) / 2;
        let len = angleLen * r;
        let notchCount = Math.round(len / params.notchMaxGap);
        arc.complementParams.primaryDiv = rand() < 0.5 ? 6 : 8;
        let sdVals = [2, 3];
        shuffle(sdVals);
        arc.complementParams.secondaryDiv = sdVals[0];
        let ps = arc.complementParams.primaryDiv * arc.complementParams.secondaryDiv;
        while (true) {
          if (notchCount / ps == Math.floor(notchCount / ps)) break;
          ++notchCount;
        }
        arc.complementParams.tertiaryDiv = notchCount / ps;
      }
    }

    // Only one arc may get an eclipse
    for (const arc of arcs) {
      if (arc.complementParams && arc.complementParams.eclipse && arc != outermostEclipseArc)
        arc.complementParams.eclipse = false;
    }
  }

  function checkArcTypeConstraints() {
    const minArcWidthCogwheels = h * 0.06;
    let hasCogwheels = false;
    let ok = true;
    for (let i = 0; i < arcs.length; ++i) {
      // Make note of cogwheels and notches
      if (arcs[i].type == ArcTypes.Cogwheels) hasCogwheels = true;
      // Cogwheel must not be too narrow
      if (arcs[i].type == ArcTypes.Cogwheels && arcs[i].radOut - arcs[i].radIn < minArcWidthCogwheels) ok = false;
      // Neighboring patterns must be different
      if (i > 0 && arcs[i-1].type == arcs[i].type) ok = false;
      // Cogwheels and ArcSine are not adjacent
      if (arcs[i].type == ArcTypes.ArcSine) {
        if (i > 0 && arcs[i-1].type == ArcTypes.Cogwheels) ok = false;
        if (i < arcs.length - 1 && arcs[i+1].type == ArcTypes.Cogwheels) ok = false;
      }
    }
    if (arcs.length < 5 && !hasCogwheels) ok = false;
    return ok;
  }

  function fillArcTypes() {
    // What arc will have what pattern?
    // Scramble in batches, so we reduce unwanted repetitions
    let patternBatch = [];
    for (const arcType in ArcTypes) patternBatch.push(arcType);
    let arcTypes = [];
    while (true) {
      shuffle(patternBatch);
      for (let i = 0; i < patternBatch.length && arcTypes.length < params.nArcs; ++i) {
        arcTypes.push(patternBatch[i]);
      }
      if (arcTypes.length == params.nArcs) break;
    }
    arcTypes.forEach((t, ix) => arcs[ix].type = t);
  }
}


function drawSideCircle(y, r, onRight) {
  let arcLen = rand_range(Math.PI * 0.5, Math.PI * 1.8);
  let joinAt = rand_range(0.2, 0.8);
  let angleStart = -arcLen * joinAt;
  if (onRight) angleStart -= Math.PI / 2;
  else angleStart += Math.PI / 2;
  let nSegs = Math.round(r * arcLen / segLen);
  let pts = [];
  let center = new Point(onRight ? (w - 1.5 * margin) : (1.5 * margin), y);
  for (let i = 0; i <= nSegs; ++i) {
    let angle = angleStart + i / nSegs * arcLen;
    let pt = new Point(Math.sin(angle), -Math.cos(angle));
    pt = pt.multiply(r);
    pt = pt.add(center);
    pts.push(pt);
  }
  drawPath(pts, false);
  let innerCircle = Path.Circle(center, r - 7);
  project.activeLayer.addChild(innerCircle);
}

function genCircleArc(arc) {

  let cp = arc.complementParams;
  let rArc = (arc.radIn + arc.radOut) / 2;
  let res = [];
  for (let i = 0; i < cp.nCircles; ++i) {
    let angle = cp.fromAngle + (cp.toAngle - cp.fromAngle) * i / cp.nCircles;
    let cc = new Point(Math.sin(angle), -Math.cos(angle)).multiply(rArc);
    cc.x += w / 2;
    cc.y += h / 2;
    let circle = Path.Circle(cc, cp.radius);
    res.push({
      path: circle,
      center: cc.clone(),
      radius: cp.radius,
    });
  }
  return res;
}


function genCircleSpiral(arc) {
  let cp = arc.complementParams;
  let res = [];
  let angle = cp.fromAngle;
  let angleStep = (cp.toAngle - cp.fromAngle) / cp.nCirclesInArcLen;
  let rArc = cp.rArcStart;
  let rCircle = 0;
  let rGrowth = h / 500;
  while (true) {
    let cc = new Point(Math.sin(angle), -Math.cos(angle)).multiply(rArc);
    cc.x += w / 2;
    cc.y += h / 2;
    if (cc.y - rCircle < margin) break;
    if (cc.y + rCircle > h - margin) break;
    if (cc.x - rCircle < 3 * margin) break;
    if (cc.x + rCircle > w - 3 * margin) break;
    if (!pastToAngle(angle)) {
      let arcProp = (angle - cp.fromAngle) / (cp.toAngle - cp.fromAngle);
      rCircle = 5 + (cp.rCircleMid - 5) * arcProp;
    }
    let circle = Path.Circle(cc, rCircle);
    res.push({
      path: circle,
      center: cc.clone(),
      radius: rCircle,
    });
    angle += angleStep;
    if (pastToAngle(angle)) {
      rArc = rArc + rGrowth;
      rGrowth *= 1.1;
      rCircle = rCircle * 1.2;
    }
  }
  return res;

  function pastToAngle(angle) {
    if (cp.toAngle > cp.fromAngle) return angle > cp.toAngle;
    else return angle < cp.toAngle;
  }
}

function drawNotchComplement(arc, masker) {

  let center = new Point(w / 2, h / 2);
  let nSmallNotches = arc.complementParams.primaryDiv *
    arc.complementParams.secondaryDiv *
    arc.complementParams.tertiaryDiv;
  let breadth = arc.radOut - arc.radIn;
  breadth = Math.min(h * 0.07, breadth);
  let angleFrom = arc.endAngle;
  angleFrom += Math.PI - (arc.endAngle - arc.startAngle + arc.complementParams.angleLen) / 2;

  for (let i = 0; i <= nSmallNotches; ++i) {
    let length = breadth * 0.4;
    if (arc.complementParams.notchPos == NotchPosition.Mid) length = breadth * 0.25;
    if (i % (arc.complementParams.primaryDiv) == 0) length = breadth * 0.6;
    if (i % (arc.complementParams.primaryDiv * arc.complementParams.secondaryDiv) == 0) length = breadth * 0.9;
    let angle = angleFrom + arc.complementParams.angleLen * i / nSmallNotches;
    drawNotch(angle, length);
  }

  return angleFrom;

  function drawNotch(angle, length) {
    let pt1 = new Point(Math.sin(angle), -Math.cos(angle));
    let pt2;
    if (arc.complementParams.notchPos == NotchPosition.Out) {
      pt1.length = arc.radOut;
      pt2 = pt1.clone();
      pt2.length -= length;
    }
    else if (arc.complementParams.notchPos == NotchPosition.In) {
      pt1.length = arc.radIn;
      pt2 = pt1.clone();
      pt2.length += length;
    }
    else {
      pt1.length = (arc.radIn + arc.radOut) / 2;
      pt2 = pt1.clone();
      pt1.length -= length / 2;
      pt2.length += length / 2;
    }
    pt1 = pt1.add(center);
    pt2 = pt2.add(center);
    drawMaskedLine(pt1, pt2, masker);
  }
}

function getAnglePair(angleArr) {
  let ix1, ix2;
  while (true) {
    ix1 = Math.floor(rand() * angleArr.length / 2);
    if (angleArr[ix1] != 0) break;
  }
  while (true) {
    ix2 = angleArr.length / 2 + Math.floor(rand() * angleArr.length / 2);
    if (angleArr[ix2] != 0) break;
  }
  let res = [angleArr[ix1], angleArr[ix2]];
  if (rand() < 0.5) res = [res[1], res[0]];
  angleArr[ix1] = angleArr[ix2] = 0;
  if (res[1] < res[0]) res[1] += 2 * Math.PI;
  return res;
}

function angleDiff(angle1, angle2) {
  while (angle1 > Math.PI * 2) angle1 -= Math.PI * 2;
  while (angle2 > Math.PI * 2) angle2 -= Math.PI * 2;
  return Math.abs(angle2 - angle1);
}

class Arc {
  constructor(center, radIn, radOut, startAngle, endAngle) {
    this.center = center;
    this.radIn = radIn;
    this.radOut = radOut;
    this.startAngle = startAngle; // Zero is top; grows clockwise
    this.endAngle = endAngle;
    this.type = null;
    this.typeParams = {};
    this.complement = null;
    this.complementParams = {};
    this.outlinePts = [];
    this.outlinePath = null;
    this.fillers = [];
  }

  genOutline() {

    let ptsInner = this.genArcPoints(this.radIn, this.startAngle, this.endAngle);
    let ptsOuter = this.genArcPoints(this.radOut, this.endAngle, this.startAngle);
    let innerLast = ptsInner[ptsInner.length - 1];
    let outerFirst = ptsOuter[0];
    let outerLast = ptsOuter[ptsOuter.length - 1];
    let innerFirst = ptsInner[0];
    let nSideSegs = Math.round((this.radOut - this.radIn) / segLen);

    this.outlinePts = [];
    this.outlinePts = this.outlinePts.concat(ptsInner);
    for (let i = 1; i < nSideSegs; ++i)
      this.outlinePts.push(innerLast.add(outerFirst.subtract(innerLast).multiply(i / nSideSegs)));
    this.outlinePts = this.outlinePts.concat(ptsOuter);
    for (let i = 1; i <= nSideSegs; ++i)
      this.outlinePts.push(outerLast.add(innerFirst.subtract(outerLast).multiply(i / nSideSegs)));

    this.outlinePath = new Path({segments: this.outlinePts});
  }

  genCrossHatchFillers() {

    this.fillers = [];
    let cm = new CanvasMasker(w, h, rf);
    cm.includePoly(this.outlinePts);
    cm.takeSnapshot();

    let midAngle = (this.endAngle + this.startAngle) / 2 * 180 / Math.PI;
    if (!this.typeParams.parallelMoire) midAngle += 90;
    let angle1 = midAngle - this.typeParams.dualAngle / 2;
    let angle2 = midAngle + this.typeParams.dualAngle / 2;
    while (angle1 < -90) angle1 += 180;
    while (angle1 > 90) angle1 -= 180;
    while (angle2 < -90) angle2 += 180;
    while (angle2 > 90) angle2 -= 180;
    let lines = genHatchLines(this.outlinePath, angle1, this.typeParams.gap);
    lines = lines.concat(genHatchLines(this.outlinePath, angle2, this.typeParams.gap));
    let maskedLines = [];
    for (const [pt1, pt2] of lines) {
      let mlns = cm.getMaskedLine(pt1, pt2, true, segLen);
      for (const [pta, ptb] of mlns) maskedLines.push([pta, ptb]);
    }

    this.fillers = maskedLines.map(ln => makeSegs(...ln));

    function makeSegs(pt1, pt2) {
      let vec = pt2.subtract(pt1);
      let nSegs = Math.round(vec.length / segLen);
      if (nSegs < 2) return [pt1, pt2];
      let pts = [];
      for (let i = 0; i <= nSegs; ++i)
        pts.push(pt1.add(vec.multiply(i / nSegs)));
      return pts;
    }

  }

  genArcFillersSine(gap, waveLenApprox, amplitude) {
    this.fillers = [];
    let arcAngle = this.endAngle - this.startAngle;
    let radMid = (this.radIn + this.radOut) / 2;
    let arcMidLen = Math.abs(radMid * arcAngle);
    let nPeriods = Math.round(arcMidLen / waveLenApprox);
    let radLen = this.radOut - this.radIn;
    let nArcs = Math.round((radLen - 2 * amplitude) / gap);
    for (let i = 0; i <= nArcs; ++i) {
      let pts = [];
      let rad = this.radIn + amplitude + (radLen - 2 * amplitude) * i / nArcs;
      let arcLen = rad * arcAngle;
      let nPoints = Math.round(arcLen / segLen);
      for (let j = 0; j <= nPoints; ++j) {
        let angle = this.startAngle + j / nPoints * arcAngle;
        let pt = new Point(Math.sin(angle), -Math.cos(angle));
        pt.length *= rad;
        let waveAngle = j / nPoints * 2 * Math.PI * nPeriods;
        pt.length += Math.sin(waveAngle) * amplitude;
        pt = pt.add(this.center);
        pts.push(pt);
      }
      this.fillers.push(pts);
    }
  }

  genRadialFillersRandom() {
    this.fillers = [];
    let arcAngle = this.endAngle - this.startAngle;
    let radMid = (this.radIn + this.radOut) / 2;
    let arcMidLen = Math.abs(radMid * arcAngle);
    let nLines = Math.round(arcMidLen / this.typeParams.avgMidGap);
    for (let i = 0; i <= nLines; ++i) {
      let angle = this.startAngle + rand() * arcAngle;
      this.fillers.push(this.genRadialLine(angle));
    }
  }

  genRadialFillersFM(midGap, dev, devPeriods) {
    this.fillers = [];
    let arcAngle = this.endAngle - this.startAngle;
    let radMid = (this.radIn + this.radOut) / 2;
    let arcMidLen = Math.abs(radMid * arcAngle);
    let nLines = Math.round(arcMidLen / midGap);
    let angleStep = arcAngle / nLines;
    let cumulativeMod = 0;
    for (let i = 0; i <= nLines; ++i) {
      let modAngle = 2 * Math.PI * devPeriods * i / nLines - Math.PI * 1.5;
      let modVal = Math.sin(modAngle) * angleStep * (dev / midGap);
      cumulativeMod += modVal;
      let angle = this.startAngle + arcAngle * i / nLines;
      angle += cumulativeMod;
      if (angle < this.startAngle || angle > this.endAngle) continue;
      this.fillers.push(this.genRadialLine(angle));
    }
  }

  genRadialFillers() {
    this.fillers = [];
    let arcAngle = this.endAngle - this.startAngle;
    let radMid = (this.radIn + this.radOut) / 2;
    let arcMidLen = Math.abs(radMid * arcAngle);
    let nLines = Math.round(arcMidLen / this.typeParams.midGap);
    for (let i = 0; i <= nLines; ++i) {
      let angle = this.startAngle + i / nLines * arcAngle;
      this.fillers.push(this.genRadialLine(angle));
    }
  }

  genRadialLine(angle) {
    let nPtsPerLine = Math.round((this.radOut - this.radIn) / segLen);
    let pts = [];
    let pt1 = new Point(Math.sin(angle), -Math.cos(angle));
    let pt2 = pt1.multiply(this.radOut).add(this.center);
    pt1 = pt1.multiply(this.radIn).add(this.center);
    for (let j = 0; j <= nPtsPerLine; ++j) {
      pts.push(pt1.add(pt2.subtract(pt1).multiply(j / nPtsPerLine)));
    }
    return pts;
  }

  genCogwheelFillers() {
    this.fillers = [];

    const rMid = (this.radIn + this.radOut) / 2;
    const startAngleDeg = this.startAngle / Math.PI * 180;
    const endAngleDeg = this.endAngle / Math.PI * 180;

    let wheeler = new Wheeler(this.radIn, this.radOut, 12, startAngleDeg, endAngleDeg);
    this.fillers.push(wheeler.innerPts);
    this.fillers.push(wheeler.outerPts);

    let wheelSizes = [];
    let teethOpts = [7, 9, 12, 15, 17, 21, 27];
    if (this.radOut - this.radIn > 90) {
      teethOpts.shift();
      teethOpts.shift();
    }
    if (this.radOut - this.radIn > 100)  teethOpts.shift();
    teethOpts.unshift(6);
    while (teethOpts.length > 0) {
      let teeth = teethOpts.shift();
      let dia = wheeler.getWheelDia(teeth);
      if (dia > (this.radOut - this.radIn) -10) break;
      wheelSizes.push([teeth, dia]);
      teeth += 2;
    }

    let arcAngleRad = 0;
    let outer = false;
    while (true) {
      let lastTeeth = wheelSizes[0][0];
      while (true) {
        shuffle(wheelSizes);
        if (wheelSizes[0][0] != lastTeeth) break;
      }
      let sz = wheelSizes[0];
      let wheelAngle = sz[1] / rMid * Math.PI * 0.45;
      if (this.startAngle + arcAngleRad + wheelAngle > this.endAngle) break;
      let polys = wheeler.genWheelOnArc(sz[0], (arcAngleRad + wheelAngle / 2) / Math.PI * 180, outer);
      for (const pts of polys) {
        this.fillers.push(pts);
      }
      arcAngleRad += wheelAngle;
      if (rand() < 0.7) outer = !outer;
    }
  }

  genArcPoints(r, angleFrom, angleTo) {
    let arcAngle = angleTo - angleFrom;
    let arcLen = Math.abs(r * arcAngle);
    let nSegs = Math.round(arcLen / segLen);
    let pts = [];
    for (let j = 0; j <= nSegs; ++j) {
      let angle = angleFrom + arcAngle * j / nSegs;
      let pt = new Point(Math.sin(angle), -Math.cos(angle));
      pt.length *= r;
      pt = pt.add(this.center);
      pts.push(pt);
    }
    return pts;
  }
}


function genCirclePts(center, radius, eclipsePhase = 0) {
  // No eclipse: just draw a circle
  if (eclipsePhase == 0 || eclipsePhase == 4) {
    let pts = [];
    let cf = 2 * Math.PI * radius;
    let nSegs = Math.round(cf / segLen);
    let vect = new Point(0, radius);
    for (let i = 0; i <= nSegs; ++i) {
      let angle = i / nSegs * 360;
      let pt = center.add(vect.rotate(angle));
      pts.push(pt);
    }
    return pts;
  }

  // Where is my occluder?
  let eRadius = radius * 1.2;
  let eCenter = center.clone();
  if (eclipsePhase == 1) eCenter.x += radius * 0.5 + eRadius;
  else if (eclipsePhase == 2) eCenter.x += eRadius;
  else if (eclipsePhase == 3) eCenter.x += eRadius - radius * 0.5;
  else if (eclipsePhase == 5) eCenter.x -= eRadius - radius * 0.5;
  else if (eclipsePhase == 6) eCenter.x -= eRadius;
  else if (eclipsePhase == 7) eCenter.x -= eRadius + radius * 0.5;
  // y-diff of intersection
  let cp = Path.Circle(center, radius);
  let ecp = Path.Circle(eCenter, eRadius);
  // project.activeLayer.addChild(cp);
  // project.activeLayer.addChild(ecp);
  let isects = cp.getIntersections(ecp);
  let isectH = Math.abs(center.y - isects[0].point.y);
  let topIsectPt = new Point(isects[0].point.x, center.y - isectH);
  // Angle on circle, and on occluder, above horizontal line
  let cAngle = -topIsectPt.subtract(center).angle * Math.PI / 180;
  let eAngle = eCenter.subtract(topIsectPt).angle * Math.PI / 180;

  let pts1, pts2;
  if (eCenter.x > center.x)
    pts1 = genArcPts(center, radius, cAngle, -cAngle);
  else
    pts1 = genArcPts(center, radius, -cAngle, cAngle);
  if (eCenter.x > center.x)
    pts2 = genArcPts(eCenter, eRadius, Math.PI - eAngle, Math.PI + eAngle);
  else
    pts2 = genArcPts(eCenter, eRadius, Math.PI + eAngle, Math.PI - eAngle);
  return pts1.concat(pts2.reverse());

  function genArcPts(center, radius, fromAngle, toAngle) {
    fromAngle = fromAngle * 180 / Math.PI;
    toAngle = toAngle * 180 / Math.PI;
    if (toAngle < fromAngle) toAngle += 360;
    let pts = [];
    let len = radius * (toAngle - fromAngle);
    let nSegs = Math.round(len / segLen);
    if (nSegs < 2) nSegs = 2;
    let vect = new Point(radius, 0);
    for (let i = 0; i <= nSegs; ++i) {
      let angle = fromAngle + i / nSegs * (toAngle - fromAngle);
      let pt = center.add(vect.rotate(angle));
      pts.push(pt);
    }
    return pts;
  }
}


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


function drawPath(pts, closed) {
  let path = new Path({ segments: pts, closed });
  paper.project.activeLayer.addChild(path);
}

function drawMaskedPath(pts, cm, mustBeMarkedVisible = false) {
  let visiblePaths = cm.getMaskedPoly(pts, mustBeMarkedVisible);
  for (const pts of visiblePaths) {
    const vp = Path.Line({segments: pts});
    paper.project.activeLayer.addChild(vp);
  }
}

function drawMaskedLine(pt1, pt2, cm, mustBeMarkedVisible = false) {
  const maskedLines = cm.getMaskedLine(pt1, pt2, mustBeMarkedVisible);
  for (const vl of maskedLines) {
    const ln = Path.Line(vl[0], vl[1]);
    paper.project.activeLayer.addChild(ln);
  }
}


class Wheeler {

  constructor(arcRadInner, arcRadOuter, pitch, startAngle, endAngle) {

    this.pitch = pitch;
    this.elev = pitch * 0.25;
    this.center = new Point(w / 2, h / 2);
    this.startAngle = startAngle;
    this.endAngle = endAngle;
    this.mechRadInner = arcRadInner + this.elev;
    this.mechRadOuter = arcRadOuter - this.elev;

    this.innerPts = this.genCoggedArcPoints(false);
    this.outerPts = this.genCoggedArcPoints(true);
  }

  genCoggedArcPoints(outer) {

    let arcAngle = this.endAngle - this.startAngle;
    let rMech = outer ? this.mechRadOuter : this.mechRadInner;
    let spoke = new Point(0, -rMech);
    let arcLen = Math.abs(rMech * arcAngle / 180 * Math.PI);
    let nTeeth = arcLen / this.pitch;
    let angleTooth = arcAngle / nTeeth;

    let pts = [];

    for (let i = 0; i < nTeeth; ++i) {
      let angleL = i / nTeeth * arcAngle - angleTooth * 0.25;
      angleL += this.startAngle;
      let toothPts = this.genToothPoints(spoke, angleL, angleTooth);
      pts.push(...toothPts);
    }
    pts.forEach(pt => {
      pt.x += this.center.x;
      pt.y += this.center.y;
    });
    return pts;
  }

  genToothPoints(spoke, angleL, angleTooth) {

    let angleX = angleTooth / 10;
    let angleC = angleL + angleTooth * 0.5;
    let angleR = angleL + angleTooth;

    let pt1 = spoke.rotate(angleL);
    let pt2 = spoke.rotate(angleL + angleX);
    pt2.length += this.elev;
    let pt3 = spoke.rotate(angleC - angleX);
    pt3.length += this.elev;
    let pt4 = spoke.rotate(angleC + angleX);
    pt4.length -= this.elev;
    let pt5 = spoke.rotate(angleR - angleX);
    pt5.length -= this.elev;
    let pt6 = spoke.rotate(angleR);

    let pts = [];
    pts.push(...this.getLinePts(pt1, pt2));
    pts.pop();
    pts.push(...this.getLinePts(pt2, pt3));
    pts.pop();
    pts.push(...this.getLinePts(pt3, pt4));
    pts.pop();
    pts.push(...this.getLinePts(pt4, pt5));
    pts.pop();
    pts.push(...this.getLinePts(pt5, pt6));
    return pts;
  }

  getWheelDia(nTeeth) {
    let cfMid = this.pitch * nTeeth;
    let rMech = cfMid / 2 / Math.PI;
    return 2 * (rMech + this.elev);
  }

  genCogwheel(nTeeth, rotAngle) {

    let cfMid = this.pitch * nTeeth;
    let rMech = cfMid / 2 / Math.PI;

    let pts = [];

    let spoke = new Point(0, -rMech);
    let angleTooth = 360 / nTeeth;

    for (let i = 0; i < nTeeth; ++i) {
      let angleL = i / nTeeth * 360 - angleTooth * 0.25 + rotAngle;
      let toothPts = this.genToothPoints(spoke, angleL, angleTooth);
      pts.push(...toothPts);
    }
    return [rMech, pts];
  }

  genWheelOnArc(nTeeth, arcAngle, outer) {

    // This is our actual result: multiple polylines
    let polys = [];

    // The cogwheel, rotated by the right amount so teeth meet
    let mechRad = outer ? this.mechRadOuter : this.mechRadInner;
    let arcTravel = 2 * mechRad * Math.PI * arcAngle / 360;
    let wheelCircf = this.pitch * nTeeth;
    let wheelTurns = arcTravel / wheelCircf;
    let rot = 360 * wheelTurns;
    if (outer) rot = -rot;
    let [rad, edgePts] = this.genCogwheel(nTeeth, rot + arcAngle + this.startAngle);
    polys.push(edgePts);

    // The hub
    let hubPts = genCirclePts(new Point(0, 0), 4);
    polys.push(hubPts);

    let ofs;
    if (outer) {
      let spoke = new Point(0, -this.mechRadOuter);
      ofs = spoke.rotate(this.startAngle + arcAngle);
      ofs.length -= rad;
    }
    else {
      let spoke = new Point(0, -this.mechRadInner);
      ofs = spoke.rotate(this.startAngle + arcAngle);
      ofs.length += rad;
    }
    ofs = ofs.add(this.center);

    // Offset all polylines. We've been working around origin so far.
    for (const pts of polys) {
      pts.forEach(pt => {
        pt.x += ofs.x;
        pt.y += ofs.y;
      });
    }

    return polys;
  }

  getLinePts(pt1, pt2) {
    const lineVect = pt2.subtract(pt1);
    const lineLength = lineVect.length;
    const nSegs = Math.max(2, Math.round(lineLength / segLen));
    const segVect = lineVect.divide(nSegs);
    const pts = [];
    for (let i = 0; i <= nSegs; ++i) {
      pts.push(pt1.add(segVect.multiply(i)));
    }
    return pts;
  }
}

function getRomanStr(year, day) {

  return romanize(year) + " " + romanize(day);

  function romanize(num) {
    if (!+num) return undefined;
    let digits = String(+num).split('');
    let key = ['', 'C', 'CC', 'CCC', 'CD', 'D', 'DC', 'DCC', 'DCCC', 'CM',
      '', 'X', 'XX', 'XXX', 'XL', 'L', 'LX', 'LXX', 'LXXX', 'XC',
      '', 'I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX'];
    let roman = '', i = 3;
    while (i--) roman = (key[+digits.pop() + (i * 10)] || '') + roman;
    return Array(+digits.join('') + 1).join('M') + roman;
  }
}

function hashCode(str) {
  let hash = 0, i, chr;
  if (str.length === 0) return hash;
  for (i = 0; i < str.length; i++) {
    chr   = str.charCodeAt(i);
    hash  = ((hash << 5) - hash) + chr;
    hash |= 0; // Convert to 32bit integer
  }
  hash = hash % 65536;
  while (hash < 0) hash += 65536;
  return hash;
}