MediaWiki:Gadget-AerofoilDesigner.js: Difference between revisions
From Aodhán Gallagher
Created blank page |
No edit summary |
||
| Line 1: | Line 1: | ||
(function () { | |||
'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'; | |||
// Edit these ranges/defaults to match your actual optimisation bounds. | |||
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 thickCurve = { | |||
x: sampleBezier(xLeadEdgeThick, n).concat(sampleBezier(xTrailEdgeThick, n)), | |||
y: sampleBezier(yLeadEdgeThick, n).concat(sampleBezier(yTrailEdgeThick, n)) | |||
}; | |||
const camberCurve = { | |||
x: sampleBezier(xLeadEdgeCamber, n).concat(sampleBezier(xTrailEdgeCamber, n)), | |||
y: sampleBezier(yLeadEdgeCamber, n).concat(sampleBezier(yTrailEdgeCamber, n)) | |||
}; | |||
// ---- 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, | |||
validGeom, | |||
checks: { negThick, thickBounds, camberBounds, overHung } | |||
}; | |||
} | |||
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 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 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(status); | |||
root.appendChild(checks); | |||
root.appendChild(unitNote); | |||
const update = () => { | |||
try { | |||
const geom = buildGeometry(values); | |||
renderPlot(svg, 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); | |||
} | |||
}()); | |||
Revision as of 09:47, 23 September 2026
(function () {
'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';
// Edit these ranges/defaults to match your actual optimisation bounds.
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 thickCurve = {
x: sampleBezier(xLeadEdgeThick, n).concat(sampleBezier(xTrailEdgeThick, n)),
y: sampleBezier(yLeadEdgeThick, n).concat(sampleBezier(yTrailEdgeThick, n))
};
const camberCurve = {
x: sampleBezier(xLeadEdgeCamber, n).concat(sampleBezier(xTrailEdgeCamber, n)),
y: sampleBezier(yLeadEdgeCamber, n).concat(sampleBezier(yTrailEdgeCamber, n))
};
// ---- 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,
validGeom,
checks: { negThick, thickBounds, camberBounds, overHung }
};
}
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 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 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(status);
root.appendChild(checks);
root.appendChild(unitNote);
const update = () => {
try {
const geom = buildGeometry(values);
renderPlot(svg, 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);
}
}());