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);