BSP Hidden Lines

Hidden-line rendering done properly: BSP + 3D ray tests.
No screen-space clipping involved.

Log in to post a comment.

// TurtleToy
Canvas.setpenopacity(1);
const turtle = new Turtle();
turtle.penup();

/* ============================================================
 *  BSP Tree (CSG-ish) + Hidden-Line "true 3D" (no 2D clipper)
 *  BSP adapted from C++ BSP Tree Demo (Bretton Wade, 1994-97)
 *  http://www.gamers.org/dhs/helpdocs/bsp_faq.html
 * ============================================================ */

/* ------------------------- Constants ------------------------- */

const HC_IN = -1, HC_ON = 0, HC_OUT = 1, HC_SPANNING = 2;
const EPSILON = 1 / 100000;

/* ------------------------- vec4 / mat4 ------------------------- */

const v4 = {
	multiplyScalar (v, s) { return [ v[0]*s, v[1]*s, v[2]*s, v[3]*s ]; },
	subtract (v, p)       { return [ v[0]-p[0], v[1]-p[1], v[2]-p[2], v[3]-p[3] ]; },
	add (v, p)            { return [ v[0]+p[0], v[1]+p[1], v[2]+p[2], v[3]+p[3] ]; },

	multiplyMatrix (v, m) {
		const px = v[0], py = v[1], pz = v[2], pw = v[3];
		return [
			px*m[0]  + py*m[4]  + pz*m[8]  + pw*m[12],
			px*m[1]  + py*m[5]  + pz*m[9]  + pw*m[13],
			px*m[2]  + py*m[6]  + pz*m[10] + pw*m[14],
			px*m[3]  + py*m[7]  + pz*m[11] + pw*m[15]
		];
	},

	crossProduct (v, p) {
		return [
			v[1]*p[2] - v[2]*p[1],
			v[2]*p[0] - v[0]*p[2],
			v[0]*p[1] - v[1]*p[0],
			0
		];
	},

	dotProduct (v, p) {
		return v[0]*p[0] + v[1]*p[1] + v[2]*p[2] + v[3]*p[3];
	},

	normalize (v) {
		const n = Math.sqrt(v[0]*v[0] + v[1]*v[1] + v[2]*v[2] + v[3]*v[3]);
		return [ v[0]/n, v[1]/n, v[2]/n, v[3]/n ];
	}
};

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
		];
	},

	inverse (m) {
		const
			a00 = m[0],  a01 = m[1],  a02 = m[2],  a03 = m[3],
			a10 = m[4],  a11 = m[5],  a12 = m[6],  a13 = m[7],
			a20 = m[8],  a21 = m[9],  a22 = m[10], a23 = m[11],
			a30 = m[12], a31 = m[13], a32 = m[14], a33 = m[15],
			b00 = a00 * a11 - a01 * a10,
			b01 = a00 * a12 - a02 * a10,
			b02 = a00 * a13 - a03 * a10,
			b03 = a01 * a12 - a02 * a11,
			b04 = a01 * a13 - a03 * a11,
			b05 = a02 * a13 - a03 * a12,
			b06 = a20 * a31 - a21 * a30,
			b07 = a20 * a32 - a22 * a30,
			b08 = a20 * a33 - a23 * a30,
			b09 = a21 * a32 - a22 * a31,
			b10 = a21 * a33 - a23 * a31,
			b11 = a22 * a33 - a23 * a32;

		let det = b00*b11 - b01*b10 + b02*b09 + b03*b08 - b04*b07 + b05*b06;
		if (!det) return this;
		det = 1.0 / det;

		return [
			(a11*b11 - a12*b10 + a13*b09)*det,
			(a02*b10 - a01*b11 - a03*b09)*det,
			(a31*b05 - a32*b04 + a33*b03)*det,
			(a22*b04 - a21*b05 - a23*b03)*det,

			(a12*b08 - a10*b11 - a13*b07)*det,
			(a00*b11 - a02*b08 + a03*b07)*det,
			(a32*b02 - a30*b05 - a33*b01)*det,
			(a20*b05 - a22*b02 + a23*b01)*det,

			(a10*b10 - a11*b08 + a13*b06)*det,
			(a01*b08 - a00*b10 - a03*b06)*det,
			(a30*b04 - a31*b02 + a33*b00)*det,
			(a21*b02 - a20*b04 - a23*b00)*det,

			(a11*b07 - a10*b09 - a12*b06)*det,
			(a00*b09 - a01*b07 + a02*b06)*det,
			(a31*b01 - a30*b03 - a32*b00)*det,
			(a20*b03 - a21*b01 + a22*b00)*det
		];
	},

	rotateXYZ (angleX, angleY, angleZ) {
		const cw = Math.cos(angleX), sw = Math.sin(angleX);
		const cy = Math.cos(angleY), sy = Math.sin(angleY);
		const ck = angleZ ? Math.cos(angleZ) : 1;
		const sk = angleZ ? Math.sin(angleZ) : 0;
		return [
			cy*ck,          cw*sk + sw*sy*ck,  sw*sk - cw*sy*ck,  0,
			-cy*sk,         cw*ck - sw*sy*sk,  sw*ck + cw*sy*sk,  0,
			sy,             -sw*cy,            cw*cy,             0,
			0,              0,                 0,                 1
		];
	},

	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) {
		angle = angle * (Math.PI / 180);
		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) {
		angle = angle * (Math.PI / 180);
		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) {
		angle = angle * (Math.PI / 180);
		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
		]);
	}
};

