Source code of plot #035 back to plot

Download full working sketch as 035.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, rand_select, randn_bm, setRandomGenerator, shuffle} from "./utils/random.js"
import {lnPtDist} from "./utils/geo.js";
import {CanvasMasker} from "./utils/canvas-masker.js";
import * as THREE from "../pub/lib/three.module.js";
import {Vector3} from "../pub/lib/three.module.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 = 1480;     // Drawing width
const h = 2100;     // Drawing height
const margin = 200;
// const w = 500;     // Drawing width
// const h = 700;     // Drawing height

let camAltitude = 0;       // Cam altitude in degress
let camAzimuth = 0;        // Cam azimuth in degrees; front (from Z) is 0, goes CCW from Y top
let camDistance = 4*w;      // Cam distance from origin

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

// Canvas masker & Three JS canvas/machinery
let segLen = 2;
let rf = 2;                 // Occlusion canvas & three canvas are this many times larger than our area
let cm;                     // Canvas masker
let elmThreeCanvas;
let renderer, scene, cam, ray;

let distTolerance = 0.01;   // Used in line merging
let angleTolerance = 0.01;  // Used in line merging


let seed;     // Random seed
if (window.fxhash) seed = Math.round(fxrand() * 65535);
else seed = Math.round(Math.random() * 65535);
// seed = 58265;

setRandomGenerator(mulberry32(seed));
let simplex;
let noise;

setSketch(function () {

  info("Seed: " + seed, seed);
  init(w, h, pw, ph);

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

  // 2D canvas masker
  if (screen.width < 768) rf = 1; // Large canvas doesn't work on mobile devices
  cm = new CanvasMasker(w, h, rf);

  // Three JS canvas
  initThree();

  setTimeout(draw, 10);
});

async function draw() {

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

  let tubes = [];

  let hueStart = 11;
  let hueOfs = 8;
  let bbox = new THREE.Box3();

  tubes.push(makeRandomTube(0, h * -0.2));
  tubes.push(makeRandomTube(w * -0.1, h * -0.08));
  tubes.push(makeRandomTube(w * 0.1, h * -0.08));
  tubes.push(makeRandomTube(w * -0.1, h * 0.08));
  tubes.push(makeRandomTube(w * 0.1, h * 0.08));
  tubes.push(makeRandomTube(0, h * 0.2));

  let hatchGaps = [[5, 11], [5, 11], [10, 9], [10, 9], [15, 7], [15, 7]];
  shuffle(hatchGaps);
  for (let i = 0; i < hatchGaps.length; ++i) {
    tubes[i].leftHatchGap = hatchGaps[i][0];
    tubes[i].sideHatchGap = hatchGaps[i][1];
  }

  // shuffle(tubes);

  let zofs = 0;
  for (const tube of tubes) {
    tube.translate(0, 0, zofs);
    bbox.setFromObject(tube.group);
    zofs += Math.abs(bbox.max.z - bbox.min.z) + w * 0.1;
  }

  function makeRandomTube(x, y) {
    let wid = rand_range(150, 450);
    let hei = rand_range(250, 600);
    let thk = rand_range(50, Math.min(wid, hei) * 0.4);
    let ts = rand_range(50, Math.min(wid, hei) * 0.3);
    let tt = rand_range(50, Math.min(wid, hei) * 0.3);

    let tube = new Tube(2*thk, wid, hei, ts, tt, true, hueStart);
    hueStart += hueOfs;
    tube.rotY(Math.PI * rand_range(0.2, 0.8));
    tube.rotZ(Math.PI * rand_range(-0.2, 0.2));
    tube.rotX(Math.PI * rand_range(-0.2, 0.2));
    tube.translate(x, y, 0);
    return tube;
  }

  tubes.forEach(t => scene.add(t.group));
  renderer.render(scene, cam);
  let pixels = new Uint8Array(w * rf * h * rf * 4);
  let ctx = elmThreeCanvas.getContext("webgl2", {preserveDrawingBuffer: true});
  ctx.readPixels(0, 0, ctx.drawingBufferWidth, ctx.drawingBufferHeight, ctx.RGBA, ctx.UNSIGNED_BYTE, pixels);

  cm.clear();
  let edgeLines = [], hiddens = [], facePaths = [];
  for (let i = tubes.length - 1; i >= 0; --i) {
    getAllEdgesAndHatch(tubes[i], edgeLines, hiddens, facePaths);
  }

  let mergedEdgeLines = mergeLines(edgeLines);

  for (const [pt1, pt2] of mergedEdgeLines) {
    let squiggleLen = 10;
    let bgain = 0.9;
    let sgain = 1.0;
    if (squiggly) drawSquigglyLine([pt1, pt2], false, squiggleLen, noise, bgain, sgain, 0, null);
    else {
      let ln = Path.Line(pt1, pt2);
      project.activeLayer.addChild(ln);
    }
  }

  cm.clear();
  for (const fpath of facePaths) {
    if (!fpath.segments) continue;
    let pts = [];
    fpath.segments.forEach(seg => pts.push(seg.point));
    cm.blockPoly(pts);
  }

  // project.currentStyle.strokeColor = "maroon";
  let fwidth = w * 0.4;
  let fheight = h * 0.7;
  let fframe = Path.Rectangle((w-fwidth)/2, (h-fheight)/2, fwidth, fheight);
  cm.includeRect((w-fwidth)/2, (h-fheight)/2, fwidth, fheight);

  // let hatchLines = genHatchLines(fframe, -20, 10);
  // for (const hl of hatchLines) {
  //   let vlns = cm.getMaskedLine(hl[0], hl[1], true);
  //   for (const vl of vlns) {
  //     let ln = Path.Line(...vl);
  //     project.activeLayer.addChild(ln)
  //   }
  // }

  fillSticks([
    new Point((w-fwidth)/2, (h-fheight)/2),
    new Point((w+fwidth)/2, (h-fheight)/2),
    new Point((w+fwidth)/2, (h+fheight)/2),
    new Point((w-fwidth)/2, (h+fheight)/2),
  ]);

  function getAllEdgesAndHatch(tube, edgeLines, hiddens, paths) {
    getFaceEdgesAndHatch(tube, Faces.Front, edgeLines, hiddens, paths);
    getFaceEdgesAndHatch(tube, Faces.Back, edgeLines, hiddens, paths);
    getFaceEdgesAndHatch(tube, Faces.Top, edgeLines, hiddens, paths);
    getFaceEdgesAndHatch(tube, Faces.Bottom, edgeLines, hiddens, paths);
    getFaceEdgesAndHatch(tube, Faces.InnerFront, edgeLines, hiddens, paths);
    getFaceEdgesAndHatch(tube, Faces.InnerBack, edgeLines, hiddens, paths);
    getFaceEdgesAndHatch(tube, Faces.InnerTop, edgeLines, hiddens, paths);
    getFaceEdgesAndHatch(tube, Faces.InnerBottom, edgeLines, hiddens, paths);
    getFaceEdgesAndHatch(tube, Faces.Right, edgeLines, hiddens, paths);
    getFaceEdgesAndHatch(tube, Faces.Left, edgeLines, hiddens, paths);
  }

  function getFaceEdgesAndHatch(tube, face, lines, hiddens, paths) {
    let pf = tube.getProjectedFace(cam, pixels, face);
    lines.push(...pf.visibleLines);
    hiddens.push(...pf.hiddenLines);
    paths.push(...pf.simplePaths);
    if (pf.face == Faces.Left) hatchLeftFace(pixels, pf, tube.leftHatchGap);
    else hatchSide(pixels, pf, tube.sideHatchGap);
  }
}

