Xenolith
A porous shelll drawn with contour lines and a little cross-hatching
Log in to post a comment.
Canvas.setpenopacity(1);
const cells = 500; // min=250 max=750 step=50 Surface resolution
const pores = 3.8; // min=2.5 max=5 step=0.1 Pore frequency
const wall = 0.31; // min=0.2 max=0.6 step=0.01 Shell thickness
const twist = 0.2; // min=-0.5 max=0.5 step=0.05 Twist per unit height
const yaw = 30; // min=0 max=180 step=5 Horizontal view angle
const pitch = 38; // min=-60 max=60 step=5 Vertical view angle
const spacing = 0.045; // min=0.035 max=0.08 step=0.005 Engraving spacing
const turtle = new Turtle();
const {sin, cos, abs, max, min, floor, ceil, hypot} = Math;
const cosYaw = cos(yaw * Math.PI / 180);
const sinYaw = sin(yaw * Math.PI / 180);
const cosPitch = cos(pitch * Math.PI / 180);
const sinPitch = sin(pitch * Math.PI / 180);
const rowWidth = cells + 1;
const gridStep = 5.6 / cells;
const drawingScale = 32;
const samples = [];
const vertices = new Map();
const segments = [];
let contourPaths;
let nextPath = 0;
function toObjectSpace(x, y, z) {
const u = x * cosYaw + z * sinYaw;
const w = -x * sinYaw + z * cosYaw;
return [u, y * cosPitch - w * sinPitch, y * sinPitch + w * cosPitch];
}
function sceneDistance(x, y, z) {
const p = toObjectSpace(x, y, z);
const angle = twist * p[1];
const u = pores * (p[0] * cos(angle) - p[2] * sin(angle));
const v = pores * p[1];
const w = pores * (p[0] * sin(angle) + p[2] * cos(angle));
// A gyroid repeats forever. The ellipsoid cuts out one piece of it.
// Taking abs(g) keeps a thin wall on both sides of its zero surface.
const g = sin(u) * cos(v) + sin(v) * cos(w) + sin(w) * cos(u);
const shell = (abs(g) - wall) / (pores * 2.5);
const ellipsoid = (hypot(p[0] / .92, p[1] / 1.13, p[2] / .92) - 2.35) * .8;
return max(shell, ellipsoid);
}
function sampleSurface(x, y) {
let z = 4.3;
let hit = false;
// This field only approximates distance, so take smaller steps to
// reduce the chance of stepping straight through a thin wall.
for (let i = 0; i < 260 && z > -4.3; i++) {
const d = sceneDistance(x, y, z);
if (d < .0008) {
hit = true;
break;
}
z -= max(.0005, d * .8);
}
if (!hit) return {x, y, hit: 0};
const epsilon = .002;
let nx = sceneDistance(x + epsilon, y, z) - sceneDistance(x - epsilon, y, z);
let ny = sceneDistance(x, y + epsilon, z) - sceneDistance(x, y - epsilon, z);
let nz = sceneDistance(x, y, z + epsilon) - sceneDistance(x, y, z - epsilon);
const normalLength = hypot(nx, ny, nz);
nx /= normalLength;
ny /= normalLength;
nz /= normalLength;
// Probe just outside the surface. Nearby walls darken the recesses.
let openness = 0;
for (const [h, weight] of [[.03, .6], [.08, .25], [.18, .15]]) {
const clearance = sceneDistance(x + nx * h, y + ny * h, z + nz * h) / h;
openness += max(0, min(1, clearance)) * weight;
}
const light = max(.04, (-.5 * nx + .65 * ny + .65 * nz) / 1.047) * (.25 + .75 * openness);
const p = toObjectSpace(x, y, z);
// These planes live in object space, so their contours wrap around
// the shell instead of looking like stripes laid over the picture.
return {
x, y, z, hit: 1, light,
bands: .75 * p[1] + .25 * p[2],
hatch: .6 * p[0] - .3 * p[1] + .5 * p[2]
};
}
function vertexAt(point, family) {
// Neighbouring triangles calculate the same endpoint independently.
// Round the lookup key to join them despite tiny floating-point differences.
const key = family + ':' + Math.round(point[0] * 1e6) + ',' + Math.round(point[1] * 1e6);
if (!vertices.has(key)) vertices.set(key, {point, edges: []});
return vertices.get(key);
}
function addSegment(a, b, family) {
const start = vertexAt(a, family);
const end = vertexAt(b, family);
if (start === end) return;
const edge = {start, end, used: false};
start.edges.push(edge);
end.edges.push(edge);
segments.push(edge);
}
function traceContour(triangle, field, level, family, cutoff = 2) {
const crossings = [];
for (let i = 0; i < 3; i++) {
const a = triangle[i];
const b = triangle[(i + 1) % 3];
if ((a[field] < level) === (b[field] < level)) continue;
const t = (level - a[field]) / (b[field] - a[field]);
crossings.push([
a.x + (b.x - a.x) * t,
a.y + (b.y - a.y) * t,
field === 'hit' ? 0 : a.light + (b.light - a.light) * t
]);
}
if (crossings.length !== 2) return;
// Extra hatch lines stop at the light threshold. Clipping here avoids
// a whole triangle disappearing when just one corner is too bright.
let [a, b] = crossings;
if (a[2] >= cutoff && b[2] >= cutoff) return;
if ((a[2] < cutoff) !== (b[2] < cutoff)) {
const t = (cutoff - a[2]) / (b[2] - a[2]);
const clipped = [a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1]), cutoff];
if (a[2] >= cutoff) a = clipped;
else b = clipped;
}
addSegment(a.slice(0, 2), b.slice(0, 2), family);
}
function traceBands(triangle, field, interval, offset, family, cutoff) {
const low = min(...triangle.map(p => p[field]));
const high = max(...triangle.map(p => p[field]));
for (let k = ceil((low - offset) / interval); k <= floor((high - offset) / interval); k++) {
traceContour(triangle, field, k * interval + offset, family, cutoff);
}
}
function engraveTriangle(triangle) {
traceContour(triangle, 'hit', .5, 'outline');
if (triangle.some(p => !p.hit)) return;
// A sudden depth change belongs to a different surface behind this one.
// Don't bridge that gap with an engraving line.
const depthJump = max(...triangle.map(p => p.z)) - min(...triangle.map(p => p.z));
if (depthJump > .12) return;
traceBands(triangle, 'bands', spacing, 0, 'main', 2);
traceBands(triangle, 'bands', spacing, spacing / 2, 'shadow', .48);
traceBands(triangle, 'hatch', spacing * .85, 0, 'cross', .24);
}
function joinContours() {
const result = [];
function follow(start, edge) {
const path = [start.point];
let at = start;
while (edge && !edge.used) {
edge.used = true;
at = edge.start === at ? edge.end : edge.start;
path.push(at.point);
edge = at.edges.find(e => !e.used);
}
if (path.length > 3) result.push(path);
}
// Start with the open contours. Anything left afterwards is a loop.
for (const vertex of vertices.values()) {
if (vertex.edges.length === 2) continue;
for (const edge of vertex.edges) {
if (!edge.used) follow(vertex, edge);
}
}
for (const edge of segments) {
if (!edge.used) follow(edge.start, edge);
}
return result;
}
function drawContour(path) {
// Two corner-cutting passes soften the sampling grid. Keep the ends
// fixed so smoothing doesn't pull the lines away from the silhouette.
let points = path;
for (let pass = 0; pass < 2; pass++) {
const smooth = [points[0]];
for (let j = 1; j < points.length; j++) {
const a = points[j - 1];
const b = points[j];
smooth.push([.75 * a[0] + .25 * b[0], .75 * a[1] + .25 * b[1]]);
smooth.push([.25 * a[0] + .75 * b[0], .25 * a[1] + .75 * b[1]]);
}
smooth.push(points[points.length - 1]);
points = smooth;
}
turtle.penup();
turtle.goto(points[0][0] * drawingScale, -points[0][1] * drawingScale);
turtle.pendown();
for (let j = 1; j < points.length; j++) {
turtle.goto(points[j][0] * drawingScale, -points[j][1] * drawingScale);
}
}
function walk(i) {
// Sample first, trace next, then draw. Doing one row at a time lets
// Turtletoy yield between chunks of the expensive surface calculation.
if (i < rowWidth) {
for (let x = 0; x < rowWidth; x++) {
samples.push(sampleSurface(-2.8 + x * gridStep, -2.8 + i * gridStep));
}
} else if (i < rowWidth + cells) {
const y = i - rowWidth;
for (let x = 0; x < cells; x++) {
const a = samples[y * rowWidth + x];
const b = samples[y * rowWidth + x + 1];
const c = samples[(y + 1) * rowWidth + x + 1];
const d = samples[(y + 1) * rowWidth + x];
engraveTriangle([a, b, c]);
engraveTriangle([a, c, d]);
}
} else {
if (!contourPaths) contourPaths = joinContours();
if (nextPath === contourPaths.length) return false;
drawContour(contourPaths[nextPath++]);
}
return true;
}