/* ------------------------- mat4 chaining (builder) ------------------------- */

const mat4 = () => new Mat4();

class Mat4 {
	constructor () { this.m = m4.identity(); }
	scale (x, y, z)     { this.m = m4.scale(this.m, x, y, z); 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); return this; }
	rotateY (a)         { this.m = m4.rotateY(this.m, a); return this; }
	rotateZ (a)         { this.m = m4.rotateZ(this.m, a); return this; }
}

/* ------------------------- Polygon primitive ------------------------- */

const polygon = {

	createIndexed (p, list) {
		const points = [];
		for (const index of list) points.push(p[index]);
		points.plane = this.definePlane(points);
		return points;
	},

	create (p) {
		p.plane = this.definePlane(p);
		return p;
	},

	definePlane (poly) {
		// Newell normal (robust for convex polys too)
		let sum = [0,0,0,0];
		for (let i = 0, last = poly.length - 1; i < poly.length; last = i, i++) {
			const A = poly[last]; // expects CCW order
			const B = poly[i];
			sum[0] += (A[1] - B[1]) * (A[2] + B[2]);
			sum[1] += (A[2] - B[2]) * (A[0] + B[0]);
			sum[2] += (A[0] - B[0]) * (A[1] + B[1]);
		}
		sum = v4.normalize(sum);
		const p = poly[0];
		return [
			sum[0],
			sum[1],
			sum[2],
			-(sum[0]*p[0] + sum[1]*p[1] + sum[2]*p[2] + sum[3]*p[3])
		];
	},

	split (poly, plane) {
		const outpts = [], inpts = [];
		let outp, inp, out_c = 0, in_c = 0, poly_class = HC_ON;

		let ptA = poly[poly.length - 1];
		let sideA = v4.dotProduct(ptA, plane);

		for (const ptB of poly) {
			const sideB = v4.dotProduct(ptB, plane);

			if (sideB > EPSILON) {
				if (poly_class === HC_ON) poly_class = HC_OUT;
				else if (poly_class !== HC_OUT) poly_class = HC_SPANNING;

				if (sideA < -EPSILON) {
					const v = v4.subtract(ptB, ptA);
					outpts[out_c++] = inpts[in_c++] =
						v4.add(ptA, v4.multiplyScalar(v, -v4.dotProduct(ptA, plane) / v4.dotProduct(v, plane)));
					poly_class = HC_SPANNING;
				}
				outpts[out_c++] = ptB;

			} else if (sideB < -EPSILON) {
				if (poly_class === HC_ON) poly_class = HC_IN;
				else if (poly_class !== HC_IN) poly_class = HC_SPANNING;

				if (sideA > EPSILON) {
					const v = v4.subtract(ptB, ptA);
					outpts[out_c++] = inpts[in_c++] =
						v4.add(ptA, v4.multiplyScalar(v, -v4.dotProduct(ptA, plane) / v4.dotProduct(v, plane)));
					poly_class = HC_SPANNING;
				}
				inpts[in_c++] = ptB;

			} else {
				outpts[out_c++] = inpts[in_c++] = ptB;
			}

			ptA = ptB;
			sideA = sideB;
		}

		switch (poly_class) {
			case HC_OUT: outp = poly; break;
			case HC_IN:  inp = poly; break;
			case HC_SPANNING:
				outp = this.create(outpts);
				inp  = this.create(inpts);
				break;
		}
		return [outp, inp, poly_class];
	}
};

/* ------------------------- Geometry brushes ------------------------- */

