Skip to content

Commit 308c45c

Browse files
committed
feat: support importing airfoil .dat files
Parse real Selig-style .dat airfoil files in the UI so custom foils can be loaded directly, and cover the importer with Airfoil Tools regression tests to keep that workflow reliable. Made-with: Cursor
1 parent 6dadb94 commit 308c45c

4 files changed

Lines changed: 442 additions & 8 deletions

File tree

flexfoil-ui/src/components/panels/AirfoilLibraryPanel.tsx

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44

55
import { useState, useCallback } from 'react';
66
import { useAirfoilStore } from '../../stores/airfoilStore';
7+
import { parseAirfoilDat } from '../../lib/airfoilImport';
78

89
export function AirfoilLibraryPanel() {
9-
const { name, generateNaca4, reset } = useAirfoilStore();
10+
const { name, generateNaca4, importAirfoil, reset } = useAirfoilStore();
1011

1112
// NACA 4-series parameters
1213
const [nacaCode, setNacaCode] = useState('0012');
@@ -32,13 +33,19 @@ export function AirfoilLibraryPanel() {
3233

3334
const reader = new FileReader();
3435
reader.onload = (event) => {
35-
const text = event.target?.result as string;
36-
// TODO: Parse airfoil coordinate file (Selig format)
37-
console.log('Imported file:', file.name, text.slice(0, 100));
38-
alert('File import not yet implemented. Use NACA generator for now.');
36+
try {
37+
const text = String(event.target?.result ?? '');
38+
const parsed = parseAirfoilDat(text, file.name);
39+
importAirfoil(parsed.name, parsed.coordinates);
40+
} catch (error) {
41+
const message = error instanceof Error ? error.message : 'Unknown import error.';
42+
window.alert(`Could not import ${file.name}: ${message}`);
43+
} finally {
44+
e.target.value = '';
45+
}
3946
};
4047
reader.readAsText(file);
41-
}, []);
48+
}, [importAirfoil]);
4249

