Source code of plot #037 back to plot

Download full working sketch as 037.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 * as THREE from "../pub/lib/three.module.js";
import {Vector3} from "../pub/lib/three.module.js";
import {OrbitControls} from "../pub/lib/OrbitControls.js"
import {WebGLCanvasMasker} from "./utils/webgl-canvas-masker.js";

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

let camFOV = 60; // Cam field of view in degrees
let camAltitude = -20; // Cam altitude in degrees
let camAzimuth = 18; // Cam azimuth in degrees; front (from Z) is 0, goes CCW from Y top
let camDistance = w * 0.6; // Cam distance from origin
let camOverride = {
  // position: { x: 0.0003603302135864198, y: 360.31643911669613, z: 0.0000012709263311965863 },
  // target: { x: 0, y: 0, z: 0 },
  position: { x: -163.43070919990362, y: 350.5107840366473, z: -565.6917517236838 },
  target: { x: -78.00868657771106, y: 106.26965243350297, z: -314.9383190807513 },
}
let sceneBgColor = "black";
let controls = false;

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

// 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 elmThreeCanvas;
let renderer, scene, cam, ray;

let colorer;

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

setRandomGenerator(mulberry32(seed));

setSketch(function () {

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

  // Three JS canvas
  initThree();

  setTimeout(draw, 10);
});