// ===========================================================================
// Parameterized tube object
// ===========================================================================

const Faces = {
  Front: "Front",
  Back: "Back",
  Top: "Top",
  Bottom: "Bottom",
  InnerFront: "InnerFront",
  InnerBack: "InnerBack",
  InnerTop: "InnerTop",
  InnerBottom: "InnerBottom",
  Left: "Left",
  Right: "Right",
};

class Face3D {
  constructor(face, hue, geo) {
    this.face = face;
    let mat = new THREE.MeshBasicMaterial({
      opacity: 1,
      side: THREE.DoubleSide,
      color: new THREE.Color("hsl(" + hue + ", 30%, 35%)")
    });
    this.geo = geo;
    this.mesh = new THREE.Mesh(geo, mat);
    this.clr8 = {
      r: Math.round(mat.color.r * 255),
      g: Math.round(mat.color.g * 255),
      b: Math.round(mat.color.b * 255),
    };
  }
}

class ProjectedFace {
  constructor(face, clr8, path, simplePaths, visibleLines, hiddenLines) {
    this.face = face;
    this.clr8 = clr8;
    this.path = path;
    this.simplePaths = simplePaths;
    this.visibleLines = visibleLines;
    this.hiddenLines = hiddenLines;
  }
}

class Tube {
  constructor(length, height, depth, thicknessTop, thicknessSide, centered, hueStart) {
    this.l = length;
    this.h = height;
    this.d = depth;
    this.tt = thicknessTop;
    this.ts = thicknessSide;
    this.vertices = this.makeVertices(centered);
    this.faces = this.makeFaces(hueStart);
    this.group = new THREE.Group();
    this.faces.forEach(f => this.group.add(f.mesh));
  }

  translate(x, y, z) {
    let vec = new THREE.Vector3(x, y, z);
    let pos = this.group.position.clone().add(vec);
    this.group.position.set(pos.x, pos.y, pos.z);
    this.vertices.forEach(v => v.add(vec));
  }

  rotX(angle) {
    let axis = new THREE.Vector3(1, 0, 0);
    this.group.rotateOnWorldAxis(axis, angle);
    this.vertices.forEach(v => v.applyAxisAngle(axis, angle));
  }

  rotY(angle) {
    let axis = new THREE.Vector3(0, 1, 0);
    this.group.rotateOnWorldAxis(axis, angle);
    this.vertices.forEach(v => v.applyAxisAngle(axis, angle));
  }

  rotZ(angle) {
    let axis = new THREE.Vector3(0, 0, 1);
    this.group.rotateOnWorldAxis(axis, angle);
    this.vertices.forEach(v => v.applyAxisAngle(axis, angle));
  }

  rotateY(angle) {
    this.group.rotateY(angle);
    let axis = new THREE.Vector3(0, 1, 0);
    this.vertices.forEach(v => v.applyAxisAngle(axis, angle));
  }

