Fork: Initial contact II

I woke up inside a geometry I didn’t remember drawing,
yet it recognized me as if I had lived there forever...

Log in to post a comment.

// Forked from "Initial contact" by ge1doot
// https://turtletoy.net/turtle/97d9047c16
// ----------------------------------------------------
// Idea:
//   1. Build all boxes in full 3D (axis-aligned)
//   2. Project into camera space (true pinhole camera)
//   3. Depth-sort boxes from near to far
//   4. For each visible face, create a 2D polygon
//   5. Use a 2D polygon boolean engine (Reinder-style) to clip
//      already drawn polygons -> hidden line removal in screen space
//   6. Add hatching on selected faces
//
//

Canvas.setpenopacity(0.8);
const turtle = new Turtle();
turtle.penup();

// Basic 3D vector helpers

function vec3(x, y, z) { return { x:x, y:y, z:z }; }

function add(a, b) { return vec3(a.x + b.x, a.y + b.y, a.z + b.z); }
function sub(a, b) { return vec3(a.x - b.x, a.y - b.y, a.z - b.z); }
function mul(a, s) { return vec3(a.x * s, a.y * s, a.z * s); }

function dot(a, b) { return a.x*b.x + a.y*b.y + a.z*b.z; }

function cross(a, b) {
    return vec3(
        a.y*b.z - a.z*b.y,
        a.z*b.x - a.x*b.z,
        a.x*b.y - a.y*b.x
    );
}

function length(a) { return Math.hypot(a.x, a.y, a.z); }

function normalize(a) {
    const l = length(a) || 1;
    return vec3(a.x / l, a.y / l, a.z / l);
}

// Camera (pinhole with optional roll + fisheye)

const camera = {
    // Camera position and orientation in world space
    eye:    vec3(0, 80, -260),
    target: vec3(0,  0,   40),
    up:     vec3(0,  1,    0),

    // Field of view (radians) – big value = wide angle
    fov: 155 * Math.PI / 180,

    // Roll around viewing direction (radians)
    roll: (-8 * Math.random() * 16) * Math.PI / 180,   // small tilt to break symmetry

    // Simple fisheye coefficient (0 = no fisheye)
    fisheyeK: 0,

    // Internal precomputed values
    setup() {
        // Forward vector: where the camera looks
        const f = normalize(sub(this.target, this.eye));

        // Right and up vectors (orthonormal basis)
        let r = normalize(cross(f, this.up));
        let u = cross(r, f);

        // Apply roll around the forward axis (f)
        if (this.roll !== 0) {
            const c = Math.cos(this.roll);
            const s = Math.sin(this.roll);
            // Rotate r,u in their 2D plane
            const r2 = vec3(
                r.x * c + u.x * s,
                r.y * c + u.y * s,
                r.z * c + u.z * s
            );
            const u2 = vec3(
                u.x * c - r.x * s,
                u.y * c - r.y * s,
                u.z * c - r.z * s
            );
            r = r2; u = u2;
        }

        // Store camera basis
        this.f = f; // forward
        this.r = r; // right
        this.u = u; // up

        // Precompute tan(fov/2) and screen scaling factor
        this.tanHalfFov = Math.tan(this.fov * 0.5);
        this.screenScale = 230; // how big the world appears on canvas
    },

    // Project a world point into 2D screen coordinates.
    // Returns {x,y,z} or null when behind the camera.
    project(p) {
        // Vector from eye to point
        const d = sub(p, this.eye);

        // Camera-space coordinates (x,y along right/up, z along forward)
        const x = dot(d, this.r);
        const y = dot(d, this.u);
        const z = dot(d, this.f);

        // Clip points that are too close / behind the camera
        if (z <= 1e-2) return null;

        // Perspective divide
        let xn = x / (z * this.tanHalfFov);
        let yn = -y / (z * this.tanHalfFov);

        // Simple radial fisheye distortion
        if (this.fisheyeK !== 0) {
            const r2 = xn*xn + yn*yn;
            const factor = 1 + this.fisheyeK * r2;
            xn *= factor;
            yn *= factor;
        }

        return {
            x: xn * this.screenScale,
            y: yn * this.screenScale,
            z: z
        };
    }
};

camera.setup();

// Box primitive (axis-aligned) + face list

class Box {
    constructor(cx, cy, cz, sx, sy, sz) {
        this.c = vec3(cx, cy, cz);
        this.s = vec3(sx, sy, sz);
        this._verts = null;
        this.depth = 0;   // filled later with min camera-space z
    }