async function draw() {

  paper.project.addLayer(new paper.Layer({name: "1-cyan"}));
  paper.project.addLayer(new paper.Layer({name: "2-magenta"}));

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

  let frame = Path.Rectangle(margin, margin, w - 2 * margin, h - 2 * margin);
  // project.activeLayer.addChild(frame);

  colorer = new Colorer(17, 17, 27);
  let boxes = [];

  const nHoriz = 13;
  const jointDist = w * 0.1;
  const jointSz = w * 0.02;
  const barSz = w * 0.01;
  const wf = false;

  let jointGeo = new THREE.BoxGeometry(jointSz, jointSz, jointSz).toNonIndexed();
  let barXGeo = new THREE.BoxGeometry(jointDist - jointSz - 2, barSz, barSz).toNonIndexed();
  let barYGeo = new THREE.BoxGeometry(barSz, jointDist - jointSz - 2, barSz).toNonIndexed();
  let barZGeo = new THREE.BoxGeometry(barSz, barSz, jointDist - jointSz - 2).toNonIndexed();
  let mat = new THREE.MeshBasicMaterial({vertexColors: THREE.FaceColors, side: THREE.DoubleSide});
  for (let xIx = 0; xIx < nHoriz; ++xIx) {
    const xPos = -(nHoriz / 2) * jointDist + xIx * jointDist;
    for (let yIx = 0; yIx < nHoriz; ++yIx) {
      const yPos = -(nHoriz / 2) * jointDist + yIx * jointDist;
      for (let zIx = 0; zIx < nHoriz; ++zIx) {
        const zPos = -(nHoriz / 2) * jointDist + zIx * jointDist;

        let geo = jointGeo.clone();
        colorer.colorFaces(geo);
        let jointMesh = new THREE.Mesh(geo, mat);
        if (wf) addWF(jointMesh, jointGeo);
        jointMesh.position.set(xPos, yPos, zPos);
        scene.add(jointMesh);
        boxes.push(jointMesh);

        geo = barXGeo.clone();
        colorer.colorFaces(geo);
        let barXMesh = new THREE.Mesh(geo, mat);
        if (wf) addWF(barXMesh, barXGeo);
        barXMesh.position.set(xPos - jointDist / 2, yPos, zPos);
        scene.add(barXMesh);
        boxes.push(barXMesh);

        geo = barYGeo.clone();
        colorer.colorFaces(geo);
        let barYMesh = new THREE.Mesh(geo, mat);
        if (wf) addWF(barYMesh, barYGeo);
        barYMesh.position.set(xPos, yPos - jointDist / 2, zPos);
        scene.add(barYMesh);
        boxes.push(barYMesh);

        geo = barZGeo.clone();
        colorer.colorFaces(geo);
        let barZMesh = new THREE.Mesh(geo, mat);
        if (wf) addWF(barZMesh, barZGeo);
        barZMesh.position.set(xPos, yPos, zPos - jointDist / 2);
        scene.add(barZMesh);
        boxes.push(barZMesh);
      }
    }
  }

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


  let allEdges = [];
  for (let i = boxes.length - 1; i >= 0; --i) {
    const box = boxes[i];
    let edges = getBoxEdges(box);
    allEdges.push(...edges);
  }

  // dload();
  // return;

  // Line hiding for 36819 edges, testing 154,933,630 points
  // Keep 3375 lines.
  // With allocations in get3JSMaskedLine: 50 sec
  // Eliminated allocations get3JSMaskedLineNoAlloc: 16 sec
  // Same thing in Go: 2.1 sec
  console.log("Calculating line hiding for " + allEdges.length + " edges");
  let calcStart = new Date();
  let visibleLines = [];

  // Get visible lines by probing pixels from JS
  // ============================================
  // for (let i = 0; i < allEdges.length; ++i) {
  //   const edge = allEdges[i];
  //   let viss = get3JSMaskedLineNoAlloc(edge.pt1, edge.pt2, pixels, edge.clr1, edge.clr2, true);
  //   visibleLines.push(...viss);
  // }
  // ============================================

  // Get visible lines through shader approach
  // ============================================
  const wcm = new WebGLCanvasMasker(pixels, w, h, rf);
  visibleLines = wcm.mask(allEdges, null, segLen);
  // ============================================

  let elapsed = new Date() - calcStart;
  console.log("Tested " + testedPoints + " points");
  console.log("Kept " + visibleLines.length + " lines");
  printTime(elapsed);

  for (const vl of visibleLines) {
    let ln = Path.Line(vl[0], vl[1]);
    project.activeLayer.addChild(ln);
  }


  function dload() {

    let elmInfo = document.getElementById("info");

    let edgesTxt = "";
    for (const edge of allEdges) {
      let txt = edge.pt1.x + " " + edge.pt1.y + " " + edge.pt2.x + " " + edge.pt2.y;
      txt += " " + edge.clr1.r + " " + edge.clr1.g + " " + edge.clr1.b;
      txt += " " + edge.clr2.r + " " + edge.clr2.g + " " + edge.clr2.b;
      edgesTxt += txt + "\n";
    }

    const elmDlLines = document.createElement("a");
    elmDlLines.text = "lines";
    elmDlLines.style.marginRight = "100px";
    elmInfo.appendChild(elmDlLines);
    const linesFile = new File([edgesTxt], "lines.txt", {type: 'text/plain'});
    elmDlLines.href = URL.createObjectURL(linesFile);
    elmDlLines.download = "lines.txt";

    let pixelsTxt = "";
    let count = 0;
    for (const px of pixels) {
      if (count % (w * rf * 4) == 0) {
        if (count != 0) pixelsTxt += "\n";
      }
      else pixelsTxt += " ";
      pixelsTxt += px;
      ++count;
    }

    const elmDlPixels = document.createElement("a");
    elmDlPixels.text = "pixels";
    elmDlPixels.style.marginRight = "150px";
    elmInfo.appendChild(elmDlPixels);
    const pixelsFile = new File([pixelsTxt], "pixels.txt", {type: 'text/plain'});
    elmDlPixels.href = URL.createObjectURL(pixelsFile);
    elmDlPixels.download = "pixels.txt";
  }

  dload();


  if (controls) {
    controls = new OrbitControls(cam, renderer.domElement);
    requestAnimationFrame(animate);
  }
}

function animate() {
  controls.update();
  renderer.render(scene, cam);
  requestAnimationFrame(animate);
}

