/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */

"use strict";

// dep: geo.base
// dep: geo.slicer
// dep: geo.polygons
// dep: kiri.utils
// dep: kiri.consts
// dep: kiri-mode.fdm.driver
// dep: kiri-mode.fdm.post
// use: kiri-mode.fdm.fill
// use: ext.clip2
// use: add.three
gapp.register("kiri-mode.fdm.slice", [], (root, exports) => {

const { base, kiri, noop } = root;
const { consts, driver, fill, fill_fixed, newSlice, utils } = kiri;
const { config, polygons, util, newPoint } = base;
const { fillArea } = polygons;
const { FDM } = driver;
const { doTopShells, getRangeParameters } = FDM;

const POLY = polygons,
    tracker = util.pwait,
    lopacity = 0.6,
    opacity = 1,
    fat = 1.5,
    COLOR = {
        anchor: { check: 0x999933, face: 0x999933, line: 0x999933, opacity, lopacity, fat },
        shell: { check: 0x0077bb, face: 0x0077bb, line: 0x0077bb, opacity, lopacity, fat },
        fill: { check: 0x00bb77, face: 0x00bb77, line: 0x00bb77, opacity, lopacity, fat },
        infill: { check: 0x3322bb, face: 0x3322bb, line: 0x3322bb, opacity, lopacity, fat },
        support: { check: 0xaa5533, face: 0xaa5533, line: 0xaa5533, opacity, lopacity, fat },
        gaps: { check: 0xaa3366, face: 0xaa3366, line: 0xaa3366, opacity, lopacity, fat }
    },
    PROTO = Object.clone(COLOR),
    profile = false,
    profileStart = profile ? console.profile : noop,
    profileEnd = profile ? console.profileEnd : noop,
    debug = false;

let isThin = false, // force line rendering
    isFlat = false, // force flat rendering
    offset = 0;     // poly line generation offsets

function vopt(opt) {
    if (opt) {
        if (isFlat) {
            opt.flat = true;
            opt.outline = true;
            return opt;
        }
        if (isThin) return null;
    }
    return opt;
}

FDM.sliceAll = function(settings, onupdate) {
    // future home of brim and anchor generation
    let widgets = Object.values(kiri.worker.cache)
        .filter(w => !w.meta.disabled)
        .sort((a,b) => {
            return a.slices[0].z - b.slices[0].z
        });
    // count extruders used
    let ext = [];
    for (let w of widgets) {
        if (w.anno && w.anno.extruder >= 0) {
            let e = w.anno.extruder;
            if (ext.indexOf(e) < 0) {
                ext.push(e);
            }
        }
    }
    // sort widgets by first slice Z
    widgets.sort((a,b) => {
        return a.slices[0].z - b.slices[0].z;
    });
    // give first widget a pass since it should have the anchor
    widgets.shift();
    // remove anchor slices from other widgets (only with multi-material)
    if (ext.length > 1) {
        for (let w of widgets) {
            w.slices = w.slices.filter(s => s.index >= 0);
        }
    }
};

/**
 * DRIVER SLICE CONTRACT
 *
 * Given a widget and settings object, call functions necessary to produce
 * slices and then the computations using those slices. This function is
 * designed to run client or server-side and provides all output via
 * callback functions.
 *
 * @param {Object} settings
 * @param {Widget} Widget
 * @param {Function} onupdate (called with % complete and optional message)
 * @param {Function} ondone (called when complete with an array of Slice objects)
 */
FDM.slice = function(settings, widget, onupdate, ondone) {
    let render = settings.render !== false,
        { process, device, controller } = settings,
        isBelt = device.bedBelt,
        isSynth = widget.track.synth,
        isSupport = widget.track.support,
        useAssembly = controller.assembly,
        isConcurrent = controller.threaded && kiri.minions.concurrent,
        topLayers = process.sliceTopLayers || 0,
        bottomLayers = process.sliceBottomLayers || 0,
        vaseMode = process.sliceFillType === 'vase' && !isSynth,
        metadata = widget.anno,
        maxtruder = Math.max(0, device.extruders.length - 1),
        extruder = Math.min(maxtruder, parseInt(isSynth ? process.sliceSupportNozzle : metadata.extruder || 0)),
        sliceHeight = process.sliceHeight,
        sliceHeightBase = (isBelt ? sliceHeight : process.firstSliceHeight) || sliceHeight,
        lineWidth = process.sliceLineWidth || device.extruders[extruder].extNozzle,
        fillOffsetMult = 1.0 - bound(process.sliceFillOverlap, 0, 0.8),
        shellOffset = lineWidth,
        fillSpacing = lineWidth,
        fillOffset = lineWidth * fillOffsetMult,
        clipOffset = process.sliceSupportOffset,
        sliceFillAngle = process.sliceFillAngle,
        supportDensity = process.sliceSupportDensity;

    // override globals used by vopt()
    isFlat = controller.lineType === "flat";
    isThin = !isFlat && controller.lineType === "line";
    offset = lineWidth / 2;

    // allow overriding support fill auto angle algorithm
    // also causes support fill to be aligned on start boundaries
    // best with angles that are a multiple of 90 degrees
    if (process.sliceSupportFill >= 0) {
        // yes, an ugly hack to allow it to pass through old code paths
        process.sliceSupportFill += 1000;
    }

    if (isFlat) {
        Object.values(COLOR).forEach(color => {
            color.flat = true;
            color.line = 1
            color.opacity = 1;
        });
    } else {
        Object.keys(COLOR).forEach(key => {
            const color = COLOR[key];
            const proto = PROTO[key]
            color.flat = proto.flat;
            color.line = proto.line;
            color.opacity = proto.opacity;
        });
    }

    if (!(sliceHeight > 0 && sliceHeight < 100)) {
        return ondone("invalid slice height");
    }
    if (!(lineWidth >= 0.01 && lineWidth < 100)) {
        return ondone("invalid nozzle size");
    }

    const sliceMinHeight = process.sliceAdaptive && process.sliceMinHeight > 0 ?
        Math.min(process.sliceMinHeight, sliceHeight) : 0;

    if (sliceHeightBase <= 0) {
        console.log("invalid first layer height < slice height");
        console.log("reverting to min valid slice height");
        sliceHeightBase = sliceMinHeight || sliceHeight;
    }

    let bounds = widget.getBoundingBox();
    let points = widget.getPoints();
    let indices = [];
    let heights = [];
    let healed = false;

    // handle z cutting (floor method) and base flattening
    let zPress = isBelt ? process.firstLayerFlatten || 0 : 0;
    let zCut = widget.track.zcut || 0;
    let { belt } = widget;
    if (zCut || zPress) {
        for (let p of points) {
            if (!p._z) {
                p._z = p.z;
                if (zPress) {
                    if (isBelt) {
                        let zd = (belt.slope * p.z) - p.y;
                        if (zd > 0 && zd <= zPress) {
                            p.y += zd * belt.cosf;
                            p.z -= zd * belt.sinf;
                        }
                    } else {
                        if (p.z <= zPress) p.z = 0;
                    }
                }
                if (zCut && !isBelt) {
                    p.z -= zCut;
                }
            }
        }
    }

    base.slice(points, {
        debug: process.xray,
        xray: process.xray,
        zMin: bounds.min.z,
        zMax: bounds.max.z - zCut,
        // support/synth usually has overlapping boxes
        union: controller.healMesh || isSynth,
        indices: process.indices || process.xray,
        useAssembly,
        post: 'FDM',
        post_args: {
            shellOffset,
            fillOffset,
            clipOffset,
            lineWidth,
            vaseMode,
            isSynth,
            process,
        },
        // z index generator
        zGen(zopt) {
            if (process.xray) {
                return zopt.zIndexes;
            }
            let { zMin, zMax } = zopt;
            let h1 = sliceHeight;
            let h0 = sliceHeightBase || h1;
            let hm = sliceMinHeight || 0;
            let h = h0;
            let z = h0;
            let zi = indices; // indices
            let zh = heights; // heights
            if (hm) {
                // adaptive increments based on z indices (var map to legacy code)
                let zIncFirst = h0;
                let zInc = h1;
                let zIncMin = hm;
                let zHeights = heights;
                let zIndexes = indices;
                let zOrdered = Object.values(zopt.zIndexes).map(v => parseFloat(v));
                // console.log('adaptive slicing', zIncMin, ':', zInc, 'from', zMin, 'to', zMax);
                let zPos = zIncFirst,
                    zOI = 0,
                    zDelta,
                    zDivMin,
                    zDivMax,
                    zStep,
                    nextZ,
                    lzp = zPos;
                // adaptive slice height
                // first slice/height is fixed from base
                zHeights.push(zIncFirst);
                zIndexes.push(zIncFirst);
                // console.log({zIncFirst, zOrdered})
                while (zPos < zMax && zOI < zOrdered.length) {
                    nextZ = zOrdered[zOI++];
                    if (zPos >= nextZ) {
                        // console.log('skip',{zPos},'>=',{nextZ});
                        continue;
                    }
                    zDelta = nextZ - zPos;
                    if (zDelta < zIncMin) {
                        // console.log('skip',{zDelta},'<',{zIncMin});
                        continue;
                    }
                    zDivMin = Math.floor(zDelta / zIncMin);
                    zDivMax = Math.floor(zDelta / zInc);
                    if (zDivMax && zDivMax <= zDivMin) {
                        if (zDelta % zInc > 0.01) zDivMax++;
                        zStep = zDelta / zDivMax;
                        // console.log(`--- zDivMax <= zDivMin ---`, zStep, zDelta % zInc)
                    } else {
                        zStep = zDelta;
                    }
                    // console.log({nextZ, zPos, zDelta, zStep, zDivMin, zDivMax})
                    while (zPos < nextZ) {
                        zHeights.push(zStep);
                        zIndexes.push(zPos + zStep);
                        zPos += zStep;
                        // console.log({D: zPos - lzp, zPos})
                        // lzp = zPos;
                    }
                }
                // console.log({zIndexes, zHeights});
            } else {
                // simple based + fixed increment
                while (true) {
                    // reduce slice position by half layer height
                    let realz = (z - (h / 2)).round(3);
                    if (realz > zMax) {
                        break;
                    }
                    zh.push(h);
                    zi.push(realz);
                    h = h1;
                    z += h;
                }
            }
            return zi;
        },
        // slicer function (worker local or minion distributed)
        slicer(z, points, opts) {
            // opts.debug = opts.debug || isSynth;
            return (isConcurrent ? kiri.minions.sliceZ : base.sliceZ)(z, points, opts);
        },
        onupdate(update) {
            return onupdate(0.0 + update * 0.5)
        }
    }).then((output) => {
        // post process slices and re-incorporate missing meta-data
        return output.slices.map(data => {
            let { z, clip, lines, groups, changes } = data;
            if (!data.tops) return null;
            let slice = newSlice(z).addTops(data.tops);
            slice.index = indices.indexOf(z);
            slice.height = heights[slice.index];
            slice.clips = clip;
            // do not warn on merging supports
            if (changes && !isSynth) {
                healed = true;
                slice.changes = changes;
                if (self.debug) {
                    console.log('slice healed', slice.index, slice.z, changes);
                }
            }
            if (process.xray) {
                slice.index = process.xrayi.shift();
                slice.lines = lines;
                slice.groups = groups;
                slice.xray = process.xray;
            }
            return slice;
        }).filter(s => s);
    }).then(slices => {
        return onSliceDone(slices);
    }).then(ondone);

    // shadow used to clip supports in non-belt mode
    async function doShadow(slices) {
        if (widget.shadow) {
            return;
        }
        let root = widget.group[0];
        if (root.shadow) {
            widget.shadow = root.shadow;
            return;
        }
        // console.log({ doShadow: widget, slices });
        // create shadow for clipping supports
        let alltops = widget.group
            .filter(w => !w.track.synth) // no supports in shadow
            .map(w => w.slices).flat()
            .map(s => s.tops).flat().map(t => t.simple);
        let shadow = isConcurrent ?
            await kiri.minions.union(alltops, 0.1) :
            POLY.union(alltops, 0.1, true);
        // expand shadow when requested (support clipping)
        if (process.sliceSupportExtra) {
            shadow = POLY.offset(shadow, process.sliceSupportExtra);
        }
        widget.shadow = root.shadow = POLY.setZ(shadow, 0);
        // slices[0].output()
        //     .setLayer('shadow', { line: 0xff0000, check: 0xff0000 })
        //     .addPolys(shadow);
    }

    async function onSliceDone(slices) {
        // alert non-manifold parts
        if (healed) {
            onupdate(null, null, "part may not be manifold");
        }

        // remove all empty slices above part but leave below
        // for multi-part (multi-extruder) setups where the void is ok
        // also reverse because slicing occurs bottom-up
        let found = false;
        slices = slices.reverse().filter(slice => {
            if (slice.tops.length) {
                return found = true;
            } else {
                return found;
            }
        }).reverse();

        // connect slices into linked list for island/bridge projections
        for (let i=1; i<slices.length; i++) {
            slices[i-1].up = slices[i];
            slices[i].down = slices[i-1];
        }

        widget.slices = slices;

        if (!slices || slices.length === 0) {
            return;
        }

        // attach range params to each slice
        for (let slice of slices) {
            slice.params = getRangeParameters(process, slice.index);
        }

        // create shadow for non-belt supports
        if (!isBelt && (isSynth || (!isSynth && supportDensity && process.sliceSupportEnable))) {
            await doShadow(slices);
        }

        // for synth support widgets, clip/offset to other widgets in group
        if (isSynth) {
            for (let slice of slices) {
                let gap = sliceHeight * process.sliceSupportGap;
                // clip tops to other widgets in group
                let tops = slice.topPolys();
                for (let peer of widget.group) {
                    // skip self
                    if (peer === widget || !peer.slices) {
                        continue;
                    }
                    for (let pslice of peer.slices) {
                        if (Math.abs(Math.abs(pslice.z - slice.z) - gap) > 0.1) {
                            continue;
                        }
                        let clipto = pslice.clips;
                        if (pslice.supportOutline) {
                            // merge support outlines into slice clips which
                            // should only happen when automatic and manual
                            // supports are used together
                            clipto.appendAll(pslice.supportOutline);
                        }
                        let ntops = [];
                        POLY.subtract(tops, clipto, ntops, null, slice.z, 0);
                        tops = ntops;
                    }
                    // trim to group's shadow if not in belt mode
                    if (!isBelt) {
                        tops = POLY.setZ(POLY.trimTo(tops, widget.shadow), slice.z);
                    }
                }
                slice.tops = [];
                for (let t of tops) {
                    slice.addTop(t);
                }
                doShells(slice, 1, shellOffset / 2);
            }
        }

        // calculate % complete and call onupdate()
        function doupdate(index, from, to, msg) {
            trackupdate(index / slices.length, from, to, msg);
        }

        function trackupdate(pct, from, to, msg) {
            onupdate(0.5 + (from + (pct * (to - from))) * 0.5, msg);
        }

        // for each slice, performe a function and call doupdate()
        function forSlices(from, to, fn, msg) {
            slices.forEach(slice => {
                fn(slice);
                doupdate(slice.index, from, to, msg)
            });
        }

        // do not hint polygon fill longer than a max span length
        config.hint_len_max = util.sqr(process.sliceBridgeMax);

        // reset for solids, support projections
        // and other annotations
        slices.forEach(slice => {
            slice.widget = widget;
            slice.extruder = extruder;
            slice.solids = [];
        });

        // just the top/bottom special solid layers or range defined solid layers
        forSlices(0.15, 0.2, slice => {
            let range = slice.params;
            let spaceMult = slice.index === 0 ? process.firstLayerLineMult || 1 : 1;
            let isBottom = slice.index < bottomLayers;
            let isTop = topLayers && slice.index > slices.length - topLayers - 1;
            let isDense = range.sliceFillSparse > 0.995;
            let isSolid = (isBottom || ((isTop || isDense) && !vaseMode)) && !isSynth;
            let solidWidth = isSolid ? range.sliceFillWidth || 1 : 0;
            if (solidWidth) {
                let fillSpace = fillSpacing * spaceMult * solidWidth;
                doSolidLayerFill(slice, fillSpace, sliceFillAngle);
            }
            sliceFillAngle = (sliceFillAngle + 90.0) % 360;
        }, "solid layers");

        // add lead in anchor when specified in belt mode (but not for synths)
        if (isBelt && !isSynth) {
            let { cosf, slope } = widget.belt;
            // find adjusted zero point from slices
            let smin = Infinity;
            for (let slice of slices) {
                let miny = Infinity;
                for (let poly of slice.topPolys()) {
                    let y = poly.bounds.maxy;
                    let z = slice.z;
                    // at 45 degrees, 1mm in Z is 1mm in Y
                    let by = (slope * z) - y;
                    if (by < miny) miny = by;
                    if (by < smin) smin = by;
                }
                // mark slices with tops touching belt
                slice.belt = { miny, touch: miny.round(3) < sliceHeightBase };
            }
            // find max width of first 5 layers for brim additions
            let start;
            let minx = Infinity, maxx = -Infinity;
            let peek = 0;
            for (let slice of slices) {
                if (slice.tops.length && peek++ < 5) {
                    for (let poly of slice.topPolys()) {
                        minx = Math.min(minx, poly.bounds.minx);
                        maxx = Math.max(maxx, poly.bounds.maxx);
                    }
                }
                // find first slice touching belt for start of anchor
                if (!start && slice.belt.touch) {
                    start = slice;
                }
            }
            // ensure we start against a layer with shells
            while (start && start.up && start.topShells().length === 0) {
                start = start.up;
            }
            // if a brim applies, add that width to anchor
            let brim = getRangeParameters(process, 0).firstLayerBrim || 0;
            if (brim) {
                minx -= brim;
                maxx += brim;
            }
            // array of added top.fill_sparse arrays
            let adds = [];
            let step = sliceHeight;
            let anchorlen = (process.beltAnchor || process.firstLayerBeltLead) * cosf;
            while (anchorlen && start && anchorlen >= sliceHeight) {
                let addto = start.down;
                if (!addto) {
                    addto = newSlice(start.z - step);
                    addto.extruder = extruder;
                    addto.belt = { };
                    addto.height = start.height;
                    addto.up = start;
                    start.down = addto;
                    slices.splice(0,0,addto);
                } else if (!addto.belt) {
                    console.log({addto_missing_belt: addto});
                    addto.belt = {};
                }
                addto.index = -1;
                addto.belt.anchor = true;
                // this allows the anchor to print bi-directionally
                // by removing the forced start-point in print.js
                addto.belt.touch = false;
                let z = addto.z;
                let y = (slope * z) - smin - (lineWidth / 2);
                let splat = base.newPolygon().add(minx, y, z).add(maxx, y, z).setOpen();
                let snew = addto.addTop(splat).fill_sparse = [ splat ];
                adds.push(snew);
                start = addto;
                anchorlen -= (step * slope);
            }
            // add anchor bump
            let bump = process.firstLayerBeltBump;
            if (bump) {
                adds = adds.reverse().slice(1, adds.length - 1);
                let count = 1;
                for (let add of adds) {
                    let poly = add[0];
                    let y = count++ * -start.height * 2;
                    if (-y > bump) {
                        count--;
                        // break;
                    }
                    let first = poly.first();
                    // add up/over/down to anchor line (close = down)
                    // which completes the bump perimeter
                    poly.push(poly.last().add({x:0, y, z:0}));
                    poly.push(poly.first().add({x:0, y, z:0}));
                    poly.setClosed();
                    if (count > 2 && maxx - minx > 10) {
                        // add vertical hatch lines insibe bump shell
                        let mp = (maxx + minx) / 2;
                        let dx = (maxx - minx - 2);
                        dx = (Math.floor(dx / 3) * 3) / 2;
                        let fy = first.y;
                        let fz = first.z;
                        let n2 = lineWidth / 2;
                        for (let x = mp - dx; x <= mp + dx ; x += 3) {
                            add.push( base.newPolygon().add(x, fy - n2, fz).add(x, fy + y + n2, fz).setOpen() );
                        }
                    }
                }
            }
            // experimental emboss text on a flat underside of an object
            // in belt mode only
            if (process.pooch && self.OffscreenCanvas) {
                const { length, width, height, text } = process.pooch;
                let firstZ, firstI, lastZ, lastI, minX = 0, maxX = 0, maxY = 0;
                // locate suitable flat spot
                for (let slice of slices) {
                    let { belt } = slice;
                    if (!belt.touch) {
                        continue;
                    }
                    let index = slice.index;
                    let z = slice.z;
                    outer: for (let poly of slice.topPolys()) {
                        for (let i=0, p=poly.points, l=p.length; i<l; i++) {
                            let p0 = p[i];
                            let p1 = p[(i + 1) % l];
                            let p0y = z - p0.y;
                            let p1y = z - p1.y;
                            let i_ok = lastI ? index - lastI === 1 : true
                            let y_ok = p0y > 0 && p0y < 3 && Math.abs(p0y - p1y) < 0.01;
                            let x_ok = Math.abs(width - Math.abs(p1.x - p0.x)) < 1
                            if (y_ok && x_ok) {
                                if (i_ok) {
                                    firstZ = firstZ || z;
                                    firstI = firstI || index;
                                    lastZ = z;
                                    lastI = index;
                                    minX = Math.min(p0.x, p1.x);
                                    maxX = Math.max(p0.x, p1.x);
                                    maxY = p0y;
                                    break outer;
                                } else {
                                    firstZ = lastI = 0;
                                }
                            }
                        }
                    }
                }
                let dy = Math.abs(height - maxY * (1 / Math.sqrt(2)));
                let dz = Math.abs(length - ((lastZ - firstZ) * Math.sqrt(2)));
                if (dy < 0.1 && dz < 1) {
                    // console.log('FOUND', { firstI, lastI, minX, maxX, maxY });
                    let span = lastI - firstI - 2; // x = down the belt
                    let tall = width * 2;
                    let can = new self.OffscreenCanvas(span, tall);
                    let ctx = can.getContext("2d");
                    ctx.scale(1.2, 1);
                    ctx.font = '24px sans-serif';
                    // ctx.fillStyle = 'black';
                    ctx.textBaseline = "bottom";
                    ctx.fillText(text, 1, tall - 1);
                    let img = ctx.getImageData(0, 0, span, tall).data.buffer;
                    let rgb = new Uint32Array(img);
                    // console.log({ img, rgb, p: rgb.filter(v => v) });
                    for (let x=0; x<span; x++) {
                        let str = '';
                        let maxp = 0;
                        let lines = [];
                        let start, end;
                        for (let y=tall-1; y>=0; y--) {
                            let pix = rgb[y * span + x];
                            pix = (
                                ((pix >> 24) & 0xff) +
                                ((pix >> 16) & 0xff) +
                                ((pix >>  8) & 0xff)
                            ) / 3;
                            str += pix > 30 ? '*' : '-';
                            maxp = Math.max(maxp, pix);
                            if (pix > 30) {
                                if (start >= 0) {
                                    end = tall - y;
                                } else {
                                    start = tall - y;
                                }
                            } else {
                                if (start >= 0 && end > start) {
                                    lines.push({ start, end });
                                }
                                start = end = undefined;
                            }
                        }
                        console.log((x).toString().padStart(2,0),str,maxp | 0,lines);
                        if (lines.length) {
                            let slice = slices[firstI + x + 1];
                            let supps = slice.supports = slice.supports || [];
                            let z = slice.z;
                            let y = z - smin - (lineWidth / 2);
                            for (let line of lines) {
                                supps.push(base.newPolygon()
                                    .add(minX + line.start / 2, y, z)
                                    .add(minX + line.end / 2, y, z)
                                    .setOpen()
                                );
                            }
                        }
                    }
                }
            }
        }

        // calculations only relevant when solid layers are used
        // layer boolean diffs need to be computed to find flat areas to fill
        // and overhangs that need to be supported. these are stored in flats
        // and bridges, projected up/down, and merged into an array of solids
        if (!vaseMode && !isSynth) {
            profileStart("delta");
            forSlices(0.2, 0.34, slice => {
                let params = slice.params || process;
                let solidMinArea = params.sliceSolidMinArea;
                let sliceFillGrow = params.sliceFillGrow;
                doDiff(slice, { min: solidMinArea, grow: sliceFillGrow });
            }, "layer deltas");
            profileEnd();
            profileStart("delta-project");
            forSlices(0.34, 0.35, slice => {
                let params = slice.params || process;
                topLayers = params.sliceTopLayers || 0;
                bottomLayers = params.sliceBottomLayers || 0;
                if (topLayers) projectFlats(slice, topLayers);
                if (bottomLayers) projectBridges(slice, bottomLayers);
            }, "layer deltas");
            profileEnd();
            profileStart("solid-fill")
            let promises = isConcurrent ? [] : undefined;
            forSlices(0.35, promises ? 0.4 : 0.5, slice => {
                let params = slice.params || process;
                let first = slice.index === 0;
                let solidWidth = params.sliceFillWidth || 1;
                let spaceMult = first ? params.firstLayerLineMult || 1 : 1;
                let fillSpace = fillSpacing * spaceMult * solidWidth;
                let solidMinArea = params.sliceSolidMinArea;
                doSolidsFill(slice, fillSpace, sliceFillAngle, solidMinArea, promises);
                sliceFillAngle = (sliceFillAngle + 90.0) % 360;
            }, "fill solids");
            // very last layer (top) is set to finish solid rate
            slices.last().finishSolids = true
            if (promises) {
                await tracker(promises, (i, t) => {
                    trackupdate(i / t, 0.4, 0.5);
                });
            }
            profileEnd();
        }

        // for "real" objects, fill the remaining voids with sparse fill
        // sparse layers only present when non-vase mose and sparse % > 0
        if (!isSynth && !vaseMode) {
            let lastType;
            let promises = isConcurrent ? [] : undefined;
            forSlices(0.5, promises ? 0.55 : 0.7, slice => {
                let params = slice.params || process;
                if (!params.sliceFillSparse) {
                    return;
                }
                let newType = params.sliceFillType;
                doSparseLayerFill(slice, {
                    settings,
                    process,
                    device,
                    lineWidth,
                    spacing: fillOffset,
                    density: params.sliceFillSparse,
                    bounds: widget.getBoundingBox(),
                    height: sliceHeight,
                    type: newType,
                    cache: params._range !== true && lastType === newType,
                    promises
                });
                lastType = newType;
            }, "infill");
            if (promises) {
                await tracker(promises, (i, t) => {
                    trackupdate(i / t, 0.55, 0.7);
                });
            }
            // back-fill slices marked for infill cloning
            for (let slice of slices) {
                if (slice._clone_sparse) {
                    let tops = slice.tops;
                    let down = slice.down.tops;
                    for (let i=0; i<tops.length; i++) {
                        tops[i].fill_sparse = down[i].fill_sparse.map(p => p.cloneZ(slice.z));
                    }
                }
            }
        } else if (isSynth && isSupport) {
            // convert synth support widgets into support structure
            let outline = process.sliceSupportOutline || false;
            let promises = isConcurrent ? [] : undefined;
            let resolve = [];
            forSlices(0.5, promises ? 0.6 : 0.7, slice => {
                let params = slice.params || process;
                let density = params.sliceSupportDensity;
                let supports = slice.topShells();
                slice.supports = supports;
                slice.tops = undefined; // remove outline leave only supports
                let polys = [];
                if (density) {
                    if (!outline) {
                        POLY.expand(supports, lineWidth, slice.z, polys);
                    } else {
                        POLY.expand(supports, -lineWidth/4, slice.z, polys);
                    }
                }
                fillSupportPolys({
                    promises, polys, lineWidth, density, z: slice.z, isBelt,
                    angle: process.sliceSupportFill
                });
                resolve.push({ slice, polys });
            }, "infill");
            if (promises) {
                await tracker(promises, (i, t) => {
                    trackupdate(i / t, 0.6, 0.7);
                });
            }
            for (let rec of resolve) {
                let { slice, polys } = rec;
                rec.supports = polys;
            }
        }

        // auto support generation
        if (!isBelt && !isSynth && supportDensity && process.sliceSupportEnable) {
            doShadow(slices);
            profileStart("support");
            let promises = [];
            forSlices(0.7, 0.75, slice => {
                promises.push(doSupport(slice, process, widget.shadow, { }));
            }, "support");
            await tracker(promises, (i, t) => {
                trackupdate(i / t, 0.75, 0.8);
            });
            profileEnd();
        }

        // fill all supports (auto and manual)
        // if (!isBelt && supportDensity) {
        if (supportDensity) {
            profileStart("support-fill");
            let promises = false && isConcurrent ? [] : undefined;
            forSlices(0.8, promises ? 0.88 : 0.9, slice => {
                let params = slice.params || process;
                let density = params.sliceSupportDensity;
                doSupportFill({
                    promises, slice, lineWidth, density,
                    minArea: process.sliceSupportArea, isBelt,
                    angle: process.sliceSupportFill,
                    outline: process.sliceSupportOutline !== false
                });
            }, "support");
            if (promises) {
                await tracker(promises, (i, t) => {
                    trackupdate(i / t, 0.88, 0.9);
                });
            }
            profileEnd();
        }

        // render if not explicitly disabled
        if (render) {
            forSlices(0.9, 1.0, slice => {
                let params = slice.params || process;
                doRender(slice, isSynth, params, controller.devel);
            }, "render");
        }

        if (isBelt) {
            let bounds = base.newBounds();
            let slice = slices[0];
            if (slice.tops) {
                for (let top of slice.tops) {
                    bounds.merge(top.poly.bounds);
                }
            } else if (slice.supports) {
                for (let poly of slice.supports) {
                    bounds.merge(poly);
                }
            }
            widget.belt.miny = -bounds.miny;
            widget.belt.midy = (bounds.miny + bounds.maxy) / 2;
        }
    }

}

function connect_lines(lines, maxd = Infinity) {
    const newlines = [];
    let op2;
    let eo = 0;
    for (let i=0; i<lines.length; i += 2) {
        let p1 = lines[i];
        let p2 = lines[i+1];
        // swap p1 / p2 dir every other line
        if (eo++ % 2 === 1) {
            let t = p1;
            p1 = p2;
            p2 = t;
        }
        // connect short distances between ends
        if (op2 && p1.distTo2D(op2) <= maxd) {
            let op1 = p1.clone();
            newlines.push(op2);
            newlines.push(op1);
        }
        newlines.push(p1);
        newlines.push(p2);
        op2 = p2.clone();
    }
    let idx = 0;
    for (let p of newlines) {
        p.index = (idx++ / 2) | 0;
    }
    return newlines;
}

function bound(v,min,max) {
    return Math.max(min,Math.min(max,v));
}

function doRender(slice, isSynth, params, devel) {
    const output = slice.output();
    const height = slice.height / 2;
    const solidWidth = params.sliceFillWidth || 1;

    if (slice.tops) // missing for supports
    slice.tops.forEach(top => {
        if (isThin) output
            .setLayer('part', { line: 0x333333, check: 0x333333 })
            .addPolys([top.poly]);

        output
            .setLayer(isSynth ? "support" : "shells", isSynth ? COLOR.support : COLOR.shell)
            .addPolys(top.shells || [], vopt({ offset, height, clean: true }));

        output
            .setLayer("solid fill", isSynth ? COLOR.support : COLOR.fill)
            .addLines(top.fill_lines || [], vopt({ offset: offset * solidWidth, height, z:slice.z }));

        if (!(slice.belt && slice.belt.anchor)) output
            .setLayer("sparse fill", COLOR.infill)
            .addPolys(top.fill_sparse || [], vopt({ offset, height, outline: true, trace:true }))

        if (slice.belt && slice.belt.anchor) output
            .setLayer("anchor", COLOR.anchor)
            .addPolys(top.fill_sparse || [], vopt({ offset, height, outline: true, trace:true }))

        if (top.thin_fill) output
            .setLayer("thin fill", COLOR.fill)
            .addLines(top.thin_fill, vopt({ offset, height }));

        if (top.gaps) output
            .setLayer("gaps", COLOR.gaps)
            .addPolys(top.gaps, vopt({ offset, height, thin: true }));

        if (isThin && devel && top.fill_off && top.fill_off.length) {
            slice.output()
                .setLayer('fill inset', { face: 0, line: 0xaaaaaa, check: 0xaaaaaa })
                .addPolys(top.fill_off);
                // .setLayer('last', { face: 0, line: 0x008888, check: 0x008888 })
                // .addPolys(top.last);
        }
    });

    if (isThin && devel) {
        if (slice.solids && slice.solids.length) output
            .setLayer("solids", { face: 0xbbbb00, check: 0xbbbb00 })
            .addAreas(slice.solids);

        if (slice.bridges && slice.bridges.length) output
            .setLayer("bridges", { face: 0x00cccc, line: 0x00cccc, check: 0x00cccc })
            .addAreas(slice.bridges);

        if (slice.flats && slice.flats.length) output
            .setLayer("flats", { face: 0xaa00aa, line: 0xaa00aa, check: 0xaa00aa })
            .addAreas(slice.flats);
    }

    if (slice.supports && params.sliceSupportOutline) output
        .setLayer("support", COLOR.support)
        .addPolys(slice.supports, vopt({ offset, height }));

    if (slice.supports) slice.supports.forEach(poly => {
        if (poly.fill) output
            .setLayer("support", COLOR.support)
            .addLines(poly.fill, vopt({ offset, height }));
    });

    if (slice.xray) {
        const color = [ 0xff0000, 0x00aa00, 0x0000ff, 0xaaaa00, 0xff00ff, 0x0 ];
        if (slice.lines) {
            slice.lines.forEach((line, i) => {
                const group = i % 5;
                slice.output().setLayer(`l${group}`, color[group]).addLine(line.p1, line.p2);
            });
        }
        if (slice.groups)
        POLY.nest(slice.groups).forEach((poly, i) => {
            const group = i % 5;
            slice.addTop(poly);
            // slice.output().setLayer(`g${i}`, 0x888888).addPoly(poly);
            slice.output().setLayer(`g${i}`, color[group]).addPoly(poly);
        });
    }

    // console.log(slice.index, slice.render.stats);
}

// shared with SLA driver and minions
FDM.share = {
    doShells,
    doTopShells,
    doDiff,
    projectFlats,
    projectBridges
};

/**
 * Compute offset shell polygons. For FDM, the first offset is usually half
 * of the nozzle width.  Each subsequent offset is a full nozzle width.  User
 * parameters control tweaks to these numbers to allow for better shell bonding.
 * The last shell generated is a "fillOffset" shell.  Fill lines are clipped to
 * this polygon.  Adjusting fillOffset controls bonding of infill to the shells.
 *
 * Most of this is done in slicePost() in FDM mode. now this is used by SLA, Laser
 *
 * @param {number} count
 * @param {number} offsetN
 * @param {number} fillOffset
 * @param {Obejct} options
 */
function doShells(slice, count, offset1, offsetN, fillOffset, opt = {}) {
    for (let top of slice.tops) {
        doTopShells(slice.z, top, count, offset1, offsetN, fillOffset, opt);
    }
}

/**
 * Create an entirely solid layer by filling all top polygons
 * with an alternating pattern.
 *
 * @param {number} linewidth
 * @param {number} angle
 * @param {number} density
 */
 function doSolidLayerFill(slice, spacing, angle) {
    if (slice.xray) {
        return;
    }

    if (slice.tops.length === 0 || typeof(angle) != 'number') {
        slice.isSolidLayer = false;
        return;
    }

    slice.tops.forEach(function(top) {
        let lines = fillArea(top.fill_off, angle, spacing, null);
        top.fill_lines.appendAll(lines);
    });

    slice.isSolidLayer = true;
};

/**
 * Take output from pluggable sparse infill algorithm and clip to
 * the bounds of the top polygons and their inner solid areas.
 */
function doSparseLayerFill(slice, options = {}) {
    if (slice.xray) {
        return;
    }

    let process = options.process,
        spacing = options.spacing,  // spacing space between fill lines
        density = options.density,  // density of infill 0.0 - 1.0
        bounds = options.bounds,    // bounding box of widget
        height = options.height,    // z layer height
        cache = !(options.cache === false),
        type = options.type || 'hex';

    if (slice.tops.length === 0 || density === 0.0 || slice.isSolidLayer || slice.index < 0) {
        slice.isSparseFill = false;
        return;
    }

    let tops = slice.tops,
        down = slice.down,
        clib = self.ClipperLib,
        ctyp = clib.ClipType,
        ptyp = clib.PolyType,
        cfil = clib.PolyFillType,
        clip = new clib.Clipper(),
        ctre = new clib.PolyTree(),
        poly,
        polys = [],
        lines = [],
        line = [],
        solids = [],
        // callback passed to pluggable infill algorithm
        target = {
            // slice and slice property access
            slice: function() { return slice },
            zIndex: function() { return slice.index },
            zValue: function() { return slice.z },
            // various option map access
            options: function() { return options },
            lineWidth: function() { return options.lineWidth },
            bounds: function() { return bounds },
            zHeight: function() { return height },
            offset: function() { return spacing },
            density: function() { return density },
            repeat: function() { return process.sliceFillRepeat },
            // output functions
            emit: function(x,y) {
                if (isNaN(x)) {
                    solids.push(x);
                } else {
                    line.push(newPoint(x, y, slice.z));
                    slice.isSparseFill = true;
                }
            },
            newline: function() {
                if (line.length > 0) {
                    lines.push(line);
                    line = [];
                }
            }
        };

    // use specified fill type
    if (type && fill[type]) {
        fill[type](target);
    } else {
        console.log({missing_infill: type});
        return;
    }

    // force emit of last line
    target.newline();

    // prepare top infill structure
    for (let top of tops) {
        top.fill_sparse = top.fill_sparse || [];
        polys.appendAll(top.fill_off);
        polys.appendAll(top.solids);
    }

    // update fill fingerprint for this slice
    slice._fill_finger = POLY.fingerprint(polys);

    let skippable = cache && fill_fixed[type] ? true : false;
    let miss = false;
    // if the layer below has the same fingerprint,
    // we may be able to clone the infill instead of regenerating it
    if (skippable && slice.fingerprintSame(down)) {
        // the fill fingerprint can slightly different because of solid projections
        if (down._fill_finger && POLY.fingerprintCompare(slice._fill_finger, down._fill_finger)) {
            for (let i=0; i<tops.length; i++) {
                // the layer below may not have infill computed if it's solid
                if (!down.tops[i].fill_sparse) {
                    miss = true;
                }
            }
            // mark for infill cloning if nothing is missing
            if (!miss) {
                slice._clone_sparse = true;
                return;
            }
        }
    }

    let sparse_clip = slice.isSparseFill;

    // solid fill areas
    if (solids.length) {
        for (let top of tops) {
            if (!top.fill_off) return;
            let masks = top.fill_off.slice();
            if (top.solids) {
                masks = POLY.subtract(masks, top.solids, [], null, slice.z);
            }
            let angl = process.sliceFillAngle * ((slice.index % 2) + 1);
            for (let solid of solids) {
                let inter = [],
                    fillable = [];
                for (let mask of masks) {
                    let p = solid.mask(mask);
                    if (p && p.length) inter.appendAll(p);
                }
                // offset fill area to accommodate trace
                if (inter.length) {
                    POLY.expand(inter, -options.lineWidth/2, slice.z, fillable);
                }
                // fill intersected areas
                if (inter.length) {
                    slice.isSparseFill = true;
                    for (let p of inter) {
                        p.forEachSegment((p1, p2) => {
                            top.fill_lines.push(p1, p2);
                        });
                    }
                }
                if (fillable.length) {
                    let lines = POLY.fillArea(fillable, angl, options.lineWidth);
                    top.fill_lines.appendAll(lines);
                }
            }
        }
    }

    // if only solids were added and no lines to clip
    if (!sparse_clip) {
        return;
    }

    if (options.promises) {
        options.promises.push(kiri.minions.clip(slice, polys, lines));
        return;
    }

    lines = lines.map(a => a.map(p => p.toClipper()));
    clip.AddPaths(lines, ptyp.ptSubject, false);
    clip.AddPaths(POLY.toClipper(polys), ptyp.ptClip, true);

    if (clip.Execute(ctyp.ctIntersection, ctre, cfil.pftNonZero, cfil.pftEvenOdd)) {
        for (let node of ctre.m_AllPolys) {
            poly = POLY.fromClipperNode(node, slice.z);
            for (let top of tops) {
                // use only polygons inside this top
                if (poly.isInside(top.poly)) {
                    top.fill_sparse.push(poly);
                }
            }
        }
    }
};

/**
 * Find difference between fill inset poly on two adjacent layers.
 * Used to calculate bridges, flats and then solid projections.
 * 'expand' is used for top offsets in SLA mode
 */
function doDiff(slice, options = {}) {
    const { sla, fakedown, grow, min } = options;
    if ((slice.index <= 0 && !fakedown) || slice.xray) {
        return;
    }
    const top = slice,
        down = slice.down || (fakedown ? newSlice(-1) : null),
        topInner = sla ? top.topPolys() : top.topInners(),
        downInner = sla ? down.topPolys() : down.topInners(),
        bridges = top.bridges = [],
        flats = down.flats = [];

    // skip diffing layers that are identical
    if (slice.fingerprintSame(down)) {
        top.bridges = bridges;
        down.flats = flats;
        return;
    }

    let newBridges = [];
    let newFlats = [];

    POLY.subtract(topInner, downInner, newBridges, newFlats, slice.z, min, {
        wasm: true
    });

    newBridges = newBridges.filter(p => p.areaDeep() >= min);
    newFlats = newFlats.filter(p => p.areaDeep() >= min);

    if (grow > 0 && newBridges.length) {
        newBridges = POLY.offset(newBridges, grow);
    }
    if (grow > 0 && newFlats.length) {
        newFlats = POLY.offset(newFlats, grow);
    }

    bridges.appendAll(newBridges);
    flats.appendAll(newFlats);
};

/**
 *
 *
 * @param {Polygon[]} polys
 */
function addSolidFills(slice, polys) {
    if (slice.solids) {
        slice.solids.appendAll(polys);
    } else if (polys && polys.length) {
        console.log({no_solids_in: slice, for: polys})
    }
};

/**
 * project bottom flats down
 */
function projectFlats(slice, count, expand) {
    if (!slice.down || !slice.flats) return;
    // these flats are marked for finishing print speed
    if (slice.flats.length) slice.finishSolids = true;
    if (slice && slice.flats && slice.flats.length) {
        const flats = expand ? POLY.expand(slice.flats, expand) : slice.flats;
        projectSolid(slice, flats, count, false, true);
    }
};

/**
 * project top bridges up
 */
function projectBridges(slice, count) {
    if (!slice.up || !slice.bridges) return;
    // these flats are marked for finishing print speed
    if (slice.bridges.length) slice.finishSolids = true;
    projectSolid(slice, slice.bridges, count, true, true);
};

/**
 * fill projected areas and store line data
 * @return {boolean} true if filled, false if not
 */
function doSolidsFill(slice, spacing, angle, minArea, fillQ) {
    let minarea = minArea || 1,
        tops = slice.tops,
        solids = slice.solids;

    if (!(tops && solids)) {
        return;
    }

    if (slice.isSolidLayer || slice.xray) {
        return;
    }

    let unioned = POLY.union(solids, undefined, true, { wasm: true }).flat(),
        isSLA = (spacing === undefined && angle === undefined);

    if (solids.length === 0) return false;
    if (unioned.length === 0) return false;

    let trims = [],
        inner = isSLA ? slice.topPolys() : slice.topFillOff();

    // trim each solid to the inner bounds
    for (let p of unioned) {
        p.setZ(slice.z);
        for (let i of inner) {
            let masks = p.mask(i);
            if (masks && masks.length > 0) {
                trims.appendAll(masks);
            }
        }
    }

    // clear old solids and make array for new
    tops.forEach(top => { top.solids = [] });

    // replace solids with merged and trimmed solids
    slice.solids = solids = trims;

    // parent each solid polygon inside the smallest bounding top
    let make_solid_layer = false;
    let tops_area = tops.length ? tops.map(top => top.poly.areaDeep()).reduce((a,i) => a+i) : 0;
    for (let solid of solids) {
        for (let top of tops) {
            let stop = [];
            if (top.poly.overlaps(solid)) {
                if (!solid.parent || solid.parent.area() > top.poly.area()) {
                    if (solid.areaDeep() < minarea) {
                        // console.log({i:slice.index,cull_solid:solid,area:solid.areaDeep()});
                        continue;
                    }
                    solid.parent = top.poly;
                    top.solids.push(solid);
                    stop.push(solid);
                }
            }
            // problematic for organic shapes with lots of big and small tops
            // the small tops tend to trigger entire layer fills. for now just
            // trip full solid layer if a single top area diff > 50%
            if (false && stop.length) {
                let top_area = top.poly.areaDeep();
                let stop_area = stop.map(p => p.areaDeep()).reduce((a,v) => a + v);
                // if the solid area > 50% of the top area, make entire layer solid
                if (stop_area / tops_area > 0.5) {
                    make_solid_layer = true;
                }
            }
        }
    }
    // if 50% of top is filled with solids, trigger layer conversion to solid
    // in future, this should be limited to a specific top, not entire layer
    if (make_solid_layer) {
        for (let top of tops) {
            top.solids = [];
        }
        doSolidLayerFill(slice, spacing, angle);
        return;
    }

    // for SLA to bypass line infill
    if (isSLA) {
        return true;
    }

    // create empty filled line array for each top
    for (let top of tops) {
        // synth belt anchor tops don't want fill
        if (!top.fill_lines) {
            continue;
        }
        const tofill = [];
        const angfill = [];
        const newfill = top.fill_lines = [];
        // determine fill orientation from top
        for (let solid of solids) {
            if (solid.parent === top.poly) {
                if (solid.fillang) {
                    angfill.push(solid);
                } else {
                    tofill.push(solid);
                }
            }
        }
        if (tofill.length > 0) {
            doFillArea(fillQ, tofill, angle, spacing, newfill);
            // top.fill_lines_norm = {angle:angle,spacing:spacing};
        }
        if (angfill.length > 0) {
            top.fill_lines_ang = {spacing:spacing,list:[],poly:[]};
            for (let af of angfill) {
                doFillArea(fillQ, [af], af.fillang.angle + 45, spacing, newfill);
                // top.fill_lines_ang.list.push(af.fillang.angle + 45);
                // top.fill_lines_ang.poly.push(af.clone());
            }
        }
    }
}

function doFillArea(fillQ, polys, angle, spacing, output, minLen, maxLen) {
    if (fillQ) {
        fillQ.push(kiri.minions.fill(polys, angle, spacing, output, minLen, maxLen));
    } else {
        POLY.fillArea(polys, angle, spacing, output, minLen, maxLen);
    }
}

/**
 * calculate external overhangs requiring support
 */
async function doSupport(slice, proc, shadow, opt = {}) {
    let maxBridge = proc.sliceSupportSpan || 5,
        minArea = proc.supportMinArea || 0.1,
        pillarSize = proc.sliceSupportSize,
        offset = proc.sliceSupportOffset || 0,
        gap = proc.sliceSupportGap,
        size = (pillarSize || 1),
        tops = slice.topPolys(),
        trimTo = tops;

    let traces = POLY.flatten(slice.topShells().clone(true)),
        fill = slice.topFill(),
        points = [],
        down = slice.down,
        down_tops = down ? down.topPolys() : null,
        down_traces = down ? POLY.flatten(down.topShells().clone(true)) : null;

    if (opt.exp && down_tops) {
        let points = down_tops.map(p => p.deepLength).reduce((a,v)=>a+v);
        if (points > 200) {
            // use de-rez'd top shadow instead
            down_tops = down.topSimples();
            // de-rez trace polys because it's not that important for supports
            down_traces = down_traces.map(p => p.clean(true, undefined, config.clipper / 10));
        }
    }

    // DEBUG code
    let SDBG = false;
    let cks = SDBG ? [] : undefined;
    let pip = SDBG ? [] : undefined;
    let pcl = SDBG ? [] : undefined;

    // check if point is supported by layer below
    function checkPointSupport(point) {
        if (SDBG) cks.push(point); // DEBUG
        // skip points close to other support points
        for (let i=0; i<points.length; i++) {
            if (point.distTo2D(points[i]) < size/4) return;
        }
        let supported = point.isInPolygonOnly(down_tops);
        if (SDBG && supported) pip.push(point); // DEBUG
        let dist = false; // DEBUG
        if (!supported) down_traces.forEach(function(trace) {
            trace.forEachSegment(function(p1, p2) {
                if (point.distToLine(p1, p2) < offset) {
                    dist = true;
                    return supported = true;
                }
            });
            return supported;
        });
        if (SDBG && dist) pcl.push(point); // DEBUG
        if (!supported) points.push(point);
    }

    // todo support entire line if both endpoints unsupported
    // segment line and check if midpoints are supported
    function checkLineSupport(p1, p2, poly) {
        let dist, i = 1;
        if ((dist = p1.distTo2D(p2)) >= maxBridge) {
            let slope = p1.slopeTo(p2).factor(1/dist),
                segs = Math.floor(dist / maxBridge) + 1,
                seglen = dist / segs;
            while (i < segs) {
                checkPointSupport(p1.projectOnSlope(slope, i++ * seglen));
            }
        }
        if (poly) checkPointSupport(p2);
    }

    let supports = [];

    // generate support polys from unsupported points
    if (slice.down) (function() {
        // check trace line support needs
        traces.forEach(function(trace) {
            trace.forEachSegment(function(p1, p2) { checkLineSupport(p1, p2, true) });
        });

        // add offset solids to supports (or fill depending)
        fill.forEachPair(function(p1,p2) { checkLineSupport(p1, p2, false) });

        // skip the rest if no points or supports
        if (!(points.length || supports.length)) return;

        let pillars = [];

        // for each point, create a bounding rectangle
        points.forEach(function(point) {
            pillars.push(base.newPolygon().centerRectangle(point, size/2, size/2));
        });

        supports.appendAll(POLY.union(pillars, null, true, { wasm: false }));
        // merge pillars and replace with convex hull of outer points (aka smoothing)
        pillars = POLY.union(pillars, null, true, { wasm: false }).forEach(function(pillar) {
            supports.push(base.newPolygon().createConvexHull(pillar.points));
        });
    })();

    // DEBUG code
    if (SDBG && down_traces) slice.output()
        .setLayer('cks', { line: 0xee5533, check: 0xee5533 })
        .addPolys(cks.map(p => base.newPolygon().centerRectangle(p, 0.25, 0.25)))
        .setLayer('pip', { line: 0xdd4422, check: 0xdd4422 })
        .addPolys(pip.map(p => base.newPolygon().centerRectangle(p, 0.4, 0.4)))
        .setLayer('pcl', { line: 0xcc3311, check: 0xcc3311 })
        .addPolys(pcl.map(p => base.newPolygon().centerRectangle(p, 0.3, 0.3)))
        .setLayer('pts', { line: 0xdd33dd, check: 0xdd33dd })
        .addPolys(points.map(p => base.newPolygon().centerRectangle(p, 0.8, 0.8)))
        .setLayer('dtr', { line: 0x0, check: 0x0 })
        .addPolys(POLY.setZ(down_traces.clone(true),slice.z));
        ;

    if (supports.length === 0) {
        return;
    }

    // then union supports
    if (supports.length > 10) {
        supports = await kiri.minions.union(supports);
    } else {
        supports = POLY.union(supports, null, true, { wasm: false });
    }

    // clip to top polys
    supports = POLY.trimTo(supports, shadow);

    let depth = 0;
    while (down && supports.length > 0) {
        down.supports = down.supports || [];

        let trimmed = [], culled = [];

        // culled = supports;
        // clip supports to shell offsets
        POLY.subtract(supports, down.topSimples(), trimmed, null, slice.z, minArea, { wasm: false });

        // set depth hint on support polys for infill density
        trimmed.forEach(function(trim) {
            if (trim.area() < minArea) return;
            culled.push(trim.setZ(down.z));
        });

        // exit when no more support polys exist
        if (culled.length === 0) break;

        // new bridge polys for next pass (skip first layer below)
        if (depth >= gap) {
            down.supports.appendAll(culled);
        }

        supports = culled;
        down = down.down;
        depth++;
    }

}

function doSupportFill(args) {
    const { promises, slice, lineWidth, density, minArea, isBelt, angle, outline } = args;
    let supports = slice.supports,
        nsB = [],
        nsC = [],
        min = minArea || 0.1;

    if (!supports) return;

    // union supports
    supports = POLY.setZ(POLY.union(supports, undefined, true, { wasm: false }), slice.z);

    // clip supports to slice clip offset (or shell if none)
    POLY.subtract(supports, slice.clips, nsB, null, slice.z, min, { wasm: false });
    supports = nsB;

    // also trim to lower offsets, if they exist
    if (slice.down && slice.down.clips) {
        POLY.subtract(nsB, slice.down.clips, nsC, null, slice.z, min, { wasm: false });
        supports = nsC;
    }

    if (supports) {
        fillSupportPolys({
            promises, polys: supports, lineWidth, density, z: slice.z, isBelt, angle, outline
        });
    }

    // re-assign new supports back to slice
    slice.supportOutline = supports;
    slice.supports = supports;
};

function fillSupportPolys(args) {
    const { promises, polys, lineWidth, density, z, isBelt, angle, outline } = args;
    // calculate fill density
    let spacing = lineWidth * (1 / density);
    polys.forEach(function (poly) {
        // calculate angle based on width/height ratio
        let auto = isBelt || (poly.bounds.width() / poly.bounds.height() > 1) ? 1090 : 1000;
        // inset support poly for fill lines 33% of nozzle width
        let inset = POLY.offset([poly], -lineWidth/3, {flat: true, z, wasm: true});
        // do the fill
        if (inset && inset.length > 0) {
            doFillArea(promises, inset, angle || auto, spacing, poly.fill = []);
            if (!outline && poly.fill.length) {
                poly.fill = connect_lines(poly.fill, spacing * 2);
            }
        }
        return true;
    });
}

/**
 *
 * @param {Slice} slice
 * @param {Polygon[]} polys
 * @param {number} count
 * @param {boolean} up
 * @param {boolean} first
 * @returns {*}
 */
function projectSolid(slice, polys, count, up, first) {
    if (!slice || count <= 0) {
        return;
    }
    let clones = polys.clone(true);
    if (first) {
        clones.forEach(function(p) {
            p.hintFillAngle();
        });
    }
    addSolidFills(slice, clones);
    if (count > 0) {
        if (up) projectSolid(slice.up, polys, count-1, true, false);
        else projectSolid(slice.down, polys, count-1, false, false);
    }
}

FDM.supports = function(settings, widget) {
    let isBelt = settings.device.bedBelt;
    let process = settings.process;
    let size = process.sliceSupportSize;
    let s9 = size / 9;
    let s4 = size / 4;
    let s2 = size * 0.45;
    let min = 0.01;
    let geo = new THREE.BufferGeometry();
    geo.setAttribute('position', new THREE.BufferAttribute(widget.vertices, 3));
    let rad = (Math.PI / 180);
    let deg = (180 / Math.PI);
    let angle = rad * settings.process.sliceSupportAngle;
    let thresh = -Math.sin(angle);
    let dir = new THREE.Vector3(0,0,-1)
    let add = [];
    let mat = new THREE.MeshBasicMaterial();
    let mesh = new THREE.Mesh(geo, mat);
    let platform = new THREE.Mesh(
        new THREE.PlaneGeometry(10000,10000,1), mat
    );
    const now = Date.now();
    // test point
    function tp(point) {
        if (point.added) {
            return;
        }
        // omit pillars close to existing pillars
        for (let added of add) {
            let p2 = new THREE.Vector2(point.x, point.y);
            let pm = new THREE.Vector2(added.mid.x, added.mid.y);
            if (Math.abs(point.z - added.from.z) < s2 && p2.distanceTo(pm) < s4) {
                return;
            }
        }
        let ray = new THREE.Raycaster(point, dir);
        let int = ray.intersectObjects([ mesh, platform ], false);
        if (int && int.length && int[0].distance > 0.5) {
            let mid = new THREE.Vector3().add(point).add(int[0].point).divideScalar(2);
            add.push({from: point, to: int[0].point, mid});
            point.added = true;
        }
    }
    function tf(a, b, c) {
        let dab = a.distanceTo(b);
        let dbc = b.distanceTo(c);
        let dca = c.distanceTo(a);
        let max = Math.max(dab, dbc, dca);
        if (max < size) {
            let min = Math.min(dab, dbc, dca);
            if (min < s9 && Math.random() < 0.5) {
                return;
            }
            // test midpoint of tri face
            return tp(new THREE.Vector3().add(a).add(b).add(c).divideScalar(3));
        }
        if (dab === max) {
            let mp = new THREE.Vector3().add(a).add(b).divideScalar(2);
            tf(mp, b, c);
            tf(a, mp, c);
        } else if (dbc === max) {
            let mp = new THREE.Vector3().add(b).add(c).divideScalar(2);
            tf(a, mp, c);
            tf(a, b, mp);
        } else {
            let mp = new THREE.Vector3().add(c).add(a).divideScalar(2);
            tf(a, b, mp);
            tf(mp, b, c);
        }
    }

    let filter = isBelt ? (norm) => {
        return norm.z <= thresh && norm.y < 0;
    } : (norm) => {
        return norm.z < thresh;
    };
    let { position } = geo.attributes;
    let { itemSize, count, array } = position;
    for (let i = 0; i<count; i += 3) {
        let ip = i * itemSize;
        let a = new THREE.Vector3(array[ip++], array[ip++], array[ip++]);
        let b = new THREE.Vector3(array[ip++], array[ip++], array[ip++]);
        let c = new THREE.Vector3(array[ip++], array[ip++], array[ip++]);
        let norm = THREE.computeFaceNormal(a,b,c);
        // limit to downward faces
        if (!filter(norm)) {
            continue;
        }
        // skip faces on bed
        if (Math.max(a.z, b.z, c.z) < 0.1) {
            continue;
        }
        // triangulate larger polys and test centers
        tf(a,b,c);
    }
    console.log(`support generated in ${Date.now() - now} ms`);
    widget.supports = add;
    return add.length > 0;
};

class Vector3Cache {
    constructor() {
        this.cache = {};
    }

    get(x, y, z) {
        let key = [x.round(4),y.round(4),z.round(4)].join(',');
        let val = this.cache[key];
        if (!val) {
            val = new THREE.Vector3(x, y, z);
            this.cache[key] = val;
        }
        return val;
    }
}

class Coplanars {
    constructor() {
        this.cache = {};
    }

    put(a, b, c, norm) {
        let key = norm.round(7).toString();
        let arr = this.cache[key];
        if (!arr) {
            arr = [];
            this.cache[key] = arr;
        }
        arr.push([a,b,c]);
    }

    group(union) {
        let out = {};
        for (let norm in this.cache) {
            let arr = this.cache[norm];
            let groups = [];
            for (let face of arr) {
                let match = undefined;
                // see if face matches vertices in any group
                outer: for (let group of groups) {
                    for (let el of group) {
                        if (
                            el.indexOf(face[0]) >= 0 ||
                            el.indexOf(face[1]) >= 0 ||
                            el.indexOf(face[2]) >= 0
                        ) {
                            match = group;
                            break outer;
                        }
                    }
                }
                if (match) {
                    match.push(face);
                } else {
                    groups.push([face]);
                }
            }
            if (union) {
                // convert groups of faces to contiguous polygon groups
                groups = groups.map(group => {
                    let parr = group.map(arr => {
                        return base.newPolygon()
                            .add(arr[0].x, arr[0].y, arr[0].z)
                            .add(arr[1].x, arr[1].y, arr[1].z)
                            .add(arr[2].x, arr[2].y, arr[2].z);
                    });
                    let union = parr.length === 1 ? parr :
                        POLY.union(parr, 0, true, {wasm:false});
                    union.merged = parr.length;
                    union.face = group[0];
                    return union;
                });
            }
            out[norm] = groups;
        }
        // console.log(out);
        return out;
    }
}

});
