From 4a2c3afc6879705bc87b9a432e768f00525392d5 Mon Sep 17 00:00:00 2001 From: 1-Bort-1 <323661610+1-Bort-1@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:55:50 +0200 Subject: [PATCH 1/5] ObjWing carries the mesh geometry its sections are cut from gamma_tip, radius, le_interp, te_interp, area_interp, inertia_tensor and T_cad_body were left at their empty defaults when ObjWing became obj_to_yaml + Wing(yaml), so reading the spanwise geometry off an OBJ wing hit nothing and the wing's inertia fell back to point masses. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KPJmueyiyGSNpN73BATMuH --- CHANGELOG.md | 14 +++++++++++ src/obj_adapter/obj_geometry.jl | 26 +++++++++++++++++++ src/yaml_geometry.jl | 10 +++++++- test/ram_geometry/test_kite_geometry.jl | 33 ++++++++++++++++++++----- 4 files changed, 76 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 532b0816..2f394364 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## Unreleased + +### Fixed + +- `ObjWing` fills the mesh-derived fields of the `Wing` it returns again: + `gamma_tip`, `radius`, `le_interp`, `te_interp`, `area_interp`, + `inertia_tensor` and `T_cad_body`. Building the wing through + `ObjAdapter.obj_to_yaml` + `Wing(yaml)` in v4.0.0 left all seven at their + empty defaults, so a caller reading the spanwise geometry off an OBJ wing — + `wing.le_interp[i](gamma)` is how a bridle is placed on one — hit + `nothing`, and the wing's inertia silently fell back to point masses. The + values are in the mesh's own coordinates, the frame `obj_to_yaml` cuts its + sections in, and `test/ram_geometry/test_kite_geometry.jl` asserts that. + ## VortexStepMethod v5.1.1 2026-09-12 ### Fixed diff --git a/src/obj_adapter/obj_geometry.jl b/src/obj_adapter/obj_geometry.jl index 0a5ac73a..20428005 100644 --- a/src/obj_adapter/obj_geometry.jl +++ b/src/obj_adapter/obj_geometry.jl @@ -370,3 +370,29 @@ function calc_inertia_y_rotation(I_b_tensor) end +""" + seed_mesh_geometry!(wing, obj_path; mass=1.0) + +Fill the mesh-derived fields of a [`Wing`](@ref) built from `obj_path`: the +`radius` and `gamma_tip` of the arc fitted through the mesh, the `le_interp`, +`te_interp` and `area_interp` interpolations over that arc, the thin-shell +`inertia_tensor` about the centre of mass for a wing of `mass` [kg], and +`T_cad_body`, the centre of mass negated. Positions are in the mesh's own +coordinates, the frame [`obj_to_yaml`](@ref) writes its sections in. +""" +function seed_mesh_geometry!(wing, obj_path; mass=1.0) + vertices, faces = read_faces(obj_path) + com = -center_to_com!(copy.(vertices), faces; prn=false) + circle_center_z, radius, gamma_tip = find_circle_center_and_radius(vertices) + le_interp, te_interp, area_interp = + create_interpolations(vertices, circle_center_z, radius, gamma_tip) + wing.mass = mass + wing.inertia_tensor = calculate_inertia_tensor(vertices, faces, mass, com) + wing.T_cad_body .= -com + wing.gamma_tip = gamma_tip + wing.radius = radius + wing.le_interp = le_interp + wing.te_interp = te_interp + wing.area_interp = area_interp + return wing +end diff --git a/src/yaml_geometry.jl b/src/yaml_geometry.jl index 11da48ac..05843392 100644 --- a/src/yaml_geometry.jl +++ b/src/yaml_geometry.jl @@ -443,6 +443,11 @@ polars; the default is `AirfoilAero.NeuralFoilSolver()`. By default (`remake=false`) an existing `geometry.yaml` in `output_dir` is reused, skipping the expensive polar generation. Set `remake=true` to force regeneration. + +The mesh-derived fields a wing loaded from YAML alone leaves empty — `gamma_tip`, +`radius`, `le_interp`, `te_interp`, `area_interp`, `inertia_tensor` and +`T_cad_body` — are filled from the mesh by +[`ObjAdapter.seed_mesh_geometry!`](@ref). """ function ObjWing(obj_path, dat_path=nothing; n_panels::Int=56, @@ -458,6 +463,7 @@ function ObjWing(obj_path, dat_path=nothing; output_dir::String=mktempdir(), crease_frac=0.75, verbose::Bool=false) + (!endswith(obj_path, ".obj")) && (obj_path *= ".obj") yaml_path = joinpath(output_dir, "geometry.yaml") if remake || !isfile(yaml_path) n_sec = isnothing(n_sections) ? n_panels + 1 : n_sections @@ -471,5 +477,7 @@ function ObjWing(obj_path, dat_path=nothing; crease_frac, verbose) end - return Wing(yaml_path; n_panels, spanwise_distribution, spanwise_direction, remove_nan) + wing = Wing(yaml_path; n_panels, spanwise_distribution, spanwise_direction, + remove_nan) + return ObjAdapter.seed_mesh_geometry!(wing, obj_path) end diff --git a/test/ram_geometry/test_kite_geometry.jl b/test/ram_geometry/test_kite_geometry.jl index 4c9fdc8d..65c66f3f 100644 --- a/test/ram_geometry/test_kite_geometry.jl +++ b/test/ram_geometry/test_kite_geometry.jl @@ -167,11 +167,32 @@ using Serialization @test R_b_p2 ≈ I(3) end - @testset "Converted-wing construction and deformation" begin - # TODO: redesign. These previously tested ObjWing internals (radius, - # gamma_tip, UNCHANGED distribution, obj deform\!) that were dropped when - # ObjWing was replaced by convert-then-load (obj_to_matrix_yaml -> Wing). - # Rebuild against ram_air_matrix_wing() geometry once its numerics are set. - @test_skip false + @testset "ObjWing carries the mesh geometry its sections are cut from" begin + gen_dir, _ = ram_air_matrix_dir() + obj = joinpath(dirname(dirname(@__DIR__)), "data", "ram_air_kite", + "ram_air_kite_body.obj") + wing = ObjWing(obj; n_panels=20, output_dir=gen_dir, verbose=false) + + @test wing.gamma_tip > 0 + @test wing.radius > 0 + @test !isnothing(wing.le_interp) + @test !isnothing(wing.te_interp) + @test !isnothing(wing.area_interp) + @test wing.area_interp(wing.gamma_tip) > 0 + + vertices, faces = read_faces(obj) + com = -center_to_com!(vertices, faces) + @test wing.T_cad_body ≈ -com + @test wing.inertia_tensor ≈ wing.inertia_tensor' + @test all(>(0), diag(wing.inertia_tensor)) + + # The interpolations must be in the frame the sections are cut in: at the + # centre they land on the centre section, not a centre-of-mass away from it. + le_points = [section.LE_point for section in wing.unrefined_sections] + spacing = maximum(abs(le_points[i + 1][2] - le_points[i][2]) + for i in 1:length(le_points) - 1) + le_center = [wing.le_interp[i](0.0) for i in 1:3] + nearest = argmin(le_point -> abs(le_point[2] - le_center[2]), le_points) + @test norm(nearest - le_center) < spacing end end From 5c55a570423442cd8511b893f0729fbcdcfc07d5 Mon Sep 17 00:00:00 2001 From: 1-Bort-1 <323661610+1-Bort-1@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:05:13 +0200 Subject: [PATCH 2/5] Measure the frame against the refined sections Four unrefined sections span the kite in 1.1 m steps, which is looser than the centre-of-mass offset the assertion is there to catch. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KPJmueyiyGSNpN73BATMuH --- test/ram_geometry/test_kite_geometry.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/ram_geometry/test_kite_geometry.jl b/test/ram_geometry/test_kite_geometry.jl index 65c66f3f..ac981475 100644 --- a/test/ram_geometry/test_kite_geometry.jl +++ b/test/ram_geometry/test_kite_geometry.jl @@ -188,7 +188,7 @@ using Serialization # The interpolations must be in the frame the sections are cut in: at the # centre they land on the centre section, not a centre-of-mass away from it. - le_points = [section.LE_point for section in wing.unrefined_sections] + le_points = [section.LE_point for section in wing.refined_sections] spacing = maximum(abs(le_points[i + 1][2] - le_points[i][2]) for i in 1:length(le_points) - 1) le_center = [wing.le_interp[i](0.0) for i in 1:3] From ff4111bf6badfbb5bbe428da8f6813cfcbcf6911 Mon Sep 17 00:00:00 2001 From: 1-Bort-1 <323661610+1-Bort-1@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:42:28 +0200 Subject: [PATCH 3/5] Panel the cached four-section geometry linearly UNCHANGED keeps the sections the geometry file carries, so asking it for 20 panels indexes past the fourth. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KPJmueyiyGSNpN73BATMuH --- test/ram_geometry/test_kite_geometry.jl | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/ram_geometry/test_kite_geometry.jl b/test/ram_geometry/test_kite_geometry.jl index ac981475..47bf7a92 100644 --- a/test/ram_geometry/test_kite_geometry.jl +++ b/test/ram_geometry/test_kite_geometry.jl @@ -171,7 +171,8 @@ using Serialization gen_dir, _ = ram_air_matrix_dir() obj = joinpath(dirname(dirname(@__DIR__)), "data", "ram_air_kite", "ram_air_kite_body.obj") - wing = ObjWing(obj; n_panels=20, output_dir=gen_dir, verbose=false) + wing = ObjWing(obj; n_panels=20, spanwise_distribution=LINEAR, + output_dir=gen_dir, verbose=false) @test wing.gamma_tip > 0 @test wing.radius > 0 From 1fb42244a6bf90efd68d5b245c16e7eeb26f640e Mon Sep 17 00:00:00 2001 From: 1-Bort-1 <323661610+1-Bort-1@users.noreply.github.com> Date: Tue, 15 Sep 2026 00:24:12 +0200 Subject: [PATCH 4/5] Panel a coarser cut and fly the crease the polars were made about ObjWing kept spanwise_distribution=UNCHANGED, which keeps whatever sections the geometry file carries, so asking for more panels than it has sections threw a BoundsError out of compute_refined_panel_mapping!. crease_frac reached the polar generation but not the Wing, which flew the 0.75 default instead. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012tnxXN2aT3F7zwCbsXd6Sb --- CHANGELOG.md | 5 +++++ src/yaml_geometry.jl | 9 +++++++-- test/ram_geometry/test_kite_geometry.jl | 12 ++++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f394364..76b6fa40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ `nothing`, and the wing's inertia silently fell back to point masses. The values are in the mesh's own coordinates, the frame `obj_to_yaml` cuts its sections in, and `test/ram_geometry/test_kite_geometry.jl` asserts that. +- `ObjWing` hands the `Wing` it builds the `crease_frac` it generated the polars + about, instead of leaving it at the default 0.75, and its + `spanwise_distribution` defaults to `LINEAR`. `UNCHANGED` keeps whatever + sections the geometry file carries, so asking for more panels than it has + sections threw a `BoundsError` out of `compute_refined_panel_mapping!`. ## VortexStepMethod v5.1.1 2026-09-12 diff --git a/src/yaml_geometry.jl b/src/yaml_geometry.jl index 05843392..6a542626 100644 --- a/src/yaml_geometry.jl +++ b/src/yaml_geometry.jl @@ -444,6 +444,11 @@ polars; the default is `AirfoilAero.NeuralFoilSolver()`. By default (`remake=false`) an existing `geometry.yaml` in `output_dir` is reused, skipping the expensive polar generation. Set `remake=true` to force regeneration. +`spanwise_distribution` defaults to `LINEAR`, so a mesh cut into fewer sections +than `n_panels + 1` is panelled into that many rather than kept as it is. +`crease_frac` is the chordwise hinge the polars are deflected about, and the +wing carries it. + The mesh-derived fields a wing loaded from YAML alone leaves empty — `gamma_tip`, `radius`, `le_interp`, `te_interp`, `area_interp`, `inertia_tensor` and `T_cad_body` — are filled from the mesh by @@ -456,7 +461,7 @@ function ObjWing(obj_path, dat_path=nothing; delta_range=-5:1:20, n_sections::Union{Nothing, Int}=nothing, spanwise_direction=[0.0, 1.0, 0.0], - spanwise_distribution=UNCHANGED, + spanwise_distribution=LINEAR, remove_nan::Bool=true, aero_solver=AirfoilAero.NeuralFoilSolver(), remake::Bool=false, @@ -478,6 +483,6 @@ function ObjWing(obj_path, dat_path=nothing; verbose) end wing = Wing(yaml_path; n_panels, spanwise_distribution, spanwise_direction, - remove_nan) + remove_nan, crease_frac) return ObjAdapter.seed_mesh_geometry!(wing, obj_path) end diff --git a/test/ram_geometry/test_kite_geometry.jl b/test/ram_geometry/test_kite_geometry.jl index 47bf7a92..37d269db 100644 --- a/test/ram_geometry/test_kite_geometry.jl +++ b/test/ram_geometry/test_kite_geometry.jl @@ -196,4 +196,16 @@ using Serialization nearest = argmin(le_point -> abs(le_point[2] - le_center[2]), le_points) @test norm(nearest - le_center) < spacing end + + @testset "ObjWing panels a coarser cut and flies the crease it generated" begin + gen_dir, _ = ram_air_matrix_dir() # four sections + obj = joinpath(dirname(dirname(@__DIR__)), "data", "ram_air_kite", + "ram_air_kite_body.obj") + wing = ObjWing(obj; n_panels=20, crease_frac=0.82, + output_dir=gen_dir, verbose=false) + + @test wing.n_unrefined_sections == 4 + @test length(wing.refined_sections) == 21 + @test wing.crease_frac == 0.82 + end end From cbfc32813e8faa6c6ae77d0c1545650dcc7a1897 Mon Sep 17 00:00:00 2001 From: 1-Bort-1 <323661610+1-Bort-1@users.noreply.github.com> Date: Tue, 15 Sep 2026 03:47:45 +0200 Subject: [PATCH 5/5] Put seed_mesh_geometry! in the manual makedocs checks every docstring in the modules it is given, so the new one failed the build twice over: missing from any canonical @docs block, and the @ref to it in the Wing(yaml) docstring had nothing to point at. Its own @ref to Wing is qualified because ObjAdapter imports the module, not the type. Co-Authored-By: Claude Opus 5 --- docs/src/private_functions.md | 1 + src/obj_adapter/obj_geometry.jl | 10 +++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/src/private_functions.md b/docs/src/private_functions.md index d7807c42..16d9ccc3 100644 --- a/docs/src/private_functions.md +++ b/docs/src/private_functions.md @@ -246,6 +246,7 @@ find_circle_center_and_radius march_edges calculate_inertia_tensor center_to_com! +seed_mesh_geometry! airfoils_from_yaml write_geometry_yaml resolve_aero_geometry diff --git a/src/obj_adapter/obj_geometry.jl b/src/obj_adapter/obj_geometry.jl index 20428005..4f49789c 100644 --- a/src/obj_adapter/obj_geometry.jl +++ b/src/obj_adapter/obj_geometry.jl @@ -373,11 +373,11 @@ end """ seed_mesh_geometry!(wing, obj_path; mass=1.0) -Fill the mesh-derived fields of a [`Wing`](@ref) built from `obj_path`: the -`radius` and `gamma_tip` of the arc fitted through the mesh, the `le_interp`, -`te_interp` and `area_interp` interpolations over that arc, the thin-shell -`inertia_tensor` about the centre of mass for a wing of `mass` [kg], and -`T_cad_body`, the centre of mass negated. Positions are in the mesh's own +Fill the mesh-derived fields of a [`Wing`](@ref VortexStepMethod.Wing) built from +`obj_path`: the `radius` and `gamma_tip` of the arc fitted through the mesh, the +`le_interp`, `te_interp` and `area_interp` interpolations over that arc, the +thin-shell `inertia_tensor` about the centre of mass for a wing of `mass` [kg], +and `T_cad_body`, the centre of mass negated. Positions are in the mesh's own coordinates, the frame [`obj_to_yaml`](@ref) writes its sections in. """ function seed_mesh_geometry!(wing, obj_path; mass=1.0)