MediaWiki:Gadget-AerofoilDesigner.js: Difference between revisions

From Aodhán Gallagher
No edit summary
No edit summary
Line 2: Line 2:
     'use strict';
     'use strict';


    // =====================================================================
    // Aerofoil Designer - Bezier/PARSEC-style geometry from MATLAB code
    // =====================================================================
    // IMPORTANT:
    // Your MATLAB code uses tan() and cot() directly. MATLAB's tan() expects
    // radians. The UI defaults to degrees for easier slider input and converts
    // them to radians before evaluating tan/cot. Set ANGLE_MODE to 'radians'
    // if your MATLAB input vector stores angles in radians.
     const ANGLE_MODE = 'degrees';
     const ANGLE_MODE = 'degrees';


    // Edit these ranges/defaults to match your actual optimisation bounds.
     const PARAMS = [
     const PARAMS = [
         { key: 'xt',      label: 'xₜ — thickness join',  min: 0.05, max: 0.95, step: 0.005, value: 0.25 },
         { key: 'xt',      label: 'xₜ — thickness join',  min: 0.05, max: 0.95, step: 0.005, value: 0.25 },

Revision as of 10:20, 23 September 2026

(function () {
    'use strict';

    const ANGLE_MODE = 'degrees';

    const PARAMS = [
        { key: 'xt',      label: 'xₜ — thickness join',   min: 0.05, max: 0.95, step: 0.005, value: 0.25 },
        { key: 'yt',      label: 'yₜ — thickness join',   min: 0.00, max: 0.15, step: 0.001, value: 0.03 },
        { key: 'xc',      label: 'x꜀ — camber join',      min: 0.05, max: 0.95, step: 0.005, value: 0.40 },
        { key: 'yc',      label: 'y꜀ — camber join',      min: -0.10, max: 0.15, step: 0.001, value: 0.02 },
        { key: 'rle',     label: 'rₗₑ — leading-edge radius', min: 0.001, max: 0.20, step: 0.001, value: 0.03 },
        { key: 'dzte',    label: 'Δzₜₑ — TE thickness offset', min: -0.10, max: 0.10, step: 0.001, value: 0.00 },
        { key: 'zte',     label: 'zₜₑ — TE camber',      min: -0.10, max: 0.15, step: 0.001, value: 0.00 },
        { key: 'betate',  label: 'βₜₑ — TE thickness angle', min: -45, max: 45, step: 0.5, value: 0 },
        { key: 'gammale', label: 'γₗₑ — LE camber angle', min: -45, max: 45, step: 0.5, value: 5 },
        { key: 'alphate', label: 'αₜₑ — TE camber angle', min: -45, max: 45, step: 0.5, value: 0 },
        { key: 'b0',      label: 'b₀',                  min: 0.00, max: 0.30, step: 0.005, value: 0.05 },
        { key: 'b2',      label: 'b₂',                  min: 0.00, max: 0.60, step: 0.005, value: 0.20 },
        { key: 'b8',      label: 'b₈',                  min: 0.00, max: 0.10, step: 0.001, value: 0.00 },
        { key: 'b15',     label: 'b₁₅',                 min: 0.00, max: 1.00, step: 0.005, value: 0.85 },
        { key: 'b17',     label: 'b₁₇',                 min: 0.00, max: 1.00, step: 0.005, value: 0.85 }
    ];

    function toRadians(angle) {
        return ANGLE_MODE === 'degrees' ? angle * Math.PI / 180 : angle;
    }

    function cot(angle) {
        return 1 / Math.tan(angle);
    }

    function bezier(points, u) {
        // General Bernstein-form Bezier evaluation. This exactly covers the
        // cubic (4-point) and quartic (5-point) curves used by the MATLAB code.
        const n = points.length - 1;
        let value = 0;
        for (let i = 0; i <= n; i++) {
            let binomial = 1;
            for (let j = 1; j <= i; j++) {
                binomial *= (n - j + 1) / j;
            }
            value += binomial * Math.pow(1 - u, n - i) * Math.pow(u, i) * points[i];
        }
        return value;
    }

    function sampleBezier(points, count) {
        const values = new Array(count);
        for (let i = 0; i < count; i++) {
            const u = count === 1 ? 0 : i / (count - 1);
            values[i] = bezier(points, u);
        }
        return values;
    }

    function linspace(start, stop, count) {
        const values = new Array(count);
        if (count === 1) {
            values[0] = start;
            return values;
        }
        const step = (stop - start) / (count - 1);
        for (let i = 0; i < count; i++) {
            values[i] = start + i * step;
        }
        return values;
    }

    function buildGeometry(v) {
        const beta = toRadians(v.betate);
        const gamma = toRadians(v.gammale);
        const alpha = toRadians(v.alphate);

        // ---- BEZIER CONTROL POINTS ----
        const xLeadEdgeThick = [0, 0, -3 * v.b8 * v.b8 / (2 * v.rle), v.xt];
        const yLeadEdgeThick = [0, v.b8, v.yt, v.yt];

        const xTrailEdgeThick = [
            v.xt,
            (7 * v.xt + 9 * v.b8 * v.b8 / (2 * v.rle)) / 4,
            3 * v.xt + 15 * v.b8 * v.b8 / (4 * v.rle),
            v.b15,
            1
        ];
        const yTrailEdgeThick = [
            v.yt,
            v.yt,
            (v.yt + v.b8) / 2,
            v.dzte + (1 - v.b15) * Math.tan(beta),
            v.dzte
        ];

        const xLeadEdgeCamber = [0, v.b0, v.b2, v.xc];
        const yLeadEdgeCamber = [0, v.b0 * Math.tan(gamma), v.yc, v.yc];

        const xTrailEdgeCamber = [
            v.xc,
            (3 * v.xc - v.yc * cot(gamma)) / 2,
            (-8 * v.yc * cot(gamma) + 13 * v.xc) / 6,
            v.b17,
            1
        ];
        const yTrailEdgeCamber = [
            v.yc,
            v.yc,
            5 * v.yc / 6,
            v.zte - (1 - v.b17) * Math.tan(alpha),
            v.zte
        ];

        // ---- BEZIER CURVES ----
        const n = 100;
        const u = linspace(0, 1, n);

        const thickLeadCurve = {
            x: sampleBezier(xLeadEdgeThick, n),
            y: sampleBezier(yLeadEdgeThick, n)
        };
        const thickTrailCurve = {
            x: sampleBezier(xTrailEdgeThick, n),
            y: sampleBezier(yTrailEdgeThick, n)
        };
        const camberLeadCurve = {
            x: sampleBezier(xLeadEdgeCamber, n),
            y: sampleBezier(yLeadEdgeCamber, n)
        };
        const camberTrailCurve = {
            x: sampleBezier(xTrailEdgeCamber, n),
            y: sampleBezier(yTrailEdgeCamber, n)
        };

        const thickCurve = {
            x: thickLeadCurve.x.concat(thickTrailCurve.x),
            y: thickLeadCurve.y.concat(thickTrailCurve.y)
        };
        const camberCurve = {
            x: camberLeadCurve.x.concat(camberTrailCurve.x),
            y: camberLeadCurve.y.concat(camberTrailCurve.y)
        };

        // ---- GEOMETRY VALIDITY CHECK ----
        let negThick = false;
        let thickBounds = false;
        let camberBounds = false;
        let overHung = false;

        for (let i = 0; i < thickCurve.x.length; i++) {
            if (thickCurve.y[i] < 0) negThick = true;
            if (thickCurve.x[i] < 0 || thickCurve.x[i] > 1) thickBounds = true;
            if (camberCurve.x[i] < 0 || camberCurve.x[i] > 1) camberBounds = true;
            if (i > 0 && (thickCurve.x[i] < thickCurve.x[i - 1] || camberCurve.x[i] < camberCurve.x[i - 1])) {
                overHung = true;
            }
        }

        const validGeom = !(negThick || thickBounds || camberBounds || overHung);

        // ---- INTERPOLATION ----
        // MATLAB uses interp1(..., 'linear') after checking for overhang.
        // For a valid monotonic curve, sorting by x gives the same result and
        // also lets us keep rendering something useful while the user explores
        // invalid parameter combinations.
        const x = linspace(0, 1, n * 2);
        const interpThickCurve = interpolateSorted(thickCurve.x, thickCurve.y, x);
        const interpCamberCurve = interpolateSorted(camberCurve.x, camberCurve.y, x);

        const upper = new Array(x.length);
        const lower = new Array(x.length);
        for (let i = 0; i < x.length; i++) {
            upper[i] = interpCamberCurve[i] + interpThickCurve[i];
            lower[i] = interpCamberCurve[i] - interpThickCurve[i];
        }

        return {
            x,
            upper,
            lower,
            camber: interpCamberCurve,
            thickness: interpThickCurve,
            thickCurve,
            camberCurve,
            thickLeadCurve,
            thickTrailCurve,
            camberLeadCurve,
            camberTrailCurve,
            controlPoints: {
                thicknessLead: zipPoints(xLeadEdgeThick, yLeadEdgeThick),
                thicknessTrail: zipPoints(xTrailEdgeThick, yTrailEdgeThick),
                camberLead: zipPoints(xLeadEdgeCamber, yLeadEdgeCamber),
                camberTrail: zipPoints(xTrailEdgeCamber, yTrailEdgeCamber)
            },
            validGeom,
            checks: { negThick, thickBounds, camberBounds, overHung }
        };
    }

    function zipPoints(xs, ys) {
        return xs.map((x, i) => ({ x, y: ys[i] }));
    }

    function interpolateSorted(xValues, yValues, xQuery) {
        const pairs = [];
        for (let i = 0; i < xValues.length; i++) {
            if (Number.isFinite(xValues[i]) && Number.isFinite(yValues[i])) {
                pairs.push([xValues[i], yValues[i]]);
            }
        }
        pairs.sort((a, b) => a[0] - b[0]);

        // Remove duplicate x values, keeping the first value, as a pragmatic
        // browser-side equivalent for plotting.
        const xs = [];
        const ys = [];
        for (const pair of pairs) {
            if (!xs.length || Math.abs(pair[0] - xs[xs.length - 1]) > 1e-12) {
                xs.push(pair[0]);
                ys.push(pair[1]);
            }
        }

        return xQuery.map((q) => {
            if (!xs.length || q < xs[0] || q > xs[xs.length - 1]) return NaN;
            if (xs.length === 1) return ys[0];

            let lo = 0;
            let hi = xs.length - 1;
            while (hi - lo > 1) {
                const mid = Math.floor((lo + hi) / 2);
                if (xs[mid] <= q) lo = mid;
                else hi = mid;
            }

            const dx = xs[hi] - xs[lo];
            const t = dx === 0 ? 0 : (q - xs[lo]) / dx;
            return ys[lo] + t * (ys[hi] - ys[lo]);
        });
    }

    function fmt(value) {
        if (!Number.isFinite(value)) return '—';
        if (Math.abs(value) >= 1) return value.toFixed(3);
        if (Math.abs(value) >= 0.1) return value.toFixed(4);
        return value.toFixed(5);
    }

    function createSvgElement(tag, attrs) {
        const ns = 'http://www.w3.org/2000/svg';
        const el = document.createElementNS(ns, tag);
        Object.entries(attrs).forEach(([key, value]) => el.setAttribute(key, String(value)));
        return el;
    }

    function renderPlot(svg, geom) {
        while (svg.firstChild) svg.removeChild(svg.firstChild);

        const validPoints = [];
        for (let i = 0; i < geom.x.length; i++) {
            if (Number.isFinite(geom.upper[i])) validPoints.push([geom.x[i], geom.upper[i]]);
            if (Number.isFinite(geom.lower[i])) validPoints.push([geom.x[i], geom.lower[i]]);
            if (Number.isFinite(geom.camber[i])) validPoints.push([geom.x[i], geom.camber[i]]);
        }

        if (!validPoints.length) {
            const text = createSvgElement('text', { x: 50, y: 50, 'text-anchor': 'middle', class: 'aerofoil-empty' });
            text.textContent = 'Geometry cannot be plotted for these parameters';
            svg.appendChild(text);
            return;
        }

        let minY = Math.min(...validPoints.map((p) => p[1]));
        let maxY = Math.max(...validPoints.map((p) => p[1]));
        if (!Number.isFinite(minY) || !Number.isFinite(maxY)) {
            minY = -0.1;
            maxY = 0.1;
        }
        const span = Math.max(maxY - minY, 0.04);
        const margin = span * 0.12;
        minY -= margin;
        maxY += margin;

        const W = 900;
        const H = 520;
        const padLeft = 70;
        const padRight = 25;
        const padTop = 25;
        const padBottom = 55;
        const plotW = W - padLeft - padRight;
        const plotH = H - padTop - padBottom;

        const sx = (x) => padLeft + x * plotW;
        const sy = (y) => padTop + (maxY - y) / (maxY - minY) * plotH;

        // Grid and axes.
        [0, 0.25, 0.5, 0.75, 1].forEach((xVal) => {
            const x = sx(xVal);
            svg.appendChild(createSvgElement('line', {
                x1: x, y1: padTop, x2: x, y2: padTop + plotH, class: 'aerofoil-grid'
            }));
            const label = createSvgElement('text', { x, y: H - 22, 'text-anchor': 'middle', class: 'aerofoil-tick' });
            label.textContent = xVal.toFixed(2);
            svg.appendChild(label);
        });

        const yTicks = 5;
        for (let i = 0; i <= yTicks; i++) {
            const yVal = minY + (maxY - minY) * i / yTicks;
            const y = sy(yVal);
            svg.appendChild(createSvgElement('line', {
                x1: padLeft, y1: y, x2: padLeft + plotW, y2: y, class: 'aerofoil-grid'
            }));
            const label = createSvgElement('text', { x: padLeft - 9, y: y + 4, 'text-anchor': 'end', class: 'aerofoil-tick' });
            label.textContent = fmt(yVal);
            svg.appendChild(label);
        }

        svg.appendChild(createSvgElement('line', {
            x1: padLeft, y1: sy(0), x2: padLeft + plotW, y2: sy(0), class: 'aerofoil-axis'
        }));
        svg.appendChild(createSvgElement('line', {
            x1: padLeft, y1: padTop, x2: padLeft, y2: padTop + plotH, class: 'aerofoil-axis'
        }));

        // X axis label.
        const xLabel = createSvgElement('text', { x: padLeft + plotW / 2, y: H - 4, 'text-anchor': 'middle', class: 'aerofoil-axis-label' });
        xLabel.textContent = 'x/c';
        svg.appendChild(xLabel);

        // Y axis label.
        const yLabel = createSvgElement('text', {
            x: 16, y: padTop + plotH / 2, 'text-anchor': 'middle', class: 'aerofoil-axis-label',
            transform: `rotate(-90 16 ${padTop + plotH / 2})`
        });
        yLabel.textContent = 'y/c';
        svg.appendChild(yLabel);

        function pathFromSeries(xs, ys) {
            let d = '';
            let started = false;
            for (let i = 0; i < xs.length; i++) {
                if (!Number.isFinite(xs[i]) || !Number.isFinite(ys[i])) {
                    started = false;
                    continue;
                }
                const command = started ? 'L' : 'M';
                d += `${command}${sx(xs[i]).toFixed(2)},${sy(ys[i]).toFixed(2)} `;
                started = true;
            }
            return d.trim();
        }

        const upperPath = pathFromSeries(geom.x, geom.upper);
        const lowerPath = pathFromSeries([...geom.x].reverse(), [...geom.lower].reverse());

        const profile = createSvgElement('path', { d: `${upperPath} ${lowerPath}`, class: 'aerofoil-profile' });
        svg.appendChild(profile);

        const upper = createSvgElement('path', { d: upperPath, class: 'aerofoil-upper' });
        const lower = createSvgElement('path', { d: pathFromSeries(geom.x, geom.lower), class: 'aerofoil-lower' });
        const camber = createSvgElement('path', { d: pathFromSeries(geom.x, geom.camber), class: 'aerofoil-camber' });
        svg.appendChild(upper);
        svg.appendChild(lower);
        svg.appendChild(camber);
    }

    function renderConstructionPlot(svg, geom) {
        while (svg.firstChild) svg.removeChild(svg.firstChild);

        const allCurves = [
            geom.thickLeadCurve,
            geom.thickTrailCurve,
            geom.camberLeadCurve,
            geom.camberTrailCurve
        ];
        const allPoints = [
            ...geom.controlPoints.thicknessLead,
            ...geom.controlPoints.thicknessTrail,
            ...geom.controlPoints.camberLead,
            ...geom.controlPoints.camberTrail
        ];

        const finite = (v) => Number.isFinite(v);
        const xs = [];
        const ys = [];
        allCurves.forEach((curve) => {
            curve.x.forEach((x) => { if (finite(x)) xs.push(x); });
            curve.y.forEach((y) => { if (finite(y)) ys.push(y); });
        });
        allPoints.forEach((p) => {
            if (finite(p.x)) xs.push(p.x);
            if (finite(p.y)) ys.push(p.y);
        });

        if (!xs.length || !ys.length) return;

        let minX = Math.min(...xs);
        let maxX = Math.max(...xs);
        let minY = Math.min(...ys);
        let maxY = Math.max(...ys);
        const spanX = Math.max(maxX - minX, 0.2);
        const spanY = Math.max(maxY - minY, 0.04);
        minX -= 0.08 * spanX;
        maxX += 0.08 * spanX;
        minY -= 0.12 * spanY;
        maxY += 0.12 * spanY;

        const W = 900;
        const H = 500;
        const padLeft = 70;
        const padRight = 25;
        const padTop = 35;
        const padBottom = 55;
        const plotW = W - padLeft - padRight;
        const plotH = H - padTop - padBottom;
        const sx = (x) => padLeft + (x - minX) / (maxX - minX) * plotW;
        const sy = (y) => padTop + (maxY - y) / (maxY - minY) * plotH;

        // Grid and x/c tick labels.
        const xTickCount = 5;
        for (let i = 0; i <= xTickCount; i++) {
            const xVal = minX + (maxX - minX) * i / xTickCount;
            const px = sx(xVal);
            svg.appendChild(createSvgElement('line', {
                x1: px, y1: padTop, x2: px, y2: padTop + plotH, class: 'aerofoil-grid'
            }));
            const label = createSvgElement('text', {
                x: px, y: H - 22, 'text-anchor': 'middle', class: 'aerofoil-tick'
            });
            label.textContent = fmt(xVal);
            svg.appendChild(label);
        }

        const yTickCount = 5;
        for (let i = 0; i <= yTickCount; i++) {
            const yVal = minY + (maxY - minY) * i / yTickCount;
            const py = sy(yVal);
            svg.appendChild(createSvgElement('line', {
                x1: padLeft, y1: py, x2: padLeft + plotW, y2: py, class: 'aerofoil-grid'
            }));
            const label = createSvgElement('text', {
                x: padLeft - 9, y: py + 4, 'text-anchor': 'end', class: 'aerofoil-tick'
            });
            label.textContent = fmt(yVal);
            svg.appendChild(label);
        }

        if (minX <= 0 && maxX >= 0) {
            const px = sx(0);
            svg.appendChild(createSvgElement('line', {
                x1: px, y1: padTop, x2: px, y2: padTop + plotH, class: 'aerofoil-axis'
            }));
        }
        if (minY <= 0 && maxY >= 0) {
            const py = sy(0);
            svg.appendChild(createSvgElement('line', {
                x1: padLeft, y1: py, x2: padLeft + plotW, y2: py, class: 'aerofoil-axis'
            }));
        }

        const xLabel = createSvgElement('text', {
            x: padLeft + plotW / 2, y: H - 4, 'text-anchor': 'middle', class: 'aerofoil-axis-label'
        });
        xLabel.textContent = 'x/c';
        svg.appendChild(xLabel);

        const yLabel = createSvgElement('text', {
            x: 16, y: padTop + plotH / 2, 'text-anchor': 'middle', class: 'aerofoil-axis-label',
            transform: `rotate(-90 16 ${padTop + plotH / 2})`
        });
        yLabel.textContent = 'y/c';
        svg.appendChild(yLabel);

        function pathFromCurve(curve) {
            let d = '';
            let started = false;
            for (let i = 0; i < curve.x.length; i++) {
                if (!finite(curve.x[i]) || !finite(curve.y[i])) {
                    started = false;
                    continue;
                }
                d += `${started ? 'L' : 'M'}${sx(curve.x[i]).toFixed(2)},${sy(curve.y[i]).toFixed(2)} `;
                started = true;
            }
            return d.trim();
        }

        // Curves.
        svg.appendChild(createSvgElement('path', {
            d: pathFromCurve(geom.thickLeadCurve), class: 'aerofoil-thickness-curve'
        }));
        svg.appendChild(createSvgElement('path', {
            d: pathFromCurve(geom.thickTrailCurve), class: 'aerofoil-thickness-curve'
        }));
        svg.appendChild(createSvgElement('path', {
            d: pathFromCurve(geom.camberLeadCurve), class: 'aerofoil-camber-curve'
        }));
        svg.appendChild(createSvgElement('path', {
            d: pathFromCurve(geom.camberTrailCurve), class: 'aerofoil-camber-curve'
        }));

        // Control polygons and labelled points.
        function drawControlSet(points, prefix, lineClass, pointClass) {
            const polygonPoints = points
                .filter((p) => finite(p.x) && finite(p.y))
                .map((p) => `${sx(p.x).toFixed(2)},${sy(p.y).toFixed(2)}`)
                .join(' ');
            if (polygonPoints) {
                svg.appendChild(createSvgElement('polyline', {
                    points: polygonPoints,
                    class: lineClass,
                    fill: 'none'
                }));
            }

            points.forEach((p, i) => {
                if (!finite(p.x) || !finite(p.y)) return;
                const px = sx(p.x);
                const py = sy(p.y);
                svg.appendChild(createSvgElement('circle', {
                    cx: px, cy: py, r: 4, class: pointClass
                }));

                const label = createSvgElement('text', {
                    x: px + 7,
                    y: py - 7,
                    class: 'aerofoil-control-point-label'
                });
                label.textContent = `${prefix}${i}`;
                svg.appendChild(label);
            });
        }

        drawControlSet(geom.controlPoints.thicknessLead, 'T', 'aerofoil-thickness-control', 'aerofoil-thickness-point');
        drawControlSet(geom.controlPoints.thicknessTrail, 'T', 'aerofoil-thickness-control', 'aerofoil-thickness-point');
        drawControlSet(geom.controlPoints.camberLead, 'C', 'aerofoil-camber-control', 'aerofoil-camber-point');
        drawControlSet(geom.controlPoints.camberTrail, 'C', 'aerofoil-camber-control', 'aerofoil-camber-point');

        // Legend.
        const legend = createSvgElement('g', { transform: `translate(${W - 220}, 18)` });
        const tLegend = createSvgElement('text', { x: 0, y: 0, class: 'aerofoil-legend-text' });
        tLegend.textContent = 'Thickness curve / controls';
        legend.appendChild(tLegend);
        const cLegend = createSvgElement('text', { x: 0, y: 20, class: 'aerofoil-legend-text' });
        cLegend.textContent = 'Camber curve / controls';
        legend.appendChild(cLegend);
        svg.appendChild(legend);
    }

    function setup(root) {
        if (root.dataset.aerofoilInitialised === '1') return;
        root.dataset.aerofoilInitialised = '1';

        const controls = document.createElement('div');
        controls.className = 'aerofoil-controls';

        const values = {};
        const inputs = {};
        const valueLabels = {};

        PARAMS.forEach((p) => {
            const row = document.createElement('div');
            row.className = 'aerofoil-control';

            const label = document.createElement('label');
            label.className = 'aerofoil-control-label';
            label.htmlFor = `aerofoil-${p.key}`;
            label.textContent = p.label;

            const range = document.createElement('input');
            range.type = 'range';
            range.id = `aerofoil-${p.key}`;
            range.min = p.min;
            range.max = p.max;
            range.step = p.step;
            range.value = p.value;

            const number = document.createElement('input');
            number.type = 'number';
            number.min = p.min;
            number.max = p.max;
            number.step = p.step;
            number.value = p.value;
            number.className = 'aerofoil-number';

            const valueSpan = document.createElement('span');
            valueSpan.className = 'aerofoil-value';

            const setValue = (raw) => {
                let value = Number(raw);
                if (!Number.isFinite(value)) value = p.value;
                value = Math.min(p.max, Math.max(p.min, value));
                range.value = value;
                number.value = value;
                values[p.key] = value;
                valueSpan.textContent = value.toFixed(Math.max(0, decimalPlaces(p.step)));
            };

            range.addEventListener('input', () => setValue(range.value));
            number.addEventListener('input', () => setValue(number.value));

            setValue(p.value);
            inputs[p.key] = range;
            valueLabels[p.key] = valueSpan;

            row.appendChild(label);
            row.appendChild(range);
            row.appendChild(number);
            row.appendChild(valueSpan);
            controls.appendChild(row);
        });

        const actions = document.createElement('div');
        actions.className = 'aerofoil-actions';

        const reset = document.createElement('button');
        reset.type = 'button';
        reset.className = 'mw-ui-button';
        reset.textContent = 'Reset';
        reset.addEventListener('click', () => {
            PARAMS.forEach((p) => {
                inputs[p.key].value = p.value;
                inputs[p.key].dispatchEvent(new Event('input', { bubbles: true }));
            });
        });
        actions.appendChild(reset);

        const plotWrap = document.createElement('div');
        plotWrap.className = 'aerofoil-plot-wrap';

        const svg = createSvgElement('svg', {
            viewBox: '0 0 900 520',
            role: 'img',
            'aria-label': 'Interactive aerofoil plot'
        });
        plotWrap.appendChild(svg);

        const constructionTitle = document.createElement('div');
        constructionTitle.className = 'aerofoil-subplot-title';
        constructionTitle.textContent = 'Bezier thickness and camber construction';

        const constructionWrap = document.createElement('div');
        constructionWrap.className = 'aerofoil-plot-wrap aerofoil-construction-wrap';

        const constructionSvg = createSvgElement('svg', {
            viewBox: '0 0 900 500',
            role: 'img',
            'aria-label': 'Bezier thickness and camber curves with control points'
        });
        constructionWrap.appendChild(constructionSvg);

        const status = document.createElement('div');
        status.className = 'aerofoil-status';

        const checks = document.createElement('div');
        checks.className = 'aerofoil-checks';

        const unitNote = document.createElement('div');
        unitNote.className = 'aerofoil-note';
        unitNote.textContent = `Angles are entered in ${ANGLE_MODE}; calculations follow the MATLAB tan/cot convention.`;

        root.appendChild(controls);
        root.appendChild(actions);
        root.appendChild(plotWrap);
        root.appendChild(constructionTitle);
        root.appendChild(constructionWrap);
        root.appendChild(status);
        root.appendChild(checks);
        root.appendChild(unitNote);

        const update = () => {
            try {
                const geom = buildGeometry(values);
                renderPlot(svg, geom);
                renderConstructionPlot(constructionSvg, geom);

                status.className = `aerofoil-status ${geom.validGeom ? 'is-valid' : 'is-invalid'}`;
                status.textContent = geom.validGeom ? 'Geometry valid' : 'Geometry invalid — see checks below.';

                checks.innerHTML = '';
                const messages = [
                    ['Negative thickness', geom.checks.negThick],
                    ['Thickness x outside [0, 1]', geom.checks.thickBounds],
                    ['Camber x outside [0, 1]', geom.checks.camberBounds],
                    ['Overhang / decreasing x', geom.checks.overHung]
                ];
                messages.forEach(([name, failed]) => {
                    const item = document.createElement('span');
                    item.className = failed ? 'fail' : 'pass';
                    item.textContent = `${failed ? '✗' : '✓'} ${name}`;
                    checks.appendChild(item);
                });
            } catch (error) {
                status.className = 'aerofoil-status is-invalid';
                status.textContent = `Geometry error: ${error.message}`;
                console.error('Aerofoil Designer:', error);
            }
        };

        Object.keys(inputs).forEach((key) => {
            inputs[key].addEventListener('input', update);
        });

        update();
    }

    function decimalPlaces(step) {
        const text = String(step);
        if (!text.includes('.')) return 0;
        return text.split('.')[1].length;
    }

    function init() {
        document.querySelectorAll('#aerofoil-tool').forEach(setup);
    }

    if (window.mw && mw.hook) {
        mw.hook('wikipage.content').add(init);
        init();
    } else {
        document.addEventListener('DOMContentLoaded', init);
    }
}());