Skip to content

Commit adf8554

Browse files
committed
fix: trace the dividing streamline directly in the canvas
Use a dedicated dividing-streamline trace for the faithful flow view and place transition markers on the interpolated upper/lower surface so the BL visualization matches the airfoil geometry. Made-with: Cursor
1 parent 79172d5 commit adf8554

5 files changed

Lines changed: 668 additions & 18 deletions

File tree

crates/rustfoil-solver/src/inviscid/mod.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,12 @@ pub mod velocity;
3030
pub mod smoke;
3131

3232
pub use error::SolverError;
33-
pub use velocity::{build_streamlines, build_streamlines_viscous, velocity_at, velocity_at_with_sources, is_inside_airfoil, psi_at, psi_at_with_sources, compute_psi_grid, compute_psi_grid_with_interior, compute_psi_grid_with_sources, StreamlineOptions, WakePanels};
33+
pub use velocity::{
34+
build_dividing_streamline, build_dividing_streamline_viscous, build_streamlines,
35+
build_streamlines_viscous, compute_psi_grid, compute_psi_grid_with_interior,
36+
compute_psi_grid_with_sources, is_inside_airfoil, psi_at, psi_at_with_sources,
37+
velocity_at, velocity_at_with_sources, StreamlineOptions, WakePanels,
38+
};
3439
pub use smoke::SmokeSystem;
3540

3641
use nalgebra::{DMatrix, DVector};

crates/rustfoil-solver/src/inviscid/velocity.rs

Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -904,6 +904,230 @@ pub fn build_streamlines(
904904
streamlines
905905
}
906906

