Source code of plot #038 back to plot

Download full working sketch as 038.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 = 2100;    // Paper width
const ph = 1480;    // Paper height
const w = 1480;     // Drawing width
const h = 1050;     // Drawing height
const margin = 50;

let camProps = {
  fov: 30,
  //"position":{"x":0,"y":3213.95,"z":-6304.47},"target":{"x":0,"y":0,"z":655.71}
  // "position":{"x":-650.49,"y":1717.12,"z":-2349.29},"target":{"x":105.62,"y":-359.09,"z":431.49}
  "position":{"x":-1993.42,"y":1605.49,"z":-3165.82},"target":{"x":22.42,"y":-679.6,"z":267}
};
let sceneBgColor = "black";

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

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

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

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

  // If colorizer is requested, create it
  if (colorizer) colorizer = new Colorer(23, 23, 23);
  let boxes = [];

  const matColor = new THREE.MeshBasicMaterial({vertexColors: THREE.FaceColors, side: THREE.DoubleSide});
  const matWF =  new THREE.MeshBasicMaterial({color: 0x806040});

  const cityX = w * 3;
  const cityZ = w * 3;
  const nBlocksX = 8;
  const nBlocksZ = 8;
  const avenueW = 40;
  const streetW = 30;
  const minH = h * 0.03;
  const maxH = h * 0.6;
  const keepProb = 0.99;
  const nBuildingsPerBlock = [6, 20];

  // let geo = new THREE.BoxGeometry(cityX, h * 0.05, cityZ).toNonIndexed();
  // let mesh = new THREE.Mesh(geo, mat);
  // if (!colorizer) addWF(mesh, geo);
  // mesh.position.y = -h * 0.05;
  // scene.add(mesh);

  for (let ix = 0; ix < nBlocksX; ++ix) {
    for (let iz = 0; iz < nBlocksZ; ++iz) {

      let blockW = (cityX - avenueW * (nBlocksX - 1)) / nBlocksX;
      let blockD = (cityZ - avenueW * (nBlocksZ - 1)) / nBlocksZ;
      let blockCenterX = -cityX / 2 + (ix + 0.5) * blockW + ix * avenueW;
      let blockCenterZ = -cityZ / 2 + (iz + 0.5) * blockD + iz * avenueW;

      let zRel = iz / nBlocksZ;
      let xRel = ix / nBlocksX;
      let blockDistSq = Math.sqrt(zRel ** 2 + xRel ** 2);

      // let ptBlockCenter = new THREE.Vector3(blockCenterX, 0, blockCenterZ);
      // let ptCam = cam.matrixWorld.getPosition().clone();
      // let blockDist = ptCam.distanceTo(ptBlockCenter);
      // console.log(blockDist);

      // let nBuildings = Math.floor(rand_range(...nBuildingsPerBlock));
      // let nBuildings = 6 + 8 * Math.round(blockDistSq);
      let nBuildings = shuffle([4,4,6,15,16,17])[0];

      let buildings = subDivBlock(blockW, blockD, nBuildings, minH, maxH, streetW, keepProb);
      for (const bdg of buildings) {
        let geo = new THREE.BoxGeometry(bdg.width, bdg.height, bdg.depth).toNonIndexed();
        let mesh;
        // Colorize faces...
        if (colorizer) {
          colorizer.colorFaces(geo);
          mesh = new THREE.Mesh(geo, matColor);
        }
        // ...or add wireframe
        else {
          mesh = new THREE.Mesh(geo, matWF);
          addWF(mesh, geo);
        }
        mesh.position.x = blockCenterX + bdg.centerX;
        mesh.position.z = blockCenterZ + bdg.centerZ;
        mesh.position.y = bdg.height / 2;
        scene.add(mesh);
        boxes.push(mesh);
      }
    }
  }

  renderer.render(scene, cam);

  // 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);
    requestAnimationFrame(animate);
    return;
  }

  // No orbit controls: render plot
  // Get pixels of 3D canvas
  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);

  // Project edges and face decor
  const allEdges = [];
  const allFaceLines = [];
  for (let i = boxes.length - 1; i >= 0; --i) {
    const box = boxes[i];
    const boxEdges = [];
    const faceLines = [];
    const gapW = rand_range(5, 15);
    const gapD = rand_range(5, 30);
    const gapH = rand_range(5, 15);
    getBoxLines(box, boxEdges, gapW, gapD, gapH, faceLines);
    allEdges.push(...boxEdges);
    allFaceLines.push(...faceLines);
  }

  // Line hiding based on pixel data
  let visibleEdges = [], visibleFaceLines = [];
  let maskFrame = [margin, margin, w - 2 * margin, h - 2 * margin];
  const wcm = new WebGLCanvasMasker(pixels, w, h, rf, true);
  visibleEdges = wcm.mask(allEdges, maskFrame, segLen);
  visibleFaceLines = wcm.mask(allFaceLines, maskFrame, segLen);

  // Render lines in Paper
  for (const vl of visibleEdges) {
    let ln = Path.Line(vl[0], vl[1]);
    project.activeLayer.addChild(ln);
  }
  // Render lines in Paper
  for (const vl of visibleFaceLines) {
    let ln = Path.Line(vl[0], vl[1]);
    project.activeLayer.addChild(ln);
  }
}