  getProjectedFace(cam, pixels, face) {
    let path;
    let simplePaths = [];
    let visibles = [], hiddens = [];
    let clr8 = this.getFace3D(face).clr8;
    let pts = [];
    if (face == Faces.Front) {
      this.getMaskedEdge(4, 5, face, Faces.Bottom, cam, pixels, pts, visibles, hiddens);
      this.getMaskedEdge(5, 6, face, Faces.Right, cam, pixels, pts, visibles, hiddens);
      this.getMaskedEdge(6, 7, face, Faces.Top, cam, pixels, pts, visibles, hiddens);
      this.getMaskedEdge(7, 4, face, Faces.Left, cam, pixels, pts, visibles, hiddens);
    }
    else if (face == Faces.Back) {
      this.getMaskedEdge(0, 1, face, Faces.Bottom, cam, pixels, pts, visibles, hiddens);
      this.getMaskedEdge(1, 2, face, Faces.Right, cam, pixels, pts, visibles, hiddens);
      this.getMaskedEdge(2, 3, face, Faces.Top, cam, pixels, pts, visibles, hiddens);
      this.getMaskedEdge(3, 0, face, Faces.Left, cam, pixels, pts, visibles, hiddens);
    }
    else if (face == Faces.Top) {
      this.getMaskedEdge(7, 6, face, Faces.Front, cam, pixels, pts, visibles, hiddens);
      this.getMaskedEdge(6, 2, face, Faces.Right, cam, pixels, pts, visibles, hiddens);
      this.getMaskedEdge(2, 3, face, Faces.Back, cam, pixels, pts, visibles, hiddens);
      this.getMaskedEdge(3, 7, face, Faces.Left, cam, pixels, pts, visibles, hiddens);
    }
    else if (face == Faces.Bottom) {
      this.getMaskedEdge(4, 5, face, Faces.Front, cam, pixels, pts, visibles, hiddens);
      this.getMaskedEdge(5, 1, face, Faces.Right, cam, pixels, pts, visibles, hiddens);
      this.getMaskedEdge(1, 0, face, Faces.Back, cam, pixels, pts, visibles, hiddens);
      this.getMaskedEdge(0, 4, face, Faces.Left, cam, pixels, pts, visibles, hiddens);
    }
    else if (face == Faces.InnerFront) {
      this.getMaskedEdge(12, 13, face, Faces.InnerBottom, cam, pixels, pts, visibles, hiddens);
      this.getMaskedEdge(13, 14, face, Faces.Right, cam, pixels, pts, visibles, hiddens);
      this.getMaskedEdge(14, 15, face, Faces.InnerTop, cam, pixels, pts, visibles, hiddens);
      this.getMaskedEdge(15, 12, face, Faces.Left, cam, pixels, pts, visibles, hiddens);
    }
    else if (face == Faces.InnerBack) {
      this.getMaskedEdge(8, 9, face, Faces.InnerBottom, cam, pixels, pts, visibles, hiddens);
      this.getMaskedEdge(9, 10, face, Faces.Right, cam, pixels, pts, visibles, hiddens);
      this.getMaskedEdge(10, 11, face, Faces.InnerTop, cam, pixels, pts, visibles, hiddens);
      this.getMaskedEdge(11, 8, face, Faces.Left, cam, pixels, pts, visibles, hiddens);
    }
    else if (face == Faces.InnerTop) {
      this.getMaskedEdge(15, 14, face, Faces.InnerFront, cam, pixels, pts, visibles, hiddens);
      this.getMaskedEdge(14, 10, face, Faces.Right, cam, pixels, pts, visibles, hiddens);
      this.getMaskedEdge(10, 11, face, Faces.InnerBack, cam, pixels, pts, visibles, hiddens);
      this.getMaskedEdge(11, 15, face, Faces.Left, cam, pixels, pts, visibles, hiddens);
    }
    else if (face == Faces.InnerBottom) {
      this.getMaskedEdge(12, 13, face, Faces.InnerFront, cam, pixels, pts, visibles, hiddens);
      this.getMaskedEdge(13, 9, face, Faces.Right, cam, pixels, pts, visibles, hiddens);
      this.getMaskedEdge(9, 8, face, Faces.InnerBack, cam, pixels, pts, visibles, hiddens);
      this.getMaskedEdge(8, 12, face, Faces.Left, cam, pixels, pts, visibles, hiddens);
    }
    else if (face == Faces.Right) {
      this.getMaskedEdge(6, 5, face, Faces.Front, cam, pixels, pts, visibles, hiddens);
      this.getMaskedEdge(5, 1, face, Faces.Bottom, cam, pixels, pts, visibles, hiddens);
      this.getMaskedEdge(1, 2, face, Faces.Back, cam, pixels, pts, visibles, hiddens);
      this.getMaskedEdge(2, 6, face, Faces.Top, cam, pixels, pts, visibles, hiddens);
    }
    else if (face == Faces.Left) {
      this.getMaskedEdge(7, 4, face, Faces.Front, cam, pixels, pts, visibles, hiddens);
      this.getMaskedEdge(4, 0, face, Faces.Bottom, cam, pixels, pts, visibles, hiddens);
      this.getMaskedEdge(0, 3, face, Faces.Back, cam, pixels, pts, visibles, hiddens);
      this.getMaskedEdge(3, 7, face, Faces.Top, cam, pixels, pts, visibles, hiddens);
    }

    path = new Path({segments: pts, closed: true});

    let pv = (vertIx) => this.projVert(cam, vertIx);

    if (face == Faces.Right) {
      let innerPts = [];
      this.getMaskedEdge(14, 13, face, Faces.InnerFront, cam, pixels, innerPts, visibles, hiddens);
      this.getMaskedEdge(13, 9, face, Faces.InnerBottom, cam, pixels, innerPts, visibles, hiddens);
      this.getMaskedEdge(9, 10, face, Faces.InnerBack, cam, pixels, innerPts, visibles, hiddens);
      this.getMaskedEdge(10, 14, face, Faces.InnerTop, cam, pixels, innerPts, visibles, hiddens);
      let innerPath = new Path({segments: innerPts, closed: true});
      path = path.subtract(innerPath);
      simplePaths.push(new Path({segments: [pv(6), pv(14), pv(10), pv(2)], closed: true}));
      simplePaths.push(new Path({segments: [pv(6), pv(14), pv(13), pv(5)], closed: true}));
      simplePaths.push(new Path({segments: [pv(1), pv(9), pv(13), pv(5)], closed: true}));
      simplePaths.push(new Path({segments: [pv(1), pv(9), pv(10), pv(2)], closed: true}));
    }
    else if (face == Faces.Left) {
      let innerPts = [];
      this.getMaskedEdge(15, 12, face, Faces.InnerFront, cam, pixels, innerPts, visibles, hiddens);
      this.getMaskedEdge(12, 8, face, Faces.InnerBottom, cam, pixels, innerPts, visibles, hiddens);
      this.getMaskedEdge(8, 11, face, Faces.InnerBack, cam, pixels, innerPts, visibles, hiddens);
      this.getMaskedEdge(11, 15, face, Faces.InnerTop, cam, pixels, innerPts, visibles, hiddens);
      let innerPath = new Path({segments: innerPts, closed: true});
      path = path.subtract(innerPath);
      simplePaths.push(new Path({segments: [pv(7), pv(15), pv(12), pv(4)], closed: true}));
      simplePaths.push(new Path({segments: [pv(7), pv(15), pv(11), pv(3)], closed: true}));
      simplePaths.push(new Path({segments: [pv(0), pv(8), pv(12), pv(4)], closed: true}));
      simplePaths.push(new Path({segments: [pv(0), pv(8), pv(11), pv(3)], closed: true}));
    }
    else simplePaths.push(path);
    return new ProjectedFace(face, clr8, path, simplePaths, visibles, hiddens);
  }