const cube = (transform) => {
	const p = [
		v4.multiplyMatrix([ 1,  1,  1, 1], transform),
		v4.multiplyMatrix([-1,  1,  1, 1], transform),
		v4.multiplyMatrix([-1, -1,  1, 1], transform),
		v4.multiplyMatrix([ 1, -1,  1, 1], transform),
		v4.multiplyMatrix([ 1,  1, -1, 1], transform),
		v4.multiplyMatrix([-1,  1, -1, 1], transform),
		v4.multiplyMatrix([-1, -1, -1, 1], transform),
		v4.multiplyMatrix([ 1, -1, -1, 1], transform)
	];
	return [
		polygon.createIndexed(p, [0, 1, 2, 3]),
		polygon.createIndexed(p, [7, 6, 5, 4]),
		polygon.createIndexed(p, [0, 3, 7, 4]),
		polygon.createIndexed(p, [0, 4, 5, 1]),
		polygon.createIndexed(p, [5, 6, 2, 1]),
		polygon.createIndexed(p, [3, 2, 6, 7])
	];
};

const pyramid = (transform) => {
	const p = [
		v4.multiplyMatrix([ 1,  0,  1, 1], transform),
		v4.multiplyMatrix([-1,  0,  1, 1], transform),
		v4.multiplyMatrix([-1,  0, -1, 1], transform),
		v4.multiplyMatrix([ 1,  0, -1, 1], transform),
		v4.multiplyMatrix([ 0,  2,  0, 1], transform)
	];
	return [
		polygon.createIndexed(p, [0, 1, 2, 3]),
		polygon.createIndexed(p, [4, 1, 0]),
		polygon.createIndexed(p, [4, 0, 3]),
		polygon.createIndexed(p, [4, 2, 1]),
		polygon.createIndexed(p, [4, 3, 2])
	];
};

const diamond = (transform) => {
	const p = [
		v4.multiplyMatrix([ 1,  0,  1, 1], transform),
		v4.multiplyMatrix([-1,  0,  1, 1], transform),
		v4.multiplyMatrix([-1,  0, -1, 1], transform),
		v4.multiplyMatrix([ 1,  0, -1, 1], transform),
		v4.multiplyMatrix([ 0,  1,  0, 1], transform),
		v4.multiplyMatrix([ 0, -1,  0, 1], transform)
	];
	return [
		polygon.createIndexed(p, [4, 1, 0]),
		polygon.createIndexed(p, [4, 0, 3]),
		polygon.createIndexed(p, [4, 2, 1]),
		polygon.createIndexed(p, [4, 3, 2]),
		polygon.createIndexed(p, [0, 1, 5]),
		polygon.createIndexed(p, [3, 0, 5]),
		polygon.createIndexed(p, [1, 2, 5]),
		polygon.createIndexed(p, [2, 3, 5])
	];
};

/* ------------------------- BSP Tree ------------------------- */

class BspTree {
	constructor () { this.node = null; }

	insert (list, keep, cur = keep) {
		if (list.length === 0) return;

		if (this.node) {
			this.node.insert(list, keep);
		} else {
			if ((cur === keep) || (keep === HC_SPANNING)) {
				this.node = new BspNode(list.pop());
				if (list.length) this.node.insert(list, HC_SPANNING);
			}
		}
	}

	pushPoly (poly, result, keep, cur) {
		if (this.node !== null) this.node.pushPoly(poly, result, keep);
		else if (cur === keep) result.push(poly);
	}

	pushList (list, result, keep, cur) {
		if (list.length === 0) return;
		if (this.node !== null) this.node.pushList(list, result, keep);
		else if (cur === keep) result.push.apply(result, list);
	}

	reduce () {
		if (this.node !== null) this.node.reduce();
	}
}

class BspNode {
	constructor (poly) {
		this.plane = poly.plane;
		this.on    = [poly];
		this.in    = new BspTree();
		this.out   = new BspTree();
	}

	insert (list, keep) {
		const inside = [], outside = [];

		for (const poly of list) {
			const [outp, inp, sgn] = polygon.split(poly, this.plane);
			if (sgn === HC_ON) this.on.push(poly);
			else {
				if ((sgn === HC_IN) || (sgn === HC_SPANNING)) inside.push(inp);
				if ((sgn === HC_OUT) || (sgn === HC_SPANNING)) outside.push(outp);
			}
		}

		if (inside.length  !== 0) this.in.insert(inside,  keep, HC_IN);
		if (outside.length !== 0) this.out.insert(outside, keep, HC_OUT);
	}

	pushPoly (poly, result, keep) {
		const [outp, inp, sgn] = polygon.split(poly, this.plane);
		if (sgn === HC_ON) result.push(poly);
		else {
			if ((sgn === HC_IN) || (sgn === HC_SPANNING))  this.in.pushPoly(inp,  result, keep, HC_IN);
			if ((sgn === HC_OUT) || (sgn === HC_SPANNING)) this.out.pushPoly(outp, result, keep, HC_OUT);
		}
	}