function subDivBlock(blockW, blockD, nBuildings, minH, maxH, streetW, keepProb) {

  // Start with 1 building occupying whole block
  let buildings = [new Building(0, 0, blockW, blockD, 0)];

  // Divide until we reach desired # of buildings
  for (let nTries = 0; buildings.length < nBuildings && nTries < 200; ++nTries) {

    // Divide building with most skewed proportions
    let toDivIx = 0;
    let toDiv = buildings[toDivIx];
    let toDivMeasure = divMeasure(toDiv);
    for (let i = 1; i < buildings.length; ++i) {
      let bdg = buildings[i];
      let measure = divMeasure(bdg);
      if (measure > toDivMeasure) {
        toDivIx = i;
        toDiv = bdg;
        toDivMeasure = measure;
      }
    }

    // Divide longer side
    let ratio = rand_range(0.2, 0.8);
    let newW1 = toDiv.width, newW2 = toDiv.width;
    let newD1 = toDiv.depth, newD2 = toDiv.depth;
    let newCX1 = toDiv.centerX, newCX2 = toDiv.centerX;
    let newCZ1 = toDiv.centerZ, newCZ2 = toDiv.centerZ;
    if (toDiv.width > toDiv.depth) {
      newW1 = newW1 * ratio - streetW / 2;
      newW2 = toDiv.width - newW1 - streetW / 2;
      newCX1 = toDiv.centerX - toDiv.width / 2 + newW1 / 2 - streetW / 4;
      newCX2 = toDiv.centerX + toDiv.width / 2 - newW2 / 2 + streetW / 4;
    }
    else {
      newD1 = newD1 * ratio - streetW / 2;
      newD2 = toDiv.width - newD1 - streetW / 2;
      newCZ1 = toDiv.centerZ - toDiv.depth / 2 + newD1 / 2 - streetW / 4;
      newCZ2 = toDiv.centerZ + toDiv.depth / 2 - newD2 / 2 + streetW / 4;
    }
    if (newW1 <= 0 || newW2 <= 0 || newD1 <= 0 || newD2 <= 0) continue;
    if (outOfProp(newW1, newD1) || outOfProp(newW2, newD2)) continue;
    let bdg1 = new Building(newCX1, newCZ1, newW1, newD1, 0);
    let bdg2 = new Building(newCX2, newCZ2, newW2, newD2, 0);
    // TODO: Elegantly this
    let newBuildings = [];
    for (let i = 0; i < buildings.length; ++i) {
      if (i != toDivIx) newBuildings.push(buildings[i]);
      else {
        newBuildings.push(bdg1);
        newBuildings.push(bdg2);
      }
    }
    buildings = newBuildings;
  }
  // Cull some buildings; set random height for those kept
  const res = [];
  for (const bdg of buildings) {
    if (rand() >= keepProb) continue;
    bdg.height = rand_range(minH, maxH);
    res.push(bdg);
  }
  return res;

  function divMeasure(bdg) {
    // return Math.max(bdg.width / bdg.depth, bdg.depth / bdg.width);
    return bdg.width * bdg.depth;
  }

  function outOfProp(width, depth) {
    let ratio = Math.max(width / depth, depth / width);
    return ratio < 0 || ratio > 4;
  }
}