  getMaskedEdge(vertIx1, vertIx2, face1, face2, cam, pixels, pts, visibleLines, hiddenLines) {
    let pt1 = this.projVert(cam, vertIx1);
    let pt2 = this.projVert(cam, vertIx2);
    let [viss, hids] = traceEdge(pt1, pt2, this.getFace3D(face1).mesh, this.getFace3D(face2).mesh);
    // let [viss, hids] = getMaskedEdge(pt1, pt2, pixels, this.getFace3D(face1).clr8, this.getFace3D(face2).clr8);
    visibleLines.push(...viss);
    hiddenLines.push(...hids);
    pts.push(pt1);
  }

  projVert(cam, vertIx) {
    let vert = this.vertices[vertIx].clone();
    let projected = vert.project(cam);
    return new Point((projected.x + 1) * w / rf, (1 - projected.y) * h / rf);
  }

  getFace3D(face) {
    return this.faces.find(f => f.face == face);
  }

  makeFaces(hueStart) {
    const v = this.vertices;
    let hue = hueStart;
    let hueStep = 31;
    let faces = [];

    let shape, hole, geo;

    // Front
    shape = new THREE.Shape();
    shape.moveTo(v[4].x, v[4].y);
    shape.lineTo(v[5].x, v[5].y);
    shape.lineTo(v[6].x, v[6].y);
    shape.lineTo(v[7].x, v[7].y);
    geo = new THREE.ShapeGeometry(shape);
    geo.translate(0, 0, v[4].z);
    faces.push(new Face3D(Faces.Front, hue, geo));

    // Back
    hue += hueStep;
    shape = new THREE.Shape();
    shape.moveTo(v[0].x, v[0].y);
    shape.lineTo(v[1].x, v[1].y);
    shape.lineTo(v[2].x, v[2].y);
    shape.lineTo(v[3].x, v[3].y);
    geo = new THREE.ShapeGeometry(shape);
    geo.translate(0, 0, v[1].z);
    faces.push(new Face3D(Faces.Back, hue, geo));

    // Top
    hue += hueStep;
    shape = new THREE.Shape();
    shape.moveTo(v[3].x, v[3].z);
    shape.lineTo(v[7].x, v[7].z);
    shape.lineTo(v[6].x, v[6].z);
    shape.lineTo(v[2].x, v[2].z);
    geo = new THREE.ShapeGeometry(shape);
    geo.rotateX(Math.PI / 2);
    geo.translate(0, v[3].y, 0);
    faces.push(new Face3D(Faces.Top, hue, geo));

    // Bottom
    hue += hueStep;
    shape = new THREE.Shape();
    shape.moveTo(v[0].x, v[0].z);
    shape.lineTo(v[4].x, v[4].z);
    shape.lineTo(v[5].x, v[5].z);
    shape.lineTo(v[1].x, v[1].z);
    geo = new THREE.ShapeGeometry(shape);
    geo.rotateX(Math.PI / 2);
    geo.translate(0, v[0].y, 0);
    faces.push(new Face3D(Faces.Bottom, hue, geo));

    // Inner Front
    hue += hueStep;
    shape = new THREE.Shape();
    shape.moveTo(v[12].x, v[12].y);
    shape.lineTo(v[13].x, v[13].y);
    shape.lineTo(v[14].x, v[14].y);
    shape.lineTo(v[15].x, v[15].y);
    geo = new THREE.ShapeGeometry(shape);
    geo.translate(0, 0, v[12].z);
    faces.push(new Face3D(Faces.InnerFront, hue, geo));

    // Inner Back
    hue += hueStep;
    shape = new THREE.Shape();
    shape.moveTo(v[8].x, v[8].y);
    shape.lineTo(v[9].x, v[9].y);
    shape.lineTo(v[10].x, v[10].y);
    shape.lineTo(v[11].x, v[11].y);
    geo = new THREE.ShapeGeometry(shape);
    geo.translate(0, 0, v[8].z);
    faces.push(new Face3D(Faces.InnerBack, hue, geo));

    // Inner Top
    hue += hueStep;
    shape = new THREE.Shape();
    shape.moveTo(v[11].x, v[11].z);
    shape.lineTo(v[15].x, v[15].z);
    shape.lineTo(v[14].x, v[14].z);
    shape.lineTo(v[10].x, v[10].z);
    geo = new THREE.ShapeGeometry(shape);
    geo.rotateX(Math.PI / 2);
    geo.translate(0, v[11].y, 0);
    faces.push(new Face3D(Faces.InnerTop, hue, geo));

    // Inner Bottom
    hue += hueStep;
    shape = new THREE.Shape();
    shape.moveTo(v[8].x, v[8].z);
    shape.lineTo(v[12].x, v[12].z);
    shape.lineTo(v[13].x, v[13].z);
    shape.lineTo(v[9].x, v[9].z);
    geo = new THREE.ShapeGeometry(shape);
    geo.rotateX(Math.PI / 2);
    geo.translate(0, v[8].y, 0);
    faces.push(new Face3D(Faces.InnerBottom, hue, geo));

    // Right
    hue += hueStep;
    shape = new THREE.Shape();
    shape.moveTo(v[6].z, v[6].y);
    shape.lineTo(v[5].z, v[5].y);
    shape.lineTo(v[1].z, v[1].y);
    shape.lineTo(v[2].z, v[2].y);
    hole = new THREE.Path();
    hole.moveTo(v[14].z, v[14].y);
    hole.lineTo(v[13].z, v[13].y);
    hole.lineTo(v[9].z, v[9].y);
    hole.lineTo(v[10].z, v[10].y);
    shape.holes.push(hole);
    geo = new THREE.ShapeGeometry(shape);
    geo.rotateY(-Math.PI / 2);
    geo.translate(v[6].x, 0, 0);
    faces.push(new Face3D(Faces.Right, hue, geo));

    // Left
    hue += hueStep;
    shape = new THREE.Shape();
    shape.moveTo(v[7].z, v[7].y);
    shape.lineTo(v[4].z, v[4].y);
    shape.lineTo(v[0].z, v[0].y);
    shape.lineTo(v[3].z, v[3].y);
    hole = new THREE.Path();
    hole.moveTo(v[15].z, v[15].y);
    hole.lineTo(v[12].z, v[12].y);
    hole.lineTo(v[8].z, v[8].y);
    hole.lineTo(v[11].z, v[11].y);
    shape.holes.push(hole);
    geo = new THREE.ShapeGeometry(shape);
    geo.rotateY(-Math.PI / 2);
    geo.translate(v[7].x, 0, 0);
    faces.push(new Face3D(Faces.Left, hue, geo));

    return faces;
  }