function printTime(msec) {
  let sec = Math.floor(msec / 1000);
  let ms = msec - sec * 1000;
  console.log("Elapsed: " + sec.toString() + "." + ms.toString());
}

class BoxEdge {
  constructor(pt1, pt2, clr1, clr2) {
    this.pt1 = pt1;
    this.pt2 = pt2;
    this.clr1 = clr1;
    this.clr2 = clr2;
  }
}

function getBoxEdges(mesh) {
  const positionAttribute = mesh.geometry.getAttribute("position");
  const colorAttribute = mesh.geometry.getAttribute("color");
  // Got 36 vertices (6 per side, 6 sides)
  //  0 -  5: Right
  //  5 - 11: Left
  // 12 - 17: Top
  // 18 - 23: Bottom
  // 24 - 29: Front
  // 30 - 35: Back
  let top = Number.MIN_VALUE, right = Number.MIN_VALUE, front = Number.MIN_VALUE;
  let bottom = Number.MAX_VALUE, left = Number.MAX_VALUE, back = Number.MAX_VALUE;
  for (let i = 0; i < positionAttribute.count; ++i) {
    const v = new THREE.Vector3();
    v.fromBufferAttribute(positionAttribute, i);
    if (v.x < left) left = v.x;
    if (v.x > right) right = v.x;
    if (v.y < bottom) bottom = v.y;
    if (v.y > top) top = v.y;
    if (v.z < back) back = v.z;
    if (v.z > front) front = v.z;
  }
  // Corners
  let tlf = new THREE.Vector3(left, top, front);
  let trf = new THREE.Vector3(right, top, front);
  let blf = new THREE.Vector3(left, bottom, front);
  let brf = new THREE.Vector3(right, bottom, front);
  let tlb = new THREE.Vector3(left, top, back);
  let trb = new THREE.Vector3(right, top, back);
  let blb = new THREE.Vector3(left, bottom, back);
  let brb = new THREE.Vector3(right, bottom, back);
  // Side colors
  let clrFront = new THREE.Color();
  let clrBack = new THREE.Color();
  let clrLeft = new THREE.Color();
  let clrRight = new THREE.Color();
  let clrTop = new THREE.Color();
  let clrBottom = new THREE.Color();
  clrFront.fromBufferAttribute(colorAttribute, 24);
  clrBack.fromBufferAttribute(colorAttribute, 30);
  clrLeft.fromBufferAttribute(colorAttribute, 6);
  clrRight.fromBufferAttribute(colorAttribute, 0);
  clrTop.fromBufferAttribute(colorAttribute, 12);
  clrBottom.fromBufferAttribute(colorAttribute, 18);
  clrFront = to8bit(clrFront);
  clrBack = to8bit(clrBack);
  clrLeft = to8bit(clrLeft);
  clrRight = to8bit(clrRight);
  clrTop = to8bit(clrTop);
  clrBottom = to8bit(clrBottom);
  // Edges - projected, with color
  let edges = [];
  // Front top
  addEdgeIfInView(tlf, trf, clrFront, clrTop);
  // Front bottom
  addEdgeIfInView(blf, brf, clrFront, clrBottom);
  // Front left
  addEdgeIfInView(tlf, blf, clrFront, clrLeft);
  // Front right
  addEdgeIfInView(trf, brf, clrFront, clrRight);
  // Back top
  addEdgeIfInView(tlb, trb, clrBack, clrTop);
  // Back bottom
  addEdgeIfInView(blb, brb, clrBack, clrBottom);
  // Back left
  addEdgeIfInView(tlb, blb, clrBack, clrLeft);
  // Back right
  addEdgeIfInView(trb, brb, clrBack, clrRight);
  // Top left dept
  addEdgeIfInView(tlf, tlb, clrTop, clrLeft);
  // Top right dep
  addEdgeIfInView(trf, trb, clrTop, clrRight);
  // Bottom left dh
  addEdgeIfInView(blf, blb, clrBottom, clrLeft);
  // Bottom right th
  addEdgeIfInView(brf, brb, clrBottom, clrRight);

  return edges;

  function addEdgeIfInView(vert1, vert2, clr1, clr2) {
    let [pt1, z1] = pr(vert1);
    let [pt2, z2] = pr(vert2);
    // If both points behind camera, or outside canvas: ignore
    if (z1 <=0 && z2 <= 0) return;
    let [l, r, t, b] = getBounds(pt1, pt2);
    if (r < 0 || l > w || b < 0 || t > h) return;
    edges.push(new BoxEdge(pt1, pt2, clr1, clr2));
  }

  function getBounds(pt1, pt2) {
    let left = Math.min(pt1.x, pt2.x);
    let right = Math.max(pt1.x, pt2.x);
    let top = Math.min(pt1.y, pt2.y);
    let bottom = Math.max(pt1.y, pt2.y);
    return [left, right, top, bottom];
  }

  function pr(vert) {
    let w = vert.clone();
    mesh.localToWorld(w);
    return proj(w);
  }

  function to8bit(clr) {
    return {
      r: Math.floor(clr.r >= 1 ? 255 : clr.r * 256),
      g: Math.floor(clr.g >= 1 ? 255 : clr.g * 256),
      b: Math.floor(clr.b >= 1 ? 255 : clr.b * 256),
    };
  }
}

