From 6d3d45211c1ce4fd04ed2fd78717145a0a2f9fff Mon Sep 17 00:00:00 2001 From: 1-Bort-1 <323661610+1-Bort-1@users.noreply.github.com> Date: Mon, 21 Sep 2026 23:19:55 +0200 Subject: [PATCH 1/4] Take each march_edges station's tangent from its neighbours The tangent stored with a station was the leading-edge step that reached it, one station stale. Near a closing tip that cut grazed the surface: station 79 of the ram-air kite sliced as an 80%-thick section. Each station now gets the central difference over its neighbouring leading edges, and each end that of the station next to it. The march itself is unchanged. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 4 ++++ src/obj_adapter/obj_slice.jl | 17 ++++++++++------- test/obj_adapter/test_obj_adapter.jl | 14 ++++++++++++++ 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22410bed..4e95279b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,10 @@ ### Fixed +- `perpendicular_sections` and `obj_to_yaml` orient each cut by the leading edge through + the neighbouring stations, not by the step that reached the station, which near a + closing tip grazed the surface and returned an 80%-thick section. Every section moves + slightly; regenerate existing geometry to benefit. - `ELLIPTIC` initial circulation works on a body with more than one wing, where it threw an `ArgumentError`: each wing gets an ellipse over its own span, along its own `spanwise_direction` and centred on its own mid-span, also for a single wing off y = 0. diff --git a/src/obj_adapter/obj_slice.jl b/src/obj_adapter/obj_slice.jl index fd3f5b43..2b9c66c9 100644 --- a/src/obj_adapter/obj_slice.jl +++ b/src/obj_adapter/obj_slice.jl @@ -278,8 +278,9 @@ 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 and each cut's plane origin and tangent. Build the -airfoil for a chosen station with `build_section`. +along the span, the LE/TE points, each cut's plane origin, and the LE tangent: the +central difference over the neighbouring stations, that of the next station in at +either end. Build the airfoil for a chosen station with `build_section`. """ function march_edges(vertices, faces; step) ys = [v[2] for v in vertices] @@ -326,22 +327,24 @@ function march_edges(vertices, faces; step) valid ? (lo = mid; tip = (; here.le, here.te, point=probe_mid)) : (hi = mid) end - tip === nothing || push!(rows, (; tip.le, tip.te, tip.point, tangent)) + tip === nothing || push!(rows, tip) break end le_step = found.le .- prev_le (norm(le_step) < 1e-9 || le_step[2] * direction <= 0.0) && break - push!(rows, (; found.le, found.te, point=probe, tangent)) + push!(rows, (; found.le, found.te, point=probe)) tangent = normalize(le_step) prev_le = found.le end return rows end - center = (; mid_cut.le, mid_cut.te, point=[0.0, y_mid, 0.0], tangent=[0.0, 1.0, 0.0]) + center = (; mid_cut.le, mid_cut.te, point=[0.0, y_mid, 0.0]) rows = vcat(reverse(march(-1.0)), [center], march(1.0)) - 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]) + le = [r.le for r in rows] + neighbours = clamp.(eachindex(le), 2, length(le) - 1) + tangent = [normalize(le[j + 1] .- le[j - 1]) for j in neighbours] + return (; le, te=[r.te for r in rows], point=[r.point for r in rows], tangent) end """ diff --git a/test/obj_adapter/test_obj_adapter.jl b/test/obj_adapter/test_obj_adapter.jl index fabdc8f6..e5560972 100644 --- a/test/obj_adapter/test_obj_adapter.jl +++ b/test/obj_adapter/test_obj_adapter.jl @@ -28,6 +28,20 @@ obj_path = normpath(joinpath(@__DIR__, "..", "..", end end + @testset "every marched station cuts across the surface, closing tips included" begin + vertices, faces = read_faces(obj_path) + ys = [v[2] for v in vertices] + for n_bins in (60, 90) + march = ObjAdapter.march_edges(vertices, faces; + step=(maximum(ys) - minimum(ys)) / n_bins) + for i in eachindex(march.le) + section = ObjAdapter.build_section(vertices, faces, march.le[i], + march.te[i], march.point[i], march.tangent[i]) + @test maximum(section.y_airfoil) - minimum(section.y_airfoil) < 0.3 + end + 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] From 87624a03a48ad1343c54c5bf3369c66d4bd7b32e Mon Sep 17 00:00:00 2001 From: 1-Bort-1 <323661610+1-Bort-1@users.noreply.github.com> Date: Tue, 22 Sep 2026 00:15:49 +0200 Subject: [PATCH 2/4] Name march_edges' difference index for what it is, and fail the tip test cleanly on a lost section Co-Authored-By: Claude Opus 5 --- src/obj_adapter/obj_slice.jl | 8 ++++---- test/obj_adapter/test_obj_adapter.jl | 4 +++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/obj_adapter/obj_slice.jl b/src/obj_adapter/obj_slice.jl index 2b9c66c9..43329cb5 100644 --- a/src/obj_adapter/obj_slice.jl +++ b/src/obj_adapter/obj_slice.jl @@ -279,8 +279,8 @@ downward can't tilt the plane toward horizontal, where its min-chord "LE" pick w 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 the LE tangent: the -central difference over the neighbouring stations, that of the next station in at -either end. Build the airfoil for a chosen station with `build_section`. +central difference over the neighbouring stations, where the end stations reuse their +inner neighbour's. Build the airfoil for a chosen station with `build_section`. """ function march_edges(vertices, faces; step) ys = [v[2] for v in vertices] @@ -342,8 +342,8 @@ function march_edges(vertices, faces; step) center = (; mid_cut.le, mid_cut.te, point=[0.0, y_mid, 0.0]) rows = vcat(reverse(march(-1.0)), [center], march(1.0)) le = [r.le for r in rows] - neighbours = clamp.(eachindex(le), 2, length(le) - 1) - tangent = [normalize(le[j + 1] .- le[j - 1]) for j in neighbours] + middle = clamp.(eachindex(le), 2, length(le) - 1) + tangent = [normalize(le[j + 1] .- le[j - 1]) for j in middle] return (; le, te=[r.te for r in rows], point=[r.point for r in rows], tangent) end diff --git a/test/obj_adapter/test_obj_adapter.jl b/test/obj_adapter/test_obj_adapter.jl index e5560972..dc9d46d8 100644 --- a/test/obj_adapter/test_obj_adapter.jl +++ b/test/obj_adapter/test_obj_adapter.jl @@ -37,7 +37,9 @@ obj_path = normpath(joinpath(@__DIR__, "..", "..", for i in eachindex(march.le) section = ObjAdapter.build_section(vertices, faces, march.le[i], march.te[i], march.point[i], march.tangent[i]) - @test maximum(section.y_airfoil) - minimum(section.y_airfoil) < 0.3 + depth = section === nothing ? Inf : + maximum(section.y_airfoil) - minimum(section.y_airfoil) + @test depth < 0.3 end end end From 82c629a5ce19e8e58dcb0f72296660bec07b2733 Mon Sep 17 00:00:00 2001 From: 1-Bort-1 <323661610+1-Bort-1@users.noreply.github.com> Date: Tue, 22 Sep 2026 01:06:19 +0200 Subject: [PATCH 3/4] Hoist march_edges' cut, march and tip bisection to module-level functions cut_station, march_stations and tip_station replace the closures inside march_edges; the marched stations are bitwise identical. Co-Authored-By: Claude Opus 5 --- docs/src/private_functions.md | 3 + src/obj_adapter/obj_slice.jl | 123 ++++++++++++++++++++-------------- 2 files changed, 75 insertions(+), 51 deletions(-) diff --git a/docs/src/private_functions.md b/docs/src/private_functions.md index b6e76f60..5fabcf1d 100644 --- a/docs/src/private_functions.md +++ b/docs/src/private_functions.md @@ -255,6 +255,9 @@ densify_contour create_interpolations find_circle_center_and_radius march_edges +cut_station +tip_station +march_stations airfoils_from_yaml write_geometry_yaml resolve_aero_geometry diff --git a/src/obj_adapter/obj_slice.jl b/src/obj_adapter/obj_slice.jl index 43329cb5..3d69d35b 100644 --- a/src/obj_adapter/obj_slice.jl +++ b/src/obj_adapter/obj_slice.jl @@ -269,6 +269,74 @@ function slice_mesh_at_plane(vertices, faces, point, normal; tol=1e-6) return segments end +""" + cut_station(vertices, faces, point, tangent) -> (; le, te) or nothing + +Cut the mesh with the vertical spanwise plane through `point` whose normal is the +spanwise component of `tangent`, and return the crossings with the least and greatest +`x` as the leading and trailing edge; `nothing` where the plane misses the mesh. +""" +function cut_station(vertices, faces, point, tangent) + segments = slice_mesh_at_plane(vertices, faces, point, + normalize([0.0, tangent[2], 0.0])) + isempty(segments) && return nothing + crossings = Vector{Float64}[] + for (start_point, end_point) in segments + push!(crossings, start_point) + push!(crossings, end_point) + end + return (; le=argmin(pt -> pt[1], crossings), te=argmax(pt -> pt[1], crossings)) +end + +""" + tip_station(vertices, faces, prev_le, tangent, step) -> (; le, te, point) or nothing + +Bisect the step of length `step` from `prev_le` along `tangent` for the outermost cut +that still yields an airfoil section whose leading edge stays within `step` of +`prev_le` chordwise; `nothing` where no such cut exists. +""" +function tip_station(vertices, faces, prev_le, tangent, step) + lo, hi, tip = 0.0, step, nothing + for _ in 1:24 + mid = (lo + hi) / 2 + probe = prev_le .+ mid .* tangent + here = cut_station(vertices, faces, probe, tangent) + valid = here !== nothing && + abs(here.le[1] - prev_le[1]) < step && + build_section(vertices, faces, here.le, here.te, probe, tangent) !== nothing + valid ? (lo = mid; tip = (; here.le, here.te, point=probe)) : (hi = mid) + end + return tip +end + +""" + march_stations(vertices, faces, start_le, step, direction) -> Vector{NamedTuple} + +March the leading edge from `start_le` in steps of arc length `step` towards `+y` for +`direction = 1.0` or `-y` for `-1.0`, returning one `(; le, te, point)` per station, +the tip station last. Stops when the leading edge stops advancing spanwise. +""" +function march_stations(vertices, faces, start_le, step, direction) + rows = NamedTuple[] + prev_le = start_le + tangent = [0.0, direction, 0.0] + for _ in 1:10_000 + probe = prev_le .+ step .* tangent + found = cut_station(vertices, faces, probe, tangent) + if found === nothing + tip = tip_station(vertices, faces, prev_le, tangent, step) + tip === nothing || push!(rows, tip) + break + end + le_step = found.le .- prev_le + (norm(le_step) < 1e-9 || le_step[2] * direction <= 0.0) && break + push!(rows, (; found.le, found.te, point=probe)) + tangent = normalize(le_step) + prev_le = found.le + end + return rows +end + """ march_edges(vertices, faces; step) -> (; le, te, point, tangent) @@ -287,60 +355,13 @@ function march_edges(vertices, faces; step) y_min, y_max = extrema(ys) y_mid = (y_min + y_max) / 2 - # Vertical spanwise plane (z dropped): a tip curl mustn't tilt the cut horizontal. - cut(point, tangent) = begin - segments = slice_mesh_at_plane(vertices, faces, point, - normalize([0.0, tangent[2], 0.0])) - isempty(segments) && return nothing - crossings = Vector{Float64}[] - for (start_point, end_point) in segments - push!(crossings, start_point) - push!(crossings, end_point) - end - return (; le=argmin(pt -> pt[1], crossings), te=argmax(pt -> pt[1], crossings)) - end - - mid_cut = cut([0.0, y_mid, 0.0], [0.0, 1.0, 0.0]) + mid_cut = cut_station(vertices, faces, [0.0, y_mid, 0.0], [0.0, 1.0, 0.0]) mid_cut === nothing && error("Could not slice mid-span; check mesh / rotation.") - march(direction) = begin - rows = NamedTuple[] - prev_le = mid_cut.le - tangent = [0.0, direction, 0.0] - for _ in 1:10_000 - probe = prev_le .+ step .* tangent - found = cut(probe, tangent) - if found === nothing - # Bisect the last step to land the tip station on the outermost cut - # that still yields a valid (non-degenerate) airfoil section. - lo, hi, tip = 0.0, step, nothing - for _ in 1:24 - mid = (lo + hi) / 2 - 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 misplace the tip station). - valid = here !== nothing && - abs(here.le[1] - prev_le[1]) < step && - build_section(vertices, faces, here.le, here.te, - probe_mid, tangent) !== nothing - valid ? (lo = mid; tip = (; here.le, here.te, point=probe_mid)) : - (hi = mid) - end - tip === nothing || push!(rows, tip) - break - end - le_step = found.le .- prev_le - (norm(le_step) < 1e-9 || le_step[2] * direction <= 0.0) && break - push!(rows, (; found.le, found.te, point=probe)) - tangent = normalize(le_step) - prev_le = found.le - end - return rows - end - center = (; mid_cut.le, mid_cut.te, point=[0.0, y_mid, 0.0]) - rows = vcat(reverse(march(-1.0)), [center], march(1.0)) + negative_y = march_stations(vertices, faces, mid_cut.le, step, -1.0) + positive_y = march_stations(vertices, faces, mid_cut.le, step, 1.0) + rows = vcat(reverse(negative_y), [center], positive_y) le = [r.le for r in rows] middle = clamp.(eachindex(le), 2, length(le) - 1) tangent = [normalize(le[j + 1] .- le[j - 1]) for j in middle] From 5f148b8eab4a24e69c3129aa8a46ba33d5206562 Mon Sep 17 00:00:00 2001 From: 1-Bort-1 <323661610+1-Bort-1@users.noreply.github.com> Date: Tue, 22 Sep 2026 02:01:03 +0200 Subject: [PATCH 4/4] Shrink march_edges' docstring to what it does, and name the crossing and row locals Co-Authored-By: Claude Opus 5 --- src/obj_adapter/obj_slice.jl | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/src/obj_adapter/obj_slice.jl b/src/obj_adapter/obj_slice.jl index 3d69d35b..2472c3a3 100644 --- a/src/obj_adapter/obj_slice.jl +++ b/src/obj_adapter/obj_slice.jl @@ -285,7 +285,8 @@ function cut_station(vertices, faces, point, tangent) push!(crossings, start_point) push!(crossings, end_point) end - return (; le=argmin(pt -> pt[1], crossings), te=argmax(pt -> pt[1], crossings)) + return (; le=argmin(crossing -> crossing[1], crossings), + te=argmax(crossing -> crossing[1], crossings)) end """ @@ -340,15 +341,11 @@ end """ 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 the LE tangent: the -central difference over the neighbouring stations, where the end stations reuse their -inner neighbour's. Build the airfoil for a chosen station with `build_section`. +Cut the mesh at mid-span and march the leading edge outward from there in both +directions with `march_stations`. Returns, ordered along the span, the LE/TE points, +each cut's plane origin, and the LE tangent: the central difference over the +neighbouring stations, where the end stations reuse their inner neighbour's. Build the +airfoil for a chosen station with `build_section`. """ function march_edges(vertices, faces; step) ys = [v[2] for v in vertices] @@ -362,10 +359,10 @@ function march_edges(vertices, faces; step) negative_y = march_stations(vertices, faces, mid_cut.le, step, -1.0) positive_y = march_stations(vertices, faces, mid_cut.le, step, 1.0) rows = vcat(reverse(negative_y), [center], positive_y) - le = [r.le for r in rows] + le = [row.le for row in rows] middle = clamp.(eachindex(le), 2, length(le) - 1) tangent = [normalize(le[j + 1] .- le[j - 1]) for j in middle] - return (; le, te=[r.te for r in rows], point=[r.point for r in rows], tangent) + return (; le, te=[row.te for row in rows], point=[row.point for row in rows], tangent) end """