BareGit
/** Material mode added by Card Collection for artwork without foil. */
const CARD_ARTWORK_MATERIAL_KIND = 2;

/** Load and parse one OBJ resource during preview initialization. */
function loadModel(url)
{
    const request = new XMLHttpRequest();
    request.open("GET", url, false);
    request.send(null);
    if(request.status != 200 && request.status != 0)
    {
        throw(new Error(`Failed to load ${url}: HTTP ${request.status}`));
    }
    return parseOBJ(request.responseText);
}

/** Shade a card front with its artwork and no foil contribution. */
class ArtworkMaterial
{
    /** Load the front artwork used by a plain card. */
    constructor(gl, artwork_url)
    {
        this.gl = gl;
        this.artwork = artwork_url instanceof Texture
            ? artwork_url
            : new Texture(gl, artwork_url, {flip_y: true});
        this.ready = this.artwork.ready;
    }

    /** Select plain artwork shading and bind the artwork texture. */
    use(program)
    {
        this.gl.uniform1i(
            program.uniform("u_material_kind"),
            CARD_ARTWORK_MATERIAL_KIND);
        this.artwork.use(program, "u_artwork", 0);
    }

    /** Release the artwork texture. */
    dispose()
    {
        this.artwork.dispose();
    }
}

/** Construct shared front and shell materials and partitioned models. */
function initScene(gl, program, obj_model, page_data)
{
    const front_material = page_data.foil_url == null
        ? new ArtworkMaterial(gl, page_data.front_url)
        : new PhysicalFoilMaterial(
            gl,
            page_data.front_url,
            page_data.foil_url,
            page_data.spectral_lut_url);
    const shell_material = new SolidColorMaterial(gl, [0.5, 0.5, 0.5]);
    const models = [];
    let front_triangle_count = 0;
    let shell_triangle_count = 0;
    for(const geometry of obj_model.geometries)
    {
        const partition = partitionCardGeometry(geometry, CARD_MESH_LAYOUT);
        const front_vertices = partition.front.data.position == null
            ? 0
            : partition.front.data.position.length / 3;
        const shell_vertices = partition.shell.data.position == null
            ? 0
            : partition.shell.data.position.length / 3;
        if(front_vertices > 0)
        {
            models.push(new Model(
                gl, program, partition.front, front_material));
            front_triangle_count += front_vertices / 3;
        }
        if(shell_vertices > 0)
        {
            models.push(new Model(
                gl, program, partition.shell, shell_material));
            shell_triangle_count += shell_vertices / 3;
        }
    }
    if(front_triangle_count == 0 || shell_triangle_count == 0)
    {
        throw(new Error(
            "Card model must contain both front and shell triangles."));
    }
    return {
        scene: new Scene(models),
        front_material,
    };
}

/** Build the projection, view, and interactive card-model matrices. */
function calculateMatrices(canvas, camera_rotation)
{
    const mat4 = glMatrix.mat4;
    const aspect = canvas.clientWidth / canvas.clientHeight;
    const projection_matrix = mat4.perspective(
        mat4.create(), Math.PI / 8.0, aspect, 0.1, 100.0);
    const view_matrix = mat4.translate(
        mat4.create(), mat4.create(), [0.0, 0.0, -5.0]);
    const model_matrix = mat4.create();
    mat4.rotateX(model_matrix, model_matrix, Math.PI / 2.0);
    mat4.rotateX(model_matrix, model_matrix, camera_rotation[0]);
    mat4.rotateZ(model_matrix, model_matrix, camera_rotation[1]);
    return {projection_matrix, view_matrix, model_matrix};
}

/** Normalize a pointer position around the center of the short canvas axis. */
function getNormalizedMousePos(move_event, canvas)
{
    const rect = canvas.getBoundingClientRect();
    const size = Math.min(rect.width, rect.height);
    const mouse_x = (move_event.clientX - rect.left
                    - (rect.width - size) * 0.5) / size - 0.5;
    const mouse_y = (rect.height - (move_event.clientY - rect.top)
                    - (rect.height - size) * 0.5) / size - 0.5;
    return [mouse_x, mouse_y];
}

/** Smoothly bound an unbounded pointer coordinate to a half turn. */
function asymptoticBound(value)
{
    return Math.atan(value) / Math.PI;
}

/** Resolve one canvas PNG export or reject a failed encoding. */
function canvasPng(canvas)
{
    return new Promise(function encode(resolve, reject)
    {
        canvas.toBlob(function finish(blob)
        {
            if(blob == null)
            {
                reject(new Error("Failed to encode the card thumbnail."));
                return;
            }
            resolve(blob);
        }, "image/png");
    });
}

/** Internal compile-time shader variants for renderer quality. */
const RENDER_QUALITY = Object.freeze({
    LOW: Object.freeze({
        LIGHT_SAMPLE_COUNT: 2,
        ORDER_COUNT: 3,
        SPECTRAL_TAP_COUNT: 1,
    }),
    DEFAULT: Object.freeze({
        LIGHT_SAMPLE_COUNT: 4,
        ORDER_COUNT: 4,
        SPECTRAL_TAP_COUNT: 9,
    }),
    HIGH: Object.freeze({
        LIGHT_SAMPLE_COUNT: 8,
        ORDER_COUNT: 8,
        SPECTRAL_TAP_COUNT: 9,
    }),
});

const FOIL_CALIBRATION = Object.freeze({
    FOIL_INTENSITY: 0.5,
});

const OUTPUT_CALIBRATION = Object.freeze({
    LEVELS_BLACK_POINT: 0.0,
    LEVELS_WHITE_POINT: 0.88,
    LEVELS_MIDTONE: 1.0,
});