4350
return (
4451
<div className="panel">
Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { parseAirfoilDat, prepareImportedAirfoil } from './airfoilImport';
3+
4+
const CLARK_Y = `CLARK Y AIRFOIL
5+
1.000000 0.000599
6+
0.990000 0.002969
7+
0.980000 0.005333
8+
0.970000 0.007687
9+
0.960000 0.010023
10+
0.940000 0.014624
11+
0.920000 0.019116
12+
0.900000 0.023502
13+
0.880000 0.027789
14+
0.860000 0.031974
15+
0.840000 0.036054
16+
0.820000 0.040024
17+
0.800000 0.043884
18+
0.780000 0.047628
19+
0.760000 0.051257
20+
0.740000 0.054767
21+
0.720000 0.058160
22+
0.700000 0.061433
23+
0.680000 0.064584
24+
0.660000 0.067605
25+
0.640000 0.070482
26+
0.620000 0.073206
27+
0.600000 0.075763
28+
0.580000 0.078145
29+
0.560000 0.080348
30+
0.540000 0.082371
31+
0.520000 0.084214
32+
0.500000 0.085877
33+
0.480000 0.087357
34+
0.460000 0.088643
35+
0.440000 0.089718
36+
0.420000 0.090566
37+
0.400000 0.091171
38+
0.380000 0.091521
39+
0.360000 0.091627
40+
0.340000 0.091508
41+
0.320000 0.091186
42+
0.300000 0.090680
43+
0.280000 0.090002
44+
0.260000 0.089084
45+
0.240000 0.087831
46+
0.220000 0.086143
47+
0.200000 0.083920
48+
0.180000 0.081069
49+
0.160000 0.077571
50+
0.140000 0.073436
51+
0.120000 0.068620
52+
0.100000 0.062998
53+
0.080000 0.056431
54+
0.060000 0.048757
55+
0.050000 0.044275
56+
0.040000 0.039128
57+
0.030000 0.033022
58+
0.020000 0.025374
59+
0.012000 0.017858
60+
0.008000 0.013735
61+
0.004000 0.008924
62+
0.002000 0.005803
63+
0.001000 0.003727
64+
0.000500 0.002339
65+
0.000000 0.000000
66+
0.000500 -0.004670
67+
0.001000 -0.005942
68+
0.002000 -0.007811
69+
0.004000 -0.010513
70+
0.008000 -0.014286
71+
0.012000 -0.016973
72+
0.020000 -0.020272
73+
0.030000 -0.022606
74+
0.040000 -0.024521
75+
0.050000 -0.026045
76+
0.060000 -0.027128
77+
0.080000 -0.028459
78+
0.100000 -0.029379
79+
0.120000 -0.029963
80+
0.140000 -0.030240
81+
0.160000 -0.030255
82+
0.180000 -0.030049
83+
0.200000 -0.029666
84+
0.220000 -0.029145
85+
0.240000 -0.028518
86+
0.260000 -0.027816
87+
0.280000 -0.027070
88+
0.300000 -0.026308
89+
0.320000 -0.025556
90+
0.340000 -0.024818
91+
0.360000 -0.024087
92+
0.380000 -0.023361
93+
0.400000 -0.022634
94+
0.420000 -0.021904
95+
0.440000 -0.021171
96+
0.460000 -0.020435
97+
0.480000 -0.019699
98+
0.500000 -0.018962
99+
0.520000 -0.018226
100+
0.540000 -0.017491
101+
0.560000 -0.016757
102+
0.580000 -0.016023
103+
0.600000 -0.015289
104+
0.620000 -0.014555
105+
0.640000 -0.013821
106+
0.660000 -0.013086
107+
0.680000 -0.012351
108+
0.700000 -0.011617
109+
0.720000 -0.010882
110+
0.740000 -0.010148
111+
0.760000 -0.009413
112+
0.780000 -0.008679
113+
0.800000 -0.007944
114+
0.820000 -0.007210
115+
0.840000 -0.006475
116+
0.860000 -0.005741
117+
0.880000 -0.005006
118+
0.900000 -0.004272
119+
0.920000 -0.003537
120+
0.940000 -0.002803
121+
0.960000 -0.002068
122+
0.970000 -0.001701
123+
0.980000 -0.001334
124+
0.990000 -0.000967
125+
1.000000 -0.000599
126+
`;
127+
128+
const E205 = `E205 (10.48%)
129+
1.000000 0.000000
130+
0.996550 0.000390
131+
0.986490 0.001740
132+
0.970490 0.004270
133+
0.949160 0.007780
134+
0.922850 0.011960
135+
0.891750 0.016680
136+
0.856240 0.021990
137+
0.816840 0.027860
138+
0.774120 0.034190
139+
0.728660 0.040880
140+
0.681080 0.047770
141+
0.632040 0.054700
142+
0.582180 0.061470
143+
0.532170 0.067820
144+
0.482650 0.073420
145+
0.434100 0.077850
146+
0.386800 0.080810
147+
0.341010 0.082140
148+
0.296990 0.081770
149+
0.254960 0.079700
150+
0.215080 0.076060
151+
0.177640 0.071110
152+
0.143020 0.065070
153+
0.111570 0.058110
154+
0.083600 0.050400
155+
0.059370 0.042110
156+
0.039090 0.033440
157+
0.022920 0.024610
158+
0.010970 0.015890
159+
0.003310 0.007660
160+
0.000020 0.000550
161+
0.002330 -0.005060
162+
0.010650 -0.009880
163+
0.024190 -0.014200
164+
0.042910 -0.017760
165+
0.066690 -0.020530
166+
0.095340 -0.022520
167+
0.128640 -0.023780
168+
0.166270 -0.024360
169+
0.207830 -0.024350
170+
0.252900 -0.023840
171+
0.300970 -0.022920
172+
0.351490 -0.021680
173+
0.403880 -0.020210
174+
0.457510 -0.018590
175+
0.511740 -0.016890
176+
0.565910 -0.015160
177+
0.619380 -0.013450
178+
0.671490 -0.011800
179+
0.721600 -0.010230
180+
0.769110 -0.008760
181+
0.813430 -0.007400
182+
0.854000 -0.006140
183+
0.890340 -0.004970
184+
0.921950 -0.003800
185+
0.948600 -0.002520
186+
0.970170 -0.001250
187+
0.986350 -0.000360
188+
0.996510 -0.000030
189+
1.000000 0.000000
190+
`;
191+
192+
const NACA_2412 = `NACA 2412
193+
1.000000 0.001300
194+
0.950000 0.011400
195+
0.900000 0.020800
196+
0.800000 0.037500
197+
0.700000 0.051800
198+
0.600000 0.063600
199+
0.500000 0.072400
200+
0.400000 0.078000
201+
0.300000 0.078800
202+
0.250000 0.076700
203+
0.200000 0.072600
204+
0.150000 0.066100
205+
0.100000 0.056300
206+
0.075000 0.049600
207+
0.050000 0.041300
208+
0.025000 0.029900
209+
0.012500 0.021500
210+
0.000000 0.000000
211+
0.012500 -0.016500
212+
0.025000 -0.022700
213+
0.050000 -0.030100
214+
0.075000 -0.034600
215+
0.100000 -0.037500
216+
0.150000 -0.041000
217+
0.200000 -0.042300
218+
0.250000 -0.042200
219+
0.300000 -0.041200
220+
0.400000 -0.038000
221+
0.500000 -0.033400
222+
0.600000 -0.027600
223+
0.700000 -0.021400
224+
0.800000 -0.015000
225+
0.900000 -0.008200
226+
0.950000 -0.004800
227+
1.000000 -0.001300
228+
`;
229+
230+
describe('parseAirfoilDat', () => {
231+
it.each([
232+
['Clark Y', CLARK_Y, 'clarky.dat', 'CLARK Y AIRFOIL'],
233+
['E205', E205, 'e205.dat', 'E205 (10.48%)'],
234+
['NACA 2412', NACA_2412, 'naca2412.dat', 'NACA 2412'],
235+
])('parses real Airfoil Tools %s data', (_label, text, fileName, expectedName) => {
236+
const parsed = parseAirfoilDat(text, fileName);
237+
238+
expect(parsed.name).toBe(expectedName);
239+
expect(parsed.coordinates.length).toBeGreaterThan(20);
240+
241+
const leIndex = parsed.coordinates.findIndex((point) => point.x === Math.min(...parsed.coordinates.map((p) => p.x)));
242+
expect(leIndex).toBeGreaterThan(0);
243+
expect(leIndex).toBeLessThan(parsed.coordinates.length - 1);
244+
245+
expect(parsed.coordinates[0].surface).toBe('upper');
246+
expect(parsed.coordinates[leIndex].surface).toBe('upper');
247+
expect(parsed.coordinates.at(-1)?.surface).toBe('lower');
248+
expect(parsed.coordinates[0].y).toBeGreaterThanOrEqual(-0.001);
249+
expect(parsed.coordinates.at(-1)?.y ?? 0).toBeLessThanOrEqual(0.001);
250+
});
251+
252+
it('falls back to the file name when the header is missing', () => {
253+
const parsed = parseAirfoilDat('1.0 0.0\n0.0 0.1\n1.0 -0.0\n', 'my-foil.dat');
254+
expect(parsed.name).toBe('my foil');
255+
});
256+
});
257+
258+
describe('prepareImportedAirfoil', () => {
259+
it('uses repaneled output when a repaneler is provided', () => {
260+
const parsed = parseAirfoilDat(NACA_2412, 'naca2412.dat');
261+
const imported = prepareImportedAirfoil(parsed, 160, () => [
262+
{ x: 1, y: 0.01 },
263+
{ x: 0, y: 0 },
264+
{ x: 1, y: -0.01 },
265+
]);
266+
267+
expect(imported.coordinates).toEqual(parsed.coordinates);
268+
expect(imported.panels).toEqual([
269+
{ x: 1, y: 0.01, surface: 'upper' },
270+
{ x: 0, y: 0, surface: 'upper' },
271+
{ x: 1, y: -0.01, surface: 'lower' },
272+
]);
273+
});
274+
275+
it('falls back to raw coordinates when repaneling is unavailable', () => {
276+
const parsed = parseAirfoilDat(E205, 'e205.dat');
277+
const imported = prepareImportedAirfoil(parsed, 160);
278+
279+
expect(imported.panels).toEqual(parsed.coordinates);
280+
});
281+
});

0 commit comments

Comments
 (0)