class Colorer {

  constructor(nHues, nSats, nLights) {
    this.currIx = 0;
    const minSat = 30;
    const maxSat = 80;
    const minLight = 30;
    const maxLight = 80;
    this.colors = [];
    for (let iHue = 0; iHue < nHues; ++iHue) {
      for (let iSat = 0; iSat < nSats; ++iSat) {
        for (let iLight = 0; iLight < nLights; ++iLight) {
          let hue = 360 * iHue / nHues;
          let sat = minSat + (maxSat - minSat) * iSat / nSats;
          let light = minLight + (maxLight - minLight) * iLight / nLights;
          hue = Math.round(hue);
          sat = Math.round(sat);
          light = Math.round(light);
          let str = "hsl(" + hue + ", " + sat + "%, " + light + "%)";
          this.colors.push(new THREE.Color(str));
        }
      }
    }
    shuffle(this.colors);
  }

  next() {
    let res = this.colors[this.currIx];
    this.currIx = (this.currIx + 1) % this.colors.length;
    return res;
  }

  colorFaces(geo) {
    const positionAttribute = geo.getAttribute('position');
    const colors = [];
    let color;
    for (let i = 0; i < positionAttribute.count; ++i) {
      if ((i % 6) == 0) color = this.next();
      colors.push(color.r, color.g, color.b);
    }
    // define the new attribute
    geo.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
  }
}

// ===========================================================================
// 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 asprat = 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";

  let D = w;
  // cam = new THREE.OrthographicCamera(-D, D, D / asprat, -D / asprat, 1, 10000);
  cam = new THREE.PerspectiveCamera(camFOV, asprat, 1, 40000);

  if (!camOverride) {
    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.setViewOffset(w, h, 0, camViewOfsY, w, h);
    cam.updateProjectionMatrix();
  }
  else {
    cam.position.set(camOverride.position.x, camOverride.position.y, camOverride.position.z);
    cam.lookAt(camOverride.target.x, camOverride.target.y, camOverride.target.z);
    cam.updateProjectionMatrix();
  }

  scene = new THREE.Scene();
  scene.background = new THREE.Color(sceneBgColor);

  ray = new THREE.Raycaster();
}