  makeVertices(centered) {
    let verts = [
      new THREE.Vector3(0, 0, 0),
      new THREE.Vector3(this.l, 0, 0),
      new THREE.Vector3(this.l, this.h, 0),
      new THREE.Vector3(0, this.h, 0),
      new THREE.Vector3(0, 0, this.d),
      new THREE.Vector3(this.l, 0, this.d),
      new THREE.Vector3(this.l, this.h, this.d),
      new THREE.Vector3(0, this.h, this.d),
      new THREE.Vector3(0, this.tt, this.ts),
      new THREE.Vector3(this.l, this.tt, this.ts),
      new THREE.Vector3(this.l, this.h - this.tt, this.ts),
      new THREE.Vector3(0, this.h - this.tt, this.ts),
      new THREE.Vector3(0, this.tt, this.d - this.ts),
      new THREE.Vector3(this.l, this.tt, this.d - this.ts),
      new THREE.Vector3(this.l, this.h - this.tt, this.d - this.ts),
      new THREE.Vector3(0, this.h - this.tt, this.d - this.ts),
    ];
    if (centered) {
      let ofs = new Vector3(this.l/2, this.h/2, this.d/2);
      verts.forEach(vert => vert.sub(ofs));
    }
    return verts;
  }
}


// ===========================================================================
// 2D line merging and pruning
// ===========================================================================

function mergeLines(lines) {
  let lnGroups = [];
  for (const ln of lines) {
    if (lnGroups.length == 0) {
      lnGroups.push(new AngleLineGroup(ln));
      continue;
    }
    let foundGroup = false;
    for (const g of lnGroups) {
      if (g.addSegIfOnSameLine(ln)) {
        foundGroup = true;
        break;
      }
    }
    if (!foundGroup) lnGroups.push(new AngleLineGroup(ln));
  }
  let res = [];
  for (const g of lnGroups) {
    g.mergeLines();
    res.push(...g.lines);
  }
  return res;
}

