Re-wrap a deformed section with the ball it was first wrapped with, and cut the loops out of the clearance offset - #365
Conversation
A self-crossing contour can reach a bare Fortran STOP inside XFoil, which ends the Julia process with exit code 0 and no exception. `analyze_sweep` now checks the contour before `set_coordinates` and throws `ArgumentError` instead, so a section XFoil has no solution for is one failed sweep rather than the end of the session. The same check covers a contour with more nodes than XFoil's panel arrays hold: ABCOPY refused it and left the previously loaded airfoil in place, so every angle came back solved on the wrong shape. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018rHHzpuPmi8pBjVk3r5QZD
# Conflicts: # CHANGELOG.md
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…n its polygon deform_section took a fixed ShrinkWrap(clearance=0.0) for the re-wrap, so a section wrapped at min_concave_radius 0.2 was re-wrapped at 0.02. shrink_wrap rolled that ball on the input's nodes only, and across the long panels of a thin wrapped canopy it touched the other skin and came back crossing itself. deform_section now takes the wrap the section came from and re-wraps with its ball at zero clearance; the wrap is threaded through generate_airfoils, generate_airfoil_aero, generate_aero_matrices, generate_polar_from_coordinates and the Makie previews. shrink_wrap densifies a closed input to edges of at most min(0.01, radius/2) so the ball rolls on the polygon, and warns when the contour it returns crosses itself. densify_contour moves from ObjAdapter to the shrink wrap it now serves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… takes Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1-Bort-1
left a comment
There was a problem hiding this comment.
Independent review (advisory)
Verdict: APPROVE WITH COMMENTS · 3 inline, 0 off the diff
Good
- The fix matches the card:
deform_sectionre-wraps with the caller's ball at zero clearance, andwrap_methodis passed through all six callers named in the card (checked with grep in obj_to_yaml.jl:221/239 and SurfplanAdapter.jl:71/76). - The new default
ShrinkWrap()with clearance forced to 0 gives the same re-wrap as the oldShrinkWrap(clearance=0.0), so callers that pass nowrap_methodkeep the ball they had. generate_section_aero(solver, x, y)now delegates togenerate_airfoil_aeroinstead of repeating the wrap-and-fit. This is the §2 dedup the card names.densify_contourmoved instead of being copied: the ObjAdapter copy is deleted,build_sectioncalls the AirfoilAero one, and the private docs list moved the entry.eltype(contour)[]handles both the tuple and the Vector callers.- The warning reuses
crossing_panelsfrom #320 instead of adding a third crossing check. The card measures its cost. - The new test would fail without the fix: it covers both radii the card reports as crossing and both deflections, and the card says 46 of 75 failed on the base branch.
- The card's pushback section discloses the follow-ups (#363, #364) and the polar shift on Kulfan inputs instead of leaving them out.
Not good
src/airfoil_aero/airfoil_solvers/common.jl:110— This buildsShrinkWrappositionally, so it silently depends on the field orderclearance, min_concave_radius, min_clearance, n_points, curvature_weight. Adding or reordering a field would mis-assign values without any error. The keyword constructor at shrink_wrap.jl:41 already exists and names each value.src/airfoil_aero/shrink_wrap.jl:366— The0.01edge cap is a bare magic number, and the docstring does not state it. Every closed input with edges over 0.01 is now densified, which is why Kulfan-derived polars move. The docstring should state the rule, or it should be a named constant.test/airfoil_aero/test_airfoil_aero.jl:303— This@test_logsrequires the default settings to cross oncanopy[2]. That pins the known #363 defect as expected behaviour, so fixing #363 will break this test. It would be better to test the warning on a constructed crossing input that does not depend on the default mesh settings.- The card lists
obj_to_yamlpolars shifting because Kulfan contours get densified as a risk that no test pins. That is a behaviour change to generated numbers and deserves its own CHANGELOG clause, not only the card. shrink_wrapnow warns inside everydeform_section, once per delta, so a delta sweep on the default mesh settings repeats the same warning n_delta times per section. That is noisy until #363 lands.- Neither the docs build nor the local CI mirror finished before the PR opened. The
densify_contourmove changes the private docs list, whichmake.jlchecks. - Test comment at test_airfoil_aero.jl:294-295 describes the fix ("neither padded again nor collapsed"). Under §3 that belongs in the testset name or the PR, not in code.
claude, rubric CLEAN_CODE.md. A different lab from the implementer
on purpose: a reviewer sharing its blind spots would not flag its mistakes.
| turn_trailing_edge!(delta, xd, yd, lower, upper, crease_frac; thickness_frac=pivot) | ||
| end | ||
| xd, yd = shrink_wrap(xd, yd, wrap_method) | ||
| rewrap = ShrinkWrap(0.0, wrap_method.min_concave_radius, wrap_method.min_clearance, |
There was a problem hiding this comment.
MINOR: This builds ShrinkWrap positionally, so it silently depends on the field order clearance, min_concave_radius, min_clearance, n_points, curvature_weight. Adding or reordering a field would mis-assign values without any error. The keyword constructor at shrink_wrap.jl:41 already exists and names each value.
There was a problem hiding this comment.
Fixed in 7c53474: the re-wrap is built with the keyword constructor.
| xn, yn, _ = normalize_airfoil(collect(float.(x)), collect(float.(y))) | ||
| closed = hypot(xn[end] - xn[1], yn[end] - yn[1]) < 0.02 | ||
| if closed | ||
| max_edge = min(0.01, method.min_concave_radius / 2) |
There was a problem hiding this comment.
MINOR: The 0.01 edge cap is a bare magic number, and the docstring does not state it. Every closed input with edges over 0.01 is now densified, which is why Kulfan-derived polars move. The docstring should state the rule, or it should be a named constant.
There was a problem hiding this comment.
Fixed in 7c53474: the shrink_wrap docstring and a Changed entry in the CHANGELOG now give the cap, min(0.01, min_concave_radius/2) chord.
| @test isnothing(crossing_panels(def.x, def.y)) | ||
| @test shoelace(def.x, def.y) ≈ shoelace(xw, yw) rtol = 0.05 | ||
|
|
||
| @test_logs (:warn, r"cross") match_mode=:any shrink_wrap(canopy[2].x_airfoil, |
There was a problem hiding this comment.
… a built input The crossing warning is now tested on a wavy membrane wrapped at a clearance its waves cannot take, with a clean wrap of the same curve as the control, instead of on a V3 slice whose default-settings crossing is #363. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Local full suite: PASS (11 min, Julia 1.13.0, one cell of the matrix) |
|
Should merge into main, and be based on main |
#320 closed unmerged, its fix having gone to xfoil_light instead. This branch carried it only as a base; the shrink wrap's warning still uses crossing_panels. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Along a straight edge the side-of-line products are rounding noise of either sign, so two disjoint panels on it could be reported as crossing. That made the re-wrap test fail on CI's dependency versions near a V3 section's straight nose. segments_cross now also requires the two segments' extents to overlap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
On a thin canopy the clearance offset of the contact polygon turns back on itself where contacts lie closer than the clearance, and shrink_wrap then returned a crossing contour and warned: 6 of 18 V3 sections at the default MeshSettings. pivot_contour now walks the offset and replaces each loop by its crossing point (cut_loops). Closes #363 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…polar_from_coordinates Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TL;DR
deform_sectionnow re-wraps with the rolling ball the section was first wrapped with, at zero clearance, andshrink_wraprolls that ball on the polygon of a closed input instead of only on its nodes. A thin wrapped canopy section used to come back fromdeform_sectionwith its two skins crossing, at every flap deflection. The clearance offset of the first wrap also crossed itself on thin sections, 6 of 18 V3 sections at the defaultMeshSettings, and it now has its loops cut out. Both wraps now come back as simple closed curves on all 18 V3 sections at every radius, clearance and deflection tested, and the default settings no longer warn.What was wrong
deform_sectionre-wrapped every deflection with a fixedShrinkWrap(clearance=0.0), a ball of radius 0.02, whatever the section was first wrapped with. A single-skin canopy wrapped once is a capsule about 0.002 thick, with panels up to 0.09 long in mid-chord.shrink_wraptreats its input as loose points, so between two nodes a distance h apart the ball dips h²/8r below the panel. Past h ≈ 0.018 at r = 0.02 that dip is more than the capsule is thick, and the ball touches the other skin. The traced loop then switches between skins 18–74 times, and resampling it turns the pinches into crossing panels.The flap rotation is not the cause: rotating without re-wrapping gave 0 crossings, and the same sections crossed at δ = 0, 0.01°, 5° and 15° alike. Handing the re-wrap the first wrap's radius, as asked on the thread, is not enough on its own. At r = 0.2, 6 of 18 V3 sections still crossed, because the first wrap's panels there are 0.08–0.1 long and even a ball of 0.2 bulges 0.0044 past such a panel, twice the thickness.
What changed
deform_section(x, y, delta; wrap_method)takes theShrinkWrapthe section came from. The re-wrap uses its radius,min_clearance,n_pointsandcurvature_weightwithclearance = 0, since re-applying the first pass's clearance thickened the section a second time (area 0.0190 → 0.0278 on one V3 section). The samewrap_methodkeyword is threaded throughgenerate_airfoils,generate_airfoil_aero,generate_aero_matricesandgenerate_polar_from_coordinates.obj_to_yaml, the Surfplan adapter and the two Makie previews pass the one they wrapped with. The default isShrinkWrap(), which re-wraps with exactly the ball the old default did.shrink_wrapsplits the edges of a closed input to at mostmin(0.01, min_concave_radius/2)before rolling, so the ball rolls on the contour rather than falling between its nodes. Raw.objslices are already densified to 0.005 chord bybuild_section, so their first wrap is unchanged.densify_contourmoves from ObjAdapter into the shrink wrap for this, unchanged apart from keeping its input's point type.shrink_wrapwarns, naming the ball radius and clearance, when the contour it returns crosses itself. It usescrossing_panels, about 0.07 ms on a 239-node contour. That check came from Refuse a contour XFoil's panel code cannot take #320, which closed unmerged; this branch keepscrossing_panelsand its two helpers and leaves out Refuse a contour XFoil's panel code cannot take #320's XFoil guard.pivot_contourcuts out the loops its clearance offset makes (cut_loops), which fixes A wrap at the default clearance crosses itself on thin canopy sections #363 here as asked on the thread. On a thin canopy, where contacts on the two skins lie closer thanclearance, the offset polyline turns back on itself: 4–19 loops on each of the three sections I counted (2, 9 and 16), most spanning two or three nodes.cut_loopswalks the offset from its first node, and where a new edge crosses an earlier one it drops everything in between and keeps the crossing point. At the defaultclearance = 0.006,min_concave_radius = 0.02, 6 of 18 V3 slices crossed before and none after. At r ∈ {0.02, 0.05, 0.2} × clearance ∈ {0, 0.006, 0.02}, none of the 162 wraps cross, where 0.02 used to cross on all 18 at the two smaller radii. It is a quadratic walk over the offset nodes: on a 1931-node V3 offset it takes 6.3 ms, so a first wrap goes from about 2 to 8 ms. The zero-clearance re-wrap indeform_sectiongoes from 0.68 to 0.91 ms.segments_crossnow also requires the two segments' extents to overlap. Along a straight edge its side-of-line products are rounding noise of either sign, so two disjoint panels on one straight nose edge were reported as crossing. That was the first CI run's only real failure, on all three cells: the section came out slightly differently under CI's freshly resolved dependencies. Locally, a sweep of 6400 re-wraps (both meshes, 10–40 sections, r = 0.1–0.3, δ = 0 and 5°) had 15 such false crossings before the fix and none after.generate_section_aero(solver, x, y)now callsgenerate_airfoil_aero(solver, x, y)instead of repeating its wrap-and-fit.Section 7 of
V3_25.obj(perpendicular_sections(…, 18; n_bins=100)), wrapped withShrinkWrap(clearance=0, min_concave_radius=0.2), thendeform_section(…, 0.0; wrap_method=same). Before:crossing_panels= (55, 189). After:nothing.Crossing sections out of 18 V3 sections, first wrap and deform_section at δ = 0, 5, −5, 15° (72 per row)
The "before" figures come from the investigation scripts on the base branch. The "after" column is this branch. "6 → 0" is before and after
cut_loops.Where I would push back
data/are not regenerated here.kulfan_to_coordinatesoutput (up to 0.016) in theobj_to_yamlpath, so its deflected shapes shift slightly even though none of them crossed before. The CHANGELOG lists this under Changed.get_lower_upperputs it near the chord line on cambered sections. That is get_lower_upper puts the negative-flap hinge near the chord line on cambered sections #364.Verification
deform_sectionof V3 sections wrapped at r = 0.2 with the samewrap_methodcrossed on 12 of 36 section × δ pairs (sections 3, 4, 6, 7, 13, 14).test/airfoil_aero/test_airfoil_aero.jl"re-wrapping a wrapped section keeps it a simple closed curve": 46 of 75 failed on the base branch and all pass after (juliaserver). The 75 checks are now split across three testsets: the V3 sweep (72), the clearance-padded re-wrap (2), and "shrink_wrap warns when its contour crosses itself" (2). The last one uses a wavy membrane at clearance 0.05 that crosses, with the same curve at clearance 0 as a control that must not warn. It no longer depends on the A wrap at the default clearance crosses itself on thin canopy sections #363 defect. The whole file passes after the review fixes.ShrinkWrap(), no warning and no crossing, plus the wavy membrane at clearance 0.05 without a warning): 13 of 37 failed withpivot_contourreturning the raw offset, and 37 of 37 pass withcut_loops(juliaserver). That membrane no longer crosses, so "shrink_wrap warns when its contour crosses itself" now uses a closed wavy loop 2·10⁻⁴ thick at clearance 0, whose resampled skins cross, with the same loop 2·10⁻³ thick as the control that must not warn. The whole oftest_airfoil_aero.jlpasses, and so dotest_obj_adapter.jl(54/54),test_live_polar.jlandram_geometry/test_kite_geometry.jl.airfoil_aero/test_airfoil_aero.jl,airfoil_aero/test_live_polar.jl,obj_adapter/test_obj_adapter.jl,surfplan/test_surfplan.jl,settings/test_settings.jl,solver/test_backend_comparison.jl,plotting/test_plotting.jl.crossing_panelstestset: the collinear-edge case (real node coordinates from the V3 section) failed before thesegments_crossfix and passes after.test_airfoil_aero.jlandtest_obj_adapter.jlgreen after it.main(dcd4f10). Merging Polars follow table_format through the node-table I/O, and losecsvfrom their names #372's Arrow polar writers conflicted only ingenerate_polar_from_coordinates, resolved to main'swrite_polar/write_polar_matrixand table wording plus this branch'swrap_method; merging Each panel takes its own wing's spanwise direction in calc_forces! and calculate_results #367/Take the panel normal from the quarter-chord step #273 conflicted only in two changelog Fixed entries, both kept. After the merges:test_airfoil_aero.jl,test_obj_adapter.jl(63/63),test_surfplan.jl(8/8),test_load_polar_data.jl(27/27) green. ci-local (Julia 1.13) on dcd4f10: PASS in 6 min. GitHub CI on dcd4f10: every job passes (Julia 1.12 on ubuntu, macOS and Windows, Julia 1.13 on ubuntu, setup test, Documentation, codecov/patch).Documentationjob passes on ee7a6b3. Not built locally, becausedocs/has no manifest. No new public symbol.densify_contourmoves to the AirfoilAero private list, andcut_loopsis added there.cut_loopskeeps the piece that holds the offset's first node, at the leftmost cloud point. A loop that enclosed that node would be kept in place of the airfoil. No V3 or ram-air section does this.Scope
+265 / −74 across 13 files, against
main. The logic iscut_loops(+33),shrink_wrap(densify and warn, +15),deform_section(the re-wrap settings) andcrossing_panelswith its segment helpers (+45, from #320 plus the extent check). The rest is thewrap_methodkeyword threaded through the six callers and their docstrings, thedensify_contourmove (−21 in ObjAdapter, +19 in the shrink wrap), the tests (in four testsets) and the changelog. Based onmain; #320's commits are in the history, but its XFoil guard is taken back out, so the diff againstmainholds only this change.Closes #363
Closes #321 · task
VortexStepMethod.jl-321