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.
Unless otherwise noted, code published here is © Gábor L Ugray, shared under the Creative Commons
BY-NC-SA license (Attribution, Non-Commercial, Share-Alike). Files in lib/thirdparty
, and additional
libraries in the downloadable archive, are shared under their respective open-source licenses, attributed
to their authors.
///<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;
}