function areOnSameLine(ln1Pts, ln2Pts) {

  const bigLen = 100_000;
  let ln1Vec = ln1Pts[1].subtract(ln1Pts[0]);
  ln1Vec = ln1Vec.multiply(bigLen / ln1Vec.length);
  let ln2Vec = ln2Pts[1].subtract(ln2Pts[0]);
  ln2Vec = ln2Vec.multiply(bigLen / ln2Vec.length);
  while (ln1Vec.angle > 90) ln1Vec = ln1Vec.rotate(-180);
  while (ln2Vec.angle > 90) ln2Vec = ln2Vec.rotate(-180);
  while (ln1Vec.angle < -90) ln1Vec = ln1Vec.rotate(180);
  while (ln2Vec.angle < -90) ln2Vec = ln2Vec.rotate(180);

  // Angle the same at all?
  let angleDiff1 = Math.abs(ln2Vec.angle - ln1Vec.angle);
  let angleDiff2 = angleDiff1 - 180;
  let differentAngle = angleDiff1 > angleTolerance && angleDiff2 < -angleTolerance;
  if (differentAngle) return false;

  // Create very long lines
  let lnA = Path.Line(ln1Pts[0].add(ln1Vec), ln1Pts[0].subtract(ln1Vec));
  let lnB = Path.Line(ln2Pts[0].add(ln2Vec), ln2Pts[0].subtract(ln2Vec));

  // More vertical: test X axis
  if (ln1Vec.angle < -45 || ln1Vec.angle > 45) {
    let lnAxis = Path.Line(new Point(-bigLen, 0), new Point(bigLen, 0));
    let x1 = lnAxis.getIntersections(lnA)[0].point.x;
    let x2 = lnAxis.getIntersections(lnB)[0].point.x;
    return Math.abs(x2 - x1) < distTolerance;
  }
  // More horizontal: test Y axis
  else {
    let lnAxis = Path.Line(new Point(0, -bigLen), new Point(0, bigLen));
    let y1 = lnAxis.getIntersections(lnA)[0].point.y;
    let y2 = lnAxis.getIntersections(lnB)[0].point.y;
    return Math.abs(y2 - y1) < distTolerance;
  }
}

class AngleLineGroup {
  constructor(ln) {
    this.lines = [ln];
    this.angle = ln[1].subtract(ln[0]).angle;
    while (this.angle > 90) this.angle -= 180;
    while (this.angle < -90) this.angle += 180;
  }

  addSegIfOnSameLine(lnPts) {
    if (!areOnSameLine(this.lines[0], lnPts)) return false;
    this.lines.push(lnPts);
    return true;
  }

  mergeLines() {

    let genOrienter = function(horiz) {
      return ln => {
        if (horiz) {
          if (ln[0].x > ln[1].x) return [ln[1], ln[0]];
          else return ln;
        }
        else {
          if (ln[0].y > ln[1].y) return [ln[1], ln[0]];
          else return ln;
        }
      }
    }

    let genComparer = function(horiz) {
      return (lnA, lnB) => {
        if (horiz) return lnA[0].x - lnB[0].x;
        else return lnA[0].y - lnB[0].y;
      };
    };

    let genOvlTester = function(horiz) {
      return (lnA, lnB) => {
        if (horiz) return lnB[0].x - lnA[1].x < distTolerance;
        else return lnB[0].y - lnA[1].y < distTolerance;
      };
    };

    let horiz = this.angle > -45 && this.angle < 45;
    let orient = genOrienter(horiz);
    let cmp = genComparer(horiz);
    let ovl = genOvlTester(horiz);

    this.lines.forEach((ln, ix) => this.lines[ix] = orient(ln));
    this.lines.sort(cmp);
    let merged = [this.lines[0]];
    for (let i = 1; i < this.lines.length; ++i) {
      let ln = this.lines[i];
      let mLast = merged[merged.length-1];
      if (ovl(mLast, ln)) {
        let lineMax = horiz ? ln[1].x : ln[1].y;
        let mLastMax = horiz ? mLast[1].x : mLast[1].y;
        if (lineMax > mLastMax)
          merged[merged.length - 1] = [mLast[0], ln[1]];
      }
      else merged.push(ln);
    }
    this.lines = merged;
  }
}

// ===========================================================================
// Three JS machinery and masking
// ===========================================================================

function initThree() {

  const elmPaperCanvas = document.getElementById("paper-canvas");
  const elmCanvasHost = document.getElementById("canvasHost");
  const canvasWidth = elmPaperCanvas.clientWidth;
  const canvasHeight = canvasWidth * h / w;
  const aspect = w / h;

  renderer = new THREE.WebGLRenderer({preserveDrawingBuffer: true});
  elmCanvasHost.appendChild(renderer.domElement);
  elmThreeCanvas = renderer.domElement;
  elmThreeCanvas.id = "three-canvas";
  renderer.setSize(w * rf, h * rf);

  elmCanvasHost.style.width = (canvasWidth * 2) + "px";
  elmPaperCanvas.style.width = canvasWidth + "px";
  elmPaperCanvas.style.position = "relative";
  elmThreeCanvas.style.position = "relative";
  elmThreeCanvas.style.float = "right";
  elmThreeCanvas.style.width = canvasWidth + "px";
  elmThreeCanvas.style.height = canvasHeight + "px";
  //renderer.setPixelRatio(w / canvasWidth * rf);

  let D = w / 2;
  cam = new THREE.OrthographicCamera(-D, D, D / aspect, -D / aspect, 1, 10000);
  // cam = new THREE.PerspectiveCamera(50, aspect, 1, 10000);
  let camPos = new Vector3(
    Math.sin(camAzimuth*Math.PI/180) * Math.cos(camAltitude*Math.PI/180),
    Math.sin(camAltitude*Math.PI/180),
    Math.cos(camAzimuth*Math.PI/180) * Math.cos(camAltitude*Math.PI/180),
  );
  camPos.multiplyScalar(camDistance);
  cam.position.set(camPos.x, camPos.y, camPos.z);
  cam.lookAt(0, 0, 0);
  cam.updateProjectionMatrix();
  scene = new THREE.Scene();
  scene.background = new THREE.Color("#303030");

  ray = new THREE.Raycaster();
}