	pushList (list, result, keep) {
		const inside = [], outside = [];

		for (const poly of list) {
			const [outp, inp, sgn] = polygon.split(poly, this.plane);
			if (sgn === HC_ON) result.push(poly);
			else {
				if ((sgn === HC_IN) || (sgn === HC_SPANNING))  inside.push(inp);
				if ((sgn === HC_OUT) || (sgn === HC_SPANNING)) outside.push(outp);
			}
		}

		if (inside.length  !== 0) this.in.pushList(inside,  result, keep, HC_IN);
		if (outside.length !== 0) this.out.pushList(outside, result, keep, HC_OUT);
	}

	reduce () {
		// "Extrude" negative parts: compute boundary polys
		const results = [], boundary = [];

		for (const poly of this.on) {
			// if plane differs, choose which side to treat as inside/outside
			if (Math.abs(poly.plane[3] + this.plane[3]) > EPSILON) {
				this.in.pushPoly (poly, results, HC_IN,  HC_IN);
				this.out.pushList(results, boundary, HC_OUT, HC_OUT);
			} else {
				this.out.pushPoly(poly, results, HC_OUT, HC_OUT);
				this.in.pushList (results, boundary, HC_IN,  HC_IN);
			}
		}

		this.on = boundary;
		this.in.reduce();
		this.out.reduce();
	}
}

/* ------------------------- Camera ------------------------- */

const camera = {
	frame: 1,
	aspect: 100,

	look (e, to, zoom) {
		this.posEye = [e[0], e[1], e[2], 1];
		this.zoom = zoom;

		const vpn = v4.normalize(v4.subtract(this.posEye, [to[0], to[1], to[2], 1]));

		// build orthonormal basis (store it!)
		let u = v4.crossProduct([0, 1, 0, 0], vpn);
		u = v4.normalize(u);

		let v = v4.crossProduct(vpn, u);
		v = v4.normalize(v);

		const vrp = v4.add(this.posEye, v4.multiplyScalar(vpn, -zoom));

		this.u = u;       // vec4
		this.v = v;       // vec4
		this.n = vpn;     // vec4
		this.vrp = vrp;   // vec4

		this.view = m4.multiply(this.viewMatrix(u, v, vpn, vrp), this.perspective(zoom));
	},

	perspective (distance) {
		// w = 1 - z / distance  (so projection is x/w, y/w)
		return [
			1,0,0,0,
			0,1,0,0,
			0,0,1,-1/distance,
			0,0,0,1
		];
	},

	viewMatrix (u, v, n, r) {
		return [
			u[0], v[0], n[0], 0,
			u[1], v[1], n[1], 0,
			u[2], v[2], n[2], 0,
			-(r[0]*u[0] + r[1]*u[1] + r[2]*u[2] + r[3]*u[3]),
			-(r[0]*v[0] + r[1]*v[1] + r[2]*v[2] + r[3]*v[3]),
			-(r[0]*n[0] + r[1]*n[1] + r[2]*n[2] + r[3]*n[3]), 1
		];
	}
};

/* ===========================================================
 * BSP + Hidden-Line (true 3D, no 2D clipper)
 *
 * Pipeline
 * 1) BSP world.reduce() => boundary polygons (the "solid" skin)
 * 2) Cad.collectPolys() => grab all boundary polys from the BSP tree
 * 3) Cad.buildEdges()  => build unique edges by vertex-key hashing
 * 4) Cad.filterSplitEdges() => drop coplanar adjacency edges (BSP split artifacts)
 * 5) Cad.purgeTJunctionEdges() => drop internal T-junction edges on coplanar faces
 * 6) Cad.drawHiddenEdges() => recursive edge splitting + per-point visibility via ray casting
 *
 * ================================================================ */

class Cad {

	constructor (world, camera, turtle) {
		this.world  = world;
		this.camera = camera;
		this.turtle = turtle;

		this.polys  = [];
		this.edges  = []; // { a, b, faces:[p0,p1?] }

		// topology tolerances
		this.epsKey = 1 / 10000; // vertex quantization
		this.epsN   = 1 / 10000; // plane normal epsilon
		this.epsD   = 1 / 10000; // plane d epsilon

		// hidden-line params
		this.epsHit   = 1 / 100000; // ray-plane epsilon
		this.pixelTol = 0.6;        // screen-space tolerance
		this.maxSplit = 15;         // recursion cap
		this.wMin     = 0.05;       // near-plane safety
	}

	/* ===================== Private helpers ===================== */

