Geometric hidden line elimination still lives!
Log in to post a comment.
// You can find the Turtle API reference here: https://turtletoy.net/syntax
Canvas.setpenopacity(1);
// Global code will be evaluated once.
const turtle = new Turtle();
// SVG 3D module
const svg3d = SVG3D();
// Vectors
const v2 = {
length(a, b) {
const dx = b[0] - a[0];
const dy = b[1] - a[1];
return Math.sqrt(dx * dx + dy * dy);
}
};
const v3 = {
addScalar(a, s) {
return [
a[0] + s,
a[1] + s,
a[2] + s
];
},
scale(a, s) {
return [
a[0] * s,
a[1] * s,
a[2] * s
];
},
sub(a, b) {
return [
a[0] - b[0],
a[1] - b[1],
a[2] - b[2]
];
},
add(a, b) {
return [
a[0] + b[0],
a[1] + b[1],
a[2] + b[2]
];
},
multiplyMatrix(v, m) {
const x = v[0],
y = v[1],
z = v[2];
return [
x * m[0] + y * m[4] + z * m[8] + m[12],
x * m[1] + y * m[5] + z * m[9] + m[13],
x * m[2] + y * m[6] + z * m[10] + m[14]
];
},
cross(a, b) {
return [
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0]
];
},
dot(a, b) {
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
},
length(a, b) {
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const dz = b[2] - a[2];
return Math.sqrt(dx * dx + dy * dy + dz * dz);
}
};
// Matrix 4x4
const m4 = {
identity() {
return [
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1
];
},
multiply(m, n) {
const
m1XX = m[0], m1XY = m[1], m1XZ = m[2], m1XW = m[3],
m1YX = m[4], m1YY = m[5], m1YZ = m[6], m1YW = m[7],
m1ZX = m[8], m1ZY = m[9], m1ZZ = m[10], m1ZW = m[11],
m1WX = m[12], m1WY = m[13], m1WZ = m[14], m1WW = m[15],
m2XX = n[0], m2XY = n[1], m2XZ = n[2], m2XW = n[3],
m2YX = n[4], m2YY = n[5], m2YZ = n[6], m2YW = n[7],
m2ZX = n[8], m2ZY = n[9], m2ZZ = n[10], m2ZW = n[11],
m2WX = n[12], m2WY = n[13], m2WZ = n[14], m2WW = n[15];
return [
m1XX * m2XX + m1XY * m2YX + m1XZ * m2ZX + m1XW * m2WX,
m1XX * m2XY + m1XY * m2YY + m1XZ * m2ZY + m1XW * m2WY,
m1XX * m2XZ + m1XY * m2YZ + m1XZ * m2ZZ + m1XW * m2WZ,
m1XX * m2XW + m1XY * m2YW + m1XZ * m2ZW + m1XW * m2WW,
m1YX * m2XX + m1YY * m2YX + m1YZ * m2ZX + m1YW * m2WX,
m1YX * m2XY + m1YY * m2YY + m1YZ * m2ZY + m1YW * m2WY,
m1YX * m2XZ + m1YY * m2YZ + m1YZ * m2ZZ + m1YW * m2WZ,
m1YX * m2XW + m1YY * m2YW + m1YZ * m2ZW + m1YW * m2WW,
m1ZX * m2XX + m1ZY * m2YX + m1ZZ * m2ZX + m1ZW * m2WX,
m1ZX * m2XY + m1ZY * m2YY + m1ZZ * m2ZY + m1ZW * m2WY,
m1ZX * m2XZ + m1ZY * m2YZ + m1ZZ * m2ZZ + m1ZW * m2WZ,
m1ZX * m2XW + m1ZY * m2YW + m1ZZ * m2ZW + m1ZW * m2WW,
m1WX * m2XX + m1WY * m2YX + m1WZ * m2ZX + m1WW * m2WX,
m1WX * m2XY + m1WY * m2YY + m1WZ * m2ZY + m1WW * m2WY,
m1WX * m2XZ + m1WY * m2YZ + m1WZ * m2ZZ + m1WW * m2WZ,
m1WX * m2XW + m1WY * m2YW + m1WZ * m2ZW + m1WW * m2WW
];
},
scale(m, x, y, z) {
return this.multiply(m, [
x, 0, 0, 0,
0, y, 0, 0,
0, 0, z, 0,
0, 0, 0, 1
]);
},
translate(m, x, y, z) {
return this.multiply(m, [
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
x, y, z, 1
]);
},
rotateX(m, angle) {
const c = Math.cos(angle),
s = Math.sin(angle);
return this.multiply(m, [
1, 0, 0, 0,
0, c, s, 0,
0, -s, c, 0,
0, 0, 0, 1
]);
},
rotateY(m, angle) {
const c = Math.cos(angle),
s = Math.sin(angle);
return this.multiply(m, [
c, 0, -s, 0,
0, 1, 0, 0,
s, 0, c, 0,
0, 0, 0, 1
]);
},
rotateZ(m, angle) {
const c = Math.cos(angle),
s = Math.sin(angle);
return this.multiply(m, [
c, -s, 0, 0,
s, c, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1
]);
}
};
/**
* simplified port from https://github.com/mapbox/earcut
Usage
var triangles = Earcut.triangulate([10,0, 0,50, 60,60, 70,10]); // returns [1,0,3, 3,2,1]
Signature: earcut(vertices[, holes, dimensions = 2]).
Vertices is a flat array of vertex coordinates like [x0,y0, x1,y1, x2,y2, ...].
holes is an array of hole indices if any (e.g. [5, 8] for a 12-vertex input would mean one hole with vertices 5–7 and another with 8–11).
dimensions is the number of coordinates per vertex in the input array (2 by default).
Each group of three vertex indices in the resulting array forms a triangle.
Triangulating a polygon with a hole
var triangles = Earcut.triangulate([0,0, 100,0, 100,100, 0,100, 20,20, 80,20, 80,80, 20,80], [4]);
// [3,0,4, 5,4,0, 3,4,7, 5,0,1, 2,3,7, 6,5,1, 2,7,6, 6,1,2]
*/
function Earcut () {
// create a circular doubly linked list from polygon points in the specified winding order
function linkedList(data, start, end, dim, clockwise) {
let i, last;
if (clockwise === signedArea(data, start, end, dim) > 0) {
for (i = start; i < end; i += dim)
last = insertNode(i, data[i], data[i + 1], last);
} else {
for (i = end - dim; i >= start; i -= dim)
last = insertNode(i, data[i], data[i + 1], last);
}
if (last && equals(last, last.next)) {
removeNode(last);
last = last.next;
}
return last;
}
// eliminate colinear or duplicate points
function filterPoints(start, end) {
if (!start) return start;
if (!end) end = start;
let p = start,
again;
do {
again = false;
if (!p.steiner && (equals(p, p.next) || area(p.prev, p, p.next) === 0)) {
removeNode(p);
p = end = p.prev;
if (p === p.next) break;
again = true;
} else {
p = p.next;
}
} while (again || p !== end);
return end;
}
// main ear slicing loop which triangulates a polygon (given as a linked list)
function earcutLinked(ear, triangles, dim) {
if (!ear) return;
let stop = ear,
prev,
next;
// iterate through ears, slicing them one by one
while (ear.prev !== ear.next) {
prev = ear.prev;
next = ear.next;
if (isEar(ear)) {
// cut off the triangle
triangles.push(prev.i / dim);
triangles.push(ear.i / dim);
triangles.push(next.i / dim);
removeNode(ear);
// skipping the next vertex leads to less sliver triangles
ear = next.next;
stop = next.next;
continue;
}
ear = next;
}
}
// check whether a polygon node forms a valid ear with adjacent nodes
function isEar(ear) {
const a = ear.prev,
b = ear,
c = ear.next;
if (area(a, b, c) >= 0) return false; // reflex, can't be an ear
// now make sure we don't have other points inside the potential ear
let p = ear.next.next;
while (p !== ear.prev) {
if (
pointInTriangle(a.x, a.y, b.x, b.y, c.x, c.y, p.x, p.y) &&
area(p.prev, p, p.next) >= 0
)
return false;
p = p.next;
}
return true;
}
// link every hole into the outer loop, producing a single-ring polygon without holes
function eliminateHoles(data, holeIndices, outerNode, dim) {
const queue = [];
let i, len, start, end, list;
for (i = 0, len = holeIndices.length; i < len; i++) {
start = holeIndices[i] * dim;
end = i < len - 1 ? holeIndices[i + 1] * dim : data.length;
list = linkedList(data, start, end, dim, false);
if (list === list.next) list.steiner = true;
queue.push(getLeftmost(list));
}
queue.sort(compareX);
// process holes from left to right
for (i = 0; i < queue.length; i++) {
eliminateHole(queue[i], outerNode);
outerNode = filterPoints(outerNode, outerNode.next);
}
return outerNode;
}
function compareX(a, b) {
return a.x - b.x;
}
// find a bridge between vertices that connects hole with an outer ring and and link it
function eliminateHole(hole, outerNode) {
outerNode = findHoleBridge(hole, outerNode);
if (outerNode) {
const b = splitPolygon(outerNode, hole);
// filter collinear points around the cuts
filterPoints(outerNode, outerNode.next);
filterPoints(b, b.next);
}
}
// David Eberly's algorithm for finding a bridge between hole and outer polygon
function findHoleBridge(hole, outerNode) {
let p = outerNode;
const hx = hole.x;
const hy = hole.y;
let qx = -Infinity,
m;
// find a segment intersected by a ray from the hole's leftmost point to the left;
// segment's endpoint with lesser x will be potential connection point
do {
if (hy <= p.y && hy >= p.next.y && p.next.y !== p.y) {
const x = p.x + ((hy - p.y) * (p.next.x - p.x)) / (p.next.y - p.y);
if (x <= hx && x > qx) {
qx = x;
if (x === hx) {
if (hy === p.y) return p;
if (hy === p.next.y) return p.next;
}
m = p.x < p.next.x ? p : p.next;
}
}
p = p.next;
} while (p !== outerNode);
if (!m) return null;
if (hx === qx) return m; // hole touches outer segment; pick leftmost endpoint
// look for points inside the triangle of hole point, segment intersection and endpoint;
// if there are no points found, we have a valid connection;
// otherwise choose the point of the minimum angle with the ray as connection point
const stop = m,
mx = m.x,
my = m.y;
let tanMin = Infinity,
tan;
p = m;
do {
if (
hx >= p.x &&
p.x >= mx &&
hx !== p.x &&
pointInTriangle(
hy < my ? hx : qx,
hy,
mx,
my,
hy < my ? qx : hx,
hy,
p.x,
p.y
)
) {
tan = Math.abs(hy - p.y) / (hx - p.x); // tangential
if (
locallyInside(p, hole) &&
(tan < tanMin ||
(tan === tanMin &&
(p.x > m.x || (p.x === m.x && sectorContainsSector(m, p)))))
) {
m = p;
tanMin = tan;
}
}
p = p.next;
} while (p !== stop);
return m;
}
// whether sector in vertex m contains sector in vertex p in the same coordinates
function sectorContainsSector(m, p) {
return area(m.prev, m, p.prev) < 0 && area(p.next, m, m.next) < 0;
}
// find the leftmost node of a polygon ring
function getLeftmost(start) {
let p = start,
leftmost = start;
do {
if (p.x < leftmost.x || (p.x === leftmost.x && p.y < leftmost.y))
leftmost = p;
p = p.next;
} while (p !== start);
return leftmost;
}
// check if a point lies within a convex triangle
function pointInTriangle(ax, ay, bx, by, cx, cy, px, py) {
return (
(cx - px) * (ay - py) - (ax - px) * (cy - py) >= 0 &&
(ax - px) * (by - py) - (bx - px) * (ay - py) >= 0 &&
(bx - px) * (cy - py) - (cx - px) * (by - py) >= 0
);
}
// signed area of a triangle
function area(p, q, r) {
return (q.y - p.y) * (r.x - q.x) - (q.x - p.x) * (r.y - q.y);
}
// check if two points are equal
function equals(p1, p2) {
return p1.x === p2.x && p1.y === p2.y;
}
// check if a polygon diagonal is locally inside the polygon
function locallyInside(a, b) {
return area(a.prev, a, a.next) < 0
? area(a, b, a.next) >= 0 && area(a, a.prev, b) >= 0
: area(a, b, a.prev) < 0 || area(a, a.next, b) < 0;
}
// link two polygon vertices with a bridge; if the vertices belong to the same ring, it splits polygon into two;
// if one belongs to the outer ring and another to a hole, it merges it into a single ring
function splitPolygon(a, b) {
const a2 = new Node(a.i, a.x, a.y),
b2 = new Node(b.i, b.x, b.y),
an = a.next,
bp = b.prev;
a.next = b;
b.prev = a;
a2.next = an;
an.prev = a2;
b2.next = a2;
a2.prev = b2;
bp.next = b2;
b2.prev = bp;
return b2;
}
// create a node and optionally link it with previous one (in a circular doubly linked list)
function insertNode(i, x, y, last) {
const p = new Node(i, x, y);
if (!last) {
p.prev = p;
p.next = p;
} else {
p.next = last.next;
p.prev = last;
last.next.prev = p;
last.next = p;
}
return p;
}
function removeNode(p) {
p.next.prev = p.prev;
p.prev.next = p.next;
if (p.prevZ) p.prevZ.nextZ = p.nextZ;
if (p.nextZ) p.nextZ.prevZ = p.prevZ;
}
function Node(i, x, y) {
// vertex index in coordinates array
this.i = i;
// vertex coordinates
this.x = x;
this.y = y;
// previous and next vertex nodes in a polygon ring
this.prev = null;
this.next = null;
// z-order curve value
this.z = null;
// previous and next nodes in z-order
this.prevZ = null;
this.nextZ = null;
// indicates whether this is a steiner point
this.steiner = false;
}
function signedArea(data, start, end, dim) {
let sum = 0;
for (let i = start, j = end - dim; i < end; i += dim) {
sum += (data[j] - data[i]) * (data[i + 1] + data[j + 1]);
j = i;
}
return sum;
}
return {
triangulate: function (data, holeIndices, dim = 2) {
const hasHoles = holeIndices && holeIndices.length;
const outerLen = hasHoles ? holeIndices[0] * dim : data.length;
let outerNode = linkedList(data, 0, outerLen, dim, true);
const triangles = [];
if (!outerNode || outerNode.next === outerNode.prev) return triangles;
if (hasHoles) outerNode = eliminateHoles(data, holeIndices, outerNode, dim);
earcutLinked(outerNode, triangles, dim);
return triangles;
}
}
};
// SVG 3D module
function SVG3D () {
// triangulation (with holes)
const earcut = Earcut();
const Poly = class {
constructor(world, points) {
this.world = world;
this.triangles = [];
this.paths = [];
this.points = [];
this.points3d = [];
this.points2d = [];
this.holes = [];
this.m = m4.identity();
this.points.push(...points);
}
hole(points) {
this.holes.push(this.points.length / 2);
this.points.push(...points);
return this;
}
translate(x, y, z) {
this.m = m4.translate(this.m, x, y, z);
return this;
}
rotateX(a) {
this.m = m4.rotateX(this.m, (a * Math.PI) / 180);
return this;
}
rotateY(a) {
this.m = m4.rotateY(this.m, (a * Math.PI) / 180);
return this;
}
rotateZ(a) {
this.m = m4.rotateZ(this.m, (a * Math.PI) / 180);
return this;
}
pushPoints(start, end) {
const len = end - start + 1;
const points3d = this.points3d.slice(start, end + 1);
const points2d = this.points2d.slice(start, end + 1);
for (let j = 0; j < len; j++) {
this.paths.push([
points3d[j],
points3d[(j + 1) % len],
v3.length(points3d[j], points3d[(j + 1) % len])
]);
}
}
compute() {
// transform
this.points3d.length = 0;
for (let i = 0; i < this.points.length; i += 2) {
const m = m4.multiply(this.m, this.world.m);
const v = v3.multiplyMatrix([-50 + this.points[i], - 50 + this.points[i + 1], 0.0], m);
this.points3d.push(v);
}
// project
this.points2d.length = 0;
const fov = this.world.fov;
for (const point of this.points3d) {
this.points2d.push([
point[0] * (fov / (point[2] + fov)),
point[1] * (fov / (point[2] + fov))
]);
}
// triangles
this.triangles.length = 0;
const triangles = earcut.triangulate(this.points, this.holes, 2);
for (let i = 0; i < triangles.length; i += 3) {
this.triangles.push([
this.points3d[triangles[i]],
this.points3d[triangles[i + 1]],
this.points3d[triangles[i + 2]]
]);
}
// paths (outer points and hole points)
const outerLen = this.holes.length > 0 ? this.holes[0] : this.points3d.length;
this.pushPoints(0, outerLen - 1);
for (let i = 0; i < this.holes.length; i++) {
const start = this.holes[i];
const end =
i < this.holes.length - 1
? this.holes[i + 1] - 1
: this.points3d.length - 1;
this.pushPoints(start, end);
}
}
render() {
const fov = this.world.fov;
for (const line of this.paths) {
const p0 = line[0];
const p1 = line[1];
const len = Math.floor(line[2]) * 10;
const [dx, dy, dz] = v3.sub(p1, p0);
const sx = dx / len;
const sy = dy / len;
const sz = dz / len;
let [px, py, pz] = p0;
let pVisible = false;
let strokes = [];
const R1 = [0, 0, -this.world.fov];
for (let i = 0; i < len; i++) {
const R0 = [px, py, pz];
let visible = true;
for (const poly of this.world.polygons) {
if (this === poly) continue;
for (const triangle of poly.triangles) {
const intersec = intersectSegmentWithTriangle(
R0,
R1,
triangle[0],
triangle[1],
triangle[2]
);
if (intersec === true) {
visible = false;
break;
}
}
if (visible === false) break;
}
if (visible !== pVisible) {
strokes.push([
px * (fov / (pz + fov)),
py * (fov / (pz + fov))
]);
}
px += sx;
py += sy;
pz += sz;
pVisible = visible;
}
strokes.push([
p1[0] * (fov / (p1[2] + fov)),
p1[1] * (fov / (p1[2] + fov))
]);
// drawing polyline
for (let i = 0; i < strokes.length - 1; i += 2) {
const p0 = strokes[i];
const p1 = strokes[i + 1];
turtle.penup();
turtle.goto(p0[0], p0[1]);
turtle.pendown();
turtle.goto(p1[0], p1[1]);
}
}
}
};
const World = class {
constructor() {
this.polygons = [];
this.m = m4.identity();
this.fov = 350;
}
translate(x, y, z) {
this.m = m4.translate(this.m, x, y, z);
return this;
}
rotateX(a) {
this.m = m4.rotateX(this.m, (a * Math.PI) / 180);
return this;
}
rotateY(a) {
this.m = m4.rotateY(this.m, (a * Math.PI) / 180);
return this;
}
rotateZ(a) {
this.m = m4.rotateZ(this.m, (a * Math.PI) / 180);
return this;
}
render() {
for (const poly of this.polygons) {
poly.compute();
}
for (const poly of this.polygons) {
poly.render();
}
}
poly(points) {
const poly = new Poly(this, points);
this.polygons.push(poly);
return poly;
}
rect(x, y, w, h) {
return [
x,
y,
x + w,
y,
x + w,
y + h,
x,
y + h
];
}
};
function intersectSegmentWithTriangle(R0, R1, S0, S1, S2) {
// Ref: http://geomalgorithms.com/a06-_intersect-2.html
// segment plane
const u = v3.sub(S1, S0);
const v = v3.sub(S2, S0);
const n = v3.cross(u, v);
const dir = v3.sub(R1, R0);
const w0 = v3.sub(R0, S0);
const a = -v3.dot(n, w0);
const b = v3.dot(n, dir);
if (Math.abs(b) < 0.00000001) return false;
const r = a / b;
if (r < 0.0) return false;
if (r > 1.0) return false;
// intersection point in i
const dr = v3.scale(dir, r);
const i = v3.add(R0, dr);
const w = v3.sub(i, S0);
const uu = v3.dot(u, u);
const uv = v3.dot(u, v);
const vv = v3.dot(v, v);
const wu = v3.dot(w, u);
const wv = v3.dot(w, v);
const d = uv * uv - uu * vv;
const s = (uv * wv - vv * wu) / d;
if (s < 0.0 || s > 1.0) return false;
const t = (uv * wu - uu * wv) / d;
if (t < 0.0 || s + t > 1.0) return false;
return true;
}
return {
World: World
}
};
console.clear();
const scene = () => {
const world = new svg3d.World();
const R = world.rect;
const h1 = [10, 5, 15, 25,5, 25];// triangle
const h2 = R(20, 15, 40, 65);
const h3 = R(70, 40, 20, 50);
const h4 = R(70, 5, 20, 20);
const wall1 = world.poly(R(0, 0, 100, 100)).hole(h1).hole(h2).hole(h3).hole(h4);
const wall2 = world.poly(R(0, 0, 100, 100)).hole(h1).hole(h2).hole(h3).hole(h4).translate(12, 0, 0).rotateX(-90);
world.rotateX(Math.random() * 360).rotateY(Math.random() * 360);
world.render();
};
scene();