Load engines from TOML and improve scenario audio and carb tuning
Migrate 32 engine definitions into validated startup-loaded TOML files and separate configuration and snapshots from scenario assembly. Add camera-based stereo propagation from shared pipe geometry, preserve exhaust character, tune carburetor mixtures and idle, use ignition cuts for carb engines, and support returning to the scenario menu. Validation: full Windows build and all 14 CTest checks passed.
This commit is contained in:
@@ -33,5 +33,18 @@ int main() {
|
||||
camera.orbit({40,20});camera.pan({20,15},500);camera.zoom(2);
|
||||
check(camera.pitch<=80 && camera.pitch>=-75 && camera.distance>.35f,"camera bounds");
|
||||
check(camera.target.x!=before.x || camera.target.y!=before.y,"camera panning must move target");
|
||||
for(float yaw:{-90.f,-28.f,0.f,90.f,180.f})for(float pitch:{-60.f,0.f,70.f}) {
|
||||
camera.yaw=yaw;camera.pitch=pitch;camera.target={.7f,.4f,-.2f};camera.distance=3;
|
||||
const auto listener=camera.listener(1.8f,1.1f);
|
||||
const float y=yaw*.017453293f,p=pitch*.017453293f;
|
||||
const float dx=listener.position.x-camera.target.x-1.1f;
|
||||
const float dy=listener.position.y-camera.target.y,dz=listener.position.z-camera.target.z;
|
||||
const float x1=std::cos(y)*dx+std::sin(y)*dz,z1=-std::sin(y)*dx+std::cos(y)*dz;
|
||||
check(std::abs(x1)<2e-6f && std::abs(std::cos(p)*dy-std::sin(p)*z1)<2e-6f &&
|
||||
std::abs(std::sin(p)*dy+std::cos(p)*z1-5.4f)<2e-6f,
|
||||
"audio listener must coincide with the rendered camera including fit offsets");
|
||||
check(std::abs(std::cos(y)*listener.right.x+std::sin(y)*listener.right.z-1)<1e-6f,
|
||||
"audio ears must rotate with the camera right axis");
|
||||
}
|
||||
std::cout<<"Desktop layout, spring pedal, keyboard travel and orbit camera checks passed\n";
|
||||
}
|
||||
|
||||
78
tests/engine_config_tests.cpp
Normal file
78
tests/engine_config_tests.cpp
Normal file
@@ -0,0 +1,78 @@
|
||||
#include "FluidSim/ScenarioCatalog.hpp"
|
||||
#include "FluidSim/DesktopScenario.hpp"
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
|
||||
using namespace fluidsim::port;
|
||||
namespace {
|
||||
void check(bool condition,const std::string& message) {if(!condition)throw std::runtime_error(message);}
|
||||
std::string read(const std::filesystem::path& path) {
|
||||
std::ifstream file(path);std::ostringstream text;text<<file.rdbuf();return text.str();
|
||||
}
|
||||
void replace(std::string& text,const std::string& from,const std::string& to) {
|
||||
const auto pos=text.find(from);check(pos!=std::string::npos,"test fixture missing "+from);text.replace(pos,from.size(),to);
|
||||
}
|
||||
struct TemporaryDirectory {
|
||||
std::filesystem::path path=std::filesystem::temp_directory_path()/
|
||||
("fluidsim-engine-config-"+std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
|
||||
TemporaryDirectory() {std::filesystem::create_directory(path);}
|
||||
~TemporaryDirectory() {std::error_code error;std::filesystem::remove_all(path,error);}
|
||||
};
|
||||
}
|
||||
int main() {
|
||||
const auto& catalog=scenario_examples();
|
||||
check(catalog.size()>=32,"bundled engines missing");
|
||||
const auto& thumper=scenario_example("single-450");
|
||||
check(thumper.engine.carbureted && std::abs(thumper.engine.cylinder.bore-.096f)<1e-7f,
|
||||
"four-stroke units or fuel type changed");
|
||||
check(std::abs(scenario_example("yz-racing").two.idle_slide_opening-.03f)<1e-7f,
|
||||
"125 idle tune lost");
|
||||
for(const auto& e:catalog) {
|
||||
DesktopScenario s(e);s.set_controls(.35f,0,1);
|
||||
for(int i=0;i<256;++i) {
|
||||
const auto audio=s.process();
|
||||
check(std::isfinite(audio.left) && std::isfinite(audio.right),e.id+": invalid audio after TOML load");
|
||||
}
|
||||
const auto state=s.snapshot();
|
||||
check(state.rpm>0 && state.pipe_positivity_repairs==0,e.id+": startup changed");
|
||||
}
|
||||
|
||||
TemporaryDirectory temp;
|
||||
const auto file=temp.path/"test.toml";
|
||||
const auto original=read(engine_directory()/"twin.toml");
|
||||
const auto reject=[&](std::string text,const std::string& expected) {
|
||||
{std::ofstream output(file);output<<text;}
|
||||
bool rejected=false;
|
||||
try {load_engine_file(file);}
|
||||
catch(const std::exception& error) {
|
||||
const std::string message=error.what();
|
||||
check(message.find("test.toml")!=std::string::npos,"diagnostic lost the file path");
|
||||
check(message.find(expected)!=std::string::npos,"unexpected diagnostic: "+message);
|
||||
rejected=true;
|
||||
}
|
||||
check(rejected,"invalid config accepted: "+expected);
|
||||
};
|
||||
auto changed=original;replace(changed,"schema_version = 1","schema_version = 99");reject(changed,"schema_version");
|
||||
changed=original;replace(changed,"bore_mm = 75.0","bore_mn = 75.0");reject(changed,"bore_mm");
|
||||
changed=original;replace(changed,"bore_mm = 75.0","bore_mm = -75.0");reject(changed,"cylinder");
|
||||
changed=original;replace(changed,"cylinders = 2","cylinders = 2.5");reject(changed,"integer");
|
||||
changed=original;replace(changed,"layout = \"inline\"","layout = \"flatbread\"");reject(changed,"layout");
|
||||
changed=original;replace(changed,"topology = \"collector\"","topology = \"american_v8\"");reject(changed,"V8");
|
||||
changed=original;replace(changed,"cells = 10","cells = 1000000");reject(changed,"cell");
|
||||
changed=original;replace(changed,"phase_degrees = [0.0, 180.0]","phase_degrees = [0.0]");reject(changed,"phase_degrees");
|
||||
reject(original+"\n[unknown_section]\nvalue = 1\n","unknown field");
|
||||
reject("schema_version = [","Error");
|
||||
|
||||
{std::ofstream output(file);output<<original;}
|
||||
std::filesystem::copy_file(file,temp.path/"duplicate.toml");
|
||||
bool duplicate=false;
|
||||
try {load_engine_directory(temp.path);}catch(const std::exception& error) {
|
||||
duplicate=std::string(error.what()).find("duplicate engine id")!=std::string::npos;
|
||||
}
|
||||
check(duplicate,"duplicate engine IDs accepted");
|
||||
std::cout<<catalog.size()<<" engine imports, startup smoke checks and invalid-config diagnostics passed\n";
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
#include "FluidSim/OutdoorAudio.hpp"
|
||||
#include "FluidSim/ReedValve.hpp"
|
||||
#include "FluidSim/Scenario.hpp"
|
||||
#include "FluidSim/ScenarioGeometry.hpp"
|
||||
#include "FluidSim/DesktopScenario.hpp"
|
||||
#include "FluidSim/ScenarioCatalog.hpp"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <iostream>
|
||||
@@ -98,6 +102,85 @@ int main() {
|
||||
}
|
||||
check(right < left, "source on right does not reach right ear first");
|
||||
|
||||
// A moved listener must converge to the same propagation as one created
|
||||
// there, including the reversed ear orientation and the reflected field.
|
||||
auto moved_config=stereo_config;
|
||||
moved_config.listener={-.5f,1.5f,4};
|
||||
moved_config.listener_right={-1,0,0};
|
||||
OutdoorAudio moving(44100,stereo_config),destination(44100,moved_config);
|
||||
moving.set_listener({moved_config.listener,moved_config.listener_right});
|
||||
for(auto* p:{&moving,&destination})p->set_monitor_filter(false);
|
||||
for(int i=0;i<44100;++i) {moving.process(0,0);destination.process(0,0);}
|
||||
for(int i=0;i<6000;++i) {
|
||||
const auto a=moving.process(0,.001f),b=destination.process(0,.001f);
|
||||
check(std::abs(a.left-b.left)<2e-7f && std::abs(a.right-b.right)<2e-7f,
|
||||
"moving listener does not update direct/reflected stereo paths");
|
||||
}
|
||||
// Exercise rapid pose changes while a wet tail is still ringing.
|
||||
for(int i=0;i<22050;++i) {
|
||||
if(i%735==0) {
|
||||
const float angle=i*.0002f;
|
||||
moving.set_listener({{3*std::sin(angle),1,3*std::cos(angle)},
|
||||
{std::cos(angle),0,-std::sin(angle)}});
|
||||
}
|
||||
const auto a=moving.process(0,.001f);
|
||||
check(std::isfinite(a.left) && std::isfinite(a.right) &&
|
||||
std::abs(a.left)<1 && std::abs(a.right)<1,"moving wet paths became unstable");
|
||||
}
|
||||
|
||||
// Source relocation must use geometry without resetting audio state.
|
||||
auto source_config=config();source_config.exhaust_position={2,1,-1};
|
||||
OutdoorAudio relocated(44100,config()),fixed_source(44100,source_config);
|
||||
relocated.set_source_positions({source_config.intake_position,source_config.exhaust_position,
|
||||
source_config.second_exhaust_position});
|
||||
for(int i=0;i<44100;++i) {relocated.process(0,0);fixed_source.process(0,0);}
|
||||
for(int i=0;i<3000;++i) {
|
||||
const auto a=relocated.process(0,.001f),b=fixed_source.process(0,.001f);
|
||||
check(std::abs(a.left-b.left)<2e-7f && std::abs(a.right-b.right)<2e-7f,
|
||||
"relocated mouth does not match world source position");
|
||||
}
|
||||
ScenarioSnapshot geometry;
|
||||
geometry.two_stroke=true;
|
||||
auto mouths=scenario_sound_sources(geometry);
|
||||
check(mouths[0].x==-1.05f && mouths[1].x==1.45f && mouths[1].y==.23f,
|
||||
"two-stroke mouths are not on the drawn pipe ends");
|
||||
geometry.two_stroke=false;geometry.cylinder_count=8;geometry.layout=EngineLayout::V;
|
||||
geometry.v8_headers=true;geometry.dual_exhaust=true;
|
||||
mouths=scenario_sound_sources(geometry);
|
||||
check(mouths[1].x==2.94f && mouths[1].z==-.39f && mouths[2].z==.39f,
|
||||
"dual V8 outlets are not at their separate tailpipe mouths");
|
||||
geometry.v8_headers=false;geometry.dual_exhaust=false;geometry.silencers=true;
|
||||
geometry.cylinder_count=4;geometry.layout=EngineLayout::Inline;
|
||||
const auto& e=geometry.exhaust_config;
|
||||
mouths=scenario_sound_sources(geometry);
|
||||
check(std::abs(mouths[1].x-(.74f+e.inlet_length+e.resonator.length+e.connecting_length+
|
||||
e.muffler.length+e.outlet_length))<1e-6f,
|
||||
"silenced exhaust source does not follow the configured pipe length");
|
||||
// Crossing a reflecting plane must not turn its virtual image into a
|
||||
// source that arrives before the actual pipe mouth.
|
||||
auto behind_config=config();behind_config.listener={-4,1,0};
|
||||
OutdoorAudio behind_wall(44100,behind_config);
|
||||
behind_wall.set_monitor_filter(false);
|
||||
for(int i=0;i<4410;++i)behind_wall.process(0,0);
|
||||
int first_behind=-1;
|
||||
for(int i=0;i<1000;++i) {
|
||||
const auto a=behind_wall.process(0,.001f);
|
||||
if(first_behind<0 && std::abs(a.left)>1e-9f)first_behind=i;
|
||||
}
|
||||
check(first_behind==514,"reflection crosses wall and precedes the physical source");
|
||||
|
||||
for(const auto& example:scenario_examples()) {
|
||||
DesktopScenario scenario(example.kind,example.two,example.exhaust,example.junctions,example.engine);
|
||||
scenario.set_listener({{2,1,3},{1,0,0}});
|
||||
const auto snapshot=scenario.snapshot();
|
||||
const auto sources=scenario_sound_sources(snapshot);
|
||||
for(auto source:sources)
|
||||
check(std::isfinite(source.x) && std::isfinite(source.y) && std::isfinite(source.z),
|
||||
"catalog scenario has an invalid sound source");
|
||||
const auto frame=scenario.process();
|
||||
check(std::isfinite(frame.left) && std::isfinite(frame.right),"catalog listener wiring failed");
|
||||
}
|
||||
|
||||
ReedValve reed;
|
||||
for (int i = 0; i < 10000; i++)
|
||||
reed.step(1.f / 44100, 100);
|
||||
|
||||
@@ -10,7 +10,7 @@ int main() {
|
||||
std::set<std::string> ids;
|
||||
for(const auto& example:scenario_examples()) {
|
||||
if(!ids.insert(example.id).second)throw std::runtime_error("duplicate example id");
|
||||
DesktopScenario scenario(example.kind,example.two,example.exhaust,example.v8,example.engine,example.title);
|
||||
DesktopScenario scenario(example.kind,example.two,example.exhaust,example.junctions,example.engine,example.title);
|
||||
scenario.set_audio_scene(OutdoorScene::Dry);
|
||||
const auto started=std::chrono::steady_clock::now();
|
||||
float peak_power=0;
|
||||
@@ -25,9 +25,17 @@ int main() {
|
||||
if(example.id=="big-carb" && peak_power<10000)throw std::runtime_error("big carb V8 failed to produce power");
|
||||
if(!std::isfinite(snapshot.rpm)||snapshot.rpm<=0)throw std::runtime_error(example.id+" invalid engine speed");
|
||||
if(example.engine.cylinders && (snapshot.cylinder_count!=example.engine.cylinders || snapshot.layout!=example.engine.layout))throw std::runtime_error("custom cylinder layout lost");
|
||||
const bool custom_v8=example.engine.american_headers();
|
||||
if(custom_v8 && (!snapshot.v8_headers || snapshot.h_pipe!=example.junctions.h_pipe || snapshot.pipe_states[25].empty()))
|
||||
throw std::runtime_error("custom V8 lost American junction headers/crossover");
|
||||
if(example.engine.staged_headers() && example.engine.layout==EngineLayout::Inline) {
|
||||
if(!snapshot.staged_headers || snapshot.dual_exhaust)
|
||||
throw std::runtime_error("inline engine lost staged junction collectors");
|
||||
}
|
||||
if(example.engine.staged_headers() && example.engine.layout==EngineLayout::V && (!snapshot.dual_exhaust || !snapshot.staged_headers))
|
||||
throw std::runtime_error("large V engine lost separate bank outlets");
|
||||
if(snapshot.example_name!=example.title)throw std::runtime_error("example name lost");
|
||||
if(example.kind==ScenarioKind::AmericanV8 && snapshot.h_pipe!=example.v8.h_pipe)throw std::runtime_error("example crossover selection lost");
|
||||
if(example.kind==ScenarioKind::SaabB205 && snapshot.silencers!=example.exhaust.enabled)throw std::runtime_error("example silencer selection lost");
|
||||
if(example.engine.has_silenced_exhaust() && snapshot.silencers!=example.exhaust.enabled)throw std::runtime_error("example silencer selection lost");
|
||||
std::cout<<example.id<<" passed, rpm="<<snapshot.rpm<<", wall/sim="<<std::chrono::duration<double>(std::chrono::steady_clock::now()-started).count()/2<<std::endl;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user