907+
fn build_dividing_streamline_internal<F>(
908+
field: &F,
909+
nodes: &[Point],
910+
effective_body: Option<&[Point]>,
911+
options: &StreamlineOptions,
912+
) -> Option<Vec<(f64, f64)>>
913+
where
914+
F: Fn(f64, f64) -> (f64, f64),
915+
{
916+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
917+
enum StreamlineSide {
918+
Above,
919+
Below,
920+
}
921+
922+
#[derive(Debug, Clone)]
923+
struct TraceResult {
924+
seed_y: f64,
925+
side: Option<StreamlineSide>,
926+
closest_distance: f64,
927+
streamline: Vec<(f64, f64)>,
928+
}
929+
930+
fn point_segment_distance_sq(px: f64, py: f64, a: Point, b: Point) -> f64 {
931+
let dx = b.x - a.x;
932+
let dy = b.y - a.y;
933+
let len_sq = dx * dx + dy * dy;
934+
if len_sq <= 1e-16 {
935+
let ex = px - a.x;
936+
let ey = py - a.y;
937+
return ex * ex + ey * ey;
938+
}
939+
let t = (((px - a.x) * dx + (py - a.y) * dy) / len_sq).clamp(0.0, 1.0);
940+
let qx = a.x + t * dx;
941+
let qy = a.y + t * dy;
942+
let ex = px - qx;
943+
let ey = py - qy;
944+
ex * ex + ey * ey
945+
}
946+
947+
fn classify_streamline(
948+
streamline: &[(f64, f64)],
949+
nodes: &[Point],
950+
) -> Option<(Option<StreamlineSide>, f64)> {
951+
if streamline.len() < 2 || nodes.len() < 2 {
952+
return None;
953+
}
954+
955+
let body_x_min = nodes.iter().map(|p| p.x).fold(f64::INFINITY, f64::min);
956+
let body_x_max = nodes.iter().map(|p| p.x).fold(f64::NEG_INFINITY, f64::max);
957+
let body_y_center = nodes.iter().map(|p| p.y).sum::<f64>() / nodes.len() as f64;
958+
let chord = (body_x_max - body_x_min).abs().max(1e-6);
959+
let x_pad = 0.05 * chord;
960+
let x_ref = body_x_min + 0.65 * chord;
961+
962+
let mut best: Option<(f64, f64)> = None;
963+
let consider_point = |best: &mut Option<(f64, f64)>, x: f64, y: f64| {
964+
let mut min_dist_sq = f64::INFINITY;
965+
for i in 0..nodes.len() {
966+
let a = nodes[i];
967+
let b = nodes[(i + 1) % nodes.len()];
968+
min_dist_sq = min_dist_sq.min(point_segment_distance_sq(x, y, a, b));
969+
}
970+
let signed_offset = y - body_y_center;
971+
if best.as_ref().is_none_or(|(best_dist_sq, _)| min_dist_sq < *best_dist_sq) {
972+
*best = Some((min_dist_sq, signed_offset));
973+
}
974+
};
975+
976+
for &(x, y) in streamline {
977+
if x >= body_x_min - x_pad && x <= body_x_max + x_pad {
978+
consider_point(&mut best, x, y);
979+
}
980+
}
981+
if best.is_none() {
982+
for &(x, y) in streamline {
983+
consider_point(&mut best, x, y);
984+
}
985+
}
986+
987+
let (dist_sq, signed_offset) = best?;
988+
let closest_distance = dist_sq.sqrt();
989+
let end = *streamline.last()?;
990+
if end.0 <= body_x_min + 0.12 * chord && closest_distance <= 0.04 * chord {
991+
let end_offset = end.1 - body_y_center;
992+
if end_offset.abs() <= 0.02 * chord {
993+
return Some((None, closest_distance));
994+
}
995+
let side = if end_offset >= 0.0 {
996+
Some(StreamlineSide::Above)
997+
} else {
998+
Some(StreamlineSide::Below)
999+
};
1000+
return Some((side, closest_distance));
1001+
}
1002+
1003+
let mut station_sample: Option<(f64, f64)> = None;
1004+
for &(x, y) in streamline {
1005+
let dx = (x - x_ref).abs();
1006+
if station_sample
1007+
.as_ref()
1008+
.is_none_or(|(best_dx, _)| dx < *best_dx)
1009+
{
1010+
station_sample = Some((dx, y));
1011+
}
1012+
}
1013+
let station_y = station_sample.map(|(_, y)| y).unwrap_or(body_y_center + signed_offset);
1014+
let side = if station_y >= body_y_center {
1015+
Some(StreamlineSide::Above)
1016+
} else {
1017+
Some(StreamlineSide::Below)
1018+
};
1019+
Some((side, closest_distance))
1020+
}
1021+
1022+
let bounds = (options.x_min, options.x_max, options.y_min, options.y_max);
1023+
let sample_count = options.seed_count.max(25).min(129);
1024+
let y_span = (options.y_max - options.y_min).abs();
1025+
let seed_tol = (1e-4 * y_span).max(1e-5);
1026+
let mut previous: Option<TraceResult> = None;
1027+
1028+
let trace_seed = |seed_y: f64| -> Option<TraceResult> {
1029+
if is_inside_airfoil(options.seed_x, seed_y, nodes)
1030+
|| effective_body.is_some_and(|poly| is_inside_polygon(options.seed_x, seed_y, poly))
1031+
{
1032+
return None;
1033+
}
1034+
1035+
let streamline = integrate_streamline(
1036+
field,
1037+
options.seed_x,
1038+
seed_y,
1039+
options.step_size,
1040+
options.max_steps,
1041+
nodes,
1042+
bounds,
1043+
effective_body,
1044+
);
1045+
if streamline.len() < 2 {
1046+
return None;
1047+
}
1048+
1049+
let (side, closest_distance) = classify_streamline(&streamline, nodes)?;
1050+
Some(TraceResult {
1051+
seed_y,
1052+
side,
1053+
closest_distance,
1054+
streamline,
1055+
})
1056+
};
1057+
1058+
let mut bracket: Option<(TraceResult, TraceResult)> = None;
1059+
1060+
for i in 0..sample_count {
1061+
let t = i as f64 / (sample_count - 1).max(1) as f64;
1062+
let seed_y = options.y_min + t * (options.y_max - options.y_min);
1063+
let Some(trace) = trace_seed(seed_y) else {
1064+
continue;
1065+
};
1066+
if trace.side.is_none() {
1067+
return Some(trace.streamline);
1068+
}
1069+
1070+
if let Some(prev) = &previous {
1071+
if prev.side.is_some() && trace.side.is_some() && trace.side != prev.side {
1072+
bracket = Some((prev.clone(), trace));
1073+
break;
1074+
}
1075+
}
1076+
1077+
previous = Some(trace);
1078+
}
1079+
1080+
let Some((mut lo, mut hi)) = bracket else {
1081+
return None;
1082+
};
1083+
if lo.seed_y > hi.seed_y {
1084+
std::mem::swap(&mut lo, &mut hi);
1085+
}
1086+
let mut best = if lo.closest_distance <= hi.closest_distance {
1087+
lo.clone()
1088+
} else {
1089+
hi.clone()
1090+
};
1091+
1092+
for _ in 0..32 {
1093+
if (hi.seed_y - lo.seed_y).abs() <= seed_tol {
1094+
break;
1095+
}
1096+
1097+
let y_mid = 0.5 * (lo.seed_y + hi.seed_y);
1098+
let Some(mid) = trace_seed(y_mid) else {
1099+
break;
1100+
};
1101+
if mid.side.is_none() {
1102+
return Some(mid.streamline);
1103+
}
1104+
if mid.closest_distance < best.closest_distance {
1105+
best = mid.clone();
1106+
}
1107+
1108+
if mid.side == lo.side {
1109+
lo = mid;
1110+
} else {
1111+
hi = mid;
1112+
}
1113+
}
1114+
1115+
Some(best.streamline)
1116+
}
1117+
1118+
/// Build the streamline whose stream-function value brackets `psi_0`.
1119+
pub fn build_dividing_streamline(
1120+
nodes: &[Point],
1121+
gamma: &[f64],
1122+
alpha: f64,
1123+
v_inf: f64,
1124+
_psi_0: f64,
1125+
options: &StreamlineOptions,
1126+
) -> Option<Vec<(f64, f64)>> {
1127+
let field = |x: f64, y: f64| velocity_at(x, y, nodes, gamma, alpha, v_inf);
1128+
build_dividing_streamline_internal(&field, nodes, None, options)
1129+
}
1130+
9071131
/// Build streamlines using the viscous velocity field (vortex + source panels).
9081132
pub fn build_streamlines_viscous(
9091133
nodes: &[Point],
@@ -966,6 +1190,25 @@ pub fn build_streamlines_viscous(
9661190
streamlines
9671191
}
9681192

1193+
/// Build the viscous dividing streamline by bracketing `psi_0` on the inflow edge
1194+
/// and bisecting between streamlines that lie above and below the separatrix.
1195+
pub fn build_dividing_streamline_viscous(
1196+
nodes: &[Point],
1197+
gamma: &[f64],
1198+
sigma: &[f64],
1199+
alpha: f64,
1200+
v_inf: f64,
1201+
_psi_0: f64,
1202+
wake_panels: Option<&WakePanels>,
1203+
effective_body: Option<&[Point]>,
1204+
options: &StreamlineOptions,
1205+
) -> Option<Vec<(f64, f64)>> {
1206+
let field = |x: f64, y: f64| {
1207+
velocity_at_with_sources(x, y, nodes, gamma, sigma, alpha, v_inf, wake_panels)
1208+
};
1209+
build_dividing_streamline_internal(&field, nodes, effective_body, options)
1210+
}
1211+
9691212
#[cfg(test)]
9701213
mod tests {
9711214
use super::*;
@@ -1056,4 +1299,67 @@ mod tests {
10561299
let finite_count = grid.iter().filter(|&&v| v.is_finite()).count();
10571300
assert!(finite_count > 50, "Should have many finite values outside airfoil");
10581301
}
1302+
1303+
#[test]
1304+
fn test_build_dividing_streamline_brackets_circle_stagnation() {
1305+
let circle = make_circle(96, 0.5);
1306+
let gamma = vec![0.0; circle.len()];
1307+
let options = StreamlineOptions {
1308+
seed_count: 25,
1309+
seed_x: -1.0,
1310+
y_min: -1.0,
1311+
y_max: 1.0,
1312+
step_size: 0.01,
1313+
max_steps: 400,
1314+
x_min: -1.2,
1315+
x_max: 1.5,
1316+
};
1317+
1318+
let streamline = build_dividing_streamline(&circle, &gamma, 0.0, 1.0, 0.0, &options)
1319+
.expect("expected dividing streamline");
1320+
1321+
let seed = streamline.first().copied().expect("seed point");
1322+
let last = streamline.last().copied().expect("terminal point");
1323+
1324+
assert!(seed.1.abs() < 1e-3, "seed should converge to y=0, got {}", seed.1);
1325+
assert!(
1326+
(last.0 + 0.5).abs() < 0.05,
1327+
"streamline should end near stagnation x=-0.5, got {}",
1328+
last.0
1329+
);
1330+
assert!(last.1.abs() < 0.05, "streamline should remain near y=0, got {}", last.1);
1331+
}
1332+
1333+
#[test]
1334+
fn test_build_dividing_streamline_viscous_matches_zero_source_case() {
1335+
let circle = make_circle(96, 0.5);
1336+
let gamma = vec![0.0; circle.len()];
1337+
let sigma = vec![0.0; circle.len()];
1338+
let options = StreamlineOptions {
1339+
seed_count: 25,
1340+
seed_x: -1.0,
1341+
y_min: -1.0,
1342+
y_max: 1.0,
1343+
step_size: 0.01,
1344+
max_steps: 400,
1345+
x_min: -1.2,
1346+
x_max: 1.5,
1347+
};
1348+
1349+
let streamline = build_dividing_streamline_viscous(
1350+
&circle,
1351+
&gamma,
1352+
&sigma,
1353+
0.0,
1354+
1.0,
1355+
0.0,
1356+
None,
1357+
None,
1358+
&options,
1359+
)
1360+
.expect("expected viscous dividing streamline");
1361+
1362+
let seed = streamline.first().copied().expect("seed point");
1363+
assert!(seed.1.abs() < 1e-3, "seed should converge to y=0, got {}", seed.1);
1364+
}
10591365
}

0 commit comments

Comments
 (0)