Skip to content
Merged
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@
- `Solver(body_aero; kwargs...)` and `Solver(body_aero, settings)` are deprecated and warn
on use; build the solver with `Solver(settings)` or
`Solver(n_panels, n_unrefined_sections)` instead.
- BREAKING: `obj_to_yaml` and `perpendicular_sections` spread the sections evenly over
the span, measured along the quarter-chord line without its chordwise component,
instead of over leading-edge arc length, and `wingtip_distance` is that spanwise
length. A tip whose leading edge runs aft no longer gathers sections into its last
centimetres. The same mesh and settings give different section positions, so a tuned
`wingtip_distance` and any geometry generated from one have to be redone.
`march_edges` no longer returns `arclen`.

### Fixed

Expand Down
6 changes: 3 additions & 3 deletions docs/src/airfoil_pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ The conversion runs four stages per spanwise station:
## 1. Slice

[`perpendicular_sections`](@ref VortexStepMethod.ObjAdapter.perpendicular_sections)
places `n_sections` stations at equal leading-edge arc-length intervals and cuts the
places `n_sections` stations evenly over the span and cuts the
mesh *perpendicular to the local span*, rather than along a fixed global plane. On a
curved kite tip a fixed-plane cut would smear the profile out and exaggerate the
chord; a perpendicular cut keeps each airfoil undistorted. Each slice comes back as a
Expand Down Expand Up @@ -113,8 +113,8 @@ For each unique airfoil id `j`, `obj_to_yaml` writes into `output_dir`:
directory

A tip that tapers to a point has no airfoil to slice, so the outermost stations stop at
the last slice that still has a chord; `wingtip_distance` moves them a further arc length
inboard when the slices just short of the tip are still too thin to analyse. A
the last slice that still has a chord; `wingtip_distance` moves them a further spanwise
length inboard when the slices just short of the tip are still too thin to analyse. A
near-vanishing slice that does get through can shrink-wrap to an implausibly thick blob;
such a degenerate section reuses its nearest valid neighbour's airfoil and polar while
keeping its own edge positions, and a warning lists the reuse. All floats are rounded to millimetre
Expand Down
2 changes: 1 addition & 1 deletion docs/src/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ wings:
n_bins: 60 # leading-edge stations marched across the span
# rows of the mesh-to-slicer rotation, whose x = chord, y = span, z = up
rotation: [[0, 0, -1], [-1, 0, 0], [0, 1, 0]]
wingtip_distance: 0.0 # arc length the outermost sections stop short [m]
wingtip_distance: 0.0 # span the outermost sections stop short [m]
clearance: 0.006 # shrink-wrap offset outside the cloud [chord fraction]
min_concave_radius: 0.02 # shrink-wrap rolling-ball radius [chord fraction]

Expand Down
47 changes: 24 additions & 23 deletions src/obj_adapter/obj_slice.jl
Original file line number Diff line number Diff line change
Expand Up @@ -270,16 +270,16 @@ function slice_mesh_at_plane(vertices, faces, point, normal; tol=1e-6)
end

"""
march_edges(vertices, faces; step) -> (; le, te, point, tangent, arclen)
march_edges(vertices, faces; step) -> (; le, te, point, tangent)

March the leading edge outward from mid-span in both directions in steps of arc
length `step`. Each cut is a vertical spanwise plane (both the chordwise and vertical
components of the running LE tangent dropped from the normal) so a tip that curls
downward can't tilt the plane toward horizontal, where its min-chord "LE" pick would
jump across the wing. Marching stops when the leading edge stops advancing spanwise.
Cuts sample mesh *edges*, so the picks are robust to vertex density. Returns, ordered
along the span, the LE/TE points, each cut's plane origin and tangent, and the
cumulative LE arc length. Build the airfoil for a chosen station with `build_section`.
along the span, the LE/TE points and each cut's plane origin and tangent. Build the
airfoil for a chosen station with `build_section`.
"""
function march_edges(vertices, faces; step)
ys = [v[2] for v in vertices]
Expand Down Expand Up @@ -318,7 +318,7 @@ function march_edges(vertices, faces; step)
probe_mid = prev_le .+ mid .* tangent
here = cut(probe_mid, tangent)
# Reject a near-degenerate tip slice whose min-chord "LE" has jumped
# chordwise (an artifact that would skew the leading-edge arc length).
# chordwise (an artifact that would misplace the tip station).
valid = here !== nothing &&
abs(here.le[1] - prev_le[1]) < step &&
build_section(vertices, faces, here.le, here.te,
Expand All @@ -340,12 +340,8 @@ function march_edges(vertices, faces; step)

center = (; mid_cut.le, mid_cut.te, point=[0.0, y_mid, 0.0], tangent=[0.0, 1.0, 0.0])
rows = vcat(reverse(march(-1.0)), [center], march(1.0))
arclen = zeros(length(rows))
for i in 2:length(rows)
arclen[i] = arclen[i-1] + norm(rows[i].le - rows[i-1].le)
end
return (; le=[r.le for r in rows], te=[r.te for r in rows],
point=[r.point for r in rows], tangent=[r.tangent for r in rows], arclen)
point=[r.point for r in rows], tangent=[r.tangent for r in rows])
end