/** Read the non-executable page-data block emitted by the server. */
function readCardPageData()
{
    return JSON.parse(document.getElementById("CardPageData").textContent);
}

/** Update the accessible card-preview status. */
function showPreviewStatus(message, kind)
{
    const status = document.getElementById("PreviewStatus");
    if(status != null)
    {
        status.textContent = message;
        status.dataset.kind = kind;
    }
}

/** Initialize and run the physical card preview copied from foil. */
function main()
{
    try
    {
        const page_data = readCardPageData();
        const canvas = document.getElementById("GLCanvas");
        const gl = canvas.getContext("webgl2", {alpha: true});
        if(!gl)
        {
            throw(new Error("Failed to initialize WebGL 2."));
        }

        const shader_definitions = Object.assign(
            {}, RENDER_QUALITY.DEFAULT,
            FOIL_CALIBRATION, OUTPUT_CALIBRATION);
        const program = new ShaderProgram(
            gl,
            page_data.vertex_shader_url,
            page_data.fragment_shader_url,
            shader_definitions);
        const renderer = new Renderer(gl, program);
        const card = initScene(
            gl,
            program,
            loadModel(page_data.model_url),
            page_data);
        card.front_material.ready.then(function reportReady()
        {
            showPreviewStatus(
                page_data.foil_url == null
                    ? "Standard finish"
                    : "Foil finish",
                "success");
        }).catch(function reportTextureFailure(error)
        {
            console.error(error);
            showPreviewStatus(error.message, "error");
        });

        const light = new DiskLight(
            [0, 1.0, 2.0],
            [0, -1.0, -2.0],
            1,
            3.6);
        const camera_rotation = [0.0, 0.0];
        let material_update = Promise.resolve();

        /** Replace the preview material with selected local image files. */
        function setFiles(front_file, foil_file)
        {
            if(front_file == null)
            {
                return material_update;
            }
            material_update = material_update.catch(function recover()
            {
                return undefined;
            }).then(async function update()
            {
                const old_material = card.front_material;
                let new_material = null;
                if(foil_file == null)
                {
                    const artwork = await Texture.fromFile(
                        gl, front_file, {flip_y: true});
                    new_material = new ArtworkMaterial(gl, artwork);
                }
                else
                {
                    new_material = await PhysicalFoilMaterial.fromFiles(
                        gl,
                        front_file,
                        foil_file,
                        [0, 0, 0, 255],
                        page_data.spectral_lut_url);
                }
                await new_material.ready;
                for(const model of card.scene.models)
                {
                    if(model.material == old_material)
                    {
                        model.material = new_material;
                    }
                }
                card.front_material = new_material;
                old_material.dispose();
                showPreviewStatus(
                    foil_file == null ? "Standard finish" : "Foil finish",
                    "success");
            }).catch(function reportUpdateFailure(error)
            {
                console.error(error);
                showPreviewStatus(error.message, "error");
                throw(error);
            });
            return material_update;
        }

        /** Capture a cropped neutral PNG after pending textures load. */
        async function captureThumbnail(
            long_side = page_data.thumbnail_long_side)
        {
            await material_update;
            const previous_rotation = camera_rotation.slice();
            camera_rotation[0] = 0.0;
            camera_rotation[1] = 0.0;
            try
            {
                renderer.draw(
                    card.scene,
                    calculateMatrices(canvas, camera_rotation),
                    light);

                const pixels = new Uint8Array(
                    canvas.width * canvas.height * 4);
                gl.readPixels(
                    0,
                    0,
                    canvas.width,
                    canvas.height,
                    gl.RGBA,
                    gl.UNSIGNED_BYTE,
                    pixels);
                const bounds = window.cardPreviewMath.findAlphaBounds(
                    pixels, canvas.width, canvas.height);
                if(bounds == null)
                {
                    throw(new Error(
                        "The card preview is fully transparent."));
                }
                const cropped = window.cardPreviewMath.cropWebGLPixels(
                    pixels, canvas.width, bounds);
                const source_canvas = document.createElement("canvas");
                source_canvas.width = cropped.width;
                source_canvas.height = cropped.height;
                const cropped_image = new ImageData(
                    cropped.data, cropped.width, cropped.height);
                source_canvas.getContext("2d").putImageData(
                    cropped_image, 0, 0);

                const scaled = window.cardPreviewMath.scaleDimensions(
                    cropped.width, cropped.height, long_side);
                const output_canvas = document.createElement("canvas");
                output_canvas.width = scaled.width;
                output_canvas.height = scaled.height;
                output_canvas.getContext("2d").drawImage(
                    source_canvas, 0, 0, scaled.width, scaled.height);
                return await canvasPng(output_canvas);
            }
            finally
            {
                camera_rotation[0] = previous_rotation[0];
                camera_rotation[1] = previous_rotation[1];
            }
        }

        /** Form-facing preview controls for local files and thumbnails. */
        window.cardPreview = {setFiles, captureThumbnail};

        /** Draw one animation frame using the latest pointer rotation. */
        function render()
        {
            const matrices = calculateMatrices(canvas, camera_rotation);
            renderer.draw(card.scene, matrices, light);
            requestAnimationFrame(render);
        }

        canvas.addEventListener("mousemove", function rotateCard(event)
        {
            const position = getNormalizedMousePos(event, canvas);
            camera_rotation[0] = -asymptoticBound(position[1] * 10.0);
            camera_rotation[1] = -asymptoticBound(position[0] * 10.0);
        });
        requestAnimationFrame(render);
    }
    catch(error)
    {
        console.error(error);
        showPreviewStatus(error.message, "error");
    }
}

window.addEventListener("load", main);