class Building {
  constructor(centerX, centerZ, width, depth, height) {
    this.centerX = centerX;
    this.centerZ = centerZ;
    this.width = width;
    this.depth = depth;
    this.height = height;
  }
}

function animate() {

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

  renderer.render(scene, cam);
  requestAnimationFrame(animate);
}

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

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

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

function getBoxLines(mesh, edges, gapW, gapD, gapH, faceLines) {
  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
  // Front top
  addIfInView(tlf, trf, clrFront, clrTop, edges);
  // Front bottom
  addIfInView(blf, brf, clrFront, clrBottom, edges);
  // Front left
  addIfInView(tlf, blf, clrFront, clrLeft, edges);
  // Front right
  addIfInView(trf, brf, clrFront, clrRight, edges);
  // Back top
  addIfInView(tlb, trb, clrBack, clrTop, edges);
  // Back bottom
  addIfInView(blb, brb, clrBack, clrBottom, edges);
  // Back left
  addIfInView(tlb, blb, clrBack, clrLeft, edges);
  // Back right
  addIfInView(trb, brb, clrBack, clrRight, edges);
  // Top left depth
  addIfInView(tlf, tlb, clrTop, clrLeft, edges);
  // Top right depth
  addIfInView(trf, trb, clrTop, clrRight, edges);
  // Bottom left depth
  addIfInView(blf, blb, clrBottom, clrLeft, edges);
  // Bottom right depth
  addIfInView(brf, brb, clrBottom, clrRight, edges);

  // Face lines
  // Front
  fillFace(tlf, blf, trf, brf, clrFront, gapH, gapW);
  // Back
  fillFace(trb, brb, tlb, blb, clrBack, gapH, gapW);
  // Right
  fillFace(trf, brf, trb, brb, clrRight, gapH, gapD);
  // Left
  fillFace(tlb, blb, tlf, blf, clrLeft, gapH, gapD);
  // Top
  fillFace(tlb, tlf, trb, trf, clrTop, gapD, gapW);

  function fillFace(p1, p2, p3, p4, clr, gap1, gap2) {

    // Project into view
    let [pr1, z1] = pr(p1);
    let [pr2, z2] = pr(p2);
    let [pr3, z3] = pr(p3);
    let [pr4, z4] = pr(p4);

    // Whole face not in view? Skip.
    let [l, r, t, b] = getBounds([pr1, pr2, pr3, pr4]);
    if (r < 0 || l > w || b < 0 || t > h) return;

    // When looking at front, pr1-pr2 is TL -> BL
    //                        pr3-pr4 is TR -> BR
    // gap1 is for horizontal, gap2 is for vertical lines on front
    let len1 = (pr1.subtract(pr2).length + pr3.subtract(pr4).length) / 2;
    let len2 = (pr1.subtract(pr3).length + pr2.subtract(pr4).length) / 2;
    let n1 = Math.round(len1 / gap1);
    let n2 = Math.round(len2 / gap2);

    // Horizontal lines
    for (let i = 0; i <= n1; ++i) {
      let r = i / n1;
      faceLines.push(new FilterableLine(
        pr1.add(pr2.subtract(pr1).multiply(r)),
        pr3.add(pr4.subtract(pr3).multiply(r)),
        clr, clr));
    }

    // Vertical lines
    for (let i = 0; i <= n2; ++i) {
      let r = i / n2;
      faceLines.push(new FilterableLine(
        pr1.add(pr3.subtract(pr1).multiply(r)),
        pr2.add(pr4.subtract(pr2).multiply(r)),
        clr, clr));
    }
  }

  function addIfInView(vert1, vert2, clr1, clr2, arr) {
    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;
    arr.push(new FilterableLine(pt1, pt2, clr1, clr2));
  }

  function getBounds(pts) {
    let left = Number.MAX_VALUE, top = Number.MAX_VALUE;
    let right = Number.MIN_VALUE, bottom = Number.MIN_VALUE;
    for (const pt of pts) {
      left = Math.min(pt.x, left);
      right = Math.max(pt.x, right);
      top = Math.min(pt.y, top);
      bottom = Math.max(pt.y, bottom);
    }
    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
// ===========================================================================


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(camProps.fov, asprat, 1, 40000);

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