From 9af06901b1bff043e93343779d6eef3174431879 Mon Sep 17 00:00:00 2001 From: 1-Bort-1 <323661610+1-Bort-1@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:33:12 +0200 Subject: [PATCH 1/7] Spread sliced sections evenly over the span, not over leading-edge arc length station_indices now places its targets along the quarter-chord line with the chordwise component dropped, so a tip whose leading edge runs aft no longer gathers sections, and wingtip_distance is a spanwise inset. march_edges drops its arclen field, which only station_indices read. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 4 +++ docs/src/airfoil_pipeline.md | 6 ++-- docs/src/settings.md | 2 +- src/obj_adapter/obj_slice.jl | 47 ++++++++++++++-------------- src/obj_adapter/obj_to_yaml.jl | 9 +++--- src/settings.jl | 2 +- test/obj_adapter/test_obj_adapter.jl | 19 +++++++++++ 7 files changed, 56 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c00c754..de2e0921 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ - The Makie `plot!` methods for a `Panel` or a `BodyAerodynamics` return a `Vector{Makie.AbstractPlot}` instead of a `Vector{Any}`; for a `BodyAerodynamics` drawn as flat panels it is one flat list rather than a list per panel. +- `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. ### Fixed diff --git a/docs/src/airfoil_pipeline.md b/docs/src/airfoil_pipeline.md index 7d8b7a58..9aca652b 100644 --- a/docs/src/airfoil_pipeline.md +++ b/docs/src/airfoil_pipeline.md @@ -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 @@ -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 diff --git a/docs/src/settings.md b/docs/src/settings.md index 3b2fba46..4eb611e4 100644 --- a/docs/src/settings.md +++ b/docs/src/settings.md @@ -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] diff --git a/src/obj_adapter/obj_slice.jl b/src/obj_adapter/obj_slice.jl index 729895f7..fd3f5b43 100644 --- a/src/obj_adapter/obj_slice.jl +++ b/src/obj_adapter/obj_slice.jl @@ -270,7 +270,7 @@ 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 @@ -278,8 +278,8 @@ components of the running LE tangent dropped from the normal) so a tip that curl 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] @@ -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, @@ -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 """ @@ -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. @@ -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 diff --git a/src/obj_adapter/obj_to_yaml.jl b/src/obj_adapter/obj_to_yaml.jl index 10dc038f..72180949 100644 --- a/src/obj_adapter/obj_to_yaml.jl +++ b/src/obj_adapter/obj_to_yaml.jl @@ -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()` diff --git a/src/settings.jl b/src/settings.jl index a5aade8c..4c486b5a 100644 --- a/src/settings.jl +++ b/src/settings.jl @@ -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 diff --git a/test/obj_adapter/test_obj_adapter.jl b/test/obj_adapter/test_obj_adapter.jl index e2f0ae91..317fe88b 100644 --- a/test/obj_adapter/test_obj_adapter.jl +++ b/test/obj_adapter/test_obj_adapter.jl @@ -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")) From 1fe72b9a2ef3d0e3c91a181597fd02011119eca5 Mon Sep 17 00:00:00 2001 From: 1-Bort-1 <323661610+1-Bort-1@users.noreply.github.com> Date: Sun, 20 Sep 2026 18:19:10 +0200 Subject: [PATCH 2/7] Mark the section-placement change BREAKING in the changelog The same mesh and wingtip_distance now give different section positions, and march_edges no longer returns arclen. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de2e0921..b9c1868a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,13 @@ - The Makie `plot!` methods for a `Panel` or a `BodyAerodynamics` return a `Vector{Makie.AbstractPlot}` instead of a `Vector{Any}`; for a `BodyAerodynamics` drawn as flat panels it is one flat list rather than a list per panel. -- `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. +- 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 From a2cd2f2a5044a0f8cc6c0f072bfc95b0d116559a Mon Sep 17 00:00:00 2001 From: 1-Bort-1 <323661610+1-Bort-1@users.noreply.github.com> Date: Sun, 20 Sep 2026 18:53:53 +0200 Subject: [PATCH 3/7] Guard the plotted deflection on cl, which carries it, not on cd The polar tables are written with four decimals, and a 1 deg deflection moves Cd by about 2e-5 on these sections, so the two curves are bit-identical once the fixture re-slices. Co-Authored-By: Claude Opus 5 --- test/plotting/test_plotting.jl | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/plotting/test_plotting.jl b/test/plotting/test_plotting.jl index 669bf42f..df41fca9 100644 --- a/test/plotting/test_plotting.jl +++ b/test/plotting/test_plotting.jl @@ -492,7 +492,8 @@ section_polar_curves(plt, curve) = Tuple(channel[curve] for channel in plt.Y) @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] + # cd is stored to four decimals, which a 1° deflection does not reach; cl does. + @test deflected[1] != stored[1] end @testset "is_save writes section_polars.png" begin From 2dde816752220874641f1de06a1728d4e4d6e22e Mon Sep 17 00:00:00 2001 From: 1-Bort-1 <323661610+1-Bort-1@users.noreply.github.com> Date: Sun, 20 Sep 2026 18:55:28 +0200 Subject: [PATCH 4/7] Pin the undeflected curve on cl as well The cd check could not tell the two operating points apart, so it held whatever delta the plot used. Co-Authored-By: Claude Opus 5 --- test/plotting/test_plotting.jl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/plotting/test_plotting.jl b/test/plotting/test_plotting.jl index df41fca9..0d263e89 100644 --- a/test/plotting/test_plotting.jl +++ b/test/plotting/test_plotting.jl @@ -491,8 +491,8 @@ 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) - # cd is stored to four decimals, which a 1° deflection does not reach; cl does. + @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 From 40f69c3a30224ff2273eea6d6a44ffbe17ab8f21 Mon Sep 17 00:00:00 2001 From: 1-Bort-1 <323661610+1-Bort-1@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:29:28 +0200 Subject: [PATCH 5/7] Place LINEAR and COSINE panels by the slicer's spanwise length Refinement measured spanwise position as the full 3D quarter-chord length, the slicer as that length with the chordwise x step dropped. Both now take it from one spanwise_arc_length, and span_position replaces the two hand-rolled segment searches, so a swept wing gets panels of equal span. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 12 +- docs/src/private_functions.md | 2 + src/VortexStepMethod.jl | 4 +- src/obj_adapter/obj_slice.jl | 17 +-- src/wing_geometry.jl | 140 ++++++++--------------- test/wing_geometry/test_wing_geometry.jl | 10 ++ 6 files changed, 72 insertions(+), 113 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0f09f8d..d0078c64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,11 +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 +- BREAKING: spanwise position is measured along the quarter-chord line without its + chordwise component, in both places that spread sections over the span. + `obj_to_yaml` and `perpendicular_sections` used leading-edge arc length, and + `wingtip_distance` is now that spanwise length; `LINEAR` and `COSINE` refinement used + the full 3D quarter-chord length. A tip whose leading edge runs aft no longer gathers + sections into its last centimetres, and a swept wing gets panels of equal span. The + same mesh, wing and settings give different section and panel positions, so a tuned `wingtip_distance` and any geometry generated from one have to be redone. `march_edges` no longer returns `arclen`. diff --git a/docs/src/private_functions.md b/docs/src/private_functions.md index ad67330d..4f57d2ab 100644 --- a/docs/src/private_functions.md +++ b/docs/src/private_functions.md @@ -91,6 +91,8 @@ unrefined_section_range deform! compute_refined_panel_mapping! compute_refined_section_interpolation! +spanwise_arc_length +span_position copy_sections copy_sections_to_refined! _apply_refined_section_thetas! diff --git a/src/VortexStepMethod.jl b/src/VortexStepMethod.jl index f28a73f5..ed805cc8 100644 --- a/src/VortexStepMethod.jl +++ b/src/VortexStepMethod.jl @@ -286,8 +286,8 @@ const LEI_AIRFOIL_BREUKELS = POLY Enumeration of the implemented panel distributions. # Elements -- LINEAR # Linear distribution -- COSINE # Cosine distribution +- LINEAR # Even spacing over the spanwise arc length, see [`refine!`](@ref) +- COSINE # Cosine spacing over the spanwise arc length - `SPLIT_PROVIDED` # Split provided sections - `UNCHANGED` # 1:1 copy of unrefined to refined sections (no interpolation) - `BILLOWING` # Split provided + sinusoidal TE billowing between ribs diff --git a/src/obj_adapter/obj_slice.jl b/src/obj_adapter/obj_slice.jl index fd3f5b43..210c19af 100644 --- a/src/obj_adapter/obj_slice.jl +++ b/src/obj_adapter/obj_slice.jl @@ -452,21 +452,16 @@ 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 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. +their [`spanwise_arc_length`](@ref VortexStepMethod.spanwise_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. 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) - 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 + span = VortexStepMethod.spanwise_arc_length(march.le, march.te) inner, outer = span[first(usable)], span[last(usable)] d = clamp(wingtip_distance, 0.0, (outer - inner) / 2) n == 1 && return [argmin(abs.(span .- (inner + outer) / 2))] diff --git a/src/wing_geometry.jl b/src/wing_geometry.jl index 7329dcc2..e1717bcd 100644 --- a/src/wing_geometry.jl +++ b/src/wing_geometry.jl @@ -869,8 +869,8 @@ body_aero = BodyAerodynamics([wing]) # Create aerodynamics ``` # Distribution Methods -- `LINEAR`: Linear interpolation between sections -- `COSINE`: Cosine spacing (more panels near tips) +- `LINEAR`: Even spacing over the [`spanwise_arc_length`](@ref) of the sections +- `COSINE`: Cosine spacing over that length (more panels near tips) - `SPLIT_PROVIDED`: Split each unrefined section into sub-panels - `UNCHANGED`: 1:1 copy when n_unrefined_sections == n_panels+1 @@ -1071,6 +1071,38 @@ function compute_refined_panel_mapping!(wing::AbstractWing) return nothing end +""" + spanwise_arc_length(sections) -> Vector{Float64} + spanwise_arc_length(le_points, te_points) -> Vector{Float64} + +Cumulative length [m] of the quarter-chord line through the sections, or through the +given leading and trailing edges, with the chordwise `x` component of each step dropped. +""" +function spanwise_arc_length(le_points, te_points) + arc = zeros(length(le_points)) + for i in 2:length(arc) + step = 0.75 .* (le_points[i] .- le_points[i-1]) .+ + 0.25 .* (te_points[i] .- te_points[i-1]) + arc[i] = arc[i-1] + hypot(step[2], step[3]) + end + return arc +end + +spanwise_arc_length(sections::AbstractVector{<:Section}) = spanwise_arc_length( + [section.LE_point for section in sections], [section.TE_point for section in sections]) + +""" + span_position(arc, target) -> (index, fraction) + +Segment of the cumulative lengths `arc` that reaches `target` first, and the fraction +of that segment's length at which `target` lies (0 on a segment of zero length). +""" +function span_position(arc, target) + index = clamp(searchsortedfirst(arc, target) - 1, 1, length(arc) - 1) + segment = arc[index + 1] - arc[index] + return index, segment > 1e-30 ? (target - arc[index]) / segment : 0.0 +end + """ compute_refined_section_interpolation!(wing::AbstractWing; reuse_aero_data=false) @@ -1081,7 +1113,7 @@ sections. For refined section i, the interpolated value is: out[i] = weight[i] * unrefined[left_idx[i]] + (1 - weight[i]) * unrefined[left_idx[i] + 1] -Positions are quarter-chord arc-length along the unrefined and refined sections. +Positions are the [`spanwise_arc_length`](@ref) of the unrefined and refined sections. The first refined section is pinned to `left_idx == 1`, `weight == 1` (returns `unrefined[1]` exactly) and the last refined section to `left_idx == n_unref - 1`, `weight == 0` (returns `unrefined[end]` exactly). @@ -1112,48 +1144,12 @@ function compute_refined_section_interpolation!(wing::AbstractWing{T}; return nothing end - @inline qc(s, j) = s.LE_point[j] + 0.25 * (s.TE_point[j] - s.LE_point[j]) - - s_unref = Vector{Float64}(undef, n_unref) - s_unref[1] = 0.0 - for k in 2:n_unref - u_prev = wing.unrefined_sections[k - 1] - u_cur = wing.unrefined_sections[k] - d = 0.0 - for j in 1:3 - dq = qc(u_cur, j) - qc(u_prev, j) - d += dq * dq - end - s_unref[k] = s_unref[k - 1] + sqrt(d) - end - - s_ref = Vector{Float64}(undef, n_sections) - s_ref[1] = 0.0 - for i in 2:n_sections - r_prev = wing.refined_sections[i - 1] - r_cur = wing.refined_sections[i] - d = 0.0 - for j in 1:3 - dq = qc(r_cur, j) - qc(r_prev, j) - d += dq * dq - end - s_ref[i] = s_ref[i - 1] + sqrt(d) - end - + s_unref = spanwise_arc_length(wing.unrefined_sections) + s_ref = spanwise_arc_length(view(wing.refined_sections, 1:n_sections)) for i in 1:n_sections - target = s_ref[i] - left = 1 - for k in 1:(n_unref - 1) - if s_unref[k + 1] >= target || k == n_unref - 1 - left = k - break - end - end - seg = s_unref[left + 1] - s_unref[left] - t = seg > 1e-30 ? (target - s_unref[left]) / seg : 0.0 - t = clamp(t, 0.0, 1.0) + left, t = span_position(s_unref, s_ref[i]) wing.refined_section_left_idx[i] = Int16(left) - wing.refined_section_weight[i] = T(1.0 - t) + wing.refined_section_weight[i] = T(1.0 - clamp(t, 0.0, 1.0)) end wing.refined_section_left_idx[1] = Int16(1) @@ -1307,8 +1303,8 @@ end refine_mesh_for_linear_cosine_distribution!(wing, idx, dist, n_sections, sections; endpoints, reuse_aero_data) -Refine wing mesh using linear or cosine spacing. Reads LE/TE -directly from a `Vector{Section}` (zero matrix allocations). +Refine wing mesh using linear or cosine spacing over the [`spanwise_arc_length`](@ref) +of `sections`. """ function refine_mesh_for_linear_cosine_distribution!( wing::AbstractWing, @@ -1319,35 +1315,19 @@ function refine_mesh_for_linear_cosine_distribution!( endpoints::Bool=true, reuse_aero_data::Bool=false) - n_input = length(sections) - @inline _le(s, j) = @inbounds s.LE_point[j] @inline _te(s, j) = @inbounds s.TE_point[j] - # Compute total quarter-chord length (scalar only) - qc_total = 0.0 - for i in 1:(n_input - 1) - d = 0.0 - s_i = sections[i]; s_ip = sections[i+1] - for j in 1:3 - qc_j = (_le(s_ip, j) + 0.25 * (_te(s_ip, j) - - _le(s_ip, j))) - - (_le(s_i, j) + 0.25 * (_te(s_i, j) - - _le(s_i, j))) - d += qc_j * qc_j - end - qc_total += sqrt(d) - end - + span = spanwise_arc_length(sections) new_le = MVec3(0.0, 0.0, 0.0) new_te = MVec3(0.0, 0.0, 0.0) dir = MVec3(0.0, 0.0, 0.0) for i in 1:n_sections target = if spanwise_distribution == LINEAR - qc_total * (i - 1) / (n_sections - 1) + span[end] * (i - 1) / (n_sections - 1) elseif spanwise_distribution == COSINE - qc_total * (1 - cos(π * (i - 1) / + span[end] * (1 - cos(π * (i - 1) / (n_sections - 1))) / 2 else throw(ArgumentError( @@ -1355,38 +1335,8 @@ function refine_mesh_for_linear_cosine_distribution!( "$spanwise_distribution")) end - cum = 0.0 - si = 1 - for k in 1:(n_input - 1) - d = 0.0 - s_k = sections[k]; s_kp = sections[k+1] - for j in 1:3 - qc_j = (_le(s_kp, j) + 0.25 * (_te(s_kp, j) - - _le(s_kp, j))) - - (_le(s_k, j) + 0.25 * (_te(s_k, j) - - _le(s_k, j))) - d += qc_j * qc_j - end - next_cum = cum + sqrt(d) - if next_cum >= target || k == n_input - 1 - si = k - break - end - cum = next_cum - end + si, t = span_position(span, target) s_l = sections[si]; s_r = sections[si + 1] - - seg_d = 0.0 - for j in 1:3 - qc_j = (_le(s_r, j) + 0.25 * (_te(s_r, j) - - _le(s_r, j))) - - (_le(s_l, j) + 0.25 * (_te(s_l, j) - - _le(s_l, j))) - seg_d += qc_j * qc_j - end - seg_len = sqrt(seg_d) - t = seg_len > 1e-30 ? - (target - cum) / seg_len : 0.0 wl = 1 - t; wr = t lc_len = 0.0; rc_len = 0.0 diff --git a/test/wing_geometry/test_wing_geometry.jl b/test/wing_geometry/test_wing_geometry.jl index c78a5dba..900542e4 100644 --- a/test/wing_geometry/test_wing_geometry.jl +++ b/test/wing_geometry/test_wing_geometry.jl @@ -229,6 +229,16 @@ end end end + @testset "LINEAR panels are equal in span on a wing swept past a kink" begin + wing = Wing(4; spanwise_distribution=LINEAR) + add_section!(wing, [1.0, 2.0, 0.0], [2.0, 2.0, 0.0], INVISCID) + add_section!(wing, [0.0, 1.0, 0.0], [1.0, 1.0, 0.0], INVISCID) + add_section!(wing, [0.0, 0.0, 0.0], [1.0, 0.0, 0.0], INVISCID) + refine!(wing) + y = [section.LE_point[2] for section in wing.refined_sections] + @test y ≈ [2.0, 1.5, 1.0, 0.5, 0.0] + end + @testset "Single panel" begin n_panels = 1 span = 20.0 From 09d4e844fa09a3ce32f3b0132a640fcd86f58bce Mon Sep 17 00:00:00 2001 From: 1-Bort-1 <323661610+1-Bort-1@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:42:32 +0200 Subject: [PATCH 6/7] Measure the spanwise step without allocating The cumulative-length helper allocated three arrays per call, which took refine! past its allocation budgets in test/bench.jl (UNCHANGED 12 > 5). spanwise_step now gives the length between two sections as a scalar and span_position walks the sections, so refine! allocates less than before the branch (UNCHANGED 0). Co-Authored-By: Claude Opus 5 --- docs/src/private_functions.md | 2 +- src/VortexStepMethod.jl | 4 +-- src/obj_adapter/obj_slice.jl | 8 +++-- src/wing_geometry.jl | 68 ++++++++++++++++++----------------- 4 files changed, 45 insertions(+), 37 deletions(-) diff --git a/docs/src/private_functions.md b/docs/src/private_functions.md index 4f57d2ab..d056008c 100644 --- a/docs/src/private_functions.md +++ b/docs/src/private_functions.md @@ -91,7 +91,7 @@ unrefined_section_range deform! compute_refined_panel_mapping! compute_refined_section_interpolation! -spanwise_arc_length +spanwise_step span_position copy_sections copy_sections_to_refined! diff --git a/src/VortexStepMethod.jl b/src/VortexStepMethod.jl index ed805cc8..70ff4a82 100644 --- a/src/VortexStepMethod.jl +++ b/src/VortexStepMethod.jl @@ -286,8 +286,8 @@ const LEI_AIRFOIL_BREUKELS = POLY Enumeration of the implemented panel distributions. # Elements -- LINEAR # Even spacing over the spanwise arc length, see [`refine!`](@ref) -- COSINE # Cosine spacing over the spanwise arc length +- LINEAR # Even spacing over the span, see [`refine!`](@ref) +- COSINE # Cosine spacing over the span - `SPLIT_PROVIDED` # Split provided sections - `UNCHANGED` # 1:1 copy of unrefined to refined sections (no interpolation) - `BILLOWING` # Split provided + sinusoidal TE billowing between ribs diff --git a/src/obj_adapter/obj_slice.jl b/src/obj_adapter/obj_slice.jl index 210c19af..d6dc33b5 100644 --- a/src/obj_adapter/obj_slice.jl +++ b/src/obj_adapter/obj_slice.jl @@ -452,7 +452,7 @@ 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 evenly over -their [`spanwise_arc_length`](@ref VortexStepMethod.spanwise_arc_length). Stations +their summed [`spanwise_step`](@ref VortexStepMethod.spanwise_step)s. 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 @@ -461,7 +461,11 @@ 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) - span = VortexStepMethod.spanwise_arc_length(march.le, march.te) + span = zeros(length(march.le)) + for i in 2:length(span) + span[i] = span[i-1] + VortexStepMethod.spanwise_step( + march.le[i-1], march.te[i-1], march.le[i], march.te[i]) + end inner, outer = span[first(usable)], span[last(usable)] d = clamp(wingtip_distance, 0.0, (outer - inner) / 2) n == 1 && return [argmin(abs.(span .- (inner + outer) / 2))] diff --git a/src/wing_geometry.jl b/src/wing_geometry.jl index e1717bcd..8022c205 100644 --- a/src/wing_geometry.jl +++ b/src/wing_geometry.jl @@ -869,7 +869,7 @@ body_aero = BodyAerodynamics([wing]) # Create aerodynamics ``` # Distribution Methods -- `LINEAR`: Even spacing over the [`spanwise_arc_length`](@ref) of the sections +- `LINEAR`: Even spacing over the summed [`spanwise_step`](@ref)s of the sections - `COSINE`: Cosine spacing over that length (more panels near tips) - `SPLIT_PROVIDED`: Split each unrefined section into sub-panels - `UNCHANGED`: 1:1 copy when n_unrefined_sections == n_panels+1 @@ -1072,35 +1072,38 @@ function compute_refined_panel_mapping!(wing::AbstractWing) end """ - spanwise_arc_length(sections) -> Vector{Float64} - spanwise_arc_length(le_points, te_points) -> Vector{Float64} + spanwise_step(section_a, section_b) + spanwise_step(le_a, te_a, le_b, te_b) -Cumulative length [m] of the quarter-chord line through the sections, or through the -given leading and trailing edges, with the chordwise `x` component of each step dropped. +Length [m] of the quarter-chord step from one section to the next, given as +[`Section`](@ref)s or as leading and trailing edge points, without its chordwise `x` +component. """ -function spanwise_arc_length(le_points, te_points) - arc = zeros(length(le_points)) - for i in 2:length(arc) - step = 0.75 .* (le_points[i] .- le_points[i-1]) .+ - 0.25 .* (te_points[i] .- te_points[i-1]) - arc[i] = arc[i-1] + hypot(step[2], step[3]) - end - return arc +function spanwise_step(le_a, te_a, le_b, te_b) + dy = 0.75 * (le_b[2] - le_a[2]) + 0.25 * (te_b[2] - te_a[2]) + dz = 0.75 * (le_b[3] - le_a[3]) + 0.25 * (te_b[3] - te_a[3]) + return hypot(dy, dz) end -spanwise_arc_length(sections::AbstractVector{<:Section}) = spanwise_arc_length( - [section.LE_point for section in sections], [section.TE_point for section in sections]) +spanwise_step(section_a::Section, section_b::Section) = spanwise_step( + section_a.LE_point, section_a.TE_point, section_b.LE_point, section_b.TE_point) """ - span_position(arc, target) -> (index, fraction) + span_position(sections, target) -> (index, fraction) -Segment of the cumulative lengths `arc` that reaches `target` first, and the fraction -of that segment's length at which `target` lies (0 on a segment of zero length). +Segment between `sections[index]` and `sections[index + 1]` that first reaches `target` +[m] of summed [`spanwise_step`](@ref)s, and the fraction of that segment's step at +which `target` lies (0 on a segment of zero length). """ -function span_position(arc, target) - index = clamp(searchsortedfirst(arc, target) - 1, 1, length(arc) - 1) - segment = arc[index + 1] - arc[index] - return index, segment > 1e-30 ? (target - arc[index]) / segment : 0.0 +function span_position(sections, target) + reached = 0.0 + for index in 1:(length(sections) - 1) + segment = spanwise_step(sections[index], sections[index + 1]) + if reached + segment >= target || index == length(sections) - 1 + return index, segment > 1e-30 ? (target - reached) / segment : 0.0 + end + reached += segment + end end """ @@ -1113,7 +1116,7 @@ sections. For refined section i, the interpolated value is: out[i] = weight[i] * unrefined[left_idx[i]] + (1 - weight[i]) * unrefined[left_idx[i] + 1] -Positions are the [`spanwise_arc_length`](@ref) of the unrefined and refined sections. +Positions are summed [`spanwise_step`](@ref)s along the unrefined and refined sections. The first refined section is pinned to `left_idx == 1`, `weight == 1` (returns `unrefined[1]` exactly) and the last refined section to `left_idx == n_unref - 1`, `weight == 0` (returns `unrefined[end]` exactly). @@ -1144,10 +1147,11 @@ function compute_refined_section_interpolation!(wing::AbstractWing{T}; return nothing end - s_unref = spanwise_arc_length(wing.unrefined_sections) - s_ref = spanwise_arc_length(view(wing.refined_sections, 1:n_sections)) + reached = 0.0 for i in 1:n_sections - left, t = span_position(s_unref, s_ref[i]) + i > 1 && (reached += spanwise_step(wing.refined_sections[i - 1], + wing.refined_sections[i])) + left, t = span_position(wing.unrefined_sections, reached) wing.refined_section_left_idx[i] = Int16(left) wing.refined_section_weight[i] = T(1.0 - clamp(t, 0.0, 1.0)) end @@ -1303,8 +1307,8 @@ end refine_mesh_for_linear_cosine_distribution!(wing, idx, dist, n_sections, sections; endpoints, reuse_aero_data) -Refine wing mesh using linear or cosine spacing over the [`spanwise_arc_length`](@ref) -of `sections`. +Refine wing mesh using linear or cosine spacing over the summed +[`spanwise_step`](@ref)s of `sections`. """ function refine_mesh_for_linear_cosine_distribution!( wing::AbstractWing, @@ -1318,16 +1322,16 @@ function refine_mesh_for_linear_cosine_distribution!( @inline _le(s, j) = @inbounds s.LE_point[j] @inline _te(s, j) = @inbounds s.TE_point[j] - span = spanwise_arc_length(sections) + span = sum(k -> spanwise_step(sections[k], sections[k + 1]), 1:(length(sections) - 1)) new_le = MVec3(0.0, 0.0, 0.0) new_te = MVec3(0.0, 0.0, 0.0) dir = MVec3(0.0, 0.0, 0.0) for i in 1:n_sections target = if spanwise_distribution == LINEAR - span[end] * (i - 1) / (n_sections - 1) + span * (i - 1) / (n_sections - 1) elseif spanwise_distribution == COSINE - span[end] * (1 - cos(π * (i - 1) / + span * (1 - cos(π * (i - 1) / (n_sections - 1))) / 2 else throw(ArgumentError( @@ -1335,7 +1339,7 @@ function refine_mesh_for_linear_cosine_distribution!( "$spanwise_distribution")) end - si, t = span_position(span, target) + si, t = span_position(sections, target) s_l = sections[si]; s_r = sections[si + 1] wl = 1 - t; wr = t From f06df0b95dbb4543f88811d939f9d93a1fd57521 Mon Sep 17 00:00:00 2001 From: 1-Bort-1 <323661610+1-Bort-1@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:59:01 +0200 Subject: [PATCH 7/7] Revert refinement to the full 3D quarter-chord length Reverts 09d4e84 and 40f69c3. Only the slicer drops the chordwise step: there the leading edge turns into the side of the wing at no definite point. Refinement works on sections already cut and keeps measuring the bound vortex line, the local span direction of Gaunaa et al. 2026 (CP1). Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 12 +- docs/src/private_functions.md | 2 - src/VortexStepMethod.jl | 4 +- src/obj_adapter/obj_slice.jl | 17 +-- src/wing_geometry.jl | 144 +++++++++++++++-------- test/wing_geometry/test_wing_geometry.jl | 10 -- 6 files changed, 111 insertions(+), 78 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0078c64..f0f09f8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,13 +29,11 @@ - `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: spanwise position is measured along the quarter-chord line without its - chordwise component, in both places that spread sections over the span. - `obj_to_yaml` and `perpendicular_sections` used leading-edge arc length, and - `wingtip_distance` is now that spanwise length; `LINEAR` and `COSINE` refinement used - the full 3D quarter-chord length. A tip whose leading edge runs aft no longer gathers - sections into its last centimetres, and a swept wing gets panels of equal span. The - same mesh, wing and settings give different section and panel positions, so a tuned +- 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`. diff --git a/docs/src/private_functions.md b/docs/src/private_functions.md index d056008c..ad67330d 100644 --- a/docs/src/private_functions.md +++ b/docs/src/private_functions.md @@ -91,8 +91,6 @@ unrefined_section_range deform! compute_refined_panel_mapping! compute_refined_section_interpolation! -spanwise_step -span_position copy_sections copy_sections_to_refined! _apply_refined_section_thetas! diff --git a/src/VortexStepMethod.jl b/src/VortexStepMethod.jl index 70ff4a82..f28a73f5 100644 --- a/src/VortexStepMethod.jl +++ b/src/VortexStepMethod.jl @@ -286,8 +286,8 @@ const LEI_AIRFOIL_BREUKELS = POLY Enumeration of the implemented panel distributions. # Elements -- LINEAR # Even spacing over the span, see [`refine!`](@ref) -- COSINE # Cosine spacing over the span +- LINEAR # Linear distribution +- COSINE # Cosine distribution - `SPLIT_PROVIDED` # Split provided sections - `UNCHANGED` # 1:1 copy of unrefined to refined sections (no interpolation) - `BILLOWING` # Split provided + sinusoidal TE billowing between ribs diff --git a/src/obj_adapter/obj_slice.jl b/src/obj_adapter/obj_slice.jl index d6dc33b5..fd3f5b43 100644 --- a/src/obj_adapter/obj_slice.jl +++ b/src/obj_adapter/obj_slice.jl @@ -452,19 +452,20 @@ 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 evenly over -their summed [`spanwise_step`](@ref VortexStepMethod.spanwise_step)s. 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. +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) - span = zeros(length(march.le)) + 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) - span[i] = span[i-1] + VortexStepMethod.spanwise_step( - march.le[i-1], march.te[i-1], march.le[i], march.te[i]) + 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) diff --git a/src/wing_geometry.jl b/src/wing_geometry.jl index 8022c205..7329dcc2 100644 --- a/src/wing_geometry.jl +++ b/src/wing_geometry.jl @@ -869,8 +869,8 @@ body_aero = BodyAerodynamics([wing]) # Create aerodynamics ``` # Distribution Methods -- `LINEAR`: Even spacing over the summed [`spanwise_step`](@ref)s of the sections -- `COSINE`: Cosine spacing over that length (more panels near tips) +- `LINEAR`: Linear interpolation between sections +- `COSINE`: Cosine spacing (more panels near tips) - `SPLIT_PROVIDED`: Split each unrefined section into sub-panels - `UNCHANGED`: 1:1 copy when n_unrefined_sections == n_panels+1 @@ -1071,41 +1071,6 @@ function compute_refined_panel_mapping!(wing::AbstractWing) return nothing end -""" - spanwise_step(section_a, section_b) - spanwise_step(le_a, te_a, le_b, te_b) - -Length [m] of the quarter-chord step from one section to the next, given as -[`Section`](@ref)s or as leading and trailing edge points, without its chordwise `x` -component. -""" -function spanwise_step(le_a, te_a, le_b, te_b) - dy = 0.75 * (le_b[2] - le_a[2]) + 0.25 * (te_b[2] - te_a[2]) - dz = 0.75 * (le_b[3] - le_a[3]) + 0.25 * (te_b[3] - te_a[3]) - return hypot(dy, dz) -end - -spanwise_step(section_a::Section, section_b::Section) = spanwise_step( - section_a.LE_point, section_a.TE_point, section_b.LE_point, section_b.TE_point) - -""" - span_position(sections, target) -> (index, fraction) - -Segment between `sections[index]` and `sections[index + 1]` that first reaches `target` -[m] of summed [`spanwise_step`](@ref)s, and the fraction of that segment's step at -which `target` lies (0 on a segment of zero length). -""" -function span_position(sections, target) - reached = 0.0 - for index in 1:(length(sections) - 1) - segment = spanwise_step(sections[index], sections[index + 1]) - if reached + segment >= target || index == length(sections) - 1 - return index, segment > 1e-30 ? (target - reached) / segment : 0.0 - end - reached += segment - end -end - """ compute_refined_section_interpolation!(wing::AbstractWing; reuse_aero_data=false) @@ -1116,7 +1081,7 @@ sections. For refined section i, the interpolated value is: out[i] = weight[i] * unrefined[left_idx[i]] + (1 - weight[i]) * unrefined[left_idx[i] + 1] -Positions are summed [`spanwise_step`](@ref)s along the unrefined and refined sections. +Positions are quarter-chord arc-length along the unrefined and refined sections. The first refined section is pinned to `left_idx == 1`, `weight == 1` (returns `unrefined[1]` exactly) and the last refined section to `left_idx == n_unref - 1`, `weight == 0` (returns `unrefined[end]` exactly). @@ -1147,13 +1112,48 @@ function compute_refined_section_interpolation!(wing::AbstractWing{T}; return nothing end - reached = 0.0 + @inline qc(s, j) = s.LE_point[j] + 0.25 * (s.TE_point[j] - s.LE_point[j]) + + s_unref = Vector{Float64}(undef, n_unref) + s_unref[1] = 0.0 + for k in 2:n_unref + u_prev = wing.unrefined_sections[k - 1] + u_cur = wing.unrefined_sections[k] + d = 0.0 + for j in 1:3 + dq = qc(u_cur, j) - qc(u_prev, j) + d += dq * dq + end + s_unref[k] = s_unref[k - 1] + sqrt(d) + end + + s_ref = Vector{Float64}(undef, n_sections) + s_ref[1] = 0.0 + for i in 2:n_sections + r_prev = wing.refined_sections[i - 1] + r_cur = wing.refined_sections[i] + d = 0.0 + for j in 1:3 + dq = qc(r_cur, j) - qc(r_prev, j) + d += dq * dq + end + s_ref[i] = s_ref[i - 1] + sqrt(d) + end + for i in 1:n_sections - i > 1 && (reached += spanwise_step(wing.refined_sections[i - 1], - wing.refined_sections[i])) - left, t = span_position(wing.unrefined_sections, reached) + target = s_ref[i] + left = 1 + for k in 1:(n_unref - 1) + if s_unref[k + 1] >= target || k == n_unref - 1 + left = k + break + end + end + seg = s_unref[left + 1] - s_unref[left] + t = seg > 1e-30 ? (target - s_unref[left]) / seg : 0.0 + t = clamp(t, 0.0, 1.0) wing.refined_section_left_idx[i] = Int16(left) - wing.refined_section_weight[i] = T(1.0 - clamp(t, 0.0, 1.0)) + wing.refined_section_weight[i] = T(1.0 - t) end wing.refined_section_left_idx[1] = Int16(1) @@ -1307,8 +1307,8 @@ end refine_mesh_for_linear_cosine_distribution!(wing, idx, dist, n_sections, sections; endpoints, reuse_aero_data) -Refine wing mesh using linear or cosine spacing over the summed -[`spanwise_step`](@ref)s of `sections`. +Refine wing mesh using linear or cosine spacing. Reads LE/TE +directly from a `Vector{Section}` (zero matrix allocations). """ function refine_mesh_for_linear_cosine_distribution!( wing::AbstractWing, @@ -1319,19 +1319,35 @@ function refine_mesh_for_linear_cosine_distribution!( endpoints::Bool=true, reuse_aero_data::Bool=false) + n_input = length(sections) + @inline _le(s, j) = @inbounds s.LE_point[j] @inline _te(s, j) = @inbounds s.TE_point[j] - span = sum(k -> spanwise_step(sections[k], sections[k + 1]), 1:(length(sections) - 1)) + # Compute total quarter-chord length (scalar only) + qc_total = 0.0 + for i in 1:(n_input - 1) + d = 0.0 + s_i = sections[i]; s_ip = sections[i+1] + for j in 1:3 + qc_j = (_le(s_ip, j) + 0.25 * (_te(s_ip, j) - + _le(s_ip, j))) - + (_le(s_i, j) + 0.25 * (_te(s_i, j) - + _le(s_i, j))) + d += qc_j * qc_j + end + qc_total += sqrt(d) + end + new_le = MVec3(0.0, 0.0, 0.0) new_te = MVec3(0.0, 0.0, 0.0) dir = MVec3(0.0, 0.0, 0.0) for i in 1:n_sections target = if spanwise_distribution == LINEAR - span * (i - 1) / (n_sections - 1) + qc_total * (i - 1) / (n_sections - 1) elseif spanwise_distribution == COSINE - span * (1 - cos(π * (i - 1) / + qc_total * (1 - cos(π * (i - 1) / (n_sections - 1))) / 2 else throw(ArgumentError( @@ -1339,8 +1355,38 @@ function refine_mesh_for_linear_cosine_distribution!( "$spanwise_distribution")) end - si, t = span_position(sections, target) + cum = 0.0 + si = 1 + for k in 1:(n_input - 1) + d = 0.0 + s_k = sections[k]; s_kp = sections[k+1] + for j in 1:3 + qc_j = (_le(s_kp, j) + 0.25 * (_te(s_kp, j) - + _le(s_kp, j))) - + (_le(s_k, j) + 0.25 * (_te(s_k, j) - + _le(s_k, j))) + d += qc_j * qc_j + end + next_cum = cum + sqrt(d) + if next_cum >= target || k == n_input - 1 + si = k + break + end + cum = next_cum + end s_l = sections[si]; s_r = sections[si + 1] + + seg_d = 0.0 + for j in 1:3 + qc_j = (_le(s_r, j) + 0.25 * (_te(s_r, j) - + _le(s_r, j))) - + (_le(s_l, j) + 0.25 * (_te(s_l, j) - + _le(s_l, j))) + seg_d += qc_j * qc_j + end + seg_len = sqrt(seg_d) + t = seg_len > 1e-30 ? + (target - cum) / seg_len : 0.0 wl = 1 - t; wr = t lc_len = 0.0; rc_len = 0.0 diff --git a/test/wing_geometry/test_wing_geometry.jl b/test/wing_geometry/test_wing_geometry.jl index 900542e4..c78a5dba 100644 --- a/test/wing_geometry/test_wing_geometry.jl +++ b/test/wing_geometry/test_wing_geometry.jl @@ -229,16 +229,6 @@ end end end - @testset "LINEAR panels are equal in span on a wing swept past a kink" begin - wing = Wing(4; spanwise_distribution=LINEAR) - add_section!(wing, [1.0, 2.0, 0.0], [2.0, 2.0, 0.0], INVISCID) - add_section!(wing, [0.0, 1.0, 0.0], [1.0, 1.0, 0.0], INVISCID) - add_section!(wing, [0.0, 0.0, 0.0], [1.0, 0.0, 0.0], INVISCID) - refine!(wing) - y = [section.LE_point[2] for section in wing.refined_sections] - @test y ≈ [2.0, 1.5, 1.0, 0.5, 0.0] - end - @testset "Single panel" begin n_panels = 1 span = 20.0