Outliner utility

The Outliner utility takes a list of segments and draws an outline optimized for plotters.
The demo is slowed down for effect. Comment out the sleep() for instant drawing.
Here are a couple of demos:
- Pacman
- turtletoy.net/turtle/58e92841cd

Log in to post a comment.

// LL 2021

const precision = 30; // min=2 max=100 step=1
const thickness = 2; // min=1 max=3 step=1
const method = 0; // min=0, max=1, step=1 (Individual points,List of points)

// TODO:
// Performance: Spacial partitioning for line to segment intersection
// Experiment: Do hidden line ellimination at draw time instead of load time
// Quality: Only fill gaps for dangling points of adjacent segments


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

var outliner;

function walk(i, t) {
    if (i==0) {
        // 1. Create Outliner with optional parameters
        outliner = new Outliner(turtle, precision, thickness);

        if (method == 0) {
            // Option 1: add points and segments one at a time        

            // 2. Specify points as ([x, y], radius)
            const p1 = outliner.addPoint([ 0,  30], 3);
            const p2 = outliner.addPoint([ 0, -30], 3);
            const p3 = outliner.addPoint([-30,  0], 3);
            const p4 = outliner.addPoint([ 30,  0], 3);
            const p5 = outliner.addPoint([ 57,  0], 6);
            const p6 = outliner.addPoint([ 70,  0], 9);

            // 3. Link points into segments
            outliner.addSegment(p1, p2);
            outliner.addSegment(p3, p4);
        } else {
            // Option 2: add a group of points and segments as a list

            // 2. Specify a list of points as [ [x, y], radius ]
            outliner.addPoints([ [[ 0, 30], 3], [[ 30, 60], 6], [[-30, 60], 2] ]);

            // 3. Link points into segments
            outliner.addSegments([ [0, 1], [1, 2], [0, 2] ]);
        }

        // 4. Build and optimize
        outliner.build();
    }

    // 5. Draw a little bit of the outlines in a loop until there is no more to draw
    if (!outliner.drawNext()) return false;

    // Slow down for effect
    sleep(5);

    return true;
}

function sleep(milliseconds) { start=Date.now(); while (Date.now()-start<milliseconds); }


/////////////////////////////////////////////////////////////////
// Outliner utility code v03 - Created by Lionel Lemarie 2021
// https://turtletoy.net/turtle/a206296e80
/////////////////////////////////////////////////////////////////