function traceEdge(pt1, pt2, mesh1, mesh2) {

  let visibles = [], hiddens = [];

  // Get all meshes in scene: we'll be testing for intersection with them
  const meshes = [];
  scene.traverse(obj => {
    if (obj.isMesh) meshes.push(obj);
  });

  // Build points: short segments of the requested length
  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)));
  }
  let orto = pt2.subtract(pt1).rotate(90);
  orto.length = 1;

  let firstVisible = null, firstHidden = null;
  for (let i = 0; i < pts.length; ++i) {

    let isVisible = false;

    let pt = pts[i];
    let ptl = pt.add(orto);
    let ptr = pt.subtract(orto);

    // Trace sample point itself
    let [nearerFaceIsect, nearestDist] = trace(pt);

    // Intersects with one of the sides
    if (nearerFaceIsect != null) {
      isVisible = nearerFaceIsect.distance == nearestDist;
    }
    // Does not intersect with either side: test points off to left and right
    else {
      [nearerFaceIsect, nearestDist] = trace(ptl);
      if (nearerFaceIsect != null) {
        isVisible = nearerFaceIsect.distance == nearestDist;
      }
      else {
        [nearerFaceIsect, nearestDist] = trace(ptr);
        if (nearerFaceIsect != null) {
          isVisible = nearerFaceIsect.distance == nearestDist;
        }
        else isVisible = false;
      }
    }

    if (isVisible && firstVisible == null) firstVisible = pts[i];
    else if (!isVisible && firstVisible != null) {
      if (firstVisible != pts[i-1]) visibles.push([firstVisible, pts[i-1]]);
      firstVisible = null;
    }

    if (!isVisible && firstHidden == null) firstHidden = pts[i];
    else if (isVisible && firstHidden != null) {
      if (firstHidden != pts[i-1]) hiddens.push([firstHidden, pts[i-1]]);
      firstHidden = null;
    }
  }
  if (firstVisible != null && firstVisible != pts[pts.length-1]) visibles.push([firstVisible, pts[pts.length-1]]);
  if (firstHidden != null && firstHidden != pts[pts.length-1]) hiddens.push([firstHidden, pts[pts.length-1]]);

  return [visibles, hiddens];

  function trace(pt) {
    let vec2 = new THREE.Vector2(pt.x/w*2 - 1, -pt.y/h*2 + 1);
    ray.setFromCamera(vec2, cam);
    let isects = ray.intersectObjects(meshes, false);
    let nearerFaceIsect = null;
    let nearestDist = Number.MAX_VALUE;
    isects.forEach(i => {
      if (i.distance < nearestDist) nearestDist = i.distance;
      if (i.object != mesh1 && i.object != mesh2) return;
      if (nearerFaceIsect == null || i.distance < nearerFaceIsect.distance) nearerFaceIsect = i;
    });
    return [nearerFaceIsect, nearestDist];
  }
}

function get3JSMaskedLine(pt1, pt2, pixels, clr) {

  // Build points: short segments of the requested length
  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)));
  }

  let visibleLines = [];
  let firstVisible = null
  for (let i = 0; i < pts.length; ++i) {

    let pt = pts[i];
    let clrHere = getPixel(pixels, pt.x, pt.y);
    let isVisible = clrEq(clrHere, clr);

    if (isVisible && firstVisible == null) firstVisible = pts[i];
    else if (!isVisible && firstVisible != null) {
      if (firstVisible != pts[i-1]) visibleLines.push([firstVisible, pts[i-1]]);
      firstVisible = null;
    }
  }
  if (firstVisible != null && firstVisible != pts[pts.length-1]) visibleLines.push([firstVisible, pts[pts.length-1]]);
  return visibleLines;ß
}

function getMaskedEdge(pt1, pt2, pixels, clr1, clr2) {

  // Build points: short segments of the requested length
  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)));
  }
  let orto = pt2.subtract(pt1).rotate(90);
  orto.length = 1;

  let visibles = [], hiddens = [];
  let firstVisible = null, firstHidden = null;
  for (let i = 0; i < pts.length; ++i) {

    let sidePts = [];
    for (let j = 1; j <= 2; ++j) {
      sidePts.push(pts[i].add(orto.multiply(j)));
      sidePts.push(pts[i].subtract(orto.multiply(j)));
    }

    let clrs = [];
    sidePts.forEach(pt => clrs.push(getPixel(pixels, pt.x, pt.y)));
    let isVisible = false;
    clrs.forEach(clr => {
      if (clrEq(clr, clr1)) isVisible = true;
      if (clrEq(clr, clr2)) isVisible = true;
    })

    if (isVisible && firstVisible == null) firstVisible = pts[i];
    else if (!isVisible && firstVisible != null) {
      if (firstVisible != pts[i-1]) visibles.push([firstVisible, pts[i-1]]);
      firstVisible = null;
    }

    if (!isVisible && firstHidden == null) firstHidden = pts[i];
    else if (isVisible && firstHidden != null) {
      if (firstHidden != pts[i-1]) hiddens.push([firstHidden, pts[i-1]]);
      firstHidden = null;
    }
  }
  if (firstVisible != null && firstVisible != pts[pts.length-1]) visibles.push([firstVisible, pts[pts.length-1]]);
  if (firstHidden != null && firstHidden != pts[pts.length-1]) hiddens.push([firstHidden, pts[pts.length-1]]);
  return [visibles, hiddens];
}

function clrEq(a, b) {
  return a.r == b.r && a.g == b.g && a.b == b.b;
}

function getPixel(pixels, x, y) {
  x = Math.round(x * rf);
  y = h * rf - Math.round(y * rf);
  let ix = (y * w * rf + x) * 4;
  let clr = {
    r: pixels[ix],
    g: pixels[ix+1],
    b: pixels[ix+2],
  }
  return clr;
}