"""
Expand Down Expand Up @@ -429,12 +425,12 @@ end

Extract `n_sections` airfoil cross-sections following a curved or swept span. The
leading edge is marched into `n_bins` stations (`march_edges`); the airfoil is
built (`build_section`) at the marched station nearest each equal
**leading-edge arc-length** target. Each section is
built (`build_section`) at the marched station nearest each of `n_sections` targets
spread evenly over the span ([`station_indices`](@ref)). Each section is
`(; LE_point, TE_point, span_dir, contour3d, x_airfoil, y_airfoil)`.

Stations closed to a point at the tips are skipped ([`station_indices`](@ref)), and
`wingtip_distance` insets the outermost sections a further arc length.
Stations closed to a point at the tips are skipped, and `wingtip_distance` [m]
insets the outermost sections a further spanwise length.

The slicer assumes `x` = chordwise, `y` = spanwise, `z` = up. Pass a `3×3` rotation
matrix to reorient a mesh stored in another convention before slicing.
Expand All @@ -455,19 +451,24 @@ end
"""
station_indices(march, n; wingtip_distance=0.0, min_chord_frac=0.01) -> Vector{Int}

Indices of the [`march_edges`](@ref) stations nearest `n` targets spread over the
leading-edge arc length. Stations whose chord has closed to less than
`min_chord_frac` of the longest one are left out of that range first, so a wing
tapering to a point puts its outermost sections on the last stations that still
have an airfoil to slice rather than on the point itself. The remaining first and
last targets sit a further `wingtip_distance` (arc length) inboard.
Indices of the [`march_edges`](@ref) stations nearest `n` targets spread evenly over
the spanwise length of the quarter-chord line: its arc length with the chordwise `x`
component dropped. Stations whose chord has closed to less than `min_chord_frac` of
the longest one are left out of that range first, so a wing tapering to a point puts
its outermost sections on the last stations that still have an airfoil to slice. The
remaining first and last targets sit a further `wingtip_distance` [m] inboard.
"""
function station_indices(march, n; wingtip_distance=0.0, min_chord_frac=0.01)
chords = [norm(te .- le) for (le, te) in zip(march.le, march.te)]
usable = findall(≥(min_chord_frac * maximum(chords)), chords)
arclen = march.arclen
inner, outer = arclen[first(usable)], arclen[last(usable)]
quarter_chord = [le .+ 0.25 .* (te .- le) for (le, te) in zip(march.le, march.te)]
span = zeros(length(quarter_chord))
for i in 2:length(span)
step = quarter_chord[i] .- quarter_chord[i-1]
span[i] = span[i-1] + hypot(step[2], step[3])
end
inner, outer = span[first(usable)], span[last(usable)]
d = clamp(wingtip_distance, 0.0, (outer - inner) / 2)
n == 1 && return [argmin(abs.(arclen .- (inner + outer) / 2))]
return [argmin(abs.(arclen .- t)) for t in range(inner + d, outer - d, n)]
n == 1 && return [argmin(abs.(span .- (inner + outer) / 2))]
return [argmin(abs.(span .- t)) for t in range(inner + d, outer - d, n)]
end
9 changes: 4 additions & 5 deletions src/obj_adapter/obj_to_yaml.jl
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,13 @@ end

Convert a 3D wing `.obj` mesh to the native YAML geometry route.

Stations are placed at equal leading-edge arc-length intervals and sliced
Stations are spread evenly over the span ([`station_indices`](@ref)) and sliced
perpendicular to the local span (see [`perpendicular_sections`](@ref)), which
keeps the airfoil undistorted near curved tips; each shape is then shrink-wrapped
into a clean airfoil and evaluated with `aero_solver`. The leading edge is marched
into `n_bins` stations. A tip that tapers to a
point carries no airfoil, so the outermost stations stop at the last slice that
still has a chord ([`station_indices`](@ref)); `wingtip_distance` moves them a
further arc length inboard.
into `n_bins` stations. A tip that tapers to a point carries no airfoil, so the
outermost stations stop at the last slice that still has a chord; `wingtip_distance`
moves them a further spanwise length inboard.

`aero_solver` selects the 2D-airfoil backend: [`NeuralFoilSolver`](@ref) (default,
fast) or [`XFoilSolver`](@ref) (viscous panel code); pass `aero_solver=XFoilSolver()`
Expand Down
2 changes: 1 addition & 1 deletion src/settings.jl
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ slices as an unconfigured call.
trace (default `60`).
- `rotation`: Rows of the mesh-to-slicer rotation, which brings the mesh into the
slicer's convention of x = chord, y = span, z = up (default the identity).
- `wingtip_distance`: Arc length [m] the outermost sections stop short of each tip
- `wingtip_distance`: Spanwise length [m] the outermost sections stop short of each tip
(default `0.0`).
- `clearance`: Shrink-wrap offset [chord fraction] the contour holds outside every
cloud point, and the radius its convex corners are rounded at (default
Expand Down
19 changes: 19 additions & 0 deletions test/obj_adapter/test_obj_adapter.jl
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,25 @@ obj_path = normpath(joinpath(@__DIR__, "..", "..",
end
end

@testset "station_indices spreads sections evenly in span past a raked tip" begin
# Straight LE over |y| ≤ 1, then tip caps whose LE runs 40 mm aft per 3 mm span.
cap = [(1.0 + 0.003k, 0.04k) for k in 1:20]
stations = vcat(reverse([(-y, x) for (y, x) in cap]),
[(y, 0.0) for y in -1.0:0.05:1.0], cap)
march = (; le=[[x, y, 0.0] for (y, x) in stations],
te=[[1.0, y, 0.0] for (y, _) in stations])
half_span = 1.06

y = [march.le[i][2] for i in ObjAdapter.station_indices(march, 9)]
@test all(isapprox.(diff(y), 2half_span / 8; atol=0.05))

wingtip_distance = 0.3
y = [march.le[i][2]
for i in ObjAdapter.station_indices(march, 9; wingtip_distance)]
@test y[1] ≈ -(half_span - wingtip_distance) atol=0.05
@test y[end] ≈ half_span - wingtip_distance atol=0.05
end

@testset "obj_to_yaml (alpha,delta) matrices -> loadable Wing (NeuralFoil)" begin
@test isfile(yaml)
@test isfile(joinpath(out, "polars", "1.csv"))
Expand Down
5 changes: 3 additions & 2 deletions test/plotting/test_plotting.jl
Original file line number Diff line number Diff line change
Expand Up @@ -494,8 +494,9 @@ section_polar_curves(plt, curve) = Tuple(channel[curve] for channel in plt.Y)
@test deflected[1] ≈ panel.cl_interp.(flap_alphas, delta)
@test deflected[2] ≈ panel.cd_interp.(flap_alphas, delta)
@test deflected[3] ≈ panel.cm_interp.(flap_alphas, delta)
@test stored[2] ≈ panel.cd_interp.(flap_alphas, panel.delta)
@test deflected[2] != stored[2]
@test stored[1] ≈ panel.cl_interp.(flap_alphas, panel.delta)
# cd is stored to four decimals, which a 1 deg deflection does not reach; cl does.
@test deflected[1] != stored[1]
end

@testset "is_save writes section_polars.png" begin
Expand Down
Loading