	#collectNode (node) {
		for (const p of node.on) this.polys.push(p);
		if (node.in  && node.in.node)  this.#collectNode(node.in.node);
		if (node.out && node.out.node) this.#collectNode(node.out.node);
	}

	#key3 (v, eps) {
		const x = Math.round(v[0] / eps);
		const y = Math.round(v[1] / eps);
		const z = Math.round(v[2] / eps);
		return x + "," + y + "," + z;
	}

	#coplanarPlanes (p0, p1) {
		const dot = p0[0]*p1[0] + p0[1]*p1[1] + p0[2]*p1[2];

		const epsN = 3  * this.epsN;
		const epsD = 10 * this.epsD;

		if (Math.abs(dot - 1) < epsN) return (Math.abs(p0[3] - p1[3]) < epsD);
		if (Math.abs(dot + 1) < epsN) return (Math.abs(p0[3] + p1[3]) < epsD);
		return false;
	}

	#clipWorldSegToWMin (A, B) {
		const wMin = this.wMin;

		// camera space endpoints
		const Ac = v4.multiplyMatrix(A, this.camera.viewing);
		const Bc = v4.multiplyMatrix(B, this.camera.viewing);

		const wa = Ac[3];
		const wb = Bc[3];

		const ina = wa >= wMin;
		const inb = wb >= wMin;

		if (ina && inb) return [A, B];
		if (!ina && !inb) return null;

		// t where w(t) = wMin in camera space
		const t = (wMin - wa) / (wb - wa);

		// interpolate in WORLD space (camera transform is linear)
		const C = [
			A[0] + (B[0] - A[0]) * t,
			A[1] + (B[1] - A[1]) * t,
			A[2] + (B[2] - A[2]) * t,
			1
		];

		return ina ? [A, C] : [C, B];
	}

	#edgeOnPolyBoundary (A, B, poly, plane) {
		// project everything to a stable 2D basis on the plane,
		// then test if AB matches some polygon boundary edge (colinear + overlap).

		const n = [plane[0], plane[1], plane[2], 0];

		let ux = 0, uy = 0, uz = 0;
		if (Math.abs(n[0]) < 0.9) { ux = 1; uy = 0; uz = 0; }
		else { ux = 0; uy = 1; uz = 0; }

		const u = v4.normalize(v4.crossProduct([ux,uy,uz,0], n));
		const v = v4.normalize(v4.crossProduct(n, u));

		const ax = A[0]*u[0] + A[1]*u[1] + A[2]*u[2];
		const ay = A[0]*v[0] + A[1]*v[1] + A[2]*v[2];
		const bx = B[0]*u[0] + B[1]*u[1] + B[2]*u[2];
		const by = B[0]*v[0] + B[1]*v[1] + B[2]*v[2];

		const eps = 1 / 5000;

		const m = poly.length;
		for (let i = 0; i < m; i++) {
			const P = poly[i];
			const Q = poly[(i + 1) % m];

			const px = P[0]*u[0] + P[1]*u[1] + P[2]*u[2];
			const py = P[0]*v[0] + P[1]*v[1] + P[2]*v[2];
			const qx = Q[0]*u[0] + Q[1]*u[1] + Q[2]*u[2];
			const qy = Q[0]*v[0] + Q[1]*v[1] + Q[2]*v[2];

			const abx = bx - ax, aby = by - ay;
			const apx = px - ax, apy = py - ay;
			const aqx = qx - ax, aqy = qy - ay;

			const c1 = abx * apy - aby * apx;
			const c2 = abx * aqy - aby * aqx;
			if (Math.abs(c1) > eps || Math.abs(c2) > eps) continue;

			const denom = (abx*abx + aby*aby);
			if (denom < eps) continue;

			const tp = (apx*abx + apy*aby) / denom;
			const tq = (aqx*abx + aqy*aby) / denom;

			const mn = Math.min(tp, tq);
			const mx = Math.max(tp, tq);

			if (mn <= 0 + 5*eps && mx >= 1 - 5*eps) return true;
		}

		return false;
	}

	#pointInConvexPoly3D (Q, poly, plane) {
		// winding-agnostic half-space test:
		// inside if all edge tests have same sign (within eps)
		const nx = plane[0], ny = plane[1], nz = plane[2];
		const n = poly.length;

		let pos = false, neg = false;

		for (let i = 0; i < n; i++) {
			const A = poly[i];
			const B = poly[(i + 1) % n];

			const abx = B[0] - A[0], aby = B[1] - A[1], abz = B[2] - A[2];
			const aqx = Q[0] - A[0], aqy = Q[1] - A[1], aqz = Q[2] - A[2];

			const cx = aby * aqz - abz * aqy;
			const cy = abz * aqx - abx * aqz;
			const cz = abx * aqy - aby * aqx;

			const s = cx*nx + cy*ny + cz*nz;

			if (s >  this.epsHit) pos = true;
			else if (s < -this.epsHit) neg = true;

			if (pos && neg) return false;
		}
		return true;
	}

	#isOccludedPoint (P, f0, f1, p2cached) {

		const p2 = p2cached || this.project(P);
		const x = p2[0], y = p2[1];

		// ray through pixel
		const [O, D] = this.rayFromScreen(x, y); // D normalized

		// distance of P along ray
		const OP = v4.subtract(P, O);
		const tP = OP[0]*D[0] + OP[1]*D[1] + OP[2]*D[2];
		if (tP <= this.epsHit) return false;

		for (const poly of this.polys) {

			// skip owning faces (avoid self-occlusion)
			if (poly === f0 || poly === f1) continue;

			// screen AABB cull
			if (x < poly._bb0 || x > poly._bb2 || y < poly._bb1 || y > poly._bb3) continue;

			const pl = poly.plane;
			const nx = pl[0], ny = pl[1], nz = pl[2], d = pl[3];

			// back-face cull (front-facing occluders only)
			const nd = nx*D[0] + ny*D[1] + nz*D[2];
			if (nd >= -this.epsHit) continue;

			// ray-plane intersection
			const no = nx*O[0] + ny*O[1] + nz*O[2] + d;
			const tHit = -no / nd;

			if (tHit <= this.epsHit) continue;
			if (tHit >= tP - this.epsHit) continue;

			const Q = [
				O[0] + D[0] * tHit,
				O[1] + D[1] * tHit,
				O[2] + D[2] * tHit,
				1
			];

			if (this.#pointInConvexPoly3D(Q, poly, pl)) return true;
		}

		return false;
	}

	#isVisiblePoint (P, f0, f1, p2cached) {
		return !this.#isOccludedPoint(P, f0, f1, p2cached);
	}

	#splitDrawEdge (A, B, f0, f1, depth) {

		// near-plane safety: clip segment before any projection
		const clipped = this.#clipWorldSegToWMin(A, B);
		if (!clipped) return;
		A = clipped[0];
		B = clipped[1];

		const a2 = this.project(A);
		const b2 = this.project(B);

		const dx = b2[0] - a2[0];
		const dy = b2[1] - a2[1];
		const d2 = dx*dx + dy*dy;

		const visA = this.#isVisiblePoint(A, f0, f1, a2);
		const visB = this.#isVisiblePoint(B, f0, f1, b2);

		// fast accept
		if (visA && visB && (d2 <= this.pixelTol*this.pixelTol || depth >= this.maxSplit)) {
			const t = this.turtle;
			t.penup();   t.goto(a2[0], a2[1]);
			t.pendown(); t.goto(b2[0], b2[1]);
			return;
		}

		// fast reject
		if (!visA && !visB && (d2 <= this.pixelTol*this.pixelTol || depth >= this.maxSplit)) {
			return;
		}

		// split
		const M = [
			(A[0] + B[0]) * 0.5,
			(A[1] + B[1]) * 0.5,
			(A[2] + B[2]) * 0.5,
			1
		];

		const m2 = this.project(M);
		const visM = this.#isVisiblePoint(M, f0, f1, m2);

		// if all same state but still large, keep splitting
		if (visA === visM && visM === visB && depth < this.maxSplit) {
			this.#splitDrawEdge(A, M, f0, f1, depth + 1);
			this.#splitDrawEdge(M, B, f0, f1, depth + 1);
			return;
		}

		// mixed states => split adaptively
		if (depth < this.maxSplit) {
			this.#splitDrawEdge(A, M, f0, f1, depth + 1);
			this.#splitDrawEdge(M, B, f0, f1, depth + 1);
		} else {
			// fallback: draw if midpoint visible
			if (visM) {
				const t = this.turtle;
				t.penup();   t.goto(a2[0], a2[1]);
				t.pendown(); t.goto(b2[0], b2[1]);
			}
		}
	}

	/* ====================== Public API ======================= */

	setRotation (rx, ry, rz) {
		// recompute camera transforms + rotated basis for ray casting
		this.camera.frame++;

		const r = m4.rotateXYZ(rx, ry, rz);
		const invr = m4.inverse(r);

		this.camera.viewing = m4.multiply(r, this.camera.view);
		this.camera.eye = v4.multiplyMatrix(this.camera.posEye, invr);

		// rotate camera basis + vrp the same way (world space)
		this.uR   = v4.normalize(v4.multiplyMatrix(this.camera.u, invr));
		this.vR   = v4.normalize(v4.multiplyMatrix(this.camera.v, invr));
		this.vrpR = v4.multiplyMatrix(this.camera.vrp, invr);
	}

	project (p4) {
		const p = v4.multiplyMatrix(p4, this.camera.viewing);
		const invw = 1 / p[3];
		return [
			(p[0] * invw) *  this.camera.aspect,
			(p[1] * invw) * -this.camera.aspect
		];
	}

	rayFromScreen (x, y) {
		// build a world-space ray from screen coords (turtle units)
		const nx =  x / this.camera.aspect;
		const ny = -y / this.camera.aspect;

		const Pw = v4.add(
			this.vrpR,
			v4.add(
				v4.multiplyScalar(this.uR, nx),
				v4.multiplyScalar(this.vR, ny)
			)
		);

		const O = this.camera.eye;
		const D = v4.normalize(v4.subtract(Pw, O));
		return [O, D];
	}

	collectPolys () {
		this.polys.length = 0;
		if (!this.world || !this.world.node) return this.polys;
		this.#collectNode(this.world.node);
		return this.polys;
	}

	buildEdges () {
		const map = new Map();
		const eps = this.epsKey;

		for (const poly of this.polys) {
			const n = poly.length;
			for (let i = 0; i < n; i++) {
				const a = poly[i];
				const b = poly[(i + 1) % n];

				const ka = this.#key3(a, eps);
				const kb = this.#key3(b, eps);
				const ek = (ka < kb) ? (ka + "|" + kb) : (kb + "|" + ka);

				let e = map.get(ek);
				if (!e) {
					e = { a: a, b: b, faces: [poly] };
					map.set(ek, e);
				} else {
					if (e.faces.length < 2) e.faces.push(poly);
				}
			}
		}

		this.edges.length = 0;
		for (const e of map.values()) this.edges.push(e);
		return this.edges;
	}

	filterSplitEdges () {
		// remove edges between coplanar adjacent polygons (BSP split artifacts)
		const out = [];
		for (const e of this.edges) {
			if (e.faces.length === 1) out.push(e);
			else {
				const p0 = e.faces[0], p1 = e.faces[1];
				if (!this.#coplanarPlanes(p0.plane, p1.plane)) out.push(e);
			}
		}
		this.edges = out;
		return this.edges;
	}

	purgeTJunctionEdges () {
		// remove "internal" edges caused by T-junctions on coplanar faces
		// (keeps the silhouette / boundary lines only)

		const buckets = new Map();
		const pe = 1 / 2000; // plane quantization

		const pkey = (pl) => {
			const nx = Math.round(pl[0] / pe);
			const ny = Math.round(pl[1] / pe);
			const nz = Math.round(pl[2] / pe);
			const d  = Math.round(pl[3] / (pe * 10));
			if (nx < 0 || (nx === 0 && (ny < 0 || (ny === 0 && nz < 0)))) {
				return (-nx)+","+(-ny)+","+(-nz)+","+(-d);
			}
			return nx+","+ny+","+nz+","+d;
		};

		for (const poly of this.polys) {
			const k = pkey(poly.plane);
			let b = buckets.get(k);
			if (!b) buckets.set(k, (b = []));
			b.push(poly);
		}

		const out = [];
		for (const e of this.edges) {

			// only ambiguous ones: single-face edges
			if (e.faces.length !== 1) { out.push(e); continue; }

			const owner = e.faces[0];
			const k = pkey(owner.plane);
			const candidates = buckets.get(k);

			let kill = false;
			if (candidates && candidates.length > 1) {
				for (const q of candidates) {
					if (q === owner) continue;
					if (this.#edgeOnPolyBoundary(e.a, e.b, q, owner.plane)) {
						kill = true;
						break;
					}
				}
			}

			if (!kill) out.push(e);
		}

		this.edges = out;
		return this.edges;
	}

	preparePolys2D () {
		// compute per-poly screen AABB (cheap cull for occlusion tests)
		for (const poly of this.polys) {
			let minx =  1e30, miny =  1e30;
			let maxx = -1e30, maxy = -1e30;

			for (const p0 of poly) {
				if (p0.frame !== this.camera.frame) {
					const p = v4.multiplyMatrix(p0, this.camera.viewing);
					const invw = 1 / p[3];
					p0.frame = this.camera.frame;
					p0.x = (p[0] * invw) *  this.camera.aspect;
					p0.y = (p[1] * invw) * -this.camera.aspect;
				}
				const x = p0.x, y = p0.y;
				if (x < minx) minx = x;
				if (y < miny) miny = y;
				if (x > maxx) maxx = x;
				if (y > maxy) maxy = y;
			}

			poly._bb0 = minx;
			poly._bb1 = miny;
			poly._bb2 = maxx;
			poly._bb3 = maxy;
		}
	}

	drawHiddenEdges () {
		this.preparePolys2D();
		for (const e of this.edges) {
			const f0 = e.faces[0] || null;
			const f1 = e.faces[1] || null;
			this.#splitDrawEdge(e.a, e.b, f0, f1, 0);
		}
	}

	addOccluderPolys (list) {
		for (const p of list) this.polys.push(p);
	}

	addDrawableEdges (pairs) {
		// pairs = [[A,B],[A,B]...] with A,B vec4
		for (const e of pairs) {
			this.edges.push({ a: e[0], b: e[1], faces: [] });
		}
	}
}