function proj(vec) {
  let projected = vec.clone().project(cam);
  return [new Point((projected.x + 1) * w / rf, (1 - projected.y) * h / rf), projected.z];
}


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

    // No intersection with ANYTHING in scene: visible
    if (nearestDist == Number.MAX_VALUE) {
      isVisible = true;
    }
    // Intersects with one of the sides
    else 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 nearerMeshIsect = 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 (nearerMeshIsect == null || i.distance < nearerMeshIsect.distance) nearerMeshIsect = i;
    });
    return [nearerMeshIsect, nearestDist];
  }
}

let testedPoints = 0;

function get3JSMaskedLineNoAlloc(pt1, pt2, pixels, clr1, clr2, lookAside) {

  const deltaX = pt2.x - pt1.x;
  const deltaY = pt2.y - pt1.y;
  const lineLength = Math.sqrt(deltaX ** 2 + deltaY ** 2);
  const nSegs = Math.max(2, Math.round(lineLength / segLen));
  testedPoints += nSegs + 1;

  let orto = pt2.subtract(pt1).rotate(90);
  orto.length = 1;

  let visibleLines = [];
  let firstVisible = null;
  let pt = new Point();
  let prevPt = new Point();

  for (let i = 0; i <= nSegs; ++i) {

    pt.x = pt1.x + i / nSegs * deltaX;
    pt.y = pt1.y + i / nSegs * deltaY;

    let clrHere = getPixel(pixels, pt.x, pt.y);
    let isVisible = clrEq(clrHere, clr1);
    if (!isVisible) isVisible = clrEq(clrHere, clr2);
    if (!isVisible && lookAside) {
      clrHere = getPixel(pixels, pt.x + orto.x, pt.y + orto.y);
      isVisible = clrEq(clrHere, clr1);
      if (!isVisible) isVisible = clrEq(clrHere, clr2);
    }
    if (!isVisible && lookAside) {
      clrHere = getPixel(pixels, pt.x - orto.x, pt.y - orto.y);
      isVisible = clrEq(clrHere, clr1);
      if (!isVisible) isVisible = clrEq(clrHere, clr2);
    }

    if (isVisible && firstVisible == null) {
      firstVisible = pt.clone();
    }
    else if (!isVisible && firstVisible != null) {
      if (!firstVisible.equals(prevPt)) visibleLines.push([firstVisible, prevPt.clone()]);
      firstVisible = null;
    }

    prevPt.x = pt.x;
    prevPt.y = pt.y;
  }
  if (firstVisible != null && !firstVisible.equals(pt2)) visibleLines.push([firstVisible, pt2.clone()]);
  return visibleLines;
}

function get3JSMaskedLine(pt1, pt2, pixels, clr1, clr2, lookAside) {

  // 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 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, clr1);
    if (!isVisible) isVisible = clrEq(clrHere, clr2);
    if (!isVisible && lookAside) {
      clrHere = getPixel(pixels, pt.x + orto.x, pt.y + orto.y);
      isVisible = clrEq(clrHere, clr1);
      if (!isVisible) isVisible = clrEq(clrHere, clr2);
    }
    if (!isVisible && lookAside) {
      clrHere = getPixel(pixels, pt.x - orto.x, pt.y - orto.y);
      isVisible = clrEq(clrHere, clr1);
      if (!isVisible) isVisible = clrEq(clrHere, clr2);
    }

    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;
  let eq = Math.abs(a.r - b.r) <= 1;
  eq &= Math.abs(a.g - b.g) <= 1;
  eq &= Math.abs(a.b - b.b) <= 1;
  return eq;
}

function getPixel(pixels, x, y) {
  x = Math.round(x * rf);
  y = h * rf - Math.round(y * rf);
  if (x < 0 || x >= w * rf || y < 0 || y >= h * rf)
    return { r: 0, g: 0, b: 0 };
  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) {
  let wfg = new THREE.WireframeGeometry(geo);
  let wmat = new THREE.LineBasicMaterial({color: 0xeffffff});
  let wf = new THREE.LineSegments(wfg, wmat);
  mesh.add(wf);
}