Source code of plot #044 back to plot

Download full working sketch as 044.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, showUpdatePlot, 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";

// =================================================================================
// This is the sketch that generated all my postcards for the December 2022 #PTPX
// To explore the composition, these are the two main things to play around with:
// -- Moving and zooming the camera in the browser
// -- Tweaking the ribbons, or keeping only one ribbon
//    Look at lines 104 - 129
//
// The line hiding method occasionally leaves unwanted line fragments from a deeper,
// hidden part of the drawing. The random colors are re-shuffled when you refresh the page.
// It is worth doing this until you see no extra lines. Generate the SVG for plotting
// when you have a clean render.
// =================================================================================

// Define a larger paper here (e.g., A5) and place the drawing in the middle
// The uncommented values below put a postcard-sized image on a postcard.
// const pw = 2100;    // Paper width
// const ph = 1480;    // Paper height
const pw = 1480;    // Paper width
const ph = 1050;    // Paper height
const w = 1480;     // Drawing width
const h = 1050;     // Drawing height
const margin = 50;

// This defines the initial camera position. You can use the orbit controls to change this interactively.
// Your current camera positions are saved in localStorage and will override the values below when you refresh the page.
let camProps = {
  fov: 40,
  position: { x: 0, y: 1, z: 3}, target: { x: 0, y: 0, z: 0 },
};
let sceneBgColor = "black";

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

// If true, we'll create colorizer and colorize faces to sample for vector graphics
// If false, we'll render wireframe-like 3D model
let colorizer = true;

// If true, orbit controls will be created for interactive exploration of 3D model
// If false, we'll render plot
let controls = true;

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


setSketch(function () {

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

  setRandomGenerator(mulberry32(seed));

  // Three JS canvas
  initThree();

  setTimeout(draw, 10);
});

function writeSeedAndView() {
  let msg = "Seed: " + seed;
  // position: { x: 0, y: 1, z: 3}, target: { x: 0, y: 0, z: 0 }
  msg += " position:{x:" + camProps.position.x + ",y:" + camProps.position.y + ",z:" + camProps.position.z + "}";
  msg += ",target:{x:" + camProps.target.x + ",y:" + camProps.target.y + ",z:" + camProps.target.z + "}";
  document.getElementById("info").getElementsByTagName("label")[0].textContent = msg;
}

function loadCamProps() {
  const valStr = localStorage.getItem("camProps");
  if (valStr == null) return;
  camProps = JSON.parse(valStr);
}

function saveCamProps() {
  localStorage.setItem("camProps", JSON.stringify(camProps));
}

async function draw() {

  paper.project.activeLayer.name = "0-black";
  paper.project.addLayer(new paper.Layer({name: "1-red"}));
  paper.project.currentStyle.strokeColor = "black";
  paper.project.currentStyle.strokeWidth = 2;

  // Create colorizer
  if (colorizer) colorizer = new Colorizer(31, 31, 31);

  const ribbon1 = new Ribbon(colorizer, {
    nVertSquares: 29,
    nLengthSquares: 290,
    height: 2.2,
    radius: 1.8,
    offset: -0.2,
    nTwists: 3,
    heightWaveGain: 0.1,
    nHeightWaves: 2,
  });
  // ribbon1.mesh.rotateZ(Math.PI * 0.25);
  scene.add(ribbon1.mesh);

  let ribbon2 = null;
  ribbon2 = new Ribbon(colorizer, {
    nVertSquares: 19,
    nLengthSquares: 190,
    height: 0.9,
    radius: 1.8,
    offset: 0.05,
    nTwists: 3,
    heightWaveGain: 0,
    nHeightWaves: 0,
  });
  // ribbon2.mesh.rotateZ(Math.PI * -0.25);
  scene.add(ribbon2.mesh);

  renderer.render(scene, cam);
  renderPlot();
  requestAnimationFrame(animate);
  showUpdatePlot(renderPlot);

  // If orbit controls are requested, create them, and kick off animation
  if (controls) {
    controls = new OrbitControls(cam, renderer.domElement);
    controls.target.set(camProps.target.x, camProps.target.y, camProps.target.z);
  }

  function renderPlot() {

    for (let i = 0; i < paper.project.layers.length; ++i) {
      paper.project.layers[i].activate();
      paper.project.activeLayer.removeChildren();
    }
    dbgRedraw();

    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);
    const wcm = new WebGLCanvasMasker(pixels, w, h, rf, true);

    ribbon1.renderPlot(wcm, 0, "black");
    if (ribbon2) ribbon2.renderPlot(wcm, 0, "black");
  }

  function animate() {
    if (controls) {
      controls.update();
      let newPos = cam.position;
      let newTarget = controls.target;
      let camMoved =
        !nearEq(newPos.x, camProps.position.x) || !nearEq(newPos.y, camProps.position.y) || !nearEq(newPos.z, camProps.position.z) ||
        !nearEq(newTarget.x, camProps.target.x) || !nearEq(newTarget.y, camProps.target.y) || !nearEq(newTarget.z, camProps.target.z);
      if (camMoved) {
        camProps.position.x = twoDecimals(newPos.x);
        camProps.position.y = twoDecimals(newPos.y);
        camProps.position.z = twoDecimals(newPos.z);
        camProps.target.x = twoDecimals(newTarget.x);
        camProps.target.y = twoDecimals(newTarget.y);
        camProps.target.z = twoDecimals(newTarget.z);
        console.log(JSON.stringify(camProps));
        saveCamProps();
        writeSeedAndView();
      }
      renderer.render(scene, cam);
      requestAnimationFrame(animate);
    }
  }
}