/* ------------------------- Room (occluder + drawable edges) ------------------------- */

class RoomHL {
	constructor (sx = 10, sy = 5, sz = 10) {
		this.sx = sx; this.sy = sy; this.sz = sz;
		this.m = m4.identity();
		this.polys = [];
		this.edges = [];
		this.#build();
	}

	translate (x, y, z) {
		this.m = m4.translate(this.m, x, y, z);
		this.#build();
		return this;
	}

	#pt (x, y, z) {
		return v4.multiplyMatrix([x, y, z, 1], this.m);
	}

	#build () {
		const sx = this.sx, sy = this.sy, sz = this.sz;

		const P = [
			this.#pt(-sx, -sy, -sz),
			this.#pt( sx, -sy, -sz),
			this.#pt( sx,  sy, -sz),
			this.#pt(-sx,  sy, -sz),
			this.#pt(-sx, -sy,  sz),
			this.#pt( sx, -sy,  sz),
			this.#pt( sx,  sy,  sz),
			this.#pt(-sx,  sy,  sz)
		];

		// 6 quads, CCW as seen from INSIDE the room (important for back-face cull)
		this.polys = [
			polygon.createIndexed(P, [0,1,2,3]), // back  (-z)
			polygon.createIndexed(P, [4,7,6,5]), // front (+z)
			polygon.createIndexed(P, [0,4,5,1]), // bottom(-y)
			polygon.createIndexed(P, [3,2,6,7]), // top   (+y)
			polygon.createIndexed(P, [0,3,7,4]), // left  (-x)
			polygon.createIndexed(P, [1,5,6,2])  // right (+x)
		];

		// 12 edges as indices
		this.edges = [
			[P[0],P[1]],[P[1],P[2]],[P[2],P[3]],[P[3],P[0]],
			[P[4],P[5]],[P[5],P[6]],[P[6],P[7]],[P[7],P[4]],
			[P[0],P[4]],[P[1],P[5]],[P[2],P[6]],[P[3],P[7]]
		];
	}
}

