Source code of plot #040 back to plot
Download full working sketch as 040.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, 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";
// https://codepen.io/dissimulate/pen/nYQrNP
// https://steven.codes/blog/cloth-simulation/
// https://istemi-bahceci.github.io/blog/physically%20based%20simulations/2017/03/14/ClothSimulation.html
// https://github.com/matthias-research/pages/blob/master/tenMinutePhysics/14-cloth.html
const pw = 2100; // Paper width
const ph = 1480; // Paper height
const w = 1480; // Drawing width
const h = 1050; // Drawing height
const margin = 50;
const cpars = {
// nClothSquares: 120,
nClothSquares: 120,
clothSize: 3,
nSimIters: 8,
gravity: 1,
kStructural: 100,
kShear: 1000,
kFlex: 200,
dampening: 0.995,
dt: 0.016,
};
const nIters1 = 100;
const nIters2 = 100;
let camProps = {
fov: 30,
//position: { x: 0, y: -0.5, z: 3}, target: { x: 0, y: 0, z: 0 },
//"position":{"x":0,"y":2.73,"z":5.29},"target":{"x":0,"y":0.16,"z":0}
"position":{"x":-3.82,"y":1.64,"z":1.98},"target":{"x":-0.12,"y":-0.58,"z":-0.09}
};
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 = false;
let seed; // Random seed
if (window.fxhash) seed = Math.round(fxrand() * 65535);
else seed = Math.round(Math.random() * 65535);
// seed = 57737;
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.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 Colorizer(31, 31, 31);
const cloth = new Cloth(0.5, false);
// Collision testers
cloth.objFun = (x, y, z, prevx, prevy, prevz) => {
// Object 1
if (x >= -0.3 && x <= 0.2 && z >= -0.4 && z <= -0.2) {
if (y < 0.45) return true;
}
// Object 2
if (x >= 0.4 && x <= 0.6 && z >= 0.4 && z <= 0.5) {
if (y < 0.35) return true;
}
// Object 3
if (x >= -0.7 && x <= -0.6 && z >= 0.6 && z <= 0.7) {
if (y < 0.25) return true;
}
// Floor-ish
if (x >= -1.0 && x <= 1.0 && z >= -1.0 && z <= 1.0) {
if (y < -0.3) return true;
}
return null;
};
for (let i = 0; i < nIters1; ++i) {
cloth.update(cpars.dt);
if ((i % 100) == 0) console.log(i);
}
const mesh = cloth.makeMesh(colorizer);
scene.add(mesh);
let iter = 0;
cloth.updateGeo();
requestAnimationFrame(animate);
// 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() {
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 lines = cloth.getLines();
const wcm = new WebGLCanvasMasker(pixels, w, h, rf, true);
const allLines = lines.horiz.concat(...lines.deep).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;
}
}
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);
}
}
function animate() {
let clothChanged = false;
if (nIters2 <= 0 || iter < nIters2) {
++iter;
cloth.update(cpars.dt);
cloth.updateGeo();
clothChanged = true;
if ((iter % 100) == 0) console.log(iter);
}
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));
}
}
else {
if (iter == nIters2) {
renderPlot();
++iter;
}
}
if (controls || clothChanged) {
renderer.render(scene, cam);
requestAnimationFrame(animate);
}
}
}
class FilterableLine {
constructor(pt1, pt2, clr1, clr2) {
this.pt1 = pt1;
this.pt2 = pt2;
this.clr1 = clr1;
this.clr2 = clr2;
}
}
class Cloth {
constructor(startY, pinCorners) {
this.colors = null;
this.mesh = null;
this.geoPosAttr = null;
this.nParticles = (cpars.nClothSquares + 1) * (cpars.nClothSquares + 1);
this.partPosArr = new Float64Array(this.nParticles * 3);
this.partPrevArr = new Float64Array(this.nParticles * 3);
this.partForceArr = new Float64Array(this.nParticles * 3);
this.pins = new Set();
this.objFun = null;
this.vtemp = new Vector3();
this.partDist = cpars.clothSize / cpars.nClothSquares;
this.diagDist = Math.sqrt(2 * this.partDist * this.partDist);
let ix = 0;
for (let z = 0; z <= cpars.nClothSquares; z++) {
for (let x = 0; x <= cpars.nClothSquares; x++, ix++) {
let px = -cpars.clothSize / 2 + x * this.partDist;
let py = startY;
let pz = -cpars.clothSize / 2 + z * this.partDist;
this.partPosArr[ix * 3] = px;
this.partPosArr[ix * 3 + 1] = py;
this.partPosArr[ix * 3 + 2] = pz;
this.partPrevArr[ix * 3] = px;
this.partPrevArr[ix * 3 + 1] = py;
this.partPrevArr[ix * 3 + 2] = pz;
this.partForceArr[ix * 3] = 0;
this.partForceArr[ix * 3 + 1] = 0;
this.partForceArr[ix * 3 + 2] = 0;
}
}
if (pinCorners) {
this.pins.add(this.partIx(0, 0));
this.pins.add(this.partIx(cpars.nClothSquares, 0));
this.pins.add(this.partIx(0, cpars.nClothSquares));
this.pins.add(this.partIx(cpars.nClothSquares, cpars.nClothSquares));
}
}
partIx(x, z) {
return x + z * (cpars.nClothSquares + 1);
}
makeMesh(colorizer) {
const nTriangles = 2 * cpars.nClothSquares * cpars.nClothSquares;
this.colors = [];
for (let i = 0; i < nTriangles; ++i)
this.colors.push(colorizer.next());
const geo = new THREE.BufferGeometry();
const posArr = new Float32Array(nTriangles * 3 * 3);
this.geoPosAttr = new THREE.BufferAttribute(posArr, 3, false);
geo.setAttribute('position', this.geoPosAttr);
this.updateGeo();
const clrArr = new Float32Array(nTriangles * 3 * 3);
for (let i = 0; i < nTriangles; ++i) {
const clr = this.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('a_color', new THREE.BufferAttribute(clrArr, 3, false));
const mat = new THREE.ShaderMaterial({
vertexShader: clothVertShader,
fragmentShader: clothFragShader,
transparent: false,
blending: THREE.NoBlending,
depthTest: true,
vertexColors: true,
side: THREE.DoubleSide,
});
// const mat = new THREE.MeshBasicMaterial({vertexColors: THREE.VertexColors, side: THREE.DoubleSide});
this.mesh = new THREE.Mesh(geo, mat);
return this.mesh;
}
updateGeo() {
// pXZ
const p00 = new Vector3();
const p10 = new Vector3();
const p01 = new Vector3();
const p11 = new Vector3();
for (let z = 0; z < cpars.nClothSquares; ++z) {
for (let x = 0; x < cpars.nClothSquares; ++x) {
// Coordinates of four particles around this square
let partIx = this.partIx(x, z) * 3;
p00.set(this.partPosArr[partIx], this.partPosArr[partIx + 1], this.partPosArr[partIx + 2]);
partIx = this.partIx(x + 1, z) * 3;
p10.set(this.partPosArr[partIx], this.partPosArr[partIx + 1], this.partPosArr[partIx + 2]);
partIx = this.partIx(x, z + 1) * 3;
p01.set(this.partPosArr[partIx], this.partPosArr[partIx + 1], this.partPosArr[partIx + 2]);
partIx = this.partIx(x + 1, z + 1) * 3;
p11.set(this.partPosArr[partIx], this.partPosArr[partIx + 1], this.partPosArr[partIx + 2]);
// Start of two triangles. Front facing is clockwise, so:
// 00 - 10 - 01
// 10 - 11 - 01
const triIx = (z * cpars.nClothSquares + x) * 2 * 3;
this.geoPosAttr.setXYZ(triIx, p00.x, p00.y, p00.z);
this.geoPosAttr.setXYZ(triIx + 1, p10.x, p10.y, p10.z);
this.geoPosAttr.setXYZ(triIx + 2, p01.x, p01.y, p01.z);
this.geoPosAttr.setXYZ(triIx + 3, p10.x, p10.y, p10.z);
this.geoPosAttr.setXYZ(triIx + 4, p11.x, p11.y, p11.z);
this.geoPosAttr.setXYZ(triIx + 5, p01.x, p01.y, p01.z);
}
}
this.geoPosAttr.needsUpdate = true;
}
getLines() {
const res = {
// Line segments along the X axis, each line left to right
horiz: [],
// Line segments along the Z axiz, each line back to front
deep: [],
// Diagonal line segments, each line front left to back right
diag: [],
};
const vert1 = new Vector3();
const vert2 = new Vector3();
const getFL = (x1, z1, x2, z2) => {
const ix1 = this.partIx(x1, z1) * 3;
const ix2 = this.partIx(x2, z2) * 3;
vert1.set(this.partPosArr[ix1], this.partPosArr[ix1 + 1], this.partPosArr[ix1 + 2]);
vert2.set(this.partPosArr[ix2], this.partPosArr[ix2 + 1], this.partPosArr[ix2 + 2]);
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, clr1Ix, clr2Ix) => {
if (clr1Ix == -1) clr1Ix = clr2Ix;
else if (clr2Ix == -1) clr2Ix = clr1Ix;
fl.clr1 = clrTo8Bit(this.colors[clr1Ix]);
fl.clr2 = clrTo8Bit(this.colors[clr2Ix]);
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),
};
}
}
// X axis lines
for (let z = 0; z <= cpars.nClothSquares; ++z) {
for (let x = 0; x < cpars.nClothSquares; ++x) {
const fl = getFL(x, z, x + 1, z);
let clr1Ix = -1, clr2Ix = -1;
if (z > 0) clr1Ix = (z - 1) * cpars.nClothSquares * 2 + x * 2 + 1;
if (z < cpars.nClothSquares) clr2Ix = z * cpars.nClothSquares * 2 + x * 2;
addColors(fl, clr1Ix, clr2Ix);
res.horiz.push(fl);
}
}
// Z axis lines
for (let x = 0; x <= cpars.nClothSquares; ++x) {
for (let z = 0; z < cpars.nClothSquares; ++z) {
const fl = getFL(x, z, x, z + 1);
let clr1Ix = -1, clr2Ix = -1;
if (x > 0) clr1Ix = z * cpars.nClothSquares * 2 + x * 2 - 1;
if (x < cpars.nClothSquares) clr2Ix = z * cpars.nClothSquares * 2 + x * 2;
addColors(fl, clr1Ix, clr2Ix);
res.deep.push(fl);
}
}
// Diagonals
for (let n = 1; n < 2 * cpars.nClothSquares - 1; ++n) {
// TODO
}
return res;
}
updateParticle(ix, dtSq) {
const ixX = ix * 3;
const ixY = ixX + 1;
const ixZ = ixX + 2;
const px = this.partPosArr[ixX];
const py = this.partPosArr[ixY];
const pz = this.partPosArr[ixZ];
const prevx = this.partPrevArr[ixX];
const prevy = this.partPrevArr[ixY];
const prevz = this.partPrevArr[ixZ];
const forcex = this.partForceArr[ixX];
const forcey = this.partForceArr[ixY];
const forcez = this.partForceArr[ixZ];
const newX = px + (px - prevx) * cpars.dampening + forcex * dtSq;
const newY = py + (py - prevy) * cpars.dampening + forcey * dtSq;
const newZ = pz + (pz - prevz) * cpars.dampening + forcez * dtSq;
this.partPrevArr[ixX] = px;
this.partPrevArr[ixY] = py;
this.partPrevArr[ixZ] = pz;
this.partPosArr[ixX] = newX;
this.partPosArr[ixY] = newY;
this.partPosArr[ixZ] = newZ;
this.partForceArr[ixX] = 0;
this.partForceArr[ixY] = 0;
this.partForceArr[ixZ] = 0;
}
applyToSprings(x, z, fun) {
let ix = this.partIx(x, z);
if (x > 0) {
fun(ix, this.partIx(x - 1, z), this.partDist, cpars.kStructural);
if (z < cpars.nClothSquares - 1)
fun(ix, this.partIx(x - 1, z + 1), this.diagDist, cpars.kShear);
}
if (x > 1) {
fun(ix, this.partIx(x - 2, z), 2 * this.partDist, cpars.kFlex);
// if (z < cpars.nClothSquares - 2)
// fun(ix, this.partIx(x - 2, z + 2), 2 * this.diagDist, kShear);
}
if (z > 0) {
fun(ix, this.partIx(x, z - 1), this.partDist, cpars.kStructural);
if (x > 0)
fun(ix, this.partIx(x - 1, z - 1), this.diagDist, cpars.kShear);
}
if (z > 1) {
fun(ix, this.partIx(x, z - 2), 2 * this.partDist, cpars.kFlex);
// if (x > 1)
// fun(ix, this.partIx(x - 2, z - 2), 2 * this.diagDist, kShear);
}
}
resolveSpring(ix1, ix2, length, k) {
const ix1x = ix1 * 3;
const ix1y = ix1x + 1;
const ix1z = ix1x + 2;
const ix2x = ix2 * 3;
const ix2y = ix2x + 1;
const ix2z = ix2x + 2;
const x1 = this.partPosArr[ix1x];
const y1 = this.partPosArr[ix1y];
const z1 = this.partPosArr[ix1z];
const x2 = this.partPosArr[ix2x];
const y2 = this.partPosArr[ix2y];
const z2 = this.partPosArr[ix2z];
this.vtemp.x = x1 - x2;
this.vtemp.y = y1 - y2;
this.vtemp.z = z1 - z2;
const dist = this.vtemp.length();
const forceScalar = length / dist - 1; // Negative to bring closer, positive to repel
this.vtemp.multiplyScalar(k * forceScalar / dist); // 1 / dist is unit length; multiplied by scalar force * spring constant
this.partForceArr[ix1x] += this.vtemp.x;
this.partForceArr[ix1y] += this.vtemp.y;
this.partForceArr[ix1z] += this.vtemp.z;
this.partForceArr[ix2x] -= this.vtemp.x;
this.partForceArr[ix2y] -= this.vtemp.y;
this.partForceArr[ix2z] -= this.vtemp.z;
}
update(dt) {
const dtSq = (dt / cpars.nSimIters) * (dt / cpars.nSimIters);
for (let i = 0; i < cpars.nSimIters; ++i) {
for (let ix = 0; ix < this.nParticles; ++ix)
this.partForceArr[ix * 3 + 1] += -cpars.gravity;
for (let z = 0; z <= cpars.nClothSquares; ++z) {
for (let x = 0; x <= cpars.nClothSquares; ++x) {
this.applyToSprings(x, z, (ix1, ix2, length, k) => {
this.resolveSpring(ix1, ix2, length, k);
});
}
}
for (let ix = 0; ix < this.nParticles; ++ix) {
if (this.pins.has(ix)) continue;
this.updateParticle(ix, dtSq);
if (!this.objFun) continue;
const ixX = ix * 3;
const ixY = ixX + 1;
const ixZ = ixX + 2;
let upd = this.objFun(this.partPosArr[ixX], this.partPosArr[ixY], this.partPosArr[ixZ], this.vtemp);
if (upd) {
this.partPosArr[ixX] = this.partPrevArr[ixX];
this.partPosArr[ixY] = this.partPrevArr[ixY];
this.partPosArr[ixZ] = this.partPrevArr[ixZ];
}
}
}
}
}
const clothVertShader = `
precision mediump float;
precision mediump int;
attribute vec4 a_color;
varying vec4 vColor;
void main() {
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
vColor = a_color;
}
`;
const clothFragShader = `
precision mediump float;
precision mediump int;
varying vec4 vColor;
void main() {
vec4 color = vec4(vColor);
gl_FragColor = vColor;
}
`;
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;
// cam = new THREE.OrthographicCamera(-D, D, D / asprat, -D / asprat, 1, 10000);
cam = new THREE.PerspectiveCamera(camProps.fov, asprat, 1, 2000);
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;
}