Improve physical geometry, fluid solvers, audio, and profiling

This commit is contained in:
max
2026-10-30 18:57:56 +01:00
parent bf8621ad39
commit eb2b6366c2
130 changed files with 14649 additions and 862 deletions

View File

@@ -1,10 +1,29 @@
#include "FluidSim/Desktop/DesktopUi.hpp"
#include "FluidSim/Scenario.hpp"
#include "FluidSim/ScenarioCatalog.hpp"
#include "FluidSim/DesktopScenario.hpp"
#include "FluidSim/Desktop/PlaybackControl.hpp"
#include "FluidSim/Desktop/PipePath.hpp"
#include <algorithm>
#include <cmath>
#include <iostream>
#include <stdexcept>
using namespace fluidsim::port::desktop;
void check(bool condition,const char* reason) {if(!condition)throw std::runtime_error(reason);}
int main() {
int main(int argc,char** argv) {
PlaybackControl playback;
sf::Event space=sf::Event::KeyPressed{.code=sf::Keyboard::Key::Space};
sf::Event wheel=sf::Event::MouseWheelScrolled{sf::Mouse::Wheel::Vertical,-1.f,{400,300}};
check(playback.realtime()&&!playback.handle_event(wheel,true),"realtime mode intercepted camera scrolling");
check(playback.handle_event(space)&&!playback.realtime(),"Space did not disable realtime");
check(!playback.handle_event(wheel)&&playback.target_speed()==1,"regular scroll changed manual speed");
check(playback.handle_event(wheel,true)&&playback.target_speed()<1,"Shift+scroll did not change manual speed");
const auto manual=playback.target_speed();playback.handle_event(space);check(playback.target_speed()==1,"Space did not restore realtime");
playback.handle_event(space);check(playback.target_speed()==manual,"manual speed was lost");
PipePath corner({{0,{0,0,0}},{1,{1,0,0}},{2,{1,1,0}}});
check(corner.position(.5f)==fluidsim::port::Vec3{.5f,0,0}&&corner.position(1.5f)==fluidsim::port::Vec3{1,.5f,0},"sharp pipe bend was rounded into a spline");
const auto miter=corner.miter_offset(1,{.70710678f,-.70710678f,0});
check(std::abs(miter.x-1)<1e-5f&&std::abs(miter.y+1)<1e-5f,"90 degree pipe joint pinches the cross-section");
for(auto size : {sf::Vector2u{933,1010},sf::Vector2u{1920,1080},sf::Vector2u{1040,800},sf::Vector2u{800,700}}) {
const auto l=WorkspaceLayout::calculate(size);
for(auto r : {l.sidebar,l.viewport,l.chart,l.inspector,l.pedal}) {
@@ -46,5 +65,31 @@ int main() {
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";
ui.configure_resolution(20);
const auto buttons=DesktopUi::resolution_buttons(size);
const auto plus=buttons[1].position+sf::Vector2f{10,8};
ui.handle_event(sf::Event::MouseButtonPressed{sf::Mouse::Button::Left,{int(plus.x),int(plus.y)}},size);
check(ui.take_resolution_request()==25,"resolution plus button failed");
ui.resolution_started();
ui.handle_event(sf::Event::MouseButtonPressed{sf::Mouse::Button::Left,{int(plus.x),int(plus.y)}},size);
check(!ui.take_resolution_request(),"resolution changed while rebuilding");
ui.resolution_finished(25,"Applied");
if(argc==3 && std::string(argv[1])=="--preview") {
const std::filesystem::path directory=argv[2];std::filesystem::create_directories(directory);
sf::ContextSettings context;context.depthBits=24;context.majorVersion=2;context.minorVersion=1;
sf::RenderWindow window(sf::VideoMode({1440,900}),"Geometry preview",sf::Style::Default,sf::State::Windowed,context);
window.setVisible(false);sf::Font font("C:/Windows/Fonts/consola.ttf");
const auto& catalog=fluidsim::port::scenario_examples();
sf::RenderTexture contact({1280,static_cast<unsigned>((catalog.size()+3)/4)*200});contact.clear(sf::Color::Black);
unsigned index=0;
for(const auto& example:catalog) {
fluidsim::port::DesktopScenario simulation(example);for(int i=0;i<512;++i)simulation.process();
const auto state=simulation.snapshot();DesktopUi preview;preview.configure_resolution(state.cells_per_meter);preview.draw(window,&font,state,{});
sf::Texture texture(window.getSize());texture.update(window);
check(texture.copyToImage().saveToFile(directory/(example.id+".png")),"could not save geometry preview");
sf::Sprite thumbnail(texture);thumbnail.setScale({320.f/window.getSize().x,200.f/window.getSize().y});thumbnail.setPosition({float(index%4)*320,float(index/4)*200});contact.draw(thumbnail);++index;
}
contact.display();check(contact.getTexture().copyToImage().saveToFile(directory/"catalog.png"),"could not save catalog preview");
}
std::cout<<"Desktop controls, resolution stepper, and camera checks passed\n";
}

View File

@@ -56,14 +56,29 @@ int main() {
}
check(rejected,"invalid config accepted: "+expected);
};
auto changed=original;replace(changed,"schema_version = 1","schema_version = 99");reject(changed,"schema_version");
auto changed=original;replace(changed,"schema_version = 2","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,"cells_per_meter = 50.0","cells_per_meter = 1000000");reject(changed,"cells_per_meter");
changed=original;replace(changed,"phase_degrees = [0.0, 180.0]","phase_degrees = [0.0]");reject(changed,"phase_degrees");
reject(original+"\n[junctions]\nmodel = \"mystery\"\n","model must be");
reject(original+"\n[junctions]\nturning_loss = -1\n","turning_loss");
reject(original+"\n[[junctions.branches]]\npipe=3\nleft=false\ndirection=[0,0,0]\n","nonzero");
{
std::ofstream output(file);
output<<original<<"\n[junctions]\nmodel=\"characteristic\"\nturning_loss=0.5\n"
<<"[[junctions.branches]]\npipe=3\nleft=false\ndirection=[-1,0,0]\n";
}
const auto directed=load_engine_file(file);
check(directed.junctions.model=="characteristic" && directed.junctions.branches.size()==1,"junction configuration was not loaded");
{
DesktopScenario scenario(directed);
for(int i=0;i<2048;++i) scenario.process();
check(scenario.snapshot().pipe_positivity_repairs==0,"configured characteristic engine needs positivity repairs");
}
reject(original+"\n[unknown_section]\nvalue = 1\n","unknown field");
reject("schema_version = [","Error");

View File

@@ -794,6 +794,38 @@ int main(int argc,char** argv) {
"thermal relaxation differs from exact exponential");
});
test("pipe solver scratch follows runtime solver changes", [] {
Pipe1D pipe(.001f,.2f,16);
pipe.left_ghost=pipe.state(0);
pipe.right_ghost=pipe.state(15);
pipe.solver=PipeSolver::Rusanov;
pipe.prepare_step();
const auto rusanov_bytes=pipe.scratch_bytes();
pipe.solver=PipeSolver::Hlle;
pipe.prepare_step();
const auto hlle_bytes=pipe.scratch_bytes();
pipe.solver=PipeSolver::Muscl;
pipe.prepare_step();
const auto muscl_bytes=pipe.scratch_bytes();
check(rusanov_bytes<hlle_bytes && hlle_bytes<muscl_bytes,
"solver-specific work buffers were not allocated on demand");
for(auto solver : {PipeSolver::Rusanov,PipeSolver::Hlle,PipeSolver::Hllc,
PipeSolver::Muscl,PipeSolver::Rusanov}) {
pipe.solver=solver;
for(int step=0;step<8;++step) {
pipe.left_ghost=pipe.state(0);
pipe.right_ghost=pipe.state(15);
const float dt=.5f*pipe.prepare_step();
pipe.advance(dt);
}
}
for(int i=0;i<16;++i) {
const auto state=pipe.state(i);
check(std::isfinite(state.rho)&&std::isfinite(state.pressure)&&
state.rho>0&&state.pressure>0,"solver switch produced a nonphysical pipe state");
}
});
test("sealed crankcase compression conserves mass above five bar", [] {
Crankshaft crank;
crank.angle = 0;

79
tests/junction_tests.cpp Normal file
View File

@@ -0,0 +1,79 @@
#include "FluidSim/Solver.hpp"
#include <array>
#include <cmath>
#include <iostream>
#include <stdexcept>
using namespace fluidsim::port;
namespace {
void check(bool condition,const char* message) { if(!condition) throw std::runtime_error(message); }
struct Outcome { std::array<double,3> mass{}, pressure{}; std::uint64_t fallbacks{}; };
Outcome flow(bool reverse,bool rotate,float loss,bool mixed=false,bool swap_donors=false) {
std::vector<Pipe1D> pipes;
for(int j=0;j<3;++j) {
pipes.emplace_back(.001f*(mixed?j+1:1),.2f,8);
for(auto& cell:pipes.back().cells) {
const double rho=mixed?(j==0?1.4:1.1):ambient_density;
const double p=mixed?(j==0?120000:101325):101325;
const double u=(swap_donors?(j==0?0:-50):(j==0?70:0))*(reverse?-1:1);
cell={float(rho),float(rho*u),float(p/pipe_gm1+.5*rho*u*u),j==0?.7f:1.f,j==0?.1f:0.f};
}
}
BoundarySystem boundaries(pipes);
std::array<std::array<float,3>,3> axes{{{-1,0,0},{1,0,0},{0,1,0}}};
if(rotate) for(auto& axis:axes) axis={-axis[1],axis[0],axis[2]};
boundaries.add_junction({{0,reverse,axes[0]},{1,!reverse,axes[1]},{2,!reverse,axes[2]}},BoundarySystem::JunctionMethod::Characteristic,loss);
boundaries.add_closed_end(0,!reverse);boundaries.add_closed_end(1,reverse);boundaries.add_closed_end(2,reverse);
const auto totals=[&] {
std::array<double,4> t{};
for(auto& p:pipes) for(int i=0;i<int(p.cells.size());++i) {
const double m=p.cell_mass(i);
t[0]+=m;t[1]+=double(p.cells[i].energy)*p.cell_area(i)*p.cell_length(i);
t[2]+=m*p.cells[i].air;t[3]+=m*p.cells[i].fuel;
}
return t;
};
const auto initial=totals();Solver solver(pipes,boundaries);solver.minimum_substeps=1;
// End before waves reflected by the sealed far ends reach the fitting.
for(int i=0;i<30;++i) solver.step(1e-5f);
const auto final=totals();
for(int j=0;j<4;++j) check(std::abs(final[j]-initial[j])<std::max(1e-10,std::abs(initial[j])*1e-5),"junction lost mass, energy, or species");
Outcome result;
for(int j=0;j<3;++j) {
const int endpoint=(j==0?reverse:!reverse)?0:7;
result.pressure[j]=pipes[j].state(endpoint).pressure;
for(int i=0;i<8;++i) result.mass[j]+=pipes[j].cell_mass(i);
check(pipes[j].positivity_repairs==0,"junction needed positivity repair");
}
result.fallbacks=boundaries.junction_fallbacks;
check(result.fallbacks==0,"subsonic junction unexpectedly fell back");
return result;
}
}
int main() {
try {
const auto ideal=flow(false,false,0);
check(std::abs(ideal.mass[1]-ideal.mass[2])<1e-10,"ideal equal-area outlets are asymmetric");
const auto directed=flow(false,false,1);
check(directed.mass[1]>directed.mass[2]+1e-8,"turning loss does not favor straight-through flow");
const auto rotated=flow(false,true,1), reversed=flow(true,false,1);
for(int j=0;j<3;++j) {
check(std::abs(rotated.mass[j]-directed.mass[j])<1e-10,"rotating junction changes flow");
check(std::abs(reversed.mass[j]-directed.mass[j])<1e-9,"pipe coordinate reversal changes flow");
}
flow(false,false,.5f,true);flow(true,true,.5f,true);
flow(false,false,.5f,true,true);flow(true,true,.5f,true,true);
// Supersonic endpoint data must explicitly select the conservative fallback.
std::vector<Pipe1D> pipes;for(int j=0;j<2;++j) pipes.emplace_back(.001f,.1f,4);
for(auto& cell:pipes[0].cells) { const float u=500;cell.momentum=cell.rho*u;cell.energy+=.5f*cell.rho*u*u; }
BoundarySystem boundaries(pipes);
boundaries.add_junction({{0,false},{1,true}});
boundaries.add_closed_end(0,true);boundaries.add_closed_end(1,false);
Solver solver(pipes,boundaries);solver.minimum_substeps=1;solver.step(1e-6f);
check(boundaries.junction_fallbacks==1,"supersonic fallback was not reported");
bool rejected=false;
try { BoundarySystem invalid(pipes);invalid.add_junction({{0,false},{1,true}},BoundarySystem::JunctionMethod::Characteristic,1); }
catch(const std::invalid_argument&) {rejected=true;}
check(rejected,"directionless turning-loss node was accepted");
std::cout<<"Junction conservation, species, direction, rotation, reversal, and fallback tests passed\n";
} catch(const std::exception& e) { std::cerr<<e.what()<<'\n';return 1; }
}

View File

@@ -0,0 +1,79 @@
#include "FluidSim/DesktopScenario.hpp"
#include <atomic>
#include <chrono>
#include <cmath>
#include <iostream>
#include <limits>
#include <numeric>
#include <stdexcept>
#include <thread>
using namespace fluidsim::port;
namespace {
void check(bool value,const char* message) {if(!value)throw std::runtime_error(message);}
bool near(float a,float b) {return std::abs(a-b)<1e-5f;}
struct TempDirectory {
std::filesystem::path path=std::filesystem::temp_directory_path()/
("fluidsim-live-tuning-"+std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
TempDirectory(){std::filesystem::create_directory(path);}
~TempDirectory(){std::error_code ignored;std::filesystem::remove_all(path,ignored);}
};
}
int main() {
const auto& source=scenario_example("single-450");
FourStrokeScenario simulation(44100,source);
for(int i=0;i<1024;++i)simulation.process();
const float rpm=simulation.crankshaft.omega,angle=simulation.crankshaft.angle;
const float pressure=simulation.cylinder1.pressure,lift=simulation.cylinder1.intake_lift();
const auto samples=simulation.sample_count;
auto tuned=source.engine;
tuned.intake_valve.mass_kg=.047f;tuned.intake_valve.spring_rate_n_per_m=45000;
tuned.intake_valve.damping_n_s_per_m=32;tuned.valve_sound.radiation_area=.0005f;
tuned.ecu.idle_base_opening=.055f;tuned.fuel.pilot_jet_diameter*=1.1f;
for(auto& p:tuned.intake_cam.points)p.lift_metres*=.8f;
for(auto& p:tuned.ecu.ignition_map)p.degrees_btdc+=4;
simulation.set_tuning(tuned);
check(simulation.crankshaft.omega==rpm && simulation.crankshaft.angle==angle && simulation.sample_count==samples,"live edit restarted the engine");
check(simulation.cylinder1.pressure==pressure && simulation.cylinder1.intake_lift()==lift,"live edit reset cylinder or valve state");
const auto snapshot=simulation.snapshot();
check(near(snapshot.intake_cam_lifts[120],FourStrokeCylinder::cam_lift_at(tuned.intake_cam,480)),"live cam profile was not published");
check(near(simulation.ecu.idle_base_opening,.055f),"ECU calibration was not applied");
for(int i=0;i<2048;++i) {
const auto frame=simulation.process();
check(std::isfinite(frame.left) && std::isfinite(frame.right),"live tuning produced nonfinite audio");
}
auto invalid=tuned;invalid.pipes[0].length*=1.5f;
bool rejected=false;try {simulation.set_tuning(invalid);}catch(const std::invalid_argument&){rejected=true;}
check(rejected,"mesh edits must require a rebuild");
invalid=tuned;invalid.intake_valve.mass_kg=std::numeric_limits<float>::quiet_NaN();
rejected=false;try {simulation.set_tuning(invalid);}catch(const std::exception&){rejected=true;}
check(rejected,"invalid calibration accepted");
TempDirectory temp;
const auto saved=save_tuned_engine(source,tuned,temp.path);
const auto loaded=load_engine_file(saved);
check(loaded.id=="single-450-tuned" && loaded.source_path!=source.source_path,"export replaced source identity");
check(near(loaded.engine.intake_valve.mass_kg,tuned.intake_valve.mass_kg),"valve mass export changed units");
check(near(loaded.engine.fuel.pilot_jet_diameter,tuned.fuel.pilot_jet_diameter),"carburetor edit was not exported");
check(near(loaded.engine.ecu.ignition_map[0].degrees_btdc,tuned.ecu.ignition_map[0].degrees_btdc),"ignition trim was not exported");
check(near(loaded.engine.intake_cam.points[10].lift_metres,tuned.intake_cam.points[10].lift_metres),"cam profile export changed units");
auto at_limit=tuned;at_limit.intake_cam.points[10].lift_metres=at_limit.intake_valve.maximum_lift_m;
check(save_tuned_engine(source,at_limit,temp.path)!=saved,"repeat export overwrote an engine");
DesktopScenario desktop(source);
desktop.set_controls(.2f,.1f,.6f);desktop.set_pipe_solver(PipeSolver::Hlle);desktop.set_audio_scene(OutdoorScene::Dry);
auto pipes=tuned;auto& route=pipes.geometry.pipes[0];const auto end=route.points.back(),start=route.points.front();
route.points.front()={end.x+(start.x-end.x)*1.2f,end.y+(start.y-end.y)*1.2f,end.z+(start.z-end.z)*1.2f};
pipes.pipes[0].length=route.length();
desktop.rebuild_engine(pipes);
const auto rebuilt=desktop.snapshot();
const float length=std::accumulate(rebuilt.pipe_lengths[0].begin(),rebuilt.pipe_lengths[0].end(),0.f);
check(near(length,pipes.pipes[0].length),"rebuilt mesh uses old dimensions");
check(rebuilt.pipe_solver==PipeSolver::Hlle && rebuilt.audio_scene==OutdoorScene::Dry && near(rebuilt.clutch,.6f),"rebuild lost scene or controls");
std::atomic<bool> finite{true};
std::jthread worker([&]{for(int i=0;i<4096;++i) {auto f=desktop.process();if(!std::isfinite(f.left)||!std::isfinite(f.right))finite=false;}});
for(int i=0;i<16;++i) {auto live=pipes;live.intake_valve.mass_kg+=float(i)*.0001f;desktop.set_tuning(live);desktop.snapshot();}
desktop.rebuild_engine(pipes);
worker.join();check(finite,"concurrent editing or rebuild corrupted the simulation");
std::cout<<"Live tuning state, export round trip, mesh rebuild and concurrent publication passed\n";
}

View File

@@ -0,0 +1,47 @@
#include "FluidSim/DesktopScenario.hpp"
#include <atomic>
#include <cmath>
#include <iostream>
#include <limits>
#include <stdexcept>
#include <thread>
using namespace fluidsim::port;
void check(bool value,const char* reason) {if(!value)throw std::runtime_error(reason);}
double volume(const ScenarioSnapshot& s) {
double sum=0;
for(int p=0;p<32;++p) for(std::size_t i=0;i<s.pipe_areas[p].size();++i)
sum+=double(s.pipe_areas[p][i])*s.pipe_lengths[p][i];
return sum;
}
int main() {
try {
check(mesh_cell_count(.1f,100)==10,"decimal cell count rounded upward");
for(const auto& example:scenario_examples())
check((example.kind==ScenarioKind::TwoStroke?example.two.cells_per_meter:example.engine.cells_per_meter)>0,"scenario lacks mesh density");
for(const auto* id:{"single-450","yz-racing","saab-chambered"}) {
DesktopScenario scenario(scenario_example(id));
scenario.set_controls(.3f,.1f,.6f);scenario.set_pipe_solver(PipeSolver::Hllc);scenario.set_audio_scene(OutdoorScene::Dry);
const double original_volume=volume(scenario.snapshot());
std::atomic<bool> finite{true};
std::jthread worker([&]{for(int i=0;i<2048;++i) {const auto f=scenario.process();if(!std::isfinite(f.left)||!std::isfinite(f.right))finite=false;}});
scenario.set_cells_per_meter(35);
worker.join();check(finite,"concurrent resolution rebuild corrupted audio");
auto state=scenario.snapshot();
check(state.cells_per_meter==35 && state.pipe_solver==PipeSolver::Hllc && state.audio_scene==OutdoorScene::Dry,"resolution rebuild lost options");
check(std::abs(volume(state)-original_volume)<original_volume*1e-5,"remeshing changed pipe volume");
for(const auto& lengths:state.pipe_lengths) for(float length:lengths)
check(length<=1.f/35+1e-6f,"profile or uniform pipe ignored cells/m");
check(std::abs(state.clutch-.6f)<1e-5f,"resolution rebuild lost controls");
const auto old_count=state.pipe_states[0].size();
for(float invalid:{0.f,-1.f,501.f,std::numeric_limits<float>::quiet_NaN()}) {
bool rejected=false;try{scenario.set_cells_per_meter(invalid);}catch(const std::exception&){rejected=true;}
check(rejected,"invalid density accepted");
check(scenario.snapshot().pipe_states[0].size()==old_count,"failed rebuild changed live mesh");
}
}
DesktopScenario a(scenario_example("single-450")),b(scenario_example("single-650"));
const float density=b.snapshot().cells_per_meter;a.set_cells_per_meter(50);
check(b.snapshot().cells_per_meter==density,"resolution leaked between scenarios");
std::cout<<"Uniform/profile mesh resolution, volume, rebuild, isolation and validation checks passed\n";
} catch(const std::exception& e) {std::cerr<<e.what()<<'\n';return 1;}
}

View File

@@ -181,6 +181,26 @@ int main() {
check(std::isfinite(frame.left) && std::isfinite(frame.right),"catalog listener wiring failed");
}
// Source stems reconstruct the mix, and muting leaves diagnostic stems alive.
auto stem_config=config();
stem_config.dual_exhaust=true; stem_config.valve_contact_enabled=true;
OutdoorAudio stems(44100,stem_config), muted(44100,stem_config);
for(std::size_t j=0;j<4;++j) muted.set_source_enabled(j,false);
for(int i=0;i<12000;++i) {
const float x=.01f*std::sin(i*.07f);
const auto mix=stems.process(x,x*.4f,-x*.2f,x*.1f);
auto sum=stems.tail_frame();
for(const auto& source:stems.source_frames()) {
sum.left+=source.rendered.left; sum.right+=source.rendered.right;
}
check(std::abs(sum.left-mix.left)<1e-6f && std::abs(sum.right-mix.right)<1e-6f,
"source stems do not reconstruct rendered mix");
const auto silent=muted.process(x,x*.4f,-x*.2f,x*.1f);
check(silent.left==0 && silent.right==0,"muted sources feed listening mix or reverb");
check(muted.source_frames()[0].rendered.left==stems.source_frames()[0].rendered.left,
"muting changes diagnostic source history");
}
ReedValve reed;
for (int i = 0; i < 10000; i++)
reed.step(1.f / 44100, 100);

View File

@@ -0,0 +1,67 @@
#include "FluidSim/DesktopScenario.hpp"
#include "FluidSim/ScenarioGeometry.hpp"
#include <toml.hpp>
#include <chrono>
#include <fstream>
#include <iostream>
#include <numeric>
#include <stdexcept>
using namespace fluidsim::port;
void check(bool value,const char* message){if(!value)throw std::runtime_error(message);}
int main() try {
for(const auto& e:scenario_examples()) {
DesktopScenario scenario(e);const auto state=scenario.snapshot();
check(bool(state.geometry),"scenario lacks physical geometry");const auto routes=scenario_pipe_routes(state);
for(std::size_t p=0;p<state.geometry->pipes.size();++p) {
const float mesh=std::accumulate(state.pipe_lengths[p].begin(),state.pipe_lengths[p].end(),0.f);
check(std::abs(mesh-state.geometry->pipes[p].length())<1e-5f*std::max(1.f,mesh),"fluid length differs from render centerline");
check(routes[p].count==state.geometry->pipes[p].points.size(),"renderer lost a bend");
for(unsigned k=0;k<routes[p].count;++k)check(geometry_distance(routes[p].points[k],state.geometry->pipes[p].points[k])<1e-7f,"render route differs from input geometry");
}
check(!state.storage_volumes.empty(),"storage volumes missing from rendering snapshot");
for(const auto& v:state.storage_volumes) {
check(v.box.volume()>0&&std::isfinite(v.pressure),"invalid physical storage volume");
const auto lo=v.box.minimum(),hi=v.box.maximum();int ports=0;
for(const auto& p:state.geometry->pipes)for(auto endpoint:{p.points.front(),p.points.back()}) {
constexpr float tolerance=2e-6f;
const bool inside=endpoint.x>=lo.x-tolerance&&endpoint.x<=hi.x+tolerance&&endpoint.y>=lo.y-tolerance&&endpoint.y<=hi.y+tolerance&&endpoint.z>=lo.z-tolerance&&endpoint.z<=hi.z+tolerance;
const bool surface=std::min({std::abs(endpoint.x-lo.x),std::abs(endpoint.x-hi.x),std::abs(endpoint.y-lo.y),std::abs(endpoint.y-hi.y),std::abs(endpoint.z-lo.z),std::abs(endpoint.z-hi.z)})<tolerance;
if(inside&&surface)++ports;
}
if(ports<2)throw std::runtime_error(e.id+": storage volume has disconnected pipe ports: "+v.id);
}
for(int i=0;i<state.cylinder_count;++i) {
const auto& c=state.geometry->cylinders[i];const float angle=-c.tilt_degrees*pi/180;
const auto attached=[&](Vec3 p) {
p={p.x-c.origin.x,p.y-c.origin.y,p.z-c.origin.z};
const float y=p.y*std::cos(angle)-p.z*std::sin(angle),z=p.y*std::sin(angle)+p.z*std::cos(angle);
return std::abs(p.x)<=state.cylinder_bore*.65f&&std::abs(z)<=state.cylinder_bore*.65f&&y>=state.cylinder_rod-state.cylinder_stroke*.5f-.005f&&y<=state.cylinder_rod+state.cylinder_stroke*.5f+state.cylinder_clearance/(pi*state.cylinder_bore*state.cylinder_bore*.25f)+.005f;
};
const auto& intake=state.geometry->pipes[state.two_stroke?2:1+i];
const auto& exhaust=state.geometry->pipes[state.two_stroke?3:1+state.cylinder_count+i];
if(!attached(intake.points.back())||!attached(exhaust.points.front()))throw std::runtime_error(e.id+": cylinder port is disconnected from its geometry");
}
if(e.kind!=ScenarioKind::TwoStroke){FourStrokeScenario four(44100,e);check(std::abs(four.intake_plenum.volume()-e.engine.geometry.volume("intake_plenum")->volume())<1e-8f,"plenum simulation ignores geometry");}
}
const auto average_primary=[](const ScenarioExample& e){float sum=0;for(int i=0;i<e.engine.cylinders;++i)sum+=e.engine.geometry.pipes[1+e.engine.cylinders+i].length();return sum/e.engine.cylinders;};
check(average_primary(scenario_example("v8-short"))<average_primary(scenario_example("v8-h"))&&average_primary(scenario_example("v8-h"))<average_primary(scenario_example("v8-long")),"short and long header variants lost their physical distinction");
auto table=toml::parse_file(scenario_example("single-450").source_path.string());
auto& volumes=*table["geometry"]["volumes"].as_array();
toml::table* plenum_table=nullptr;
for(auto& v:volumes)if((*v.as_table())["id"].value_or<std::string>("")=="intake_plenum")plenum_table=v.as_table();
check(plenum_table!=nullptr,"test volume not found");auto& plenum=*plenum_table;
plenum.erase("size_mm");plenum.erase("center_mm");
plenum.insert_or_assign("corners_mm",toml::array{toml::array{100.,200.,300.},toml::array{0.,0.,0.}});
auto& pipe=*table["geometry"]["pipes"].as_array()->get(0)->as_table();
pipe.insert_or_assign("points_mm",toml::array{toml::array{0.,0.,0.},toml::array{30.,40.,0.},toml::array{30.,40.,120.}});
const auto file=std::filesystem::temp_directory_path()/("fluidsim-geometry-"+std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())+".toml");
const auto write=[&]{{std::ofstream out(file);out<<table;}};
write();const auto loaded=load_engine_file(file);FourStrokeScenario changed(44100,loaded);
check(std::abs(changed.intake_plenum.volume()-.006f)<1e-8f,"corner-defined volume conversion is wrong");
check(geometry_distance(loaded.engine.geometry.volume("intake_plenum")->center,{.05f,.1f,.15f})<1e-7f,"corner center is wrong");
check(std::abs(std::accumulate(changed.pipes[0].cell_lengths.begin(),changed.pipes[0].cell_lengths.end(),0.f)-.17f)<1e-6f,"path length does not drive simulation");
pipe.insert_or_assign("points_mm",toml::array{toml::array{0.,0.,0.},toml::array{0.,0.,0.}});write();
bool rejected=false;try{load_engine_file(file);}catch(const std::exception& e){rejected=std::string(e.what()).find(file.filename().string())!=std::string::npos;}
std::filesystem::remove(file);check(rejected,"degenerate geometry accepted or missing file diagnostic");
std::cout<<"All scenario centerlines, storage dimensions, corner volumes and invalid geometry passed\n";
}catch(const std::exception& e){std::cerr<<e.what()<<'\n';return 1;}

View File

@@ -77,6 +77,14 @@ int main() {
require(std::abs(direction.x*direction.x+direction.y*direction.y+direction.z*direction.z-1)<1e-4f,"pipe path tangent must be normalized");
auto atm=desktop::pressure_tint(101325),vac=desktop::pressure_tint(0),peak=desktop::pressure_tint(1000000);
require(atm.y>.8f && vac.z>.99f && peak.x>.99f,"pressure palette must show atmosphere green, vacuum blue, spikes red");
const auto vacuum_before=desktop::pressure_tint(40000),vacuum_after=desktop::pressure_tint(40000,true);
const auto small_intake_pulse=desktop::pressure_tint(104325),small_exhaust_pulse=desktop::pressure_tint(104325,true);
require(vacuum_before.x==vacuum_after.x&&vacuum_before.y==vacuum_after.y&&vacuum_before.z==vacuum_after.z,
"exhaust highlighting must not change the established underpressure palette");
require(small_intake_pulse.x>.9f&&small_intake_pulse.y>.8f&&small_intake_pulse.z<.1f,
"a small positive intake pulse must be visible as yellow");
require(small_exhaust_pulse.x>.95f&&small_exhaust_pulse.y<.4f,
"a small positive exhaust pulse must be visible as orange-red");
Ecu fast_actuator;
for(int i=0;i<230;++i)fast_actuator.step(1.f/44100,1,5000);
require(fast_actuator.throttle_opening()>.99f,"throttle actuator must settle within about 5 ms");