/* ------------------------- Scene build (BSP) ------------------------- */

const world = new BspTree();

// Main "machine" 
world.insert(cube(mat4().m), 1); // base cube
world.insert(cube(mat4().scale(-1.5,-0.875,-1.5).translate(0.625,0,0).m), -1); // C cutout

world.insert(cube(mat4().scale(0.15,0.4,0.4).translate(-0.95,0,0).rotateX(45).m), 1); // window frame
world.insert(cube(mat4().scale(-0.3,-0.3,-0.3).translate(-1,0,0).rotateX(45).m), -1); // window hole

world.insert(cube(mat4().scale(0.15,0.4,0.4).translate(0.8,0,0).rotateX(45).m), 1); // window frame
world.insert(cube(mat4().scale(0.0625,1.8,0.0625).translate(0.8,0,0).m), 1); // support
world.insert(cube(mat4().scale(-1.5,-0.3,-0.3).translate(0.8,0,0).rotateX(45).m), -1); // window hole

world.insert(diamond(mat4().rotateZ(90).scale(4,0.15,0.15).rotateX(45).m), 1); // long diamond
world.insert(pyramid(mat4().scale(0.5,0.5,0.5).translate(0,-1.25,0).rotateZ(180).m), 1); // pyramid
world.insert(pyramid(mat4().scale(0.5,0.5,0.5).translate(0,-1.25,0).m), 1); // pyramid