class Ribbon {

  constructor(colorizer, params) {

    this.colorizer = colorizer;
    this.nVertSquares = params.nVertSquares;
    this.nLengthSquares = params.nLengthSquares;
    this.height = params.height;
    this.radius = params.radius;
    this.offset = params.offset;
    this.nTwists = params.nTwists;
    this.heightWaveGain = params.heightWaveGain;
    this.nHeightWaves = params.nHeightWaves;

    // These will be set in makeGeo
    this.geoPosAttr = null;
    this.mesh = null;
    this.makeGeo();

    if (!colorizer) addWF(this.mesh, this.mesh.geometry);
  }

  makeGeo() {

    this.geoPosAttr = this.makeVertices();

    const geo = new THREE.BufferGeometry();
    geo.setAttribute('position', this.geoPosAttr);

    const colors = [];
    const baseColor = new THREE.Color("hsl(10, 50%, 14%)");
    const nTriangles = 2 * this.nVertSquares * this.nLengthSquares;
    for (let i = 0; i < nTriangles; ++i) {
      if (colorizer) colors.push(colorizer.next());
      else colors.push(baseColor);
    }

    const clrArr = new Float32Array(nTriangles * 3 * 3);
    for (let i = 0; i < nTriangles; ++i) {
      const clr = colors[i];
      for (let j = 0; j < 3; ++j) {
        clrArr[i * 9 + j * 3] = clr.r;
        clrArr[i * 9 + j * 3 + 1] = clr.g;
        clrArr[i * 9 + j * 3 + 2] = clr.b;
      }
    }
    geo.setAttribute('color', new THREE.BufferAttribute(clrArr, 3, false));

    const mat = new THREE.MeshBasicMaterial({
      vertexColors: true,
      side: THREE.DoubleSide,
    });

    this.mesh = new THREE.Mesh(geo, mat);
  }

