Fork: BSP Hidden Lines
Hidden-line rendering done properly: BSP + 3D ray tests.
No screen-space clipping involved.
Log in to post a comment.
// Forked from "BSP Hidden Lines" by ge1doot
// https://turtletoy.net/turtle/20ae15ee55
// TurtleToy
Canvas.setpenopacity(0.8);
const turtle = new Turtle();
turtle.pendown();
/*
* BSP Hidden Lines V2 — ge1doot, 2026
*
* 3D BSP / CSG geometry with analytical 2D vector rendering.
*
* The BSP handles geometry, boolean operations and
* front-to-back traversal.
*
* PolyClip2D handles screen-space polygon occlusion,
* hidden-line clipping and continuous hatching.
*
* Credits:
* - BSP geometry adapted from Bretton Wade's BSP Tree Demo
* (1994-1997).
* - PolyClip2D inspired by Reinder Nijhoff's polygon
* clipping utility for TurtleToy (2019, MIT License).
*
* Original TurtleToy project:
* BSP Hidden Lines — ge1doot
*/
const Math3D = () => {
const FLOAT_ARRAY = Float64Array;
const MAT4_IDENTITY = new FLOAT_ARRAY([
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1,
]);
class Vec3 extends FLOAT_ARRAY {
constructor(x = 0, y = 0, z = 0) {
super(3);
this[0] = x;
this[1] = y;
this[2] = z;
}
copy(v) {
this.set(v);
return this;
}
get x() { return this[0]; }
set x(v) { this[0] = v; }
get y() { return this[1]; }
set y(v) { this[1] = v; }
get z() { return this[2]; }
set z(v) { this[2] = v; }
setXYZ(x, y, z) {
this[0] = x;
this[1] = y;
this[2] = z;
return this;
}
add(v) {
this[0] += v[0];
this[1] += v[1];
this[2] += v[2];
return this;
}
sub(v) {
this[0] -= v[0];
this[1] -= v[1];
this[2] -= v[2];
return this;
}
subFrom(a, b) {
this[0] = a[0] - b[0];
this[1] = a[1] - b[1];
this[2] = a[2] - b[2];
return this;
}
scale(s) {
this[0] *= s;
this[1] *= s;
this[2] *= s;
return this;
}
scaleFrom(a, s) {
this[0] = a[0] * s;
this[1] = a[1] * s;
this[2] = a[2] * s;
return this;
}
addMul(v, s) {
this[0] += v[0] * s;
this[1] += v[1] * s;
this[2] += v[2] * s;
return this;
}
crossFrom(a, b) {
const ax = a[0], ay = a[1], az = a[2];
const bx = b[0], by = b[1], bz = b[2];
this[0] = ay * bz - az * by;
this[1] = az * bx - ax * bz;
this[2] = ax * by - ay * bx;
return this;
}
transformPointMat4(m) {
const x = this[0], y = this[1], z = this[2];
this[0] = m[0] * x + m[4] * y + m[8] * z + m[12];
this[1] = m[1] * x + m[5] * y + m[9] * z + m[13];
this[2] = m[2] * x + m[6] * y + m[10] * z + m[14];
return this;
}
lerpFrom(a, b, t) {
const ax = a[0], ay = a[1], az = a[2];
this[0] = ax + t * (b[0] - ax);
this[1] = ay + t * (b[1] - ay);
this[2] = az + t * (b[2] - az);
return this;
}
normalize() {
const n = this.norm();
if (n > 0) this.scale(1 / n);
return this;
}
dot(v) {
return this[0] * v[0] + this[1] * v[1] + this[2] * v[2];
}
norm() {
const x = this[0], y = this[1], z = this[2];
return Math.sqrt(x * x + y * y + z * z);
}
}
class Vec4 extends FLOAT_ARRAY {
constructor(x = 0, y = 0, z = 0, w = 0) {
super(4);
this[0] = x;
this[1] = y;
this[2] = z;
this[3] = w;
}
copy(v) {
this.set(v);
return this;
}
get x() { return this[0]; }
set x(v) { this[0] = v; }
get y() { return this[1]; }
set y(v) { this[1] = v; }
get z() { return this[2]; }
set z(v) { this[2] = v; }
get w() { return this[3]; }
set w(v) { this[3] = v; }
scale(s) {
this[0] *= s;
this[1] *= s;
this[2] *= s;
this[3] *= s;
return this;
}
}
class Mat4 extends FLOAT_ARRAY {
constructor() {
super(16);
this[0] = 1;
this[5] = 1;
this[10] = 1;
this[15] = 1;
}
identity() {
this.set(MAT4_IDENTITY);
return this;
}
copy(a) {
this.set(a);
return this;
}
multiply(b) {
const a00 = this[0],
a01 = this[1],
a02 = this[2],
a03 = this[3];
const a10 = this[4],
a11 = this[5],
a12 = this[6],
a13 = this[7];
const a20 = this[8],
a21 = this[9],
a22 = this[10],
a23 = this[11];
const a30 = this[12],
a31 = this[13],
a32 = this[14],
a33 = this[15];
let b0, b1, b2, b3;
b0 = b[0];
b1 = b[1];
b2 = b[2];
b3 = b[3];
this[0] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30;
this[1] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31;
this[2] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32;
this[3] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33;
b0 = b[4];
b1 = b[5];
b2 = b[6];
b3 = b[7];
this[4] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30;
this[5] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31;
this[6] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32;
this[7] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33;
b0 = b[8];
b1 = b[9];
b2 = b[10];
b3 = b[11];
this[8] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30;
this[9] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31;
this[10] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32;
this[11] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33;
b0 = b[12];
b1 = b[13];
b2 = b[14];
b3 = b[15];
this[12] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30;
this[13] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31;
this[14] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32;
this[15] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33;
return this;
}
multiplyFrom(a, b) {
const a00 = a[0],
a01 = a[1],
a02 = a[2],
a03 = a[3];
const a10 = a[4],
a11 = a[5],
a12 = a[6],
a13 = a[7];
const a20 = a[8],
a21 = a[9],
a22 = a[10],
a23 = a[11];
const a30 = a[12],
a31 = a[13],
a32 = a[14],
a33 = a[15];
let b0, b1, b2, b3;
b0 = b[0];
b1 = b[1];
b2 = b[2];
b3 = b[3];
this[0] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30;
this[1] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31;
this[2] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32;
this[3] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33;
b0 = b[4];
b1 = b[5];
b2 = b[6];
b3 = b[7];
this[4] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30;
this[5] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31;
this[6] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32;
this[7] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33;
b0 = b[8];
b1 = b[9];
b2 = b[10];
b3 = b[11];
this[8] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30;
this[9] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31;
this[10] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32;
this[11] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33;
b0 = b[12];
b1 = b[13];
b2 = b[14];
b3 = b[15];
this[12] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30;
this[13] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31;
this[14] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32;
this[15] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33;
return this;
}
rotateX(rad) {
const s = Math.sin(rad);
const c = Math.cos(rad);
const a10 = this[4],
a11 = this[5],
a12 = this[6],
a13 = this[7];
const a20 = this[8],
a21 = this[9],
a22 = this[10],
a23 = this[11];
this[4] = a10 * c + a20 * s;
this[5] = a11 * c + a21 * s;
this[6] = a12 * c + a22 * s;
this[7] = a13 * c + a23 * s;
this[8] = a20 * c - a10 * s;
this[9] = a21 * c - a11 * s;
this[10] = a22 * c - a12 * s;
this[11] = a23 * c - a13 * s;
return this;
}
rotateY(rad) {
const s = Math.sin(rad);
const c = Math.cos(rad);
const a00 = this[0],
a01 = this[1],
a02 = this[2],
a03 = this[3];
const a20 = this[8],
a21 = this[9],
a22 = this[10],
a23 = this[11];
this[0] = a00 * c - a20 * s;
this[1] = a01 * c - a21 * s;
this[2] = a02 * c - a22 * s;
this[3] = a03 * c - a23 * s;
this[8] = a00 * s + a20 * c;
this[9] = a01 * s + a21 * c;
this[10] = a02 * s + a22 * c;
this[11] = a03 * s + a23 * c;
return this;
}
rotateZ(rad) {
const s = Math.sin(rad);
const c = Math.cos(rad);
const a00 = this[0],
a01 = this[1],
a02 = this[2],
a03 = this[3];
const a10 = this[4],
a11 = this[5],
a12 = this[6],
a13 = this[7];
this[0] = a00 * c + a10 * s;
this[1] = a01 * c + a11 * s;
this[2] = a02 * c + a12 * s;
this[3] = a03 * c + a13 * s;
this[4] = a10 * c - a00 * s;
this[5] = a11 * c - a01 * s;
this[6] = a12 * c - a02 * s;
this[7] = a13 * c - a03 * s;
return this;
}
translate(x, y, z) {
this[12] += x * this[0] + y * this[4] + z * this[8];
this[13] += x * this[1] + y * this[5] + z * this[9];
this[14] += x * this[2] + y * this[6] + z * this[10];
this[15] += x * this[3] + y * this[7] + z * this[11];
return this;
}
scale(x, y, z) {
this[0] *= x;
this[1] *= x;
this[2] *= x;
this[3] *= x;
this[4] *= y;
this[5] *= y;
this[6] *= y;
this[7] *= y;
this[8] *= z;
this[9] *= z;
this[10] *= z;
this[11] *= z;
return this;
}
}
return {
Vec3,
Vec4,
Mat4,
};
};
/* ------------------------- Polygon ------------------------- */
const Polygon = (math) => {
const { Vec3, Vec4 } = math;
const EPSILON = 1e-5;
const HC_IN = -1;
const HC_ON = 0;
const HC_OUT = 1;
const HC_SPANNING = 2;
let nextSurface = 0;
const flip = (poly) => {
const reversed = poly.slice().reverse();
reversed.plane = new Vec4().copy(poly.plane).scale(-1);
reversed.surface = poly.surface;
const n = poly.length;
reversed.edges = poly.edges.map((_, i) => poly.edges[(2 * n - 2 - i) % n],);
return reversed;
};
const definePlane = (poly) => {
const n = new Vec3();
for (let i = 0, last = poly.length - 1; i < poly.length; last = i, i++) {
const a = poly[last];
const b = poly[i];
n[0] += (a[1] - b[1]) * (a[2] + b[2]);
n[1] += (a[2] - b[2]) * (a[0] + b[0]);
n[2] += (a[0] - b[0]) * (a[1] + b[1]);
}
const len = n.norm();
if (len === 0) throw new Error("Degenerate polygon");
n.scale(1 / len);
return new Vec4(n[0], n[1], n[2], -n.dot(poly[0]));
};
const create = (points, edges = null) => {
points.plane = definePlane(points);
points.surface = ++nextSurface;
points.edges = edges ?? points.map(() => ({ kind: "original" }));
return points;
};
const createIndexed = (vertices, indices, topology = new Map()) => {
const edges = indices.map((a, i) => {
const b = indices[(i + 1) % indices.length];
const key = Math.min(a, b) * vertices.length + Math.max(a, b);
if (!topology.has(key)) topology.set(key, { kind: "original" });
return topology.get(key);
});
return create(
indices.map((i) => vertices[i]),
edges,
);
};
const distance = (point, plane) =>
point[0] * plane[0] +
point[1] * plane[1] +
point[2] * plane[2] +
plane[3];
const split = (poly, plane) => {
const out = [], inside = [];
const outIncoming = [], inIncoming = [];
let hasOut = false, hasIn = false;
let seam;
const cutEdge = () => (seam ??= { kind: "split", plane });
const n = poly.length;
let a = poly[n - 1];
let da = distance(a, plane);
for (let i = 0; i < n; i++) {
const b = poly[i];
const db = distance(b, plane);
const edge = poly.edges[(i + n - 1) % n];
if (db > EPSILON) {
hasOut = true;
if (da < -EPSILON) {
const p = new Vec3().lerpFrom(a, b, da / (da - db));
out.push(p);
outIncoming.push(cutEdge());
inside.push(p);
inIncoming.push(edge);
}
out.push(b);
outIncoming.push(edge);
} else if (db < -EPSILON) {
hasIn = true;
if (da > EPSILON) {
const p = new Vec3().lerpFrom(a, b, da / (da - db));
out.push(p);
outIncoming.push(edge);
inside.push(p);
inIncoming.push(cutEdge());
}
inside.push(b);
inIncoming.push(edge);
} else {
out.push(b);
outIncoming.push(da < -EPSILON ? cutEdge() : edge);
inside.push(b);
inIncoming.push(da > EPSILON ? cutEdge() : edge);
}
a = b;
da = db;
}
if (!hasOut && !hasIn) return [null, null, HC_ON];
if (!hasIn) return [poly, null, HC_OUT];
if (!hasOut) return [null, poly, HC_IN];
out.plane = inside.plane = poly.plane;
out.surface = inside.surface = poly.surface;
out.edges = out.map((_, i) => outIncoming[(i + 1) % out.length]);
inside.edges = inside.map((_, i) => inIncoming[(i + 1) % inside.length]);
return [out, inside, HC_SPANNING];
};
return {
create,
createIndexed,
definePlane,
distance,
split,
flip,
HC_IN,
HC_ON,
HC_OUT,
HC_SPANNING,
EPSILON,
};
};
/* ------------------------- Primitives ------------------------- */
const Primitives = (math, polygon) => {
const { Vec3 } = math;
const { createIndexed } = polygon;
const CUBE = {
vertices: [
[1, 1, 1],
[-1, 1, 1],
[-1, -1, 1],
[1, -1, 1],
[1, 1, -1],
[-1, 1, -1],
[-1, -1, -1],
[1, -1, -1],
],
faces: [
[0, 1, 2, 3],
[7, 6, 5, 4],
[0, 3, 7, 4],
[0, 4, 5, 1],
[5, 6, 2, 1],
[3, 2, 6, 7],
],
};
const PYRAMID = {
vertices: [
[1, 0, 1],
[-1, 0, 1],
[-1, 0, -1],
[1, 0, -1],
[0, 2, 0],
],
faces: [
[0, 1, 2, 3],
[4, 1, 0],
[4, 0, 3],
[4, 2, 1],
[4, 3, 2],
],
};
const DIAMOND = {
vertices: [
[1, 0, 1], [-1, 0, 1], [-1, 0, -1], [1, 0, -1],
[0, 1, 0], [0, -1, 0]
],
faces: [
[4, 1, 0], [4, 0, 3], [4, 2, 1], [4, 3, 2],
[0, 1, 5], [3, 0, 5], [1, 2, 5], [2, 3, 5]
]
};
const buildMesh = ({ vertices, faces }, transform) => {
const p = vertices.map(([x, y, z]) =>
new Vec3(x, y, z).transformPointMat4(transform),
);
const topology = new Map();
return faces.map((indices) => createIndexed(p, indices, topology));
};
const cube = (transform) => buildMesh(CUBE, transform);
const pyramid = (transform) => buildMesh(PYRAMID, transform);
const diamond = (transform) => buildMesh(DIAMOND, transform);
return { cube, pyramid, diamond };
};
/* ------------------------- Edges ------------------------- */
const Edges = (math, polygon) => {
const { Vec3 } = math;
const { EPSILON } = polygon;
const interval = ([a, b], axis) => [
Math.min(a[axis], b[axis]),
Math.max(a[axis], b[axis]),
];
const dominantAxis = (segments) => {
let axis = -1, max = EPSILON;
for (const [a, b] of segments) {
for (let i = 0; i < 3; i++) {
const d = Math.abs(b[i] - a[i]);
if (d > max) {
max = d;
axis = i;
}
}
}
return axis;
};
const merge = (spans) => {
spans.sort((a, b) => a[0] - b[0]);
const result = [];
for (const [lo, hi] of spans) {
const last = result[result.length - 1];
if (last && lo <= last[1] + EPSILON) last[1] = Math.max(last[1], hi);
else result.push([lo, hi]);
}
return result.filter(([lo, hi]) => hi - lo > EPSILON);
};
const makeSegments = (spans, sources, axis) => {
const ref = sources.find(
([a, b]) => Math.abs(b[axis] - a[axis]) > EPSILON,
);
if (!ref) return [];
const [a, b] = ref;
const at = (t) => new Vec3().lerpFrom(a, b, (t - a[axis]) / (b[axis] - a[axis]));
return spans.map(([lo, hi]) => [at(lo), at(hi)]);
};
const extract = (faces) => {
const originals = new Map();
const intersections = new Map();
const planeIds = new Map();
const planeCache = new Map();
const PLANE_SCALE = 1e10;
const planeId = (plane) => {
if (planeCache.has(plane)) return planeCache.get(plane);
const sign = plane.find((v) => Math.abs(v) > 1e-12) < 0 ? -1 : 1;
const key = Array.from(plane, (v) =>
Math.round(v * sign * PLANE_SCALE),
).join(",");
if (!planeIds.has(key)) planeIds.set(key, planeIds.size);
const id = planeIds.get(key);
planeCache.set(plane, id);
return id;
};
for (const face of faces) {
for (let i = 0; i < face.length; i++) {
const segment = [face[i], face[(i + 1) % face.length]];
const edge = face.edges[i];
if (edge.kind === "original") {
if (!originals.has(edge)) originals.set(edge, []);
originals.get(edge).push(segment);
} else if (edge.kind === "split") {
const a = planeId(face.plane);
const b = planeId(edge.plane);
if (a === b) continue;
const key = `${Math.min(a, b)}:${Math.max(a, b)}`;
if (!intersections.has(key)) intersections.set(key, [[], []]);
intersections.get(key)[a < b ? 0 : 1].push(segment);
}
}
}
const result = [];
for (const group of originals.values()) {
const axis = dominantAxis(group);
if (axis < 0) continue;
result.push(
...makeSegments(
merge(group.map((s) => interval(s, axis))),
group,
axis,
),
);
}
for (const [ab, ba] of intersections.values()) {
if (!ab.length || !ba.length) continue;
const axis = dominantAxis([...ab, ...ba]);
if (axis < 0) continue;
const spans = [];
for (const sa of ab) {
for (const sb of ba) {
const [alo, ahi] = interval(sa, axis);
const [blo, bhi] = interval(sb, axis);
const lo = Math.max(alo, blo);
const hi = Math.min(ahi, bhi);
if (hi - lo > EPSILON) spans.push([lo, hi]);
}
}
result.push(...makeSegments(merge(spans), ab, axis));
}
return result;
};
return { extract };
};
/* ------------------------- BSP ------------------------- */
const BSP = (polygon) => {
const {
split,
flip,
HC_IN, HC_ON, HC_OUT, HC_SPANNING
} = polygon;
const insertNode = (node, polygons, keep) => {
const inside = [];
const outside = [];
for (const poly of polygons) {
const [out, inp, kind] = split(poly, node.plane);
if (kind === HC_ON) {
node.on.push(poly);
} else {
if (inp) inside.push(inp);
if (out) outside.push(out);
}
}
if (inside.length)
node.inside = insertTree(node.inside, inside, keep, HC_IN);
if (outside.length)
node.outside = insertTree(node.outside, outside, keep, HC_OUT);
};
const insertTree = (node, polygons, keep, cur) => {
if (!polygons.length) return node;
if (node) {
insertNode(node, polygons, keep);
} else if (cur === keep || keep === HC_SPANNING) {
node = {
plane: polygons[0].plane,
on: [polygons[0]],
inside: null,
outside: null,
};
if (polygons.length > 1)
insertNode(node, polygons.slice(1), HC_SPANNING);
}
return node;
};
const pushTree = (node, polygons, keep, cur, result) => {
if (!polygons.length) return;
if (!node) {
if (cur === keep) result.push(...polygons);
return;
}
const inside = [];
const outside = [];
for (const poly of polygons) {
const [out, inp, kind] = split(poly, node.plane);
if (kind === HC_ON) {
result.push(poly);
} else {
if (inp) inside.push(inp);
if (out) outside.push(out);
}
}
pushTree(node.inside, inside, keep, HC_IN, result);
pushTree(node.outside, outside, keep, HC_OUT, result);
};
const reduceNode = (node) => {
if (!node) return;
const boundary = [];
for (const poly of node.on) {
const intermediate = [];
const aligned =
poly.plane[0] * node.plane[0] +
poly.plane[1] * node.plane[1] +
poly.plane[2] * node.plane[2] >
0;
if (aligned) {
pushTree(node.inside, [poly], HC_IN, HC_IN, intermediate);
pushTree(node.outside, intermediate, HC_OUT, HC_OUT, boundary);
} else {
pushTree(node.outside, [poly], HC_OUT, HC_OUT, intermediate);
pushTree(node.inside, intermediate, HC_IN, HC_IN, boundary);
}
}
node.on = boundary;
reduceNode(node.inside);
reduceNode(node.outside);
};
const collect = (node, result) => {
if (!node) return;
result.push(...node.on);
collect(node.inside, result);
collect(node.outside, result);
};
const collectFrontToBack = (node, eye, result) => {
if (!node) return;
const p = node.plane;
const side = eye[0] * p[0] + eye[1] * p[1] + eye[2] * p[2] + p[3];
const near = side >= 0 ? node.outside : node.inside;
const far = side >= 0 ? node.inside : node.outside;
collectFrontToBack(near, eye, result);
result.push(...node.on);
collectFrontToBack(far, eye, result);
};
const create = () => {
let root = null;
const api = {
insert(polygons, sign = 1) {
const faces = sign < 0 ? polygons.map(flip) : polygons;
const keep = sign > 0 ? HC_OUT : HC_IN;
root = insertTree(root, faces, keep, HC_OUT);
return api;
},
reduce() {
reduceNode(root);
return api;
},
faces() {
const result = [];
collect(root, result);
return result;
},
facesFrontToBack(eye) {
const result = [];
collectFrontToBack(root, eye, result);
return result;
},
};
return api;
};
return { create };
};
/* ------------------------- Camera ------------------------- */
const Camera = (math) => {
const { Vec3 } = math;
const create = (eye, target, fov = 45, near = 0.05) => {
const focus = 100 / Math.tan(fov * Math.PI / 360);
const forward = new Vec3().subFrom(target, eye).normalize();
const right = new Vec3().crossFrom(forward, new Vec3(0, 1, 0)).normalize();
const up = new Vec3().crossFrom(right, forward).normalize();
const depth = (p) =>
(p[0] - eye[0]) * forward[0] +
(p[1] - eye[1]) * forward[1] +
(p[2] - eye[2]) * forward[2];
const project = (p) => {
const dx = p[0] - eye[0], dy = p[1] - eye[1], dz = p[2] - eye[2];
const z = dx * forward[0] + dy * forward[1] + dz * forward[2];
return [
focus * (dx * right[0] + dy * right[1] + dz * right[2]) / z,
-focus * (dx * up[0] + dy * up[1] + dz * up[2]) / z
];
};
const clipSegment = (a, b) => {
const da = depth(a), db = depth(b);
if (da < near && db < near) return null;
if (da >= near && db >= near) return [a, b];
const p = new Vec3().lerpFrom(a, b, (near - da) / (db - da));
return da < near ? [p, b] : [a, p];
};
const clipPolygon = (poly) => {
const points = [];
let a = poly[poly.length - 1], da = depth(a);
for (const b of poly) {
const db = depth(b);
if ((da >= near) !== (db >= near))
points.push(new Vec3().lerpFrom(a, b, (near - da) / (db - da)));
if (db >= near) points.push(b);
a = b;
da = db;
}
return points.length >= 3 ? points.map(project) : [];
};
return { eye, near, right, up, forward, depth, project, clipSegment, clipPolygon };
};
return { create };
};
/* ---------------------- PolyClip2D ---------------------- */
const PolyClip2D = () => {
const epsilon = 1e-9, clip = Clip2D(epsilon);
function Clip2D(epsilon) {
const crossings = [], spans = [];
function gather(ax, ay, bx, by, poly, length, out) {
if (poly.maxx < Math.min(ax, bx) - epsilon || poly.minx > Math.max(ax, bx) + epsilon ||
poly.maxy < Math.min(ay, by) - epsilon || poly.miny > Math.max(ay, by) + epsilon) return;
const dx = bx - ax, dy = by - ay, invLength2 = 1 / (length * length),
sideEps = epsilon * length, tEps = epsilon / length, xy = poly.xy;
crossings.length = 0;
let x0 = xy[xy.length - 2], y0 = xy[xy.length - 1],
s0 = dx * (y0 - ay) - dy * (x0 - ax);
if (Math.abs(s0) <= sideEps) s0 = 0;
let t0 = ((x0 - ax) * dx + (y0 - ay) * dy) * invLength2;
for (let i = 0; i < xy.length; i += 2) {
const x1 = xy[i], y1 = xy[i + 1];
let s1 = dx * (y1 - ay) - dy * (x1 - ax);
if (Math.abs(s1) <= sideEps) s1 = 0;
const t1 = ((x1 - ax) * dx + (y1 - ay) * dy) * invLength2;
if (s0 === 0 && s1 === 0) {
const lo = Math.max(0, Math.min(t0, t1)), hi = Math.min(1, Math.max(t0, t1));
if (hi - lo > tEps) out.push(lo, hi);
} else if ((s0 > 0) !== (s1 > 0)) {
crossings.push(t0 + (t1 - t0) * s0 / (s0 - s1));
}
x0 = x1; y0 = y1; s0 = s1; t0 = t1;
}
crossings.sort((a, b) => a - b);
for (let i = 0; i + 1 < crossings.length; i += 2) {
const lo = Math.max(0, crossings[i]), hi = Math.min(1, crossings[i + 1]);
if (hi - lo > tEps) out.push(lo, hi);
}
}
function sortPairs(a, left, right) {
while (left < right) {
let i = left, j = right;
const pivot = a[2 * ((left + right) >> 1)];
while (i <= j) {
while (a[2 * i] < pivot) i++;
while (a[2 * j] > pivot) j--;
if (i <= j) {
const lo = a[2 * i], hi = a[2 * i + 1];
a[2 * i] = a[2 * j]; a[2 * i + 1] = a[2 * j + 1];
a[2 * j] = lo; a[2 * j + 1] = hi;
i++; j--;
}
}
if (j - left < right - i) {
if (left < j) sortPairs(a, left, j);
left = i;
} else {
if (i < right) sortPairs(a, i, right);
right = j;
}
}
}
function union(a, tEps) {
if (!a.length) return;
if (a.length > 2) sortPairs(a, 0, a.length / 2 - 1);
let lo = a[0], hi = a[1], w = 0;
for (let i = 2; i < a.length; i += 2) {
if (a[i] <= hi + tEps) hi = Math.max(hi, a[i + 1]);
else {
a[w++] = lo; a[w++] = hi;
lo = a[i]; hi = a[i + 1];
}
}
a[w++] = lo; a[w++] = hi;
a.length = w;
}
function emitIntervals(ax, ay, bx, by, occupied, inside, emit, tEps) {
const dx = bx - ax, dy = by - ay;
const output = (lo, hi) => {
if (hi - lo > tEps) emit(ax + dx * lo, ay + dy * lo, ax + dx * hi, ay + dy * hi);
};
if (inside) {
for (let i = 0; i < occupied.length; i += 2)
output(occupied[i], occupied[i + 1]);
} else {
let at = 0;
for (let i = 0; i < occupied.length; i += 2) {
output(at, occupied[i]);
at = Math.max(at, occupied[i + 1]);
}
output(at, 1);
}
}
function process(a, b, polygons, inside, emit) {
const ax = a[0], ay = a[1], bx = b[0], by = b[1],
dx = bx - ax, dy = by - ay,
length = Math.sqrt(dx * dx + dy * dy);
if (length <= epsilon) return;
spans.length = 0;
if (Array.isArray(polygons)) {
for (const poly of polygons) gather(ax, ay, bx, by, poly, length, spans);
} else gather(ax, ay, bx, by, polygons, length, spans);
const tEps = epsilon / length;
union(spans, tEps);
emitIntervals(ax, ay, bx, by, spans, inside, emit, tEps);
}
return {
prepare: (points) => {
const flat = typeof points?.[0] === "number",
count = flat ? points.length / 2 : points?.length,
xy = new Float64Array(count * 2);
let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity;
for (let i = 0; i < count; i++) {
const x = flat ? points[2 * i] : points[i][0];
const y = flat ? points[2 * i + 1] : points[i][1];
xy[i * 2] = x;
xy[i * 2 + 1] = y;
if (x < minx) minx = x;
if (x > maxx) maxx = x;
if (y < miny) miny = y;
if (y > maxy) maxy = y;
}
return { xy, minx, miny, maxx, maxy };
},
intersect: (a, b, poly, emit) => process(a, b, poly, true, emit),
subtract: (a, b, poly, emit) => process(a, b, poly, false, emit),
subtractMany: (a, b, polys, emit) => process(a, b, polys, false, emit),
subtractRanges: (a, b, ranges, emit) => {
const ax = a[0], ay = a[1], bx = b[0], by = b[1];
const length = Math.hypot(bx - ax, by - ay);
if (length <= epsilon) return;
const tEps = epsilon / length;
spans.length = 0;
for (let i = 0; i < ranges.length; i += 2) {
const lo = Math.max(0, Math.min(ranges[i], ranges[i + 1]));
const hi = Math.min(1, Math.max(ranges[i], ranges[i + 1]));
if (hi - lo > tEps) spans.push(lo, hi);
}
union(spans, tEps);
emitIntervals(ax, ay, bx, by, spans, false, emit, tEps);
}
};
}
return {
create: () => {
const layers = [];
function makeJob(emit, bounds) {
let index = 0, emitted = 0,
xmin = Infinity, ymin = Infinity, xmax = -Infinity, ymax = -Infinity;
if (bounds) {
[xmin, ymin, xmax, ymax] = bounds;
} else {
for (const { poly } of layers) {
xmin = Math.min(xmin, poly.minx);
ymin = Math.min(ymin, poly.miny);
xmax = Math.max(xmax, poly.maxx);
ymax = Math.max(ymax, poly.maxy);
}
}
const a = new Float64Array(2), b = new Float64Array(2),
p0 = new Float64Array(2), p1 = new Float64Array(2),
pieces = [], masks = [], occluders = [],
collect = (x0, y0, x1, y1) => pieces.push(x0, y0, x1, y1),
last = new Map();
for (let i = 0; i < layers.length; i++) last.set(layers[i].group, i);
const strokes = new Map(), MERGE_EPS = 1e-6;
let hx, hy, nx, ny, activeStripe;
function store(x0, y0, x1, y1) {
const u0 = x0 * hx + y0 * hy;
const u1 = x1 * hx + y1 * hy;
activeStripe.push([Math.min(u0, u1), Math.max(u0, u1)]);
}
function emitBounded(x0, y0, x1, y1) {
const dx = x1 - x0, dy = y1 - y0;
let lo = 0, hi = 1;
const trim = (p, q) => {
if (Math.abs(p) < 1e-12) return q >= 0;
const t = q / p;
if (p < 0) lo = Math.max(lo, t);
else hi = Math.min(hi, t);
return lo <= hi;
};
if (!trim(-dx, x0 - xmin) || !trim(dx, xmax - x0) ||
!trim(-dy, y0 - ymin) || !trim(dy, ymax - y0)) return;
if (hi - lo <= epsilon / Math.max(1, Math.hypot(dx, dy))) return;
emit(x0 + dx * lo, y0 + dy * lo, x0 + dx * hi, y0 + dy * hi);
emitted++;
}
function visible(x0, y0, x1, y1, output) {
p0[0] = x0; p0[1] = y0;
p1[0] = x1; p1[1] = y1;
if (!occluders.length) { output(x0, y0, x1, y1); return; }
const dx = x1 - x0, dy = y1 - y0;
const len2 = dx * dx + dy * dy;
if (len2 <= epsilon * epsilon) return;
const minx = Math.min(x0, x1), maxx = Math.max(x0, x1);
const miny = Math.min(y0, y1), maxy = Math.max(y0, y1);
masks.length = 0;
for (let j = 0; j < occluders.length; j++) {
const q = occluders[j];
if (!(q.maxx < minx || q.minx > maxx ||
q.maxy < miny || q.miny > maxy)) {
clip.intersect(p0, p1, q, (xa, ya, xb, yb) => {
masks.push(((xa - x0) * dx + (ya - y0) * dy) / len2,
((xb - x0) * dx + (yb - y0) * dy) / len2);
});
}
}
clip.subtractRanges(p0, p1, masks, output);
}
function viewportLine(s) {
let lo = -Infinity, hi = Infinity;
const cx = s * nx, cy = s * ny;
if (Math.abs(hx) < 1e-12) {
if (cx < xmin || cx > xmax) return false;
} else {
const t0 = (xmin - cx) / hx, t1 = (xmax - cx) / hx;
lo = Math.max(lo, Math.min(t0, t1));
hi = Math.min(hi, Math.max(t0, t1));
}
if (Math.abs(hy) < 1e-12) {
if (cy < ymin || cy > ymax) return false;
} else {
const t0 = (ymin - cy) / hy, t1 = (ymax - cy) / hy;
lo = Math.max(lo, Math.min(t0, t1));
hi = Math.min(hi, Math.max(t0, t1));
}
if (!(hi - lo > epsilon)) return false;
a[0] = cx + lo * hx; a[1] = cy + lo * hy;
b[0] = cx + hi * hx; b[1] = cy + hi * hy;
return true;
}
function flushGroup(group) {
const byAngle = strokes.get(group);
if (!byAngle) return;
for (const [angle, byPitch] of byAngle) {
hx = Math.cos(angle); hy = Math.sin(angle);
nx = -hy; ny = hx;
for (const [pitch, stripes] of byPitch) {
for (const [k, intervals] of stripes) {
if (!intervals.length) continue;
if (intervals.length > 1)
intervals.sort((a, b) => a[0] - b[0]);
const s = k * pitch;
const draw = (lo, hi) => {
emit(
hx * lo + nx * s, hy * lo + ny * s,
hx * hi + nx * s, hy * hi + ny * s
);
emitted++;
};
let [lo, hi] = intervals[0];
for (let i = 1; i < intervals.length; i++) {
const [x, y] = intervals[i];
if (x <= hi + MERGE_EPS) hi = Math.max(hi, y);
else { draw(lo, hi); lo = x; hi = y; }
}
draw(lo, hi);
}
}
}
strokes.delete(group);
}
return {
next: () => {
const i = index++,
emittedBefore = emitted,
layer = layers[i], poly = layer.poly,
minx = Math.max(xmin, poly.minx), maxx = Math.min(xmax, poly.maxx),
miny = Math.max(ymin, poly.miny), maxy = Math.min(ymax, poly.maxy);
if (minx < maxx && miny < maxy && (layer.pitch || layer.outline || layer.segments)) {
occluders.length = 0;
for (let j = 0; j < i; j++) {
const nearer = layers[j];
if (nearer.occlude) {
const q = nearer.poly;
if (!(q.maxx < minx || q.minx > maxx ||
q.maxy < miny || q.miny > maxy))
occluders.push(q);
}
}
if (layer.outline) {
const xy = poly.xy;
for (let q = 0; q < xy.length; q += 2) {
const n = (q + 2) % xy.length;
visible(xy[q], xy[q + 1], xy[n], xy[n + 1], emitBounded);
}
}
if (layer.segments) {
const segments = layer.segments;
if (typeof segments[0] === "number") {
for (let q = 0; q < segments.length; q += 4)
visible(segments[q], segments[q + 1], segments[q + 2], segments[q + 3], emitBounded);
} else {
for (const [p, q] of segments)
visible(p[0], p[1], q[0], q[1], emitBounded);
}
}
if (layer.pitch) {
const { pitch, angle, group } = layer;
hx = Math.cos(angle); hy = Math.sin(angle);
nx = -hy; ny = hx;
let byAngle = strokes.get(group); if (!byAngle) strokes.set(group, byAngle = new Map());
let byPitch = byAngle.get(angle); if (!byPitch) byAngle.set(angle, byPitch = new Map());
let stripes = byPitch.get(pitch); if (!stripes) byPitch.set(pitch, stripes = new Map());
const sMin = (nx > 0 ? nx * minx : nx * maxx) + (ny > 0 ? ny * miny : ny * maxy);
const sMax = (nx > 0 ? nx * maxx : nx * minx) + (ny > 0 ? ny * maxy : ny * miny);
for (let k = Math.ceil((sMin - 1e-10) / pitch);
k <= Math.floor((sMax + 1e-10) / pitch); k++) {
if (!viewportLine(k * pitch)) continue;
activeStripe = stripes.get(k);
if (!activeStripe) stripes.set(k, activeStripe = []);
pieces.length = 0;
clip.intersect(a, b, poly, collect);
for (let q = 0; q < pieces.length; q += 4) {
visible(pieces[q], pieces[q + 1], pieces[q + 2], pieces[q + 3], store);
}
}
}
}
if (last.get(layer.group) === i) flushGroup(layer.group);
return emitted > emittedBefore;
},
hasNext: () => index < layers.length
};
}
const api = {
add: (points, { outline = false, hatch = null,
segments = null, group = null, occlude = true } = {}) => {
const poly = points && points.xy instanceof Float64Array ? points : clip.prepare(points),
pitch = hatch ? (hatch.pitch ?? hatch.step ?? 0) : 0,
angle = hatch ? (hatch.angle ?? 0) : 0,
layer = { poly, pitch, angle, outline, segments, occlude, group: null };
layer.group = group ?? layer;
layers.push(layer);
return api;
},
clear: () => {
layers.length = 0;
return api;
},
render: (draw, { bounds = null } = {}) => {
const job = makeJob(draw, bounds);
while (job.hasNext()) job.next();
},
get size() { return layers.length; }
};
return api;
},
prepare: clip.prepare,
clip
};
}
/* ---------------------- Materials ---------------------- */
const Materials = (world) => {
const brightness = new Map();
const parentEdges = new Map();
const api = {
insert(faces, sign = +1, material = {}) {
const value = material.brightness ?? 1;
for (const face of faces) {
brightness.set(face.surface, value);
if (!parentEdges.has(face.surface)) {
const index = Math.max(0, face.edges.findIndex(e => e.kind === "original"));
parentEdges.set(face.surface, [face[index], face[(index + 1) % face.length]]);
}
}
world.insert(faces, sign);
return api;
},
reduce() { world.reduce(); return api; },
faces: () => world.faces(),
facesFrontToBack: (eye) => world.facesFrontToBack(eye),
brightnessOf: (surface) => brightness.get(surface) ?? 1,
parentEdgeOf: (surface) => parentEdges.get(surface),
};
return api;
};
/* ------------------------- Scene2D ------------------------- */
const Scene2D = (math, clip2d) => {
const { Vec3 } = math;
const build = (orderedFaces, camera, lighting, pitches,
brightnessOf = () => 1, parentEdgeOf = () => null) => {
const { sun, gamma, ambient } = lighting;
const levels = pitches.length;
const worldSun = new Vec3()
.scaleFrom(camera.right, sun[0])
.addMul(camera.up, sun[1])
.addMul(camera.forward, sun[2]);
const normal = new Vec3();
const parentAngles = new Map();
const angleOf = (surface) => {
if (parentAngles.has(surface)) return parentAngles.get(surface);
let angle = null;
const edge = parentEdgeOf(surface);
if (edge) {
const segment = camera.clipSegment(edge[0], edge[1]);
if (segment) {
const a = camera.project(segment[0]);
const b = camera.project(segment[1]);
const dx = b[0] - a[0], dy = b[1] - a[1];
if (Number.isFinite(dx) && Number.isFinite(dy) &&
dx * dx + dy * dy > 1e-12)
angle = Math.atan2(dy, dx);
}
}
parentAngles.set(surface, angle);
return angle;
};
const result = [];
const eye = camera.eye;
for (let order = 0; order < orderedFaces.length; order++) {
const source = orderedFaces[order];
const plane = source.plane;
normal.setXYZ(plane[0], plane[1], plane[2]);
if (normal.dot(eye) + plane[3] <= 1e-9) continue;
let clipped = false;
for (const vertex of source) {
if (camera.depth(vertex) < camera.near) {
clipped = true;
break;
}
}
const points = camera.clipPolygon(source);
if (points.length < 3) continue;
let twiceArea = 0;
let valid = true;
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
const a = points[j], b = points[i];
if (!Number.isFinite(b[0]) || !Number.isFinite(b[1])) valid = false;
twiceArea += a[0] * b[1] - b[0] * a[1];
}
if (!valid || !Number.isFinite(twiceArea) || Math.abs(twiceArea) <= 1e-12) continue;
const polygon = clip2d.prepare(points);
const lambert = Math.max(0, normal.dot(worldSun));
const light = Math.min(1, (Math.pow(lambert, 1 / gamma) + ambient) * brightnessOf(source.surface));
const tone = Math.min(levels - 1, Math.floor(light * levels));
result.push({ polygon, surface: source.surface, plane, order, tone, light, hatchAngle: angleOf(source.surface), source });
}
return { faces: result, pitches };
};
return { build };
};
/* ------------------------- EdgeHLR2D ------------------------- */
const EdgeHLR2D = clip2d => {
const render = (edges, scene, camera, emit, { extent = 100, planeEpsilon = 1e-6 } = {}) => {
const masks = [];
const emitViewport = (x0, y0, x1, y1) => {
const dx = x1 - x0, dy = y1 - y0;
let lo = 0, hi = 1;
if (Math.abs(dx) < 1e-12) {
if (Math.abs(x0) > extent) return;
} else {
const t0 = (-extent - x0) / dx, t1 = (extent - x0) / dx;
lo = Math.max(lo, Math.min(t0, t1));
hi = Math.min(hi, Math.max(t0, t1));
}
if (Math.abs(dy) < 1e-12) {
if (Math.abs(y0) > extent) return;
} else {
const t0 = (-extent - y0) / dy, t1 = (extent - y0) / dy;
lo = Math.max(lo, Math.min(t0, t1));
hi = Math.min(hi, Math.max(t0, t1));
}
if (hi - lo <= 1e-10) return;
emit(x0 + lo * dx, y0 + lo * dy, x0 + hi * dx, y0 + hi * dy);
};
for (const [a, b] of edges) {
const clipped = camera.clipSegment(a, b);
if (!clipped) continue;
const [p0, p1] = clipped;
const A = camera.project(p0), B = camera.project(p1);
const dx = B[0] - A[0], dy = B[1] - A[1];
const len2 = dx * dx + dy * dy;
if (!(len2 > 1e-18) || !Number.isFinite(len2)) continue;
const minx = Math.min(A[0], B[0]), maxx = Math.max(A[0], B[0]);
const miny = Math.min(A[1], B[1]), maxy = Math.max(A[1], B[1]);
if (minx > extent || maxx < -extent || miny > extent || maxy < -extent) continue;
masks.length = 0;
for (const face of scene.faces) {
const poly = face.polygon;
if (poly.maxx < minx || poly.minx > maxx || poly.maxy < miny || poly.miny > maxy) continue;
const q = face.plane;
const d0 = q[0] * p0[0] + q[1] * p0[1] + q[2] * p0[2] + q[3];
const d1 = q[0] * p1[0] + q[1] * p1[1] + q[2] * p1[2] + q[3];
if (d0 >= -planeEpsilon && d1 >= -planeEpsilon) continue;
let U = A, V = B;
if ((d0 > planeEpsilon && d1 < -planeEpsilon) ||
(d1 > planeEpsilon && d0 < -planeEpsilon)) {
const t = d0 / (d0 - d1);
const cut = camera.project([
p0[0] + t * (p1[0] - p0[0]),
p0[1] + t * (p1[1] - p0[1]),
p0[2] + t * (p1[2] - p0[2]),
]);
if (d0 < 0) V = cut; else U = cut;
}
clip2d.intersect(U, V, poly, (x0, y0, x1, y1) => {
const t0 = ((x0 - A[0]) * dx + (y0 - A[1]) * dy) / len2;
const t1 = ((x1 - A[0]) * dx + (y1 - A[1]) * dy) / len2;
masks.push(t0, t1);
});
}
clip2d.subtractRanges(A, B, masks, emitViewport);
}
};
return { render };
};
/* ------------------------- Groups ------------------------- */
const Groups = (math, bsp) => {
const { Mat4 } = math;
const create = () => {
const operations = [];
const build = (transform = new Mat4()) => {
const solid = bsp.create();
for (const { shape, local, sign } of operations) {
const matrix = new Mat4().multiplyFrom(transform, local);
const faces = typeof shape === "function" ? shape(matrix) : shape.build(matrix);
solid.insert(faces, sign);
}
solid.reduce();
return solid.faces();
};
const api = {
insert(shape, local, sign = +1) {
operations.push({ shape, local: new Mat4().copy(local), sign });
return api;
},
build,
place(world, transform = new Mat4(), sign = +1) {
world.insert(build(transform), sign);
return world;
}
};
return api;
};
return { create };
};
/* ------------------------- Boot ------------------------- */
const math = Math3D();
const polygon = Polygon(math);
const primitives = Primitives(math, polygon);
const bsp = BSP(polygon);
const edges = Edges(math, polygon);
const camera = Camera(math);
const groups = Groups(math, bsp);
const polyClip = PolyClip2D();
const clip2d = polyClip.clip;
const scene2d = Scene2D(math, clip2d);
const edgeHLR2d = EdgeHLR2D(clip2d);
const CONFIG = {
light: {
sun: new math.Vec3(-0.5, 0.25, -0.25),
gamma: 2.2,
ambient: 0.1,
},
ink: {
parentOffset: 15, // Degrees added to the projected parent edge (try 0 to 15).
// 16 Lambert bins: 4 almost black, 8 graduated grays, 4 white.
// Physical hatch pitch in TurtleToy coordinates; 0 = no hatching.
pitches: [
0.15, 0.15, 0.15, 0.15,
0.26, 0.36, 0.47, 0.58,
0.68, 0.79, 0.89, 1.00,
0, 0, 0, 0,
],
}
};
/* ------------------------- Infernal machine ------------------------- */
const { Mat4, Vec3 } = math;
const { cube, pyramid, diamond } = primitives;
const RAD = Math.PI / 180;
const world = Materials(bsp.create());
// Room walls
world.insert(cube(new Mat4().scale(8, 8, 8)), +1, { brightness: 100 });
world.insert(cube(new Mat4().scale(7.5, 7.5, 7.5)), -1, { brightness: 100 });
// Main C-shaped body
world.insert(cube(new Mat4()), +1);
world.insert(
cube(new Mat4().translate(0.625, 0, 0).scale(1.5, 0.875, 1.5)),
-1,
);
// Left window and frame
world.insert(
cube(
new Mat4()
.translate(-0.95, 0, 0)
.rotateX(45 * RAD)
.scale(0.15, 0.4, 0.4),
),
+1,
);
world.insert(
cube(
new Mat4()
.translate(-1, 0, 0)
.rotateX(45 * RAD)
.scale(0.3, 0.3, 0.3),
),
-1,
);
// Right window, frame and support
world.insert(
cube(
new Mat4()
.translate(0.8, 0, 0)
.rotateX(45 * RAD)
.scale(0.15, 0.4, 0.4),
),
+1,
);
world.insert(
cube(new Mat4().translate(0.8, 0, 0).scale(0.0625, 1.8, 0.0625)),
+1,
);
world.insert(
cube(
new Mat4()
.translate(0.8, 0, 0)
.rotateX(45 * RAD)
.scale(1.5, 0.3, 0.3),
),
-1,
);
// Long diamond and opposed pyramids
world.insert(
diamond(
new Mat4()
.rotateX(45 * RAD)
.scale(4, 0.15, 0.15)
.rotateZ(90 * RAD),
),
+1,
);
// Radiator group primitive
const radiator = groups.create();
radiator.insert(
cube,
new Mat4()
.translate(0, -0.3, 0)
.scale(0.5, 0.05, 0.5),
);
radiator.insert(
cube,
new Mat4()
.translate(0, -0.5, 0)
.scale(0.51, 0.05, 0.51),
);
radiator.insert(
pyramid,
new Mat4()
.translate(0, -0.15, 0)
.scale(0.5, 0.5, 0.5),
);
// insert radiators
radiator.place(
world,
new Mat4()
.translate(0, -1.15, 0)
);
radiator.place(
world,
new Mat4()
.translate(0, 1.15, 0)
.rotateZ(Math.PI)
);
world.reduce();
const faces = world.faces();
const segments = edges.extract(faces);
// Random camera orbit around the scene
const azimuth = Math.random() * 2 * Math.PI;
const elevation = ((Math.random() - 0.5) * Math.PI) / 3;
const radius = 3.5;
const eye = new Vec3(
radius * Math.cos(elevation) * Math.cos(azimuth),
radius * Math.sin(elevation),
radius * Math.cos(elevation) * Math.sin(azimuth),
);
const cam = camera.create(eye, new Vec3(0, 0, 0), 65);
const orderedFaces = world.facesFrontToBack(cam.eye);
const projected = scene2d.build(
orderedFaces,
cam,
CONFIG.light,
CONFIG.ink.pitches,
world.brightnessOf,
world.parentEdgeOf
);
/* -------------- hatch + CSG edges ---------------- */
const ink = polyClip.create();
// hatches
const offset = CONFIG.ink.parentOffset * Math.PI / 180;
for (const face of projected.faces) {
const pitch = projected.pitches[face.tone];
const angle = face.hatchAngle + offset;
ink.add(face.polygon, { group: face.surface, hatch: { angle, pitch } });
}
// draw 3D scene
const draw = (x0, y0, x1, y1) => {
turtle.jump(x0, y0);
turtle.goto(x1, y1);
};
const bounds = [-100, -100, 100, 100];
ink.render(draw, { bounds });
edgeHLR2d.render(segments, projected, cam, draw);