    // Lazy computation of the 8 corner vertices
    vertices() {
        if (this._verts) return this._verts;
        const c = this.c, s = this.s;
        this._verts = [
            vec3(c.x-s.x, c.y-s.y, c.z-s.z), // 0
            vec3(c.x+s.x, c.y-s.y, c.z-s.z), // 1
            vec3(c.x+s.x, c.y+s.y, c.z-s.z), // 2
            vec3(c.x-s.x, c.y+s.y, c.z-s.z), // 3
            vec3(c.x-s.x, c.y-s.y, c.z+s.z), // 4
            vec3(c.x+s.x, c.y-s.y, c.z+s.z), // 5
            vec3(c.x+s.x, c.y+s.y, c.z+s.z), // 6
            vec3(c.x-s.x, c.y+s.y, c.z+s.z), // 7
        ];
        return this._verts;
    }
}

// Each face: indices in the vertex array + "hatch" flag

const BOX_FACES = [
    { idx:[0,1,2,3], hatch:1 }, // front
    { idx:[0,4,5,1], hatch:1 }, // bottom / side
    { idx:[3,2,6,7], hatch:0 }, // top
    { idx:[0,3,7,4], hatch:0 }, // left
    { idx:[1,5,6,2], hatch:1 }, // right
    { idx:[4,7,6,5], hatch:0 }  // back
];

// 2D Polygon engine with occlusion + hatching
// (optimized but same idea as Reinder's original)