  makeVertices() {

    const nTriangles = 2 * this.nVertSquares * this.nLengthSquares;
    const posArr = new Float32Array(nTriangles * 3 * 3);
    const geoPosAttr = new THREE.BufferAttribute(posArr, 3, false);
    const squareHeight = this.height / this.nVertSquares;

    // Reused vectors in tight loops. We don't need no allocations.
    const p00 = new Vector3();
    const p10 = new Vector3();
    const p01 = new Vector3();
    const p11 = new Vector3();
    const yAxis = new Vector3(0, 1, 0);
    const zAxis = new Vector3(0, 0, 1);
    const vRad = new Vector3(this.radius, 0, 0); // Radius-length vector pointing right

    let gridNoise = [];
    for (let nx = 0; nx < this.nLengthSquares; ++nx) {
      let noiseCol = [];
      gridNoise.push(noiseCol);
      for (let ny = 0; ny <= this.nVertSquares; ++ny) {
        noiseCol.push((rand() - 0.5) * 2);
      }
    }

    for (let ny = 0; ny < this.nVertSquares; ++ny) {

      for (let nx = 0; nx < this.nLengthSquares; ++nx) {

        const angle1 = 2 * Math.PI * nx / this.nLengthSquares;
        const angle2 = 2 * Math.PI * (nx + 1) / this.nLengthSquares;
        const z1 = this.radius * Math.sin(-angle1);
        const z2 = this.radius * Math.sin(-angle2);
        const x1 = this.radius * Math.cos(-angle1);
        const x2 = this.radius * Math.cos(-angle2);

        const heightFact1 = 1 + this.heightWaveGain * Math.sin(angle1 * this.nHeightWaves);
        const heightFact2 = 1 + this.heightWaveGain * Math.sin(angle2 * this.nHeightWaves);
        const yLo1 = (-this.nVertSquares * 0.5 + ny) * squareHeight * heightFact1;
        const yLo2 = (-this.nVertSquares * 0.5 + ny) * squareHeight * heightFact2;
        const yHi1 = (-this.nVertSquares * 0.5 + ny + 1) * squareHeight * heightFact1;
        const yHi2 = (-this.nVertSquares * 0.5 + ny + 1) * squareHeight * heightFact2;


        // Y
        // ^
        // | p10 p11
        // | p00 p01
        // + --- --- > X
        //
        // p00: lower, not so far along length
        // p10: higher, not so far along length
        // p01: lower, farther along length
        // p11: higher, farther along length

        // Coordinates of four vertices around this square
        let jitter = 0;
        const setVert = (p, y, angle, radialNoise) => {

          p.set(this.offset, y, 0);
          p.applyAxisAngle(zAxis, angle * this.nTwists);
          p.add(vRad);
          p.applyAxisAngle(yAxis, angle);
        };
        setVert(p00, yLo1, angle1, gridNoise[nx][ny]);
        setVert(p10, yHi1, angle1, gridNoise[nx][ny+1]);
        setVert(p01, yLo2, angle2, gridNoise[(nx+1)%this.nLengthSquares][ny]);
        setVert(p11, yHi2, angle2, gridNoise[(nx+1)%this.nLengthSquares][ny+1]);

        // Two triangles
        // 00 - 10 - 01
        // 10 - 11 - 01
        const triIx = (ny * this.nLengthSquares + nx) * 2 * 3;
        geoPosAttr.setXYZ(triIx, p00.x, p00.y, p00.z);
        geoPosAttr.setXYZ(triIx + 1, p10.x, p10.y, p10.z);
        geoPosAttr.setXYZ(triIx + 2, p01.x, p01.y, p01.z);
        geoPosAttr.setXYZ(triIx + 3, p10.x, p10.y, p10.z);
        geoPosAttr.setXYZ(triIx + 4, p11.x, p11.y, p11.z);
        geoPosAttr.setXYZ(triIx + 5, p01.x, p01.y, p01.z);
      }
    }

    geoPosAttr.needsUpdate = true;

    return geoPosAttr;
  }