function Outliner(tt_=turtle, precision_=20, thickness_=1, fast_=false) {
    const timers = {}
    const timer_list = [];

    function startTimer(name) {
        const now = performance.now();
        if (!(name in timers)) {
            timers[name] = [1, -now];
            timer_list.push(name);
        } else {
            timers[name][0]++;
            timers[name][1]-=now;
        }
    }
    
    function stopTimer(name) {
        const now = performance.now();
        if (name in timers) {
            timers[name][1]+=now;
        }
    }
    
    function printTimers() {
        console.log("");
        function numberWithCommas(x) { return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); }
        timer_list.forEach(name => {
            var elapsed = timers[name][1], count = timers[name][0], unit = "ms";
            if (elapsed > 1000) { unit = "sec"; elapsed /= 1000; if (elapsed > 60) { unit = "min"; elapsed /= 60; } }
            console.log(`Outliner: ${name} - calls: ${numberWithCommas(count)} - total: ${elapsed.toFixed(1)} ${unit}`);
        });
    }
    
    startTimer("Total time");
    const lines_to_draw = [];
    const figure_points = [];
    const figure_segments = [];
    
    const epsilon = 0.0001;

    function createPointOutline(point) {
        const step = 1 / precision_;
        for (var a=0; a<1; a+=step) {
            const x0 = point[0] + point[2] * Math.cos(a * Math.PI * 2);
            const y0 = point[1] + point[2] * Math.sin(a * Math.PI * 2);
            const x1 = point[0] + point[2] * Math.cos((a+step) * Math.PI * 2);
            const y1 = point[1] + point[2] * Math.sin((a+step) * Math.PI * 2);
            addLine([ [x0, y0], [x1, y1] ]);
        }
    }
    
    function rotX(x, y, a) { return Math.cos(a) * x - Math.sin(a) * y; }
    function rotY(x, y, a) { return Math.sin(a) * x + Math.cos(a) * y; }
    
    function createSegmentOutline(segment) {
        const l = Math.hypot(segment[1][0]-segment[0][0], segment[1][1]-segment[0][1]);
        if (l < epsilon) return [];
    
        var px11 = (segment[0][0] + segment[0][2] * rotX(segment[0][0] - segment[1][0], segment[0][1] - segment[1][1], -0.5 * Math.PI) / l);
        var py11 = (segment[0][1] + segment[0][2] * rotY(segment[0][0] - segment[1][0], segment[0][1] - segment[1][1], -0.5 * Math.PI) / l);
        var px12 = (segment[0][0] + segment[0][2] * rotX(segment[0][0] - segment[1][0], segment[0][1] - segment[1][1],  0.5 * Math.PI) / l);
        var py12 = (segment[0][1] + segment[0][2] * rotY(segment[0][0] - segment[1][0], segment[0][1] - segment[1][1],  0.5 * Math.PI) / l);
        var px21 = (segment[1][0] + segment[1][2] * rotX(segment[1][0] - segment[0][0], segment[1][1] - segment[0][1], -0.5 * Math.PI) / l);
        var py21 = (segment[1][1] + segment[1][2] * rotY(segment[1][0] - segment[0][0], segment[1][1] - segment[0][1], -0.5 * Math.PI) / l);
        var px22 = (segment[1][0] + segment[1][2] * rotX(segment[1][0] - segment[0][0], segment[1][1] - segment[0][1],  0.5 * Math.PI) / l);
        var py22 = (segment[1][1] + segment[1][2] * rotY(segment[1][0] - segment[0][0], segment[1][1] - segment[0][1],  0.5 * Math.PI) / l);
    
        addSubDivLine([[px11, py11], [px22, py22]]);
        addSubDivLine([[px12, py12], [px21, py21]]);
    }
    
    function addSubDivLine(line) {
        const step = 1 / precision_;
        for (t=0; t<(1-epsilon); t+=step) {
            const x1 = line[0][0] + (line[1][0]-line[0][0]) * t;
            const y1 = line[0][1] + (line[1][1]-line[0][1]) * t;
            const x2 = line[0][0] + (line[1][0]-line[0][0]) * (t+step);
            const y2 = line[0][1] + (line[1][1]-line[0][1]) * (t+step);
            addLine([[x1, y1], [x2, y2]]);
        }
    }
    
    const binSize = 1;
    const bins = {};
    
    function point_key(point) {
        return Math.floor(point[1]/binSize) * 100000 + Math.floor(point[0]/binSize);
    }
    
    function getBinListForPoint(point) {
        var bin_list = [];
        const key0 = point_key(point);
        if (key0 in bins) bin_list.push(bins[key0]);

        var found_good = false;
        bin_list.forEach(bin => bin.forEach(index => found_good |= !inBlacklist(index)));
        if (!found_good) {
            bin_list = [];
            const search_radius = 2;
            for (var y=-search_radius; y<=search_radius+0.1; y+=1) {
                for (var x=-search_radius; x<=search_radius+0.1; x+=1) {
                    if (x==0 && y==0) continue;
                    const key = point_key([point[0]+x*binSize, point[1]+y*binSize]);
                    if (key in bins) bin_list.push(bins[key]);
                }
            }
        }
        return bin_list;
    }

    function getBinListForLine(line) {
        const bin_list = [];
        const key0 = point_key(line[0]);
        if (key0 in bins) bin_list.push(bins[key0]);
        const key1 = point_key(line[1]);
        if (key1 in bins) bin_list.push(bins[key1]);
        return bin_list;
    }
    
    function addToBin(line, index) {
        for (var i=0; i<2; i++) {
            const key = point_key(line[i]);
            if (!(key in bins)) bins[key] = [];
            bins[key].push(index);
        }
    }
    
    function checkOverlap(line1, line2) {
        const distance00 = distance2(line1[0], line2[0]);
        const distance01 = distance2(line1[0], line2[1]);
        const distance10 = distance2(line1[1], line2[0]);
        const distance11 = distance2(line1[1], line2[1]);
        const value = ! ( ((distance00 > epsilon) || (distance11 > epsilon)) && ((distance01 > epsilon) || (distance10 > epsilon)) );
        return value;
    }
    
    function addLine(line) {
        startTimer("addLine");
        var ok_to_add = true;

        if (!fast_) {
            startTimer(`addLine - figure_points ${figure_points.length}`);
            for (var i=0; i<figure_points.length && ok_to_add; i++) {
                const dist0 = distance2(line[0], figure_points[i]) * (1+epsilon);
                const dist1 = distance2(line[1], figure_points[i]) * (1+epsilon);
                ok_to_add &= dist0 > (figure_points[i][2]**2);
                ok_to_add &= dist1 > (figure_points[i][2]**2);
            }
            stopTimer(`addLine - figure_points ${figure_points.length}`);
    
            startTimer(`addLine - figure_segments ${figure_segments.length}`);
            for (var i=0; i<figure_segments.length && ok_to_add; i++) {
                const segment = [0,1].map(si => figure_points[figure_segments[i][si]]);
                ok_to_add &= !pointInSegment(line[0], segment) && !pointInSegment(line[1], segment);
            }
            stopTimer(`addLine - figure_segments ${figure_segments.length}`);
    
            getBinListForLine(line).forEach(bin => {
                for (var i=0; i<bin.length && ok_to_add; i++) {
                    ok_to_add &= !checkOverlap(line, lines_to_draw[bin[i]]);
                }
            });
        }

        if (ok_to_add) {
            addToBin(line, lines_to_draw.length);
            lines_to_draw.push(line);
        }
        stopTimer("addLine");
    }
    
    function distance2(point1, point2) {
        const dist2 = (point1[0]-point2[0]) * (point1[0]-point2[0]) + (point1[1]-point2[1]) * (point1[1]-point2[1]);
        return dist2;
    }
    
    function distanceToSegmentFactor(point, segment) {
        var l2 = distance2(segment[0], segment[1]);
        if (l2 < epsilon) return 0;
        var t = ((point[0] - segment[0][0]) * (segment[1][0] - segment[0][0]) + (point[1] - segment[0][1]) * (segment[1][1] - segment[0][1])) / l2;
        t = Math.max(0, Math.min(1, t));
        return t;
    }
    
    function distance2ToSegmentWithFactor(point, segment, t) {
        const dist2 = distance2(point, [segment[0][0] + t * (segment[1][0] - segment[0][0]), segment[0][1] + t * (segment[1][1] - segment[0][1])]);
        return dist2;
    }
    
    function pointInSegment(point, segment) {
        const t = distanceToSegmentFactor(point, segment);
        const radius = segment[0][2] * (1-t) + segment[1][2] * t;
        const inside = distance2ToSegmentWithFactor(point, segment, t) < radius*radius*(1-epsilon);
        return inside;
    }
    
    function fillGaps() {
        if (fast_) return;
        const dangling_points = [];
        for (var i1=0; i1<lines_to_draw.length; i1++) {
            for (var j1=0; j1<2; j1++) {
                var found_close = false;
                getBinListForPoint(lines_to_draw[i1][j1]).forEach(bin => {
                    var closest_point = [];
                    var closest_distance = NaN;
                    for (var i2=0; i2<bin.length; i2++) {
                        if (checkOverlap(lines_to_draw[i1], lines_to_draw[bin[i2]])) continue;
                        for (var j2=0; j2<2; j2++) {
                            const dist2 = distance2(lines_to_draw[i1][j1], lines_to_draw[bin[i2]][j2]);
                            if (isNaN(closest_distance) || dist2 < closest_distance) {
                                closest_distance = dist2;
                                closest_point = lines_to_draw[bin[i2]][j2];
                            }
                        }
                    }
                    if (!isNaN(closest_distance) && closest_distance < epsilon) {
                        found_close = true;
                    }
                });
                if (!found_close) dangling_points.push(lines_to_draw[i1][j1]);
            }
        }

        const extra_lines = [];
        while (dangling_points.length > 1) {
            const point1 = dangling_points.shift();
            var closest_index = -1;
            var closest_distance = NaN;
            for (var i=0; i<dangling_points.length; i++) {
                const dist2 = distance2(dangling_points[i], point1);
                if (closest_index < 0 || dist2 < closest_distance) {
                    closest_distance = dist2;
                    closest_index = i;
                }
            }
            if (closest_index >= 0 && closest_distance < 10) {
                const point2 = dangling_points[closest_index];
                dangling_points.splice(closest_index, 1);
                extra_lines.push([point1, point2]);
            }
        }
    
        extra_lines.forEach(l => { addToBin(l, lines_to_draw.length); lines_to_draw.push(l); });
    }
    
    const blacklist = [];
    var blacklist_count = 0;
    
    function inBlacklist(index) {
        while (blacklist.length <= index) blacklist.push(false);
        const value = blacklist[index];
        return value;
    }
    
    function addToBlacklist(index) {
        while (blacklist.length <= index) blacklist.push(false);
        blacklist[index] = true;
        blacklist_count++;
    }
    
    function drawNextLine() {
        startTimer("drawNextLine");
        if (lines_to_draw.length == 0) return false;
        
        const position = tt_.position();
        var closest_distance = 1000000;
        var closest_line_index = -1;
        var closest_point_index = -1;

        if (!fast_) {
            startTimer("drawNextLine - bins");
            getBinListForPoint(position).forEach(bin => {
                for (var i=0; i<bin.length; i++) {
                    if (inBlacklist(bin[i])) continue;
                    for (var j=0; j<2; j++) {
                        const dist2 = distance2(position, lines_to_draw[bin[i]][j]);
                        if (closest_line_index < 0 || dist2 < closest_distance) {
                            closest_distance = dist2;
                            closest_line_index = bin[i];
                            closest_point_index = j;
                        }
                    }
                }
            });
            stopTimer("drawNextLine - bins");
            
            if (closest_line_index < 0) {
                startTimer("drawNextLine - all");
                for (var i=0; i<lines_to_draw.length && closest_distance > 1; i++) {
                    if (inBlacklist(i)) continue;
                    for (var j=0; j<2; j++) {
                        const dist2 = distance2(position, lines_to_draw[i][j]);
                        if (closest_line_index < 0 || dist2 < closest_distance) {
                            closest_distance = dist2;
                            closest_line_index = i;
                            closest_point_index = j;
                        }
                    }
                }
                stopTimer("drawNextLine - all");
            }
        }
        
        if (closest_line_index < 0) {
            startTimer("drawNextLine - last restort");
            closest_line_index = 0;
            closest_point_index = 0;
            closest_distance = 1;
            while (inBlacklist(closest_line_index)) closest_line_index++;
            stopTimer("drawNextLine - last restort");
        }

        if (closest_line_index < lines_to_draw.length && closest_point_index < lines_to_draw[closest_line_index].length) {
            if (closest_distance > 0.01) {
                tt_.jump(lines_to_draw[closest_line_index][closest_point_index]);    
            }
            tt_.down();
            tt_.goto(lines_to_draw[closest_line_index][1-closest_point_index]);
        }

        if (fast_) lines_to_draw.splice(closest_line_index, 1);
        else addToBlacklist(closest_line_index);

        stopTimer("drawNextLine");
        const more_to_do = lines_to_draw.length - blacklist_count > 0;
        
        if (!more_to_do) {
            stopTimer("Total time");
            printTimers();
        }
        return more_to_do;
    }
    
    function applyThickness() {
        const original_count = lines_to_draw.length;
        const offset = 0.2;
        for (var ty=0; ty<thickness; ty++) {
            for (var tx=0; tx<thickness; tx++) {
                if (tx==0 && ty==0) continue;
                for (var i=0; i<original_count; i++) {
                    const line = lines_to_draw[i].map(p => [p[0]-tx*offset, p[1]-ty*offset, p[2]]);
                    addToBin(line, lines_to_draw.length);
                    lines_to_draw.push(line);
                }
            }
        }
    }
    
    function buildOutline() {
        startTimer("buildOutline");
        for (var i=0; i<figure_points.length; i++) {
            createPointOutline(figure_points[i]);
        }
    
        for (var i=0; i<figure_segments.length; i++) {
            const segment = [0,1].map(si => figure_points[figure_segments[i][si]]);
            createSegmentOutline(segment);
        }
        
        fillGaps();
        
        applyThickness();
        stopTimer("buildOutline");
    }

    function newPoint(point, radius) {
        figure_points.push([point[0], point[1], radius]);
        return figure_points.length - 1;
    }
    
    function newSegment(index1, index2) {
        figure_segments.push([index1, index2]);
        return figure_segments.length - 1;
    }
    
    return {
        addPoint: (point, radius) => newPoint(point, radius),
        addSegment: (index1, index2) => newSegment(index1, index2),
        addPoints: (point_list) => point_list.forEach(p => newPoint(p[0], p[1])),
        addSegments: (segment_list) => segment_list.forEach(s => newSegment(s[0], s[1])),
        build: () => buildOutline(),
        drawNext: () => drawNextLine(),
        unsortedLines: lines_to_draw
    };
}