function Polygons() {
    // List of polygons already drawn on screen
    const polygonList = [];
    // Hash of lines already drawn (avoid double-drawing in overlapping areas)
    const linesDrawn  = Object.create(null);

    class Polygon {
        constructor() {
            this.cp = [];   // "clip path": polygon vertices [ [x,y], ... ]
            this.dp = [];   // segments to draw: [p0, p1, p0, p1, ...]
            this.aabb = null;
        }

        // Add all points at once and compute AABB
        addPoints(points) {
            const cp = this.cp;
            for (let i = 0; i < points.length; i++) cp.push(points[i]);
            this.aabb = this.computeAABB();
        }

        // Add outline segments from cp into dp
        addOutline(startIndex) {
            const cp = this.cp;
            const n = cp.length;
            for (let i = startIndex || 0; i < n; i++) {
                this.dp.push(cp[i], cp[(i + 1) % n]);
            }
        }

        // Compute axis-aligned bounding box [cx, cy, halfW, halfH]
        computeAABB() {
            const cp = this.cp;
            let xmin =  1e9, ymin =  1e9;
            let xmax = -1e9, ymax = -1e9;
            for (let i = 0; i < cp.length; i++) {
                const x = cp[i][0];
                const y = cp[i][1];
                if (x < xmin) xmin = x;
                if (x > xmax) xmax = x;
                if (y < ymin) ymin = y;
                if (y > ymax) ymax = y;
            }
            const area = (xmax - xmin) * (ymax - ymin);
            this.area = area;
            return [
                (xmin + xmax) * 0.5,
                (ymin + ymax) * 0.5,
                (xmax - xmin) * 0.5,
                (ymax - ymin) * 0.5
            ];
        }

        // Actually draw all segments in dp (with line de-duplication)
        draw(t) {
            const dp = this.dp;
            for (let i = 0; i < dp.length; i += 2) {
                const d0 = dp[i];
                const d1 = dp[i + 1];
                const x0 = d0[0], y0 = d0[1];
                const x1 = d1[0], y1 = d1[1];

                // Stable hash for undirected segment
                const hx0 = x0 < x1 ? x0 : x1;
                const hx1 = x0 < x1 ? x1 : x0;
                const hy0 = y0 < y1 ? y0 : y1;
                const hy1 = y0 < y1 ? y1 : y0;
                const line_hash =
                    hx0.toFixed(2) + "-" +
                    hx1.toFixed(2) + "-" +
                    hy0.toFixed(2) + "-" +
                    hy1.toFixed(2);

                if (!linesDrawn[line_hash]) {
                    t.penup();
                    t.goto(x0, y0);
                    t.pendown();
                    t.goto(x1, y1);
                    linesDrawn[line_hash] = true;
                }
            }
        }

        // Add hatching inside this polygon
        // "angle" is direction of the dominant edge,
        // "spacing" is distance between hatch lines
        addHatching(angle, spacing) {
            const MIN_SPACING = 0.1;
            const MIN_AREA = 20;
            spacing = Math.max(spacing, MIN_SPACING);
            if (this.area < MIN_AREA) return;  // no hash on micro-faces
            angle += Math.PI * 0.5;  // make lines perpendicular to given edge
            const tp = new Polygon();

            // Simple rectangular area covering our polygon
            const [cx, cy, hw, hh] = this.aabb;
            const l = Math.sqrt((hw*2)**2 + (hh*2)**2) * 0.5;

            tp.cp.push(
                [cx - hw, cy - hh],
                [cx + hw, cy - hh],
                [cx + hw, cy + hh],
                [cx - hw, cy + hh]
            );
            tp.aabb = tp.computeAABB();

            const cx2 = Math.sin(angle) * l;
            const cy2 = Math.cos(angle) * l;

            let px = cx - Math.cos(angle) * l;
            let py = cy - Math.sin(angle) * l;

            // Create a bunch of parallel lines that cover the AABB
            for (let d = 0; d < l * 2; d += spacing) {
                tp.dp.push(
                    [px + cx2, py - cy2],
                    [px - cx2, py + cy2]
                );
                px += Math.cos(angle) * spacing;
                py += Math.sin(angle) * spacing;
            }

            // Clip those lines by our polygon (difference=false -> intersection)
            tp.boolean(this, false);
            const dp = tp.dp;
            for (let i = 0; i < dp.length; i++) {
                this.dp.push(dp[i]);
            }
        }

        // Point-in-polygon test using ray casting
        inside(p) {
            const cp = this.cp;
            const n = cp.length;
            let int = 0;
            const far = [0.1, -10000];

            for (let i = 0; i < n; i++) {
                const a = cp[i];
                const b = cp[(i + 1) % n];

                // Avoid points extremely close to vertices (numerical issues)
                const dx = p[0] - a[0];
                const dy = p[1] - a[1];
                if (dx*dx + dy*dy <= 0.001) return false;

                if (this.segIntersect(p, far, a, b)) int++;
            }
            return (int & 1) !== 0;
        }

        // Clip this.dp by polygon p
        boolean(p, diff) {
            const src = this.dp;
            const ndp = [];
            const clipCP = p.cp;
            const cl = clipCP.length;

            for (let i = 0; i < src.length; i += 2) {
                const ls0 = src[i];
                const ls1 = src[i + 1];

                // Collect all intersection points with the clip polygon
                const ints = [];
                for (let j = 0; j < cl; j++) {
                    const a = clipCP[j];
                    const b = clipCP[(j + 1) % cl];
                    const pint = this.segIntersectionPoint(ls0, ls1, a, b);
                    if (pint) ints.push(pint);
                }

                if (ints.length === 0) {
                    // No intersection: test if the whole segment is inside or outside
                    if (diff === !p.inside(ls0)) {
                        ndp.push(ls0, ls1);
                    }
                } else {
                    // There are intersections: split the segment
                    ints.push(ls0, ls1);
                    const x0 = ls0[0], y0 = ls0[1];
                    const vx = ls1[0] - x0;
                    const vy = ls1[1] - y0;

                    // Sort points along the segment
                    ints.sort((pA, pB) => {
                        const da = (pA[0]-x0)*vx + (pA[1]-y0)*vy;
                        const db = (pB[0]-x0)*vx + (pB[1]-y0)*vy;
                        return da - db;
                    });

                    // Create sub-segments between consecutive intersection points
                    for (let j = 0; j < ints.length - 1; j++) {
                        const pA = ints[j];
                        const pB = ints[j+1];

                        const dx = pA[0]-pB[0];
                        const dy = pA[1]-pB[1];
                        if (dx*dx + dy*dy < 0.01) continue; // too small

                        const mid = [(pA[0]+pB[0])*0.5, (pA[1]+pB[1])*0.5];
                        if (diff === !p.inside(mid)) {
                            ndp.push(pA, pB);
                        }
                    }
                }
            }

            this.dp = ndp;
            return ndp.length > 0;
        }

        // 2D segment helpers
        segIntersectionPoint(p1, p2, p3, p4) {
            const x1=p1[0], y1=p1[1], x2=p2[0], y2=p2[1];
            const x3=p3[0], y3=p3[1], x4=p4[0], y4=p4[1];

            const d = (y4 - y3)*(x2 - x1) - (x4 - x3)*(y2 - y1);
            if (d === 0) return null;

            const ua = ((x4 - x3)*(y1 - y3) - (y4 - y3)*(x1 - x3)) / d;
            const ub = ((x2 - x1)*(y1 - y3) - (y2 - y1)*(x1 - x3)) / d;

            if (ua >= 0 && ua <= 1 && ub >= 0 && ub <= 1) {
                return [
                    x1 + ua * (x2 - x1),
                    y1 + ua * (y2 - y1)
                ];
            }
            return null;
        }

        segIntersect(p1, p2, p3, p4) {
            return this.segIntersectionPoint(p1, p2, p3, p4) !== null;
        }
    }

    // Simple AABB overlap test
    function aabbOverlap(a, b) {
        return (
            Math.abs(a[0] - b[0]) - (a[2] + b[2]) < 0 &&
            Math.abs(a[1] - b[1]) - (a[3] + b[3]) < 0
        );
    }

    return {
        create() { return new Polygon(); },
        draw(t, poly) {
            const aabb = poly.aabb;
            const list = polygonList;
            let visible = true;

            // Test against all polygons drawn so far
            for (let i = 0; i < list.length; i++) {
                const p1 = list[i];
                if (!aabbOverlap(aabb, p1.aabb)) continue;
                if (!poly.boolean(p1, true)) {
                    visible = false;
                    break;
                }
            }
            if (!visible) return;
            poly.draw(t);
            list.push(poly);
        }
    };
}

// Scene generation – "Initial Contact"

const polygons = Polygons();
const shapes   = [];

// Simple utility
function randRange(a, b) { return a + Math.random() * (b - a); }

// Add a building defined by base rectangle and height
function addBuilding(x, y, z, w, h, p) {
    // z is the "front" of the building, p is its depth
    const cx = x;
    const cy = y;
    const cz = z + p * 0.5;
    shapes.push(new Box(cx, cy, cz, w, h, p * 0.5));
}

// Build a mix of small, medium and very large blocks

const r = (d0, d1) => Math.round(Math.random() * (d1 - d0) + d0);
let w = 100;
for (let z = 400; z > -400; z -= 10) {
	addBuilding(
		r(-w, w), r(-w, w), z,
		r(2, 30), r(2, 30), r(2, 180)
	);
	if (z > -150 && z < 150) {
		for (let i = 0; i < 4; i++) {
			addBuilding(
				r(-400, 400), r(-w, w), z,
				r(2, 80), r(2, 30), r(2, 30)
			);
            addBuilding(
				r(-w, w), r(-400, 400), z,
				r(2, 30), r(2, 80), r(2, 30)
			);
		}
	}
}

// Depth sort in camera space (near to far)

// Depth = minimum camera-space z among the 8 vertices
for (const b of shapes) {
    const verts = b.vertices();
    let zmin = 1e9;
    for (let i = 0; i < verts.length; i++) {
        const d = sub(verts[i], camera.eye);
        const z = dot(d, camera.f);
        if (z < zmin) zmin = z;
    }
    b.depth = zmin;
}

// Sort so that closest boxes are drawn first
shapes.sort((a, b) => a.depth - b.depth);

// Project box faces -> build polygons -> occlusion + hatching

function drawBoxFaces(box) {
    const verts = box.vertices();

    for (let f = 0; f < BOX_FACES.length; f++) {
        const face = BOX_FACES[f];
        const ids  = face.idx;

        const pts2D = [];
        const proj3 = [];

        let skip = false;
        for (let i = 0; i < ids.length; i++) {
            const proj = camera.project(verts[ids[i]]);
            if (!proj) { skip = true; break; }
            proj3.push(proj);
            pts2D.push([proj.x, proj.y]);
        }
        if (skip) continue;

        // Backface culling using signed area in screen space
        // Depending on winding order you may need ">= 0" instead
        const p0 = proj3[0], p1 = proj3[1], p2 = proj3[2];
        const area = (p1.x - p0.x)*(p2.y - p0.y) - (p1.y - p0.y)*(p2.x - p0.x);
        if (area <= 0) continue;

        const poly = polygons.create();
        poly.addPoints(pts2D);

        // Optional hatching for some faces
        if (face.hatch) {

            // Hatch direction aligned with first edge
            const dx = p1.x - p0.x;
            const dy = p1.y - p0.y;
            const a  = Math.atan2(dy, dx);
            poly.addHatching(a, 0.1 + Math.random());
        }

        poly.addOutline(0);
        polygons.draw(turtle, poly);
    }
}

// Draw all boxes
for (const b of shapes) {
    drawBoxFaces(b);
}

// Turtletoy "walk" function, not used here
function walk(i) {
    return false;
}