function addWF(mesh, geo) {
  return;
  let wfg = new THREE.WireframeGeometry(geo);
  let wmat = new THREE.LineBasicMaterial({color: 0x0000ff});
  let wf = new THREE.LineSegments(wfg, wmat);
  mesh.add(wf);
}

// ===========================================================================
// Hatching, squiggly lines, ...
// ===========================================================================

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 fillSticks(pts) {

  // The path to be filled
  let remaining = new Path({segments: pts, closed: true});
  let rx = remaining.bounds.left;
  let ry = remaining.bounds.top;
  let rw = remaining.bounds.width;
  let rh = remaining.bounds.height;

  let nsz = 64;
  let bn_ints = genBlueNoise(nsz, nsz);
  let bn = [];
  bn_ints.forEach(val => bn.push(val / (nsz **2)));

  let ditherThresholdStart = 0.9;
  let ditherThresholdEnd = 0.6;
  let spacing = 10;
  let stickLen = 19;
  let nHoriz = Math.round(rw / spacing);
  let nVert = Math.round(rh / spacing);
  let topLeft = new Point(rx, ry);
  for (let hix = 0; hix < nHoriz; ++hix) {
    for (let vix = 0; vix < nVert; ++vix) {

      let ditherThreshold = ditherThresholdStart - (ditherThresholdStart-ditherThresholdEnd) * vix / nVert;

      let noiseX = hix % nsz;
      let noiseY = vix % nsz;
      let noiseVal = bn[noiseY * 64 + noiseX];
      if (noiseVal < ditherThreshold) continue;

      let isVertical = Math.round(noiseVal * nsz * nsz) % 2 == 0;

      let cx = spacing * (hix + 0.5);
      let cy = spacing * (vix + 0.5);
      let pt = new Point(cx, cy).add(topLeft);

      let angle = isVertical ? 90 : 0;
      let len = stickLen + rand_range(-stickLen, stickLen) * 0.3;
      if (!isVertical) len = 10;
      else len = 2 * spacing / noiseVal;
      let vec = new Point(len / 2, 0).rotate(angle);

      let visibleLines = cm.getMaskedLine(pt.add(vec), pt.subtract(vec), true);
      for (const vl of visibleLines) {
        let ln = Path.Line(vl[0], vl[1]);
        project.activeLayer.addChild(ln);
      }
    }
  }
}

function hatchLeftFace(pixels, pf, gap) {
  let rawLines = getHatchForSide(pf, pf.path.children[0], 1, 2, gap);
  let ofs = 0;
  for (const [rl1, rl2] of rawLines) {
    ofs += 1;
    let gappedLines = getGappedLines(rl1, rl2);
    for (const [gl1, gl2] of gappedLines) {
      let visibleLines = get3JSMaskedLine(gl1, gl2, pixels, pf.clr8, pf.clr8);
      for (const [pt1, pt2] of visibleLines) {
        if (squiggly) {
          let squiggleLen = 15;
          let bgain = 0.8;
          let sgain = 0;
          drawSquigglyLine([pt1, pt2], false, squiggleLen, noise, bgain, sgain, ofs, null);
        } else {
          let ln = Path.Line(pt1, pt2);
          project.activeLayer.addChild(ln);
        }
      }
    }
  }
}

function hatchSide(pixels, pf, gap) {

  if (pf.face != Faces.Front && pf.face != Faces.Back &&
    pf.face != Faces.InnerFront && pf.face != Faces.InnerBack)
    return;

  let rawLines = getHatchForSide(pf, pf.path, 0, 1, gap);
  let ofs = 0;
  for (const [rl1, rl2] of rawLines) {
    ofs += 2;
    let visibleLines = get3JSMaskedLine(rl1, rl2, pixels, pf.clr8, pf.clr8);
    for (const [pt1, pt2] of visibleLines) {
      if (squiggly) {
        let squiggleLen = 10;
        let bgain = 0.8;
        let sgain = 8;
        drawSquigglyLine([pt1, pt2], false, squiggleLen, noise, bgain, sgain, ofs, null);
      } else {
        let ln = Path.Line(pt1, pt2);
        project.activeLayer.addChild(ln);
      }
    }
  }
}

function getHatchForSide(pf, path, ix1, ix2, gap) {
  let ptA = new Point(path.segments[ix1].point.x, path.segments[ix1].point.y);
  let ptB = new Point(path.segments[ix2].point.x, path.segments[ix2].point.y);
  let angle = 90 - ptA.subtract(ptB).angle;
  while (angle < -90) angle += 180;
  while (angle > 90) angle -= 180;
  angle = -angle;
  let brect = Path.Rectangle(pf.path.bounds.x, pf.path.bounds.y, pf.path.bounds.width, pf.path.bounds.height);
  return genHatchLines(brect, angle, gap);
}

function getGappedLines(pt1, pt2) {
  let res = [];
  let lineLenRange = [5, 40];
  let gapLens = [4];
  let curr = -rand_range(lineLenRange[1]-lineLenRange[0], 0);
  let vec = pt2.subtract(pt1);
  let fullLen = vec.length;
  vec.length = 1;
  while (curr < fullLen) {
    let next = curr + rand_range(...lineLenRange);
    if (next > 0) {
      let start = Math.max(0, curr);
      let end = next;
      res.push([pt1.add(vec.multiply(start)), pt1.add(vec.multiply(end))]);
    }
    curr = next;
    curr += rand_select(gapLens);
  }
  return res;
}

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