  getLines() {

    const res = {
      // Line segments along the length of the ribbon
      long: [],
      // Vertical line segments
      vert: [],
      // Diagonal line segments
      diag: [],
    };

    const vert1 = new Vector3();
    const vert2 = new Vector3();
    const clr1 = new THREE.Color();
    const clr2 = new THREE.Color();
    const posAttr = this.mesh.geometry.getAttribute('position');
    const clrAttr = this.mesh.geometry.getAttribute('color');

    const getTriIx = (nx, ny) => {
      return (ny * this.nLengthSquares + nx) * 2 * 3;
    }

    const getVert = (nx, ny, v) => {
      // Not topmost
      if (ny < this.nVertSquares) {
        // Not rightmost
        if (nx < this.nLengthSquares) {
          const triIx = getTriIx(nx, ny);
          v.fromBufferAttribute(posAttr, triIx);
        }
        // Rightmost
        else {
          const triIx = getTriIx(nx-1, ny);
          v.fromBufferAttribute(posAttr, triIx + 2);
        }
      }
      // Topmost
      else {
        // Not rightmost
        if (nx < this.nLengthSquares) {
          const triIx = getTriIx(nx, ny-1);
          v.fromBufferAttribute(posAttr, triIx + 1);
        }
        // Rightmost
        else {
          const triIx = getTriIx(nx-1, ny-1);
          v.fromBufferAttribute(posAttr, triIx + 4);
        }
      }
    }

    // Gets a filterable line
    const getFL = (nx1, ny1, nx2, ny2) => {
      getVert(nx1, ny1, vert1);
      getVert(nx2, ny2, vert2);
      this.mesh.localToWorld(vert1);
      this.mesh.localToWorld(vert2);
      const fl = new FilterableLine;
      fl.pt1 = new Point();
      fl.pt2 = new Point();
      projInPlace(vert1, fl.pt1);
      projInPlace(vert2, fl.pt2);
      return fl;
    }

    const addColors = (fl, triIx1, triIx2) => {
      if (triIx1 == -1) triIx1 = triIx2;
      else if (triIx2 == -1) triIx2 = triIx1;
      clr1.fromBufferAttribute(clrAttr, triIx1);
      clr2.fromBufferAttribute(clrAttr, triIx2);
      fl.clr1 = clrTo8Bit(clr1);
      fl.clr2 = clrTo8Bit(clr2);

      function clrTo8Bit(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),
        };
      }
    }

    // Along length of ribbon, at each height
    for (let ny = 0; ny <= this.nVertSquares; ++ny) {
      for (let nx = 0; nx < this.nLengthSquares; ++nx) {
        const fl = getFL(nx, ny, nx + 1, ny);
        // Flanking triangles, for color
        let triIxBelow = -1, triIxAbove = -1;
        if (ny > 0) triIxBelow = getTriIx(nx, ny - 1) + 3;
        if (ny < this.nVertSquares) triIxAbove = getTriIx(nx, ny);
        addColors(fl, triIxBelow, triIxAbove);
        res.long.push(fl);
      }
    }

    // "Vertical" lines, at each distance along ribbon
    for (let nx = 0; nx < this.nLengthSquares; ++nx) {
      for (let ny = 0; ny < this.nVertSquares; ++ny) {
        const fl = getFL(nx, ny, nx, ny + 1);
        const nxLeft = nx == 0 ? this.nLengthSquares - 1 : nx - 1;
        const triIxLeft = getTriIx(nxLeft, ny) + 3;
        const triIxRight = getTriIx(nx, ny);
        addColors(fl, triIxLeft, triIxRight);
        res.vert.push(fl);
      }
    }

    // Diagonal lines, starting at the bottom at a given point along length, going right up /
    for (let nx = 0; nx < this.nLengthSquares; ++nx) {
      for (let dist = 0; dist < this.nVertSquares; ++dist) {
        const nx1 = (nx + dist) % this.nLengthSquares;
        const nx2 = (nx + dist + 1) % this.nLengthSquares;
        const fl = getFL(nx1, dist, nx2, dist + 1);
        const triIxA = getTriIx(nx1, dist);
        const triIxB = triIxA + 3;
        addColors(fl, triIxA, triIxB);
        res.diag.push(fl);
      }
    }

    return res;
  }

  renderPlot(wcm, layerIx, color, long, vert, diag) {

    paper.project.layers[layerIx].activate();
    paper.project.currentStyle.strokeColor = color;

    const lines = this.getLines();

    const allLines = lines.long.concat(...lines.vert).concat(...lines.diag);
    const visibleLines = wcm.mask(allLines, null, segLen);

    const joinedPaths = [];
    let currPts = [];
    for (const vl of visibleLines) {
      if (currPts.length == 0) {
        currPts.push(vl[0], vl[1]);
      }
      else if (currPts[currPts.length-1].getDistance(vl[0]) < joinLen) {
        currPts.push(vl[1]);
      }
      else {
        joinedPaths.push(currPts);
        currPts = [vl[0], vl[1]];
      }
    }
    if (currPts.length != 0) joinedPaths.push(currPts);
    console.log("Paths: " + joinedPaths.length);

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


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

class Colorizer {

  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
// ===========================================================================


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 * 2;
  // cam = new THREE.OrthographicCamera(-D, D, D / asprat, -D / asprat, 1, 10000);
  cam = new THREE.PerspectiveCamera(camProps.fov, asprat, 0.01, 200);

  cam.position.set(camProps.position.x, camProps.position.y, camProps.position.z);
  cam.lookAt(camProps.target.x, camProps.target.y, camProps.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 projInPlace(vec, pt) {
  vec.project(cam);
  pt.x = (vec.x + 1) * w / rf;
  pt.y = (1 - vec.y) * h / rf;
}

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


function nearEq(f, g) {
  if (f == g) return true;
  f = twoDecimals(f);
  g = twoDecimals(g);
  let ratio = f / g;
  return ratio > 0.99 && ratio < 1.01;
}

function twoDecimals(x) {
  return Math.round(x * 100) / 100;
}