world.insert(cube(mat4().scale(0.5,0.05,0.5).translate(0,-1.4,0).m), 1);// slab
world.insert(cube(mat4().scale(0.51,0.05,0.51).translate(0,-1.6,0).m), 1); // slab
world.insert(cube(mat4().scale(0.5,0.05,0.5).translate(0,1.4,0).m), 1); // slab
world.insert(cube(mat4().scale(0.51,0.05,0.51).translate(0,1.6,0).m), 1); // slab

world.reduce(); // compute boundary polys (CSG-ish)

/* ------------------------- Run (hidden-line) ------------------------- */

camera.look([0,0,8], [0,0,0], 3);

const cad = new Cad(world, camera, turtle);

// Extract BSP boundary polys
cad.collectPolys();

// Add a room as occluder + drawable edges
const room = new RoomHL(10, 5, 10);
cad.addOccluderPolys(room.polys);

// Build drawable edge list from boundary polys + room edges
cad.buildEdges();
cad.filterSplitEdges();
cad.purgeTJunctionEdges();
cad.addDrawableEdges(room.edges);

// Random rotation (rx, ry, rz in radians)
cad.setRotation(-0.25 * Math.PI + Math.random() * 0.5 * Math.PI, Math.random() * 2 * Math.PI, 0);

// Hidden-line render (true 3D: rays vs polygons)
cad.drawHiddenEdges();