668 lines
36 KiB
C++
668 lines
36 KiB
C++
#include "FluidSim/DesktopScenario.hpp"
|
|
#include "FluidSim/SoundEngine.hpp"
|
|
#include "FluidSim/DynoCurve.hpp"
|
|
|
|
#include <SFML/Graphics.hpp>
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <chrono>
|
|
#include <cstdlib>
|
|
#include <iostream>
|
|
#include <cmath>
|
|
#include <filesystem>
|
|
#include <iomanip>
|
|
#include <optional>
|
|
#include <sstream>
|
|
#include <thread>
|
|
|
|
namespace {
|
|
using namespace fluidsim::port;
|
|
|
|
constexpr unsigned int window_width = 1280;
|
|
constexpr unsigned int window_height = 720;
|
|
constexpr std::size_t ring_capacity = 8192;
|
|
constexpr std::size_t target_fill = 4096;
|
|
constexpr std::size_t preroll_samples = 4096;
|
|
constexpr float throttle_lerp_rate = 10.0f;
|
|
|
|
sf::Color pressure_color(float pressure) {
|
|
const float bar = pressure / 1.0e5f;
|
|
if (bar < 1.0f) {
|
|
const float f = std::clamp(bar, 0.0f, 1.0f);
|
|
return {0, static_cast<std::uint8_t>(255.0f * f), static_cast<std::uint8_t>(255.0f * (1.0f - f))};
|
|
}
|
|
const float f = std::min((bar - 1.0f) / 9.0f, 1.0f);
|
|
return {static_cast<std::uint8_t>(255.0f * f), static_cast<std::uint8_t>(255.0f * (1.0f - f)), 0};
|
|
}
|
|
|
|
sf::Color temperature_color(float temperature) {
|
|
const float t = std::clamp(temperature, 0.0f, 2000.0f);
|
|
if (t < 300.0f) {
|
|
const float f = t / 300.0f;
|
|
return {0, static_cast<std::uint8_t>(255.0f * f), static_cast<std::uint8_t>(255.0f * (1.0f - f))};
|
|
}
|
|
const float f = (t - 300.0f) / 1700.0f;
|
|
return {static_cast<std::uint8_t>(255.0f * f), static_cast<std::uint8_t>(255.0f * (1.0f - f)), 0};
|
|
}
|
|
|
|
void draw_label(sf::RenderWindow& window, const sf::Font* font, const std::string& text,
|
|
sf::Vector2f position, sf::Color color = sf::Color::White, unsigned int size = 14) {
|
|
if (!font) return;
|
|
sf::Text label(*font);
|
|
label.setString(text);
|
|
label.setPosition(position);
|
|
label.setFillColor(color);
|
|
label.setCharacterSize(size);
|
|
window.draw(label);
|
|
}
|
|
|
|
std::optional<ScenarioKind> choose_scenario(sf::RenderWindow& window, const sf::Font* font) {
|
|
const std::array<sf::FloatRect, 5> cards{{{{32, 110}, {656, 72}}, {{32, 190}, {656, 72}}, {{32, 270}, {656, 72}}, {{32, 350}, {656, 72}}, {{32, 430}, {656, 72}}}};
|
|
const sf::FloatRect start_button{{32, 522}, {656, 52}};
|
|
const std::array<const char*, 5> titles{"600 cc parallel twin", "YZ125 two-stroke", "Saab B205 / 9-5 wagon", "600 cc twin + VZ21-size turbo", "1980s American 5.0 V8"};
|
|
const std::array<const char*, 5> descriptions{
|
|
"Four-stroke twin, motorcycle test and dyno",
|
|
"125 cc single, expansion chamber, motorcycle test and dyno",
|
|
"2.0 litre inline-four, GT17 turbo, ECU boost control and five-speed wagon",
|
|
"Coupled turbine/compressor, shaft inertia and pressure diagnostics",
|
|
"EFI cross-plane V8, heavy flywheel, independent exhaust banks"};
|
|
int selected = 0;
|
|
while (window.isOpen()) {
|
|
while (const auto event = window.pollEvent()) {
|
|
if (event->is<sf::Event::Closed>()) return std::nullopt;
|
|
if (const auto* key = event->getIf<sf::Event::KeyPressed>()) {
|
|
switch (key->code) {
|
|
case sf::Keyboard::Key::Escape: return std::nullopt;
|
|
case sf::Keyboard::Key::Up: selected = (selected + 4) % 5; break;
|
|
case sf::Keyboard::Key::Down:
|
|
case sf::Keyboard::Key::Tab: selected = (selected + 1) % 5; break;
|
|
case sf::Keyboard::Key::Num1: selected = 0; break;
|
|
case sf::Keyboard::Key::Num2: selected = 1; break;
|
|
case sf::Keyboard::Key::Num3: selected = 2; break;
|
|
case sf::Keyboard::Key::Num4: selected = 3; break;
|
|
case sf::Keyboard::Key::Num5: selected = 4; break;
|
|
case sf::Keyboard::Key::Enter: return static_cast<ScenarioKind>(selected);
|
|
default: break;
|
|
}
|
|
}
|
|
if (const auto* move = event->getIf<sf::Event::MouseMoved>()) {
|
|
const auto point = window.mapPixelToCoords(move->position);
|
|
for (int i = 0; i < 5; ++i)
|
|
if (cards[i].contains(point)) selected = i;
|
|
}
|
|
if (const auto* click = event->getIf<sf::Event::MouseButtonReleased>();
|
|
click && click->button == sf::Mouse::Button::Left) {
|
|
const auto point = window.mapPixelToCoords(click->position);
|
|
for (int i = 0; i < 5; ++i)
|
|
if (cards[i].contains(point)) return static_cast<ScenarioKind>(i);
|
|
if (start_button.contains(point)) return static_cast<ScenarioKind>(selected);
|
|
}
|
|
}
|
|
window.clear({9, 12, 17});
|
|
draw_label(window, font, "FluidSim", {32, 24}, sf::Color::White, 28);
|
|
draw_label(window, font, "Choose a scenario to start", {32, 66}, {180, 190, 205}, 16);
|
|
for (int i = 0; i < 5; ++i) {
|
|
sf::RectangleShape card(cards[i].size);
|
|
card.setPosition(cards[i].position);
|
|
card.setFillColor(i == selected ? sf::Color(24, 46, 60) : sf::Color(18, 24, 32));
|
|
card.setOutlineColor(i == selected ? sf::Color(90, 200, 210) : sf::Color(55, 65, 78));
|
|
card.setOutlineThickness(2);
|
|
window.draw(card);
|
|
draw_label(window, font, titles[i], cards[i].position + sf::Vector2f{20, 16},
|
|
sf::Color::White, 20);
|
|
draw_label(window, font, descriptions[i], cards[i].position + sf::Vector2f{20, 54},
|
|
{180, 190, 205}, 13);
|
|
}
|
|
sf::RectangleShape start(start_button.size);
|
|
start.setPosition(start_button.position);
|
|
start.setFillColor({35, 105, 115});
|
|
window.draw(start);
|
|
draw_label(window, font, "Start selected scenario [Enter]", {52, 537}, sf::Color::White, 17);
|
|
draw_label(window, font, "Click a scenario or use Up/Down. Esc: quit", {32, 574}, {150, 165, 180}, 12);
|
|
window.display();
|
|
}
|
|
return std::nullopt;
|
|
}
|
|
|
|
void draw_volume(sf::RenderWindow& window, float pressure, sf::FloatRect bounds) {
|
|
sf::RectangleShape body({bounds.size.x, bounds.size.y});
|
|
body.setPosition(bounds.position);
|
|
body.setFillColor(pressure_color(pressure));
|
|
body.setOutlineColor(sf::Color::White);
|
|
body.setOutlineThickness(1.0f);
|
|
window.draw(body);
|
|
}
|
|
|
|
void draw_pipe(sf::RenderWindow& window, const ScenarioSnapshot& snapshot, std::size_t pipe_index,
|
|
float center_y, float x0, float x1) {
|
|
const auto& cells = snapshot.pipe_states[pipe_index];
|
|
if (cells.empty()) return;
|
|
const auto& areas = snapshot.pipe_areas[pipe_index];
|
|
const auto& lengths = snapshot.pipe_lengths[pipe_index];
|
|
float total_length = 0;
|
|
for (float length : lengths) total_length += length;
|
|
float x = x0;
|
|
for (std::size_t i = 0; i < cells.size(); ++i) {
|
|
const auto& cell = cells[i];
|
|
const float cell_width = total_length > 0 ? (x1 - x0) * lengths[i] / total_length
|
|
: (x1 - x0) / cells.size();
|
|
const float thickness = areas.empty() ? 8 : 600 * std::sqrt(4 * areas[i] / pi);
|
|
sf::RectangleShape segment({cell_width + 1.0f, thickness});
|
|
segment.setPosition({x, center_y - thickness * .5f});
|
|
segment.setFillColor(temperature_color(cell.pressure / std::max(cell.rho * 287.0f, 1e-9f)));
|
|
window.draw(segment);
|
|
x += cell_width;
|
|
}
|
|
}
|
|
|
|
void draw_cylinder(sf::RenderWindow& window, const sf::Font* font, float pressure,
|
|
float piston_fraction, float top, const char* name, bool intake_open, bool exhaust_open, float x = 470.0f) {
|
|
constexpr float width = 74.0f, height = 142.0f;
|
|
const float fraction = std::clamp(piston_fraction, 0.0f, 1.0f);
|
|
const float gas_height = height * fraction;
|
|
sf::RectangleShape wall({width, height});
|
|
wall.setPosition({x, top});
|
|
wall.setFillColor({52, 59, 68});
|
|
window.draw(wall);
|
|
sf::RectangleShape gas({width, gas_height});
|
|
gas.setPosition({x, top});
|
|
gas.setFillColor(pressure_color(pressure));
|
|
window.draw(gas);
|
|
sf::RectangleShape piston({width + 8.0f, 5.0f});
|
|
piston.setPosition({x - 4.0f, top + gas_height});
|
|
piston.setFillColor(sf::Color::White);
|
|
window.draw(piston);
|
|
sf::RectangleShape intake_valve({7.0f, 12.0f});
|
|
intake_valve.setPosition({x - 12.0f, top + 5.0f});
|
|
intake_valve.setFillColor(intake_open ? sf::Color::Green : sf::Color::Red);
|
|
window.draw(intake_valve);
|
|
sf::RectangleShape exhaust_valve({7.0f, 12.0f});
|
|
exhaust_valve.setPosition({x + width + 5.0f, top + 5.0f});
|
|
exhaust_valve.setFillColor(exhaust_open ? sf::Color::Green : sf::Color::Red);
|
|
window.draw(exhaust_valve);
|
|
sf::RectangleShape border({width, height});
|
|
border.setPosition({x, top});
|
|
border.setFillColor(sf::Color::Transparent);
|
|
border.setOutlineColor(sf::Color(190, 200, 210));
|
|
border.setOutlineThickness(1.0f);
|
|
window.draw(border);
|
|
draw_label(window, font, name, {x + 13.0f, top - 22.0f}, sf::Color::White, 14);
|
|
}
|
|
|
|
void draw_graph_line(sf::RenderWindow& window, sf::Vector2f start, sf::Vector2f end,
|
|
sf::Color color, float thickness = 1.5f) {
|
|
const float dx = end.x - start.x;
|
|
const float dy = end.y - start.y;
|
|
const float length = std::sqrt(dx * dx + dy * dy);
|
|
if (length <= 0.0f) return;
|
|
sf::RectangleShape line({length, thickness});
|
|
line.setPosition(start);
|
|
line.setRotation(sf::degrees(std::atan2(dy, dx) * 180.0f / pi));
|
|
line.setFillColor(color);
|
|
window.draw(line);
|
|
}
|
|
|
|
void draw_dyno_curve(sf::RenderWindow& window, const sf::Font* font, const DynoCurve& curve,
|
|
float graph_x, float graph_y, float graph_width, float graph_height,
|
|
float current_rpm, float current_power_kw) {
|
|
const auto& bins = curve.bins();
|
|
if (bins.empty()) return;
|
|
|
|
float max_power = 0.01f, max_torque = 0.01f, max_rpm = 1000.0f;
|
|
for (std::size_t index = 0; index < bins.size(); ++index) {
|
|
if (bins[index].power_kw > 0.0f || bins[index].torque_nm > 0.0f) {
|
|
max_power = std::max(max_power, bins[index].power_kw);
|
|
max_torque = std::max(max_torque, bins[index].torque_nm);
|
|
max_rpm = std::max(max_rpm, static_cast<float>(index) * DynoCurve::rpm_bin_size +
|
|
DynoCurve::rpm_bin_size * 0.5f);
|
|
}
|
|
}
|
|
max_power *= 1.1f;
|
|
max_torque *= 1.1f;
|
|
max_rpm = std::max(max_rpm * 1.05f, 1000.0f);
|
|
|
|
sf::RectangleShape background({graph_width, graph_height});
|
|
background.setPosition({graph_x, graph_y});
|
|
background.setFillColor({20, 20, 20, 220});
|
|
background.setOutlineColor({70, 78, 90});
|
|
background.setOutlineThickness(1.0f);
|
|
window.draw(background);
|
|
|
|
constexpr float left_margin = 50.0f, right_margin = 48.0f;
|
|
constexpr float top_margin = 24.0f, bottom_margin = 38.0f;
|
|
const float plot_x = graph_x + left_margin;
|
|
const float plot_y = graph_y + top_margin;
|
|
const float plot_width = graph_width - left_margin - right_margin;
|
|
const float plot_height = graph_height - top_margin - bottom_margin;
|
|
const sf::Color grid_color(50, 50, 50);
|
|
for (int index = 0; index <= 9; ++index) {
|
|
const float x = plot_x + static_cast<float>(index) / 9.0f * plot_width;
|
|
sf::RectangleShape grid({1.0f, plot_height});
|
|
grid.setPosition({x, plot_y});
|
|
grid.setFillColor(grid_color);
|
|
window.draw(grid);
|
|
}
|
|
for (int index = 0; index <= 5; ++index) {
|
|
const float y = plot_y + (1.0f - static_cast<float>(index) / 5.0f) * plot_height;
|
|
sf::RectangleShape grid({plot_width, 1.0f});
|
|
grid.setPosition({plot_x, y});
|
|
grid.setFillColor(grid_color);
|
|
window.draw(grid);
|
|
draw_label(window, font, std::to_string(static_cast<int>(max_power * index / 5.0f)),
|
|
{graph_x + 3.0f, y - 7.0f}, sf::Color(240, 100, 90), 9);
|
|
draw_label(window, font, std::to_string(static_cast<int>(max_torque * index / 5.0f)),
|
|
{graph_x + graph_width - 42.0f, y - 7.0f}, sf::Color(100, 150, 255), 9);
|
|
}
|
|
for (int index = 0; index <= 9; ++index) {
|
|
const float rpm = max_rpm * static_cast<float>(index) / 9.0f;
|
|
const float x = plot_x + static_cast<float>(index) / 9.0f * plot_width;
|
|
std::ostringstream tick;
|
|
tick << std::fixed << std::setprecision(1) << rpm / 1000.0f << 'k';
|
|
draw_label(window, font, tick.str(), {x - 12.0f, graph_y + graph_height - 31.0f},
|
|
sf::Color(200, 205, 215), 9);
|
|
}
|
|
draw_label(window, font, "DYNO", {graph_x + 8.0f, graph_y + 4.0f}, sf::Color::White, 11);
|
|
draw_label(window, font, "kW", {graph_x + 8.0f, graph_y + 17.0f}, sf::Color(255, 100, 90), 9);
|
|
draw_label(window, font, "Nm", {graph_x + graph_width - 30.0f, graph_y + 17.0f},
|
|
sf::Color(110, 160, 255), 9);
|
|
draw_label(window, font, "RPM", {graph_x + graph_width * 0.5f - 12.0f,
|
|
graph_y + graph_height - 15.0f}, sf::Color::White, 10);
|
|
|
|
const sf::Color power_color(255, 27, 27), torque_color(9, 90, 255);
|
|
for (std::size_t index = 1; index < bins.size(); ++index) {
|
|
const float rpm0 = (static_cast<float>(index - 1) + 0.5f) * DynoCurve::rpm_bin_size;
|
|
const float rpm1 = (static_cast<float>(index) + 0.5f) * DynoCurve::rpm_bin_size;
|
|
if (rpm0 > max_rpm || rpm1 > max_rpm) break;
|
|
const float x0 = plot_x + rpm0 / max_rpm * plot_width;
|
|
const float x1 = plot_x + rpm1 / max_rpm * plot_width;
|
|
if (bins[index - 1].power_kw > 0.0f && bins[index].power_kw > 0.0f) {
|
|
const float y0 = plot_y + (1.0f - bins[index - 1].power_kw / max_power) * plot_height;
|
|
const float y1 = plot_y + (1.0f - bins[index].power_kw / max_power) * plot_height;
|
|
draw_graph_line(window, {x0, y0}, {x1, y1}, power_color, 2.0f);
|
|
}
|
|
if (bins[index - 1].torque_nm > 0.0f && bins[index].torque_nm > 0.0f) {
|
|
const float y0 = plot_y + (1.0f - bins[index - 1].torque_nm / max_torque) * plot_height;
|
|
const float y1 = plot_y + (1.0f - bins[index].torque_nm / max_torque) * plot_height;
|
|
draw_graph_line(window, {x0, y0}, {x1, y1}, torque_color, 2.0f);
|
|
}
|
|
}
|
|
if (current_rpm > 0.0f && current_rpm <= max_rpm && current_power_kw > 0.0f) {
|
|
const float x = plot_x + current_rpm / max_rpm * plot_width;
|
|
const float y = plot_y + (1.0f - current_power_kw / max_power) * plot_height;
|
|
sf::CircleShape dot(3.0f);
|
|
dot.setPosition({x - 3.0f, y - 3.0f});
|
|
dot.setFillColor(sf::Color::White);
|
|
window.draw(dot);
|
|
}
|
|
}
|
|
|
|
void draw_dashboard(sf::RenderWindow& window, const sf::Font* font, const ScenarioSnapshot& state,
|
|
float throttle_target, float brake, double playback_speed, std::size_t buffer_fill,
|
|
std::size_t buffer_capacity, bool time_warp, std::uint64_t underrun_blocks, bool auto_gain) {
|
|
std::ostringstream status;
|
|
status << (time_warp ? "[Space] normal speed" : "[Space] real time")
|
|
<< " Sim " << std::fixed << std::setprecision(3) << playback_speed << "x"
|
|
<< " Audio buffer " << buffer_fill << "/" << buffer_capacity
|
|
<< " Underruns " << underrun_blocks
|
|
<< (state.cylinder_count == 8 ? "\nCoupe " : state.cylinder_count == 4 ? "\nWagon " : "\nBike ") << std::setprecision(1) << state.speed_kmh << " km/h Gear "
|
|
<< (state.gear == 0 ? "N" : std::to_string(state.gear))
|
|
<< " Throttle " << state.throttle * 100.0f << "% / target " << throttle_target * 100.0f << "% [W, up/down]"
|
|
<< " Clutch " << state.clutch * 100.0f << "% [C] Engine load " << brake * 100.0f << "% [left/right]"
|
|
<< " Shift [A/D], Neutral [Z]"
|
|
<< "\nScene [R]: " << scene_name(state.audio_scene) << " " << (state.fuel_cut ? "FUEL CUT" : (state.idle_control ? "IDLE CONTROL" : "RUNNING"));
|
|
if (!state.two_stroke)
|
|
status << " Mechanical [M]: " << (state.mechanical_sound_enabled ? "ON" : "OFF");
|
|
status << " Auto gain [G]: " << (auto_gain ? "ON" : "OFF");
|
|
if (font) {
|
|
sf::RectangleShape panel({1260.0f, 92.0f});
|
|
panel.setPosition({10.0f, 10.0f});
|
|
panel.setFillColor({12, 18, 26, 236});
|
|
panel.setOutlineColor({80, 110, 130});
|
|
panel.setOutlineThickness(1.0f);
|
|
window.draw(panel);
|
|
draw_label(window, font, status.str(), {22.0f, 20.0f}, sf::Color::White, 14);
|
|
draw_label(window, font,
|
|
"RPM " + std::to_string(static_cast<int>(state.rpm)) +
|
|
" Power " + std::to_string(state.average_power / 1000.0f) + " kW" +
|
|
" Vehicle kinetic energy " + std::to_string(static_cast<int>(state.kinetic_energy)) + " J",
|
|
{24.0f, 122.0f}, {220, 230, 240}, 17);
|
|
draw_label(window, font, state.two_stroke ? "REV LIMIT DISABLED" :
|
|
((state.rev_limit1 || state.rev_limit2) ? std::string("REV CUT") : "REV LIMIT " + std::to_string(int(state.rev_limit_rpm)) + " rpm"),
|
|
{24.0f, 151.0f}, {120, 220, 120}, 14);
|
|
}
|
|
if (state.turbo_enabled) {
|
|
const auto& t = state.turbo;
|
|
std::ostringstream text;
|
|
text << std::fixed << std::setprecision(0) << (state.cylinder_count==4 ? "GT17-size: " : "VZ21-size: ") << t.rpm << " rpm "
|
|
<< std::setprecision(2) << "CPR " << t.compressor_pressure_ratio
|
|
<< " Charge " << t.charge_pressure/1000 << " kPa / " << std::setprecision(0) << t.charge_temperature << " K"
|
|
<< "\nTurbine inlet " << t.turbine_inlet_pressure/1000 << " kPa Outlet " << t.turbine_outlet_pressure/1000 << " kPa"
|
|
<< " Power " << t.turbine_power << " / " << t.compressor_power << " W";
|
|
draw_label(window, font, text.str(), {625, 125}, {130, 215, 220}, 12);
|
|
}
|
|
|
|
}
|
|
|
|
void draw_two_stroke_scene(sf::RenderWindow& window,const sf::Font* font,const ScenarioSnapshot& s) {
|
|
window.clear({9,12,17});
|
|
draw_label(window,font,"YZ125 baseline | piston-controlled ports",
|
|
{650,185},sf::Color::White,14);
|
|
draw_cylinder(window,font,s.cylinder_pressure1,s.cylinder_piston_fraction1,220,"CYLINDER",
|
|
s.valve1_intake_area>0,s.valve1_exhaust_area>0);
|
|
draw_volume(window,s.crankcase_pressure,{{455,410},{110,95}});
|
|
draw_label(window,font,"CRANKCASE",{455,385},sf::Color::White,14);
|
|
draw_pipe(window,s,0,460,30,170);
|
|
draw_volume(window,s.intake_pressure,{{180,425},{95,70}});
|
|
draw_label(window,font,"CARB / INTAKE",{180,400},sf::Color::White,14);
|
|
draw_pipe(window,s,1,460,285,445);
|
|
draw_label(window,font,s.reed_open?"REED OPEN":"REED CLOSED",{290,485},s.reed_open?sf::Color::Green:sf::Color::Red,12);
|
|
draw_pipe(window,s,2,350,315,450);
|
|
draw_graph_line(window,{510,505},{510,530},{180,190,200});
|
|
draw_graph_line(window,{510,530},{315,530},{180,190,200});
|
|
draw_graph_line(window,{315,530},{315,350},{180,190,200});
|
|
draw_graph_line(window,{450,350},{450,245},{180,190,200});
|
|
draw_graph_line(window,{450,245},{467,245},{180,190,200});
|
|
draw_label(window,font,"TRANSFER",{320,325},sf::Color::White,12);
|
|
draw_graph_line(window,{551,235},{590,235},{180,190,200});
|
|
draw_pipe(window,s,3,235,590,1230);
|
|
draw_label(window,font,s.racing_exhaust ? "HEADER / DIFFUSERS / BELLY / BAFFLE / STINGER" : "STRAIGHT EXHAUST",{670,205},sf::Color::White,13);
|
|
std::ostringstream pressures;
|
|
pressures<<std::fixed<<std::setprecision(1)<<"Cylinder "<<s.cylinder_pressure1/1000<<" kPa Crankcase "
|
|
<<s.crankcase_pressure/1000<<" kPa Intake "<<s.intake_pressure/1000<<" kPa Carb AFR "
|
|
<<s.target_afr<<":1";
|
|
draw_label(window,font,pressures.str(),{22,580},sf::Color::White,17);
|
|
draw_label(window,font,"54 x 54.5 mm | 8.2:1 trapped compression | estimated pipe/ports/CDI | fixed-open power valve",
|
|
{22,625},{180,190,200},11);
|
|
}
|
|
|
|
void draw_twin_scene(sf::RenderWindow& window, const sf::Font* font, const ScenarioSnapshot& state) {
|
|
constexpr float intake_y = 310.0f;
|
|
constexpr float upper_y = 152.0f;
|
|
constexpr float lower_y = 400.0f;
|
|
window.clear({9, 12, 17});
|
|
// Intake: ambient pipe → throttle → plenum → two cylinder runners.
|
|
draw_pipe(window, state, 0, intake_y, state.turbo_enabled ? 135.f : 36.f, 165.0f);
|
|
if (state.turbo_enabled) {
|
|
draw_pipe(window, state, 6, intake_y, 36, 90);
|
|
draw_volume(window, float(state.turbo.charge_pressure), {{95, intake_y-18}, {35, 36}});
|
|
draw_label(window, font, "COMP", {92, intake_y+30}, {130,215,220}, 10);
|
|
}
|
|
sf::RectangleShape throttle({8.0f, 28.0f});
|
|
throttle.setPosition({169.0f, intake_y - 14.0f});
|
|
throttle.setFillColor(sf::Color::Yellow);
|
|
window.draw(throttle);
|
|
draw_volume(window, state.intake_pressure, {{195.0f, intake_y - 45.0f}, {62.0f, 90.0f}});
|
|
draw_pipe(window, state, 1, upper_y + 71.0f, 263.0f, 458.0f);
|
|
draw_pipe(window, state, 2, lower_y + 71.0f, 263.0f, 458.0f);
|
|
draw_cylinder(window, font, state.cylinder_pressure1, state.cylinder_piston_fraction1,
|
|
upper_y, "CYL 1", state.valve1_intake_area > 1e-7f, state.valve1_exhaust_area > 1e-7f);
|
|
draw_cylinder(window, font, state.cylinder_pressure2, state.cylinder_piston_fraction2,
|
|
lower_y, "CYL 2", state.valve2_intake_area > 1e-7f, state.valve2_exhaust_area > 1e-7f);
|
|
|
|
// Two independent exhaust headers enter a shared collector and tailpipe.
|
|
draw_pipe(window, state, 3, upper_y + 71.0f, 556.0f, 820.0f);
|
|
draw_pipe(window, state, 4, lower_y + 71.0f, 556.0f, 820.0f);
|
|
draw_volume(window, state.exhaust_pressure, {{838.0f, 305.0f}, {70.0f, 82.0f}});
|
|
draw_pipe(window, state, 5, 346.0f, 915.0f, state.turbo_enabled ? 1040.f : 1236.f);
|
|
if (state.turbo_enabled) {
|
|
draw_volume(window, float(state.turbo.turbine_inlet_pressure), {{1045, 323}, {45, 46}});
|
|
draw_label(window, font, "TURBINE", {1040, 380}, {130,215,220}, 10);
|
|
draw_pipe(window, state, 7, 346, 1100, 1236);
|
|
}
|
|
|
|
draw_label(window, font, "AMBIENT", {37.0f, intake_y + 22.0f}, {180, 190, 200}, 12);
|
|
draw_label(window, font, "THROTTLE", {151.0f, intake_y + 24.0f}, {240, 210, 90}, 11);
|
|
draw_label(window, font, "INTAKE PLENUM", {188.0f, intake_y - 62.0f}, sf::Color::White, 11);
|
|
draw_label(window, font, "SHARED EXHAUST", {818.0f, 285.0f}, sf::Color::White, 11);
|
|
draw_label(window, font, "TAILPIPE", {1084.0f, 362.0f}, {180, 190, 200}, 11);
|
|
|
|
}
|
|
|
|
void draw_b205_scene(sf::RenderWindow& window, const sf::Font* font, const ScenarioSnapshot& s) {
|
|
window.clear({9, 12, 17});
|
|
draw_label(window, font, s.turbo_enabled ? "SAAB B205 | GT17 turbo | ECU wastegate control" : "SAAB B205 | naturally aspirated baseline",
|
|
{260, 181}, sf::Color::White, 16);
|
|
draw_pipe(window, s, 0, 215, 25, 110);
|
|
if (s.turbo_enabled) {
|
|
draw_pipe(window, s, 10, 320, 25, 70);
|
|
draw_volume(window, float(s.turbo.charge_pressure), {{75, 300}, {35, 38}});
|
|
draw_graph_line(window, {90, 300}, {25, 215}, {80,145,200});
|
|
draw_label(window, font, "COMP", {75, 345}, sf::Color::White, 11);
|
|
}
|
|
draw_volume(window, s.intake_pressure, {{120, 195}, {65, 65}});
|
|
draw_label(window, font, "PLENUM", {120, 270}, sf::Color::White, 12);
|
|
draw_graph_line(window, {190, 211}, {820, 211}, {80, 145, 200});
|
|
draw_graph_line(window, {220, 420}, {850, 420}, {200, 120, 80});
|
|
for (int i = 0; i < 4; ++i) {
|
|
const float x = 230 + i*150;
|
|
const std::string title = "CYL " + std::to_string(i+1);
|
|
draw_cylinder(window, font, s.cylinder_pressures[i], s.piston_fractions[i], 255, title.c_str(),
|
|
s.intake_areas[i] > 1e-7f, s.exhaust_areas[i] > 1e-7f, x);
|
|
draw_pipe(window, s, 1+i, 235, x-15, x+75);
|
|
draw_pipe(window, s, 5+i, 410, x-15, x+75);
|
|
draw_graph_line(window, {x-15, 211}, {x-15, 235}, {80,145,200});
|
|
draw_graph_line(window, {x+75, 410}, {x+75, 420}, {200,120,80});
|
|
draw_label(window, font, std::to_string(int(s.cylinder_pressures[i]/1000))+" kPa",
|
|
{x, 445}, {180,190,205}, 12);
|
|
}
|
|
draw_volume(window, s.exhaust_pressure, {{850, 393}, {65, 48}});
|
|
draw_pipe(window, s, 9, 420, 920, s.turbo_enabled ? 990 : 1240);
|
|
if (s.turbo_enabled) {
|
|
draw_volume(window, float(s.turbo.turbine_inlet_pressure), {{995, 397}, {55, 45}});
|
|
draw_pipe(window, s, 11, 420, 1055, 1240);
|
|
draw_label(window, font, "GT17", {1000, 450}, sf::Color::White, 12);
|
|
draw_label(window, font, "Boost target " + std::to_string(int(s.boost_target/1000)) + " kPa gauge | WG duty " + std::to_string(int(100*s.wastegate_duty)) + "%",
|
|
{25, 515}, sf::Color::White, 14);
|
|
}
|
|
draw_label(window, font, s.turbo_enabled ? "COLLECTOR / GT17 / EXHAUST" : "COLLECTOR / OPEN EXHAUST", {930, 375}, sf::Color::White, 12);
|
|
draw_label(window, font, "9-5 wagon baseline: 1675 kg with driver, five-speed manual, 4.05 final drive",
|
|
{25, 550}, sf::Color::White, 14);
|
|
draw_label(window, font, "Engine dimensions/firing from Saab WIS. Manifolds, cams, losses and ECU are estimates.",
|
|
{25, 580}, {180,190,205}, 12);
|
|
}
|
|
|
|
void draw_scene(sf::RenderWindow& window, const sf::Font* font, const ScenarioSnapshot& state,
|
|
float throttle_target, float brake, double playback_speed, std::size_t buffer_fill,
|
|
std::size_t buffer_capacity, bool time_warp, DynoCurve& dyno_curve, std::uint64_t underrun_blocks, bool auto_gain) {
|
|
const float power_kw = state.average_power / 1000.0f;
|
|
dyno_curve.update(state.rpm, power_kw, state.average_torque);
|
|
if (state.cylinder_count == 8) {
|
|
window.clear({9,12,17});
|
|
draw_label(window, font, "American 5.0 V8 | EFI / ECU | 7000 RPM | heavy flywheel", {25,185}, sf::Color::White, 16);
|
|
draw_pipe(window, state, 0, 205, 25, 115);
|
|
draw_volume(window, state.intake_pressure, {{120,200},{60,25}});
|
|
draw_label(window, font, "ONE THROTTLE / SHARED PLENUM", {850,225}, sf::Color::White, 12);
|
|
for (int i = 0; i < 8; ++i) {
|
|
const float x = 70 + (i%4)*185;
|
|
const float top = i < 4 ? 230.f : 455.f;
|
|
const std::string name = "CYL " + std::to_string(i+1);
|
|
draw_cylinder(window, font, state.cylinder_pressures[i], state.piston_fractions[i], top,
|
|
name.c_str(), state.intake_areas[i]>1e-7f, state.exhaust_areas[i]>1e-7f, x);
|
|
draw_pipe(window, state, 9+i, top+150, x-10, x+90);
|
|
draw_pipe(window, state, 1+i, top+125, x-10, x+90);
|
|
draw_graph_line(window, {x+90,top+150}, {830,top+150}, {200,120,80});
|
|
}
|
|
draw_graph_line(window, {830,380}, {850,300}, {200,120,80});
|
|
draw_graph_line(window, {830,605}, {850,375}, {200,120,80});
|
|
draw_pipe(window, state, 17, 300, 850, 1250);
|
|
draw_pipe(window, state, 18, 375, 850, 1250);
|
|
draw_label(window, font, "SEPARATE BANK COLLECTORS / DUAL TAILS", {850,410}, sf::Color::White, 11);
|
|
draw_label(window, font, "EFI AFR " + std::to_string(state.target_afr), {850,430}, sf::Color::White, 13);
|
|
draw_label(window, font, "302-class | shared 4 L intake, 12 cm runners | 7:1 compression | 750 rpm idle", {25,690}, {180,190,205}, 12);
|
|
}
|
|
else if (state.two_stroke) draw_two_stroke_scene(window, font, state);
|
|
else if (state.cylinder_count == 4) draw_b205_scene(window, font, state);
|
|
else draw_twin_scene(window, font, state);
|
|
draw_dashboard(window, font, state, throttle_target, brake, playback_speed, buffer_fill,
|
|
buffer_capacity, time_warp, underrun_blocks, auto_gain);
|
|
draw_dyno_curve(window, font, dyno_curve, 860, 455, 400, 250, state.rpm, power_kw);
|
|
window.display();
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main(int argc, char** argv) {
|
|
using namespace fluidsim::port;
|
|
ScenarioKind selected_kind=ScenarioKind::Twin;
|
|
bool scenario_from_args=false;
|
|
bool turbo_from_args=false;
|
|
std::string turbo_name;
|
|
TwoStrokeConfig two_stroke_config;
|
|
OutdoorScene selected_scene=OutdoorScene::Wall;
|
|
for(int i=1;i<argc;++i){
|
|
const std::string option=argv[i];
|
|
if((option=="--scenario"||option=="--scene"||option=="--exhaust"||option=="--turbo")&&i+1<argc){
|
|
const std::string value=argv[++i];
|
|
if(option=="--scenario"){
|
|
if(value!="twin"&&value!="two-stroke"&&value!="b205"&&value!="v8"){std::cerr<<"Choose twin, two-stroke, b205 or v8\n";return 2;}
|
|
selected_kind=value=="v8" ? ScenarioKind::AmericanV8 : value=="b205" ? ScenarioKind::SaabB205 :
|
|
(value=="two-stroke" ? ScenarioKind::TwoStroke : ScenarioKind::Twin);
|
|
scenario_from_args=true;
|
|
}else if(option=="--turbo"){
|
|
if(value!="none"&&value!="vz21"&&value!="gt17"){std::cerr<<"Choose none, vz21 or gt17 turbo\n";return 2;}
|
|
turbo_name=value;
|
|
turbo_from_args=value!="none";
|
|
}else if(option=="--exhaust"){
|
|
if(value=="racing") two_stroke_config.exhaust=TwoStrokeExhaust::Racing;
|
|
else if(value=="straight") two_stroke_config.exhaust=TwoStrokeExhaust::Straight;
|
|
else { std::cerr<<"Choose racing or straight exhaust\n";return 2; }
|
|
}else{
|
|
if(value=="dry")selected_scene=OutdoorScene::Dry;
|
|
else if(value=="field")selected_scene=OutdoorScene::Field;
|
|
else if(value=="wall")selected_scene=OutdoorScene::Wall;
|
|
else if(value=="woodland")selected_scene=OutdoorScene::Woodland;
|
|
else {std::cerr<<"Choose dry, field, wall or woodland\n";return 2;}
|
|
}
|
|
}else{std::cerr<<"Usage: fluidsim_desktop [--scenario twin|two-stroke|b205|v8] [--scene dry|field|wall|woodland] [--exhaust racing|straight] [--turbo none|vz21|gt17]\n";return 2;}
|
|
}
|
|
const sf::Vector2u initial_size = scenario_from_args ? sf::Vector2u{window_width, window_height}
|
|
: sf::Vector2u{720, 606};
|
|
sf::RenderWindow window(sf::VideoMode(initial_size), "FluidSim");
|
|
window.setVerticalSyncEnabled(false);
|
|
window.setFramerateLimit(60);
|
|
window.setKeyRepeatEnabled(false);
|
|
|
|
sf::Font font;
|
|
std::vector<std::filesystem::path> font_paths{"fonts/FiraCodeNerdFont-Medium.ttf"};
|
|
if(argc>0)font_paths.push_back(std::filesystem::path(argv[0]).parent_path()/"fonts/FiraCodeNerdFont-Medium.ttf");
|
|
font_paths.emplace_back("/usr/share/fonts/Adwaita/AdwaitaMono-Regular.ttf");
|
|
font_paths.emplace_back("/usr/share/fonts/TTF/DejaVuSansMono.ttf");
|
|
font_paths.emplace_back("/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf");
|
|
if(const char* windows_directory=std::getenv("WINDIR"))font_paths.push_back(std::filesystem::path(windows_directory)/"Fonts/consola.ttf");
|
|
bool has_font=false;
|
|
for(const auto& path:font_paths)if(std::filesystem::exists(path)&&font.openFromFile(path)){has_font=true;break;}
|
|
if(!has_font)std::cerr<<"No UI font available; configure FLUIDSIM_FONT_FILE.\n";
|
|
const sf::Font* font_ptr=has_font?&font:nullptr;
|
|
|
|
if (!scenario_from_args) {
|
|
const auto choice = choose_scenario(window, font_ptr);
|
|
if (!choice) return 0;
|
|
selected_kind = *choice;
|
|
window.setSize({window_width, window_height});
|
|
window.setView(sf::View(sf::FloatRect({0, 0}, {float(window_width), float(window_height)})));
|
|
}
|
|
if(turbo_from_args) {
|
|
if(turbo_name=="gt17" && selected_kind==ScenarioKind::SaabB205) {
|
|
// Saab preset selects its GT17 calibration.
|
|
} else if(turbo_name=="vz21" && (selected_kind==ScenarioKind::Twin || selected_kind==ScenarioKind::TurboTwin)) {
|
|
selected_kind=ScenarioKind::TurboTwin;
|
|
} else {
|
|
std::cerr<<"Use gt17 with b205 or vz21 with twin\n";return 2;
|
|
}
|
|
}
|
|
window.setTitle(selected_kind == ScenarioKind::AmericanV8 ? "FluidSim - 1980s American 5.0 V8" : selected_kind == ScenarioKind::TurboTwin ? "FluidSim - twin + VZ21 turbo" : selected_kind == ScenarioKind::SaabB205 ? "FluidSim - Saab B205 / 9-5 wagon" :
|
|
(selected_kind == ScenarioKind::TwoStroke ? "FluidSim - YZ125 two-stroke" : "FluidSim - parallel twin"));
|
|
DesktopScenario scenario(selected_kind, two_stroke_config, turbo_name!="none");
|
|
scenario.set_audio_scene(selected_scene);
|
|
SimulationRingBuffer ring(ring_capacity);
|
|
for (std::size_t i = 0; i < preroll_samples; ++i) {
|
|
const AudioFrame audio = scenario.process();
|
|
ring.write(audio.left, audio.right);
|
|
}
|
|
ring.reset_consumer();
|
|
|
|
SoundEngine audio(ring);
|
|
audio.set_volume(100.0f);
|
|
audio.start();
|
|
std::jthread simulation([&](std::stop_token stop) {
|
|
constexpr std::size_t batch_size = 512;
|
|
while (ring.wait_for_space(target_fill - batch_size, stop)) {
|
|
for (std::size_t i = 0; i < batch_size; ++i) {
|
|
const AudioFrame frame = scenario.process();
|
|
if (!ring.write(frame.left, frame.right, stop)) return;
|
|
}
|
|
}
|
|
});
|
|
|
|
double desired_speed = 1.0;
|
|
double current_speed = desired_speed;
|
|
double last_normal_speed = 1.0;
|
|
bool time_warp = false;
|
|
float throttle_target = 1.0f, throttle_current = 0.0f;
|
|
float brake_target = 0.0f, brake_current = 0.0f;
|
|
float clutch_current = 0.0f;
|
|
bool throttle_held = false, clutch_held = false;
|
|
DynoCurve dyno_curve;
|
|
auto previous_frame = std::chrono::steady_clock::now();
|
|
|
|
while (window.isOpen()) {
|
|
while (const std::optional event = window.pollEvent()) {
|
|
if (event->is<sf::Event::Closed>()) window.close();
|
|
if (const auto* key = event->getIf<sf::Event::KeyPressed>()) {
|
|
switch (key->code) {
|
|
case sf::Keyboard::Key::Space:
|
|
time_warp = !time_warp;
|
|
if (!time_warp) { desired_speed = last_normal_speed; }
|
|
break;
|
|
case sf::Keyboard::Key::W: throttle_held = true; break;
|
|
case sf::Keyboard::Key::C: clutch_held = true; break;
|
|
case sf::Keyboard::Key::Up: throttle_target = std::min(1.0f, throttle_target + 0.05f); break;
|
|
case sf::Keyboard::Key::Down: throttle_target = std::max(0.0f, throttle_target - 0.05f); break;
|
|
case sf::Keyboard::Key::Left: brake_target = std::max(0.0f, brake_target - 0.05f); break;
|
|
case sf::Keyboard::Key::Right: brake_target = std::min(1.0f, brake_target + 0.05f); break;
|
|
case sf::Keyboard::Key::D: scenario.shift_up(); break;
|
|
case sf::Keyboard::Key::A: scenario.shift_down(); break;
|
|
case sf::Keyboard::Key::Z: scenario.set_neutral(); break;
|
|
case sf::Keyboard::Key::R:
|
|
selected_scene=static_cast<OutdoorScene>((static_cast<int>(selected_scene)+1)%4);
|
|
scenario.set_audio_scene(selected_scene);
|
|
break;
|
|
case sf::Keyboard::Key::M: scenario.toggle_mechanical_sound(); break;
|
|
case sf::Keyboard::Key::G: audio.set_auto_gain(!audio.auto_gain()); break;
|
|
default: break;
|
|
}
|
|
}
|
|
if (const auto* key = event->getIf<sf::Event::KeyReleased>()) {
|
|
if (key->code == sf::Keyboard::Key::W) throttle_held = false;
|
|
if (key->code == sf::Keyboard::Key::C) clutch_held = false;
|
|
}
|
|
if (const auto* wheel = event->getIf<sf::Event::MouseWheelScrolled>(); wheel && !time_warp) {
|
|
desired_speed *= wheel->delta > 0.0f ? 1.1 : (wheel->delta < 0.0f ? 1.0 / 1.1 : 1.0);
|
|
desired_speed = std::clamp(desired_speed, 0.0001, 1.0);
|
|
last_normal_speed = desired_speed;
|
|
}
|
|
}
|
|
|
|
const auto now = std::chrono::steady_clock::now();
|
|
const float frame_dt = std::chrono::duration<float>(now - previous_frame).count();
|
|
previous_frame = now;
|
|
const double target_speed = time_warp ? 1.0 : desired_speed;
|
|
current_speed += (target_speed - current_speed) * (1.0 - std::exp(-8.0 * frame_dt));
|
|
audio.set_speed(current_speed);
|
|
|
|
const float wanted_throttle = throttle_held ? throttle_target : 0.0f;
|
|
throttle_current = wanted_throttle; // ECU actuator evolves at the simulation sample rate.
|
|
brake_current += (brake_target - brake_current) * (1.0f - std::exp(-throttle_lerp_rate * frame_dt));
|
|
const float wanted_clutch = clutch_held ? 1.0f : 0.0f;
|
|
clutch_current += (wanted_clutch - clutch_current) * (1.0f - std::exp(-throttle_lerp_rate * frame_dt));
|
|
scenario.set_controls(throttle_current, brake_current, clutch_current);
|
|
|
|
draw_scene(window, font_ptr, scenario.snapshot(), throttle_target, brake_current,
|
|
current_speed, ring.available_samples(), ring_capacity, time_warp, dyno_curve, audio.underrun_blocks(), audio.auto_gain());
|
|
}
|
|
|
|
simulation.request_stop();
|
|
audio.stop();
|
|
return 0;
|
|
}
|