Add machine workshop, typed logic, mechanics and sensor-driven ECUs

This commit is contained in:
max
2026-11-02 07:27:13 +01:00
parent eb2b6366c2
commit abfcce0f46
99 changed files with 28835 additions and 145 deletions

View File

@@ -0,0 +1,338 @@
#include "MachineTestControls.hpp"
#include "FluidSim/Desktop/MachineBuilder.hpp"
#include "FluidSim/Desktop/WorkshopLayout.hpp"
#include "FluidSim/Desktop/PortAppearance.hpp"
#include "FluidSim/ScenarioCatalog.hpp"
#include "FluidSim/Desktop/MachineRenderer3D.hpp"
#include <cmath>
#include <filesystem>
#include <iostream>
#include <stdexcept>
#include <thread>
#include <fstream>
#include <cstdlib>
using namespace fluidsim::port;
using namespace fluidsim::port::desktop;
using namespace fluidsim::port::machine_math;
void check(bool condition,const char* why){if(!condition)throw std::runtime_error(why);}
void machine_test_directory(const std::string& value){
#ifdef _WIN32
_putenv_s("FLUIDSIM_MACHINE_DIR",value.c_str());
#else
if(value.empty())unsetenv("FLUIDSIM_MACHINE_DIR");else setenv("FLUIDSIM_MACHINE_DIR",value.c_str(),1);
#endif
}
struct UiPreferenceFixture {
std::filesystem::path path=std::filesystem::temp_directory_path()/("fluidsim-ui-"+std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())+".txt");
std::string previous;
static void set(const std::string& value){
#ifdef _WIN32
_putenv_s("FLUIDSIM_UI_SETTINGS",value.c_str());
#else
if(value.empty())unsetenv("FLUIDSIM_UI_SETTINGS");else setenv("FLUIDSIM_UI_SETTINGS",value.c_str(),1);
#endif
}
UiPreferenceFixture(){if(const char* old=std::getenv("FLUIDSIM_UI_SETTINGS"))previous=old;set(path.string());}
~UiPreferenceFixture(){set(previous);std::error_code error;std::filesystem::remove(path,error);}
};
int main(int argc,char** argv)try{
UiPreferenceFixture preferences;
check(port_color(PortDomain::Boolean).r>port_color(PortDomain::Boolean).g,"Boolean ports are not red");
check(port_color(PortDomain::Number).g>port_color(PortDomain::Number).r,"Number ports are not green");
MachinePortDescriptor input_pin;input_pin.direction=PortDirection::Input;auto output_pin=input_pin;output_pin.direction=PortDirection::Output;
check(!filled_port(input_pin)&&filled_port(output_pin),"Input/output silhouettes do not match ring/filled convention");
const sf::Vector2u size{1440,900};const auto viewport=MachineBuilder::viewport(size);
if(argc==3&&std::string(argv[1])=="--ui-preview"){
const std::filesystem::path output=argv[2];std::filesystem::create_directories(output);
sf::ContextSettings context;context.depthBits=24;context.majorVersion=2;context.minorVersion=1;
sf::RenderWindow window(sf::VideoMode({1920,1080}),"Workshop UI review",sf::Style::Default,sf::State::Windowed,context);window.setVisible(false);
sf::Font font("C:/Windows/Fonts/consola.ttf");MachineBuilder preview;
auto click_preview=[&](sf::Vector2f at){const sf::Vector2i pos{int(at.x),int(at.y)};preview.handle_event(sf::Event::MouseButtonPressed{sf::Mouse::Button::Left,pos},window.getSize());preview.handle_event(sf::Event::MouseButtonReleased{sf::Mouse::Button::Left,pos},window.getSize());};
auto key_preview=[&](sf::Keyboard::Key code){preview.handle_event(sf::Event::KeyPressed{.code=code},window.getSize());};
auto capture=[&](const std::string& name){preview.draw(window,&font);sf::Texture image(window.getSize());image.update(window);check(image.copyToImage().saveToFile(output/name),"UI screenshot save failed");};
for(const sf::Vector2u resolution:{sf::Vector2u{1920,1080},sf::Vector2u{1280,720}}){
window.setSize(resolution);const auto ui=WorkshopLayout::calculate(resolution);const auto prefix=std::to_string(resolution.x)+"-";
preview.set_document({});capture(prefix+"empty.png");
auto design=machine_from_engine(scenario_example("flat6"));preview.set_document(design);
click_preview({150,120}); // objects list
const auto ecu=std::find_if(design.parts.begin(),design.parts.end(),[](const auto& p){return p.kind==MachinePartKind::Ecu;});
if(ecu!=design.parts.end()){
click_preview({40,165});for(char c:std::string("ECU"))preview.handle_event(sf::Event::TextEntered{std::uint32_t(c)},resolution);key_preview(sf::Keyboard::Key::Enter);
click_preview({85,242});capture(prefix+"ecu.png");
click_preview({40,165});for(int i=0;i<3;++i)key_preview(sf::Keyboard::Key::Backspace);key_preview(sf::Keyboard::Key::Enter);
}
key_preview(sf::Keyboard::Key::H);capture(prefix+"checks.png");
key_preview(sf::Keyboard::Key::M);capture(prefix+"meters.png");
auto pump=machine_pump_example();pump.probes={{1,pump.parts.front().id,"Tank pressure",MachineQuantity::Pressure},{2,0,"Shaft speed",MachineQuantity::Rpm}};
preview.set_document(pump);preview.start_test();check(preview.testing(),"Preview test did not start");preview.update();capture(prefix+"running.png");preview.pause_test();preview.step_test();key_preview(sf::Keyboard::Key::M);capture(prefix+"paused.png");preview.stop_test();
click_preview({160,68});capture(prefix+"collapsed.png");click_preview({160,68});
preview.set_document(load_machine(machine_directory()/"shaft-drive-bench.machine.toml"));click_preview({160,120});
for(const auto& entry:std::vector<std::pair<std::string,std::string>>{{"Bench shaft","mechanical-shaft"},{"Flywheel","mechanical-flywheel"},{"Torque motor","mechanical-motor"},{"Adjustable brake","mechanical-brake"}}){
preview.handle_event(sf::Event::KeyPressed{.code=sf::Keyboard::Key::F,.control=true},resolution);for(char c:entry.first)preview.handle_event(sf::Event::TextEntered{std::uint32_t(c)},resolution);key_preview(sf::Keyboard::Key::Enter);click_preview({85,242});capture(prefix+entry.second+".png");
}
preview.start_test();preview.pause_test();for(int i=0;i<10;++i)preview.step_test();key_preview(sf::Keyboard::Key::M);capture(prefix+"mechanical-running.png");preview.stop_test();
auto tapped=machine_pump_example();const auto tank=tapped.parts.front().id;tapped.add_sensor(MachineSensorKind::Pressure,tank);tapped.add_sensor(MachineSensorKind::GasTemperature,tank);tapped.add_sensor(MachineSensorKind::Crank,machine_primary_shaft(tapped));
const auto delta=tapped.add_sensor(MachineSensorKind::DifferentialPressure,tank).id;for(const auto& p:tapped.parts)if(p.kind==MachinePartKind::Reservoir){tapped.find_sensor(delta)->reference={p.id,.5f,p.name};break;}
preview.set_document(tapped);preview.handle_event(sf::Event::KeyPressed{.code=sf::Keyboard::Key::F,.control=true},resolution);key_preview(sf::Keyboard::Key::Backspace);key_preview(sf::Keyboard::Key::Enter);
const auto marker=builder_project(preview.camera(),preview.workspace_viewport(resolution),*machine_sensor_position(tapped,tapped.sensors[0].target));check(bool(marker),"Sensor preview marker is outside the viewport");click_preview(*marker+sf::Vector2f{0,22});capture(prefix+"sensors-build.png");
preview.start_test();preview.pause_test();preview.step_test();capture(prefix+"sensors-live.png");
click_preview({ui.inspector_x+ui.inspector_width-32,286});click_preview({ui.inspector_x+ui.inspector_width-32,286});capture(prefix+"sensors-crank.png");
click_preview({ui.inspector_x+ui.inspector_width-32,286});capture(prefix+"sensors-differential.png");preview.stop_test();
preview.set_document(machine_logic_example());preview.handle_event(sf::Event::KeyPressed{.code=sf::Keyboard::Key::F,.control=true},resolution);for(char c:std::string("Bench speed drive"))preview.handle_event(sf::Event::TextEntered{std::uint32_t(c)},resolution);key_preview(sf::Keyboard::Key::Enter);click_preview({85,242});capture(prefix+"logic-owned-drive.png");
preview.start_test();preview.pause_test();for(int i=0;i<50;++i)preview.step_test();key_preview(sf::Keyboard::Key::M);capture(prefix+"logic-pressure-stop.png");preview.stop_test();
preview.set_document(machine_logic_example());preview.set_workspace_view(WorkshopView::Logic);capture(prefix+"logic-canvas-build.png");preview.start_test();preview.pause_test();for(int i=0;i<20;++i)preview.step_test();capture(prefix+"logic-canvas-live.png");preview.set_workspace_view(WorkshopView::Split);capture(prefix+"logic-split.png");preview.stop_test();preview.set_workspace_view(WorkshopView::ThreeD);
for(const int preset:{0,1,2}){
const bool injection=preset!=0;const auto design=preset==2?machine_speed_density_example():machine_sensor_engine_example(injection);const auto tag=preset==2?"speed-density-":injection?"alpha-n-":"ignition-";preview.set_document(design);preview.set_workspace_view(WorkshopView::Logic);key_preview(sf::Keyboard::Key::Home);std::uint32_t connector=0;std::string host;for(const auto& n:design.logic.nodes)if(n.kind==LogicNodeKind::Ecu)connector=n.id;for(const auto& p:design.parts)if(p.injector)host=p.name;
click_preview(preview.logic_node_rectangle(connector,resolution).position+sf::Vector2f{24,12});capture(prefix+tag+"connector-build.png");preview.start_test();check(preview.testing(),"Sensor engine preview failed to start");preview.pause_test();for(int i=0;i<50;++i)preview.step_test();capture(prefix+tag+"connector-live.png");preview.set_workspace_view(WorkshopView::ThreeD);click_preview({ui.inspector_x+30,148});capture(prefix+tag+"ecu-live.png");
if(injection){click_preview({160,120});preview.handle_event(sf::Event::KeyPressed{.code=sf::Keyboard::Key::F,.control=true},resolution);for(char c:host)preview.handle_event(sf::Event::TextEntered{std::uint32_t(c)},resolution);key_preview(sf::Keyboard::Key::Enter);click_preview({85,242});capture(prefix+tag+"injector-live.png");}
preview.stop_test();
}
const auto viewport=preview.workspace_viewport(resolution);check(viewport.position.x>ui.left.position.x+ui.left.size.x,"Viewport overlaps library");
}
std::cout<<"Workshop UI previews rendered\n";return 0;
}
{
MachineBuilder ecu_ui;auto design=machine_engine_example();std::uint32_t ecu=0,host=0;for(const auto& p:design.parts){if(p.kind==MachinePartKind::Ecu)ecu=p.id;if(p.kind==MachinePartKind::Chamber&&p.position.x<0)host=p.id;}ecu_ui.set_document(design);const auto ui=WorkshopLayout::calculate(size);
const auto click_ecu=[&](sf::Vector2f at){const sf::Vector2i point{int(at.x),int(at.y)};ecu_ui.handle_event(sf::Event::MouseButtonPressed{sf::Mouse::Button::Left,point},size);ecu_ui.handle_event(sf::Event::MouseButtonReleased{sf::Mouse::Button::Left,point},size);};
const auto choose=[&](std::uint32_t id){ecu_ui.set_workspace_view(WorkshopView::ThreeD);click_ecu({160,120});ecu_ui.handle_event(sf::Event::KeyPressed{.code=sf::Keyboard::Key::F,.control=true},size);for(char c:ecu_ui.document().find(id)->name)ecu_ui.handle_event(sf::Event::TextEntered{std::uint32_t(c)},size);ecu_ui.handle_event(sf::Event::KeyPressed{.code=sf::Keyboard::Key::Enter},size);click_ecu({85,242});check(ecu_ui.selected_parts()==std::vector<std::uint32_t>{id},"ECU workflow object selection failed");};
choose(ecu);click_ecu({ui.inspector_x+90,222});check(ecu_ui.document().find(ecu)->sensor_ecu.enabled,"ECU preset button did not select sensor ignition");click_ecu({ui.inspector_x+100,258});check(ecu_ui.workspace_view()==WorkshopView::Logic&&ecu_ui.document().logic.nodes.size()==1,"Physical ECU did not create grouped Logic connector");
choose(ecu);click_ecu({ui.inspector_x+90,222});check(ecu_ui.document().find(ecu)->sensor_ecu.strategy==SensorEcuConfig::Strategy::AlphaN&&logic_node_ports(ecu_ui.document().logic.nodes[0]).size()==3,"Alpha-N preset did not require TPS on its existing connector");
choose(ecu);click_ecu({ui.inspector_x+90,222});check(ecu_ui.document().find(ecu)->sensor_ecu.strategy==SensorEcuConfig::Strategy::SpeedDensity&&logic_node_ports(ecu_ui.document().logic.nodes[0]).size()==4,"Speed-density preset did not replace TPS with MAP/IAT");
choose(host);click_ecu({ui.inspector_x+90,222});check(bool(ecu_ui.document().find(host)->injector),"Chamber inspector did not attach an injector");click_ecu({ui.inspector_x+100,258});check(ecu_ui.document().find(host)->injector->ecu==ecu,"Injector binding picker did not choose fuel ECU");
ecu_ui.set_document(machine_sensor_engine_example(true));host=0;for(const auto& p:ecu_ui.document().parts)if(p.injector)host=p.id;ecu_ui.start_test();check(ecu_ui.testing(),"Alpha-N workshop Test failed to start");ecu_ui.pause_test();choose(host);click_ecu({ui.inspector_x+90,222});check(bool(ecu_ui.document().find(host)->injector),"Test allowed injector removal");ecu_ui.stop_test();
}
for(float yaw:{-110.f,-28.f,0.f,90.f})for(float pitch:{-50.f,0.f,60.f}){
OrbitCamera camera;camera.yaw=yaw;camera.pitch=pitch;
for(Vec3 point:{Vec3{0,.2f,0},Vec3{.2f,.3f,-.1f}}){auto projected=builder_project(camera,viewport,point);check(bool(projected),"Point in front of camera was not projected");auto ray=builder_ray(camera,viewport,*projected);check(length(cross(subtract(point,ray.origin),ray.direction))<1e-5f,"Mouse ray and render projection differ");}
}
MachinePart box;box.kind=MachinePartKind::Chamber;box.position={};box.size={.4f,.1f,.1f};box.rotation={0,0,90};
check(bool(builder_pick_part(box,{{0,0,1},{0,0,-1}})),"Picking missed a rotated part");check(!builder_pick_part(box,{{.15f,0,1},{0,0,-1}}),"Picking ignored the part rotation");
auto euler=builder_rotate_world({15,35,20},0,45);const auto transformed=rotate({0,1,0},euler),expected=rotate(rotate({0,1,0},{15,35,20}),{45,0,0});check(length(subtract(transformed,expected))<1e-5f,"World rotation composition is incorrect");
MachinePart bulge;bulge.kind=MachinePartKind::Pipe;bulge.position={};bulge.points={{-.15f,0,0},{0,0,0},{.15f,0,0}};bulge.point_diameters={.02f,.08f,.02f};
check(bool(builder_pick_part(bulge,{{0,.035f,1},{0,0,-1}})),"Picking missed the widened interior point");
check(!builder_pick_part(bulge,{{-.15f,.035f,1},{0,0,-1}}),"Picking used an interior diameter at the narrow endpoint");
MachineBuilder builder;auto doc=machine_pump_example();builder.set_document(doc);
const auto tank_id=doc.parts.front().id;const auto original=doc.find(tank_id)->position;
const auto click=[&](sf::Vector2f p){sf::Vector2i pos{int(std::round(p.x)),int(std::round(p.y))};builder.handle_event(sf::Event::MouseButtonPressed{sf::Mouse::Button::Left,pos},size);builder.handle_event(sf::Event::MouseButtonReleased{sf::Mouse::Button::Left,pos},size);};
const auto key=[&](sf::Keyboard::Key code,bool control=false){builder.handle_event(sf::Event::KeyPressed{.code=code,.control=control},size);};
key(sf::Keyboard::Key::Escape);check(!builder.finished(),"Escape exited the workshop");
MachineBuilder exit_menu;exit_menu.handle_event(sf::Event::MouseButtonPressed{sf::Mouse::Button::Left,{24,24}},size);check(exit_menu.finished()&&!exit_menu.wants_close_window(),"Menu did not explicitly leave the workshop");
MachineBuilder close_window;close_window.handle_event(sf::Event::Closed{},size);check(close_window.finished()&&close_window.wants_close_window(),"Window close did not explicitly exit the application");
auto center=builder_project(builder.camera(),builder.workspace_viewport(size),original);check(bool(center),"Tank is outside the builder camera");click(*center);key(sf::Keyboard::Key::G);
const float gizmo=builder.camera().distance*.828427f/viewport.size.y*76;auto end=builder_project(builder.camera(),builder.workspace_viewport(size),add(original,{gizmo,0,0}));check(bool(end),"Gizmo projection failed");
const sf::Vector2i start{int(end->x),int(end->y)},finish{int(end->x+50),int(end->y)};
builder.handle_event(sf::Event::MouseButtonPressed{sf::Mouse::Button::Left,start},size);builder.handle_event(sf::Event::MouseMoved{finish},size);builder.handle_event(sf::Event::MouseButtonReleased{sf::Mouse::Button::Left,finish},size);
check(builder.document().find(tank_id)->position.x>original.x+.01f,"Mouse move gizmo did not translate the selected part");validate_machine(builder.document());
key(sf::Keyboard::Key::Z,true);check(length(subtract(builder.document().find(tank_id)->position,original))<1e-6f,"Undo did not restore the transform");
key(sf::Keyboard::Key::D,true);check(builder.document().parts.size()==doc.parts.size()+1,"Duplicate did not add a part");key(sf::Keyboard::Key::Delete);check(builder.document().parts.size()==doc.parts.size(),"Delete did not remove a part");
builder.set_document(doc);click(*builder_project(builder.camera(),builder.workspace_viewport(size),doc.parts[0].position));key(sf::Keyboard::Key::LShift);click(*builder_project(builder.camera(),builder.workspace_viewport(size),doc.parts[1].position));builder.handle_event(sf::Event::KeyReleased{.code=sf::Keyboard::Key::LShift},size);
check(builder.selected_parts().size()==2,"Shift-click did not select two parts");key(sf::Keyboard::Key::D,true);check(builder.document().parts.size()==doc.parts.size()+2,"Group duplicate did not copy both parts");key(sf::Keyboard::Key::Delete);check(builder.document().parts.size()==doc.parts.size(),"Group delete did not remove both copies");
builder.set_document(doc);key(sf::Keyboard::Key::A,true);check(builder.selected_parts().size()==doc.parts.size(),"Select all missed parts");key(sf::Keyboard::Key::D,true);check(builder.document().parts.size()==doc.parts.size()*2&&builder.document().links.size()==doc.links.size()*2,"Group duplication lost internal connections");validate_machine(builder.document());
builder.set_document(doc);key(sf::Keyboard::Key::A,true);key(sf::Keyboard::Key::C,true);key(sf::Keyboard::Key::V,true);
check(builder.document().parts.size()==doc.parts.size()*2&&builder.document().links.size()==doc.links.size()*2,"Copy/paste lost group parts or internal connections");validate_machine(builder.document());
key(sf::Keyboard::Key::Escape);key(sf::Keyboard::Key::Escape);check(!builder.finished(),"Repeated Escape exited the workshop");
builder.set_document(doc);key(sf::Keyboard::Key::V,true);check(builder.document().parts.size()==doc.parts.size()*2,"Loading a document discarded the copied group");
builder.set_document(doc);key(sf::Keyboard::Key::A,true);
const auto group_screen=builder_project(builder.camera(),builder.workspace_viewport(size),doc.parts.front().position);check(bool(group_screen),"Group is outside camera");
builder.handle_event(sf::Event::MouseButtonPressed{sf::Mouse::Button::Right,{int(group_screen->x),int(group_screen->y)}},size);
check(builder.selected_parts().size()==doc.parts.size(),"Right-click collapsed the selected group");key(sf::Keyboard::Key::Escape);
{
const auto root=std::filesystem::temp_directory_path()/("fluidsim-assembly-ui-"+std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
const auto* old_directory=std::getenv("FLUIDSIM_MACHINE_DIR");const std::string previous_directory=old_directory?old_directory:"";machine_test_directory(root.string());
const float mx=float(size.x)*.5f-280,my=float(size.y)*.5f-185;
click({450,26});click({mx+450,my+30});for(char c:std::string("pump-module"))builder.handle_event(sf::Event::TextEntered{std::uint32_t(c)},size);key(sf::Keyboard::Key::Enter);
const auto saved=machine_assembly_directory()/"pump-module.assembly.toml";check(std::filesystem::exists(saved),"Save selected assembly did not create a file");
check(load_machine(saved).links.size()==doc.links.size(),"Saved assembly lost internal connections");
MachineDocument marker;marker.name="Marker";marker.add(MachinePartKind::Cap);
for(int i=0;i<5;++i)save_machine(marker,machine_assembly_directory()/("0"+std::to_string(i)+".assembly.toml"));
check(machine_assembly_files().size()==6,"Assembly library did not discover saved files");
builder.set_document({});click({450,26});builder.handle_event(sf::Event::MouseWheelScrolled{.delta=-1,.position={800,400}},size);click({mx+480,my+76+4*47+17});
check(builder.document().parts.size()==doc.parts.size()&&builder.document().links.size()==doc.links.size(),"Library could not browse and insert a saved connected assembly");validate_machine(builder.document());
key(sf::Keyboard::Key::Z,true);check(builder.document().parts.empty(),"Assembly insertion did not undo as one action");key(sf::Keyboard::Key::Y,true);check(builder.document().parts.size()==doc.parts.size(),"Assembly insertion redo failed");
for(const auto& path:machine_assembly_files())std::filesystem::remove(path);std::filesystem::remove(machine_assembly_directory());std::filesystem::remove(root);machine_test_directory(previous_directory);
}
// Atmosphere symbols share a point with their pipe end, and must remain
// selectable/movable after Connect without implicitly starting another wire.
MachineDocument ports;const auto pipe_id=ports.add(MachinePartKind::Pipe,{0,.25f,0}).id;
ports.find(pipe_id)->points={{-.28f,0,0},{0,0,0}};ports.find(pipe_id)->diameter=ports.find(pipe_id)->outlet_diameter=.12f;
const auto atmosphere_id=ports.add(MachinePartKind::Reservoir,{0,.25f,0}).id;
const auto free_atmosphere_id=ports.add(MachinePartKind::Reservoir,{.25f,.25f,.1f}).id;
ports.connect({pipe_id,1},{atmosphere_id,0});validate_machine(ports);
const auto screen_part=[&](unsigned id){auto point=builder_project(builder.camera(),builder.workspace_viewport(size),builder.document().find(id)->position);check(bool(point),"Atmosphere is outside the builder camera");return *point;};
const auto drag_body=[&](sf::Vector2f point){const sf::Vector2i from{int(std::round(point.x)),int(std::round(point.y))},to{from.x+45,from.y-30};
builder.handle_event(sf::Event::MouseButtonPressed{sf::Mouse::Button::Left,from},size);builder.handle_event(sf::Event::MouseMoved{to},size);builder.handle_event(sf::Event::MouseButtonReleased{sf::Mouse::Button::Left,to},size);};
builder.set_document(ports);key(sf::Keyboard::Key::Q);click(screen_part(atmosphere_id));
check(builder.selected_parts()==std::vector<std::uint32_t>{atmosphere_id},"Attached pipe captured atmosphere selection");
key(sf::Keyboard::Key::C);click(screen_part(atmosphere_id));check(builder.selected_parts()==std::vector<std::uint32_t>{atmosphere_id},"Connect selected the pipe instead of the atmosphere symbol");
key(sf::Keyboard::Key::Escape);drag_body(screen_part(atmosphere_id));
check(length(subtract(builder.document().find(atmosphere_id)->position,ports.find(atmosphere_id)->position))>.01f,"Escape did not return Connect to Move");
check(length(subtract(machine_port_position(*builder.document().find(pipe_id),1),builder.document().find(atmosphere_id)->position))<1e-6f,"Moving atmosphere detached its pipe endpoint");validate_machine(builder.document());
key(sf::Keyboard::Key::Z,true);check(length(subtract(builder.document().find(atmosphere_id)->position,ports.find(atmosphere_id)->position))<1e-6f,"Undo did not restore atmosphere and attached pipe");validate_machine(builder.document());
for(auto exit_key:{sf::Keyboard::Key::G,sf::Keyboard::Key::Escape}){
builder.set_document(ports);key(sf::Keyboard::Key::C);click(screen_part(free_atmosphere_id));key(exit_key);
click(screen_part(free_atmosphere_id));
// A selected port now starts a direct wire. Use the move gizmo to move
// a boundary whose sole port occupies its entire symbol.
const float handle=builder.camera().distance*.828427f/viewport.size.y*76;
drag_body(*builder_project(builder.camera(),builder.workspace_viewport(size),add(builder.document().find(free_atmosphere_id)->position,{handle,0,0})));
check(length(subtract(builder.document().find(free_atmosphere_id)->position,ports.find(free_atmosphere_id)->position))>.01f,"Move gizmo could not move a directly connectable boundary");
check(builder.document().links.size()==ports.links.size(),"Moving atmosphere changed connections");validate_machine(builder.document());
}
builder.set_document(ports);drag_body(screen_part(free_atmosphere_id));
check(length(subtract(builder.document().find(free_atmosphere_id)->position,ports.find(free_atmosphere_id)->position))>.01f,"Move required a separate selection click before dragging atmosphere");
// Explicit connection still works after the selection/drag changes.
builder.set_document(ports);key(sf::Keyboard::Key::C);click(screen_part(free_atmosphere_id));click(*builder_project(builder.camera(),builder.workspace_viewport(size),machine_port_position(*ports.find(pipe_id),0)));
check(builder.document().links.size()==ports.links.size()+1,"Explicit atmosphere connection failed");validate_machine(builder.document());
// Route insertion uses the clicked segment in perspective and preserves the
// authored length/volume until its new bend is actually moved.
MachineDocument route;const auto route_id=route.add(MachinePartKind::Pipe,{0,.3f,0}).id;
route.find(route_id)->points={{-.4f,0,-.25f},{.4f,.15f,.3f}};route.find(route_id)->rotation={15,30,10};
route.find(route_id)->point_diameters={.02f,.08f};
builder.set_document(route);const auto& route_part=*route.find(route_id);const auto local=add(route_part.points[0],multiply(subtract(route_part.points[1],route_part.points[0]),.3f));
const auto on_route=builder_project(builder.camera(),builder.workspace_viewport(size),machine_world_point(route_part,local));check(bool(on_route),"Route point is outside camera");
const auto location=builder_pipe_location(builder.camera(),viewport,route_part,*on_route);check(location&&length(subtract(location->local_point,local))<1e-5f,"Bend insertion ignored perspective or part rotation");
key(sf::Keyboard::Key::Q);click(*on_route);key(sf::Keyboard::Key::P);key(sf::Keyboard::Key::LControl);click(*on_route);builder.handle_event(sf::Event::KeyReleased{.code=sf::Keyboard::Key::LControl},size);
const auto& edited=*builder.document().find(route_id);check(edited.points.size()==3,"Ctrl-click did not insert a pipe bend");
check(std::abs(machine_pipe_length(edited)-machine_pipe_length(route_part))<1e-6f&&std::abs(machine_part_volume(edited)-machine_part_volume(route_part))<1e-8f,"Inserting a bend changed pipe length or volume");
check(edited.point_diameters.size()==3&&std::abs(edited.point_diameters[1]-.038f)<.0002f,"Inserted bend lost the original interpolated diameter");
// The selected point's diameter field changes only that point, including undo.
click(WorkshopLayout::calculate(size).field_value(6).position+sf::Vector2f{16,10});for(char c:std::string("90"))builder.handle_event(sf::Event::TextEntered{std::uint32_t(c)},size);key(sf::Keyboard::Key::Enter);
check(std::abs(builder.document().find(route_id)->point_diameters[1]-.09f)<1e-6f&&builder.document().find(route_id)->point_diameters.front()==.02f&&builder.document().find(route_id)->point_diameters.back()==.08f,"Point diameter edit changed the wrong points");
key(sf::Keyboard::Key::Z,true);check(std::abs(builder.document().find(route_id)->point_diameters[1]-.038f)<.0002f,"Undo did not restore the point diameter");
key(sf::Keyboard::Key::Z,true);check(builder.document().find(route_id)->points==route_part.points,"Undo did not restore the routed pipe");
key(sf::Keyboard::Key::Y,true);check(builder.document().find(route_id)->points.size()==3,"Redo did not restore the inserted bend");
// Checks select and focus the problem part without changing the document.
MachineDocument dry;const auto dry_cylinder=dry.add(MachinePartKind::Cylinder,{.6f,.3f,0}).id;dry.add(MachinePartKind::Pipe,{-.6f,.3f,0});
builder.set_document(dry);key(sf::Keyboard::Key::H);click({float(size.x)-280,235});
check(builder.selected_parts()==std::vector<std::uint32_t>{dry_cylinder},"Clicking a check did not select the affected cylinder");
check(length(subtract(builder.camera().target,dry.find(dry_cylinder)->position))<1e-5f,"Clicking a check did not focus the affected part");
check(builder.document().links.empty()&&builder.document().parts.size()==dry.parts.size(),"Checks changed the machine");
key(sf::Keyboard::Key::Home);const float whole_distance=builder.camera().distance;key(sf::Keyboard::Key::F);
check(builder.camera().distance<whole_distance,"F did not focus the selected part");
builder.set_document(doc);
// Test mode compiles the design without modifying any geometry and preserves the camera.
const auto camera=builder.camera();builder.start_test();check(builder.testing(),"Test did not start");builder.update();builder.stop_test();check(!builder.testing()&&builder.camera().target==camera.target,"Build/Test lost the camera");check(builder.document().parts.size()==doc.parts.size(),"Test changed the design");
auto instrumented=doc;instrumented.probes={{1,tank_id,"Tank pressure",MachineQuantity::Pressure},{2,0,"Shaft speed",MachineQuantity::Rpm}};builder.set_document(instrumented);builder.start_test();builder.pause_test();const auto paused_time=builder.playback_stats().simulation_seconds;
std::this_thread::sleep_for(std::chrono::milliseconds(30));builder.update();check(builder.playback_stats().paused&&builder.playback_stats().simulation_seconds==paused_time,"Pause allowed physics to advance in the background");
builder.set_test_speed(.1);check(builder.playback_stats().speed==.1&&builder.playback_stats().simulation_seconds==paused_time,"Changing speed while paused advanced the simulation");builder.step_test();
check(std::abs(builder.playback_stats().simulation_seconds-paused_time-.01)<1e-7,"Single step did not advance exactly 10 ms of simulation time");check(!builder.measurements().empty()&&builder.measurements().back().count==2,"Instruments did not record simulation-time samples");
builder.pause_test();const auto slow_start=builder.playback_stats().simulation_seconds;std::this_thread::sleep_for(std::chrono::milliseconds(150));builder.update();const auto slow_elapsed=builder.playback_stats().simulation_seconds-slow_start;check(slow_elapsed>.003&&slow_elapsed<.1,"Slow playback was stalled by old queued audio or ran at full speed");builder.pause_test();
const auto ui=WorkshopLayout::calculate(size);const auto motor_id=std::find_if(instrumented.parts.begin(),instrumented.parts.end(),[](const auto& p){return p.kind==MachinePartKind::DriveMotor;})->id;
click({160,120});key(sf::Keyboard::Key::F,true);for(char c:std::string("Bench speed drive"))builder.handle_event(sf::Event::TextEntered{std::uint32_t(c)},size);key(sf::Keyboard::Key::Enter);click({90,242});
check(builder.selected_parts()==std::vector<std::uint32_t>{motor_id},"Objects list did not select the drive motor");click(ui.field_value(0).position+sf::Vector2f{16,10});for(char c:std::string("1200"))builder.handle_event(sf::Event::TextEntered{std::uint32_t(c)},size);key(sf::Keyboard::Key::Enter);
check(std::abs(builder.document().find(motor_id)->drive.target_speed*60/(2*pi)-1200)<.01f,"Drive RPM could not be tuned live");const auto before_tuning_step=builder.playback_stats().simulation_seconds;builder.step_test();check(std::abs(builder.playback_stats().simulation_seconds-before_tuning_step-.01)<1e-7&&std::abs(builder.measurements().back().values[1]-1200)<.01f,"Live tuning reset time or failed to reach the running shaft");
const auto cells=builder.document().cells_per_meter;click({ui.inspector_x+12+2.5f*(ui.inspector_width-34)/6+4,148});click(ui.field_value(0).position+sf::Vector2f{16,10});for(char c:std::string("80"))builder.handle_event(sf::Event::TextEntered{std::uint32_t(c)},size);key(sf::Keyboard::Key::Enter);check(builder.document().cells_per_meter==cells,"A rebuild-only property was editable during Test");
key(sf::Keyboard::Key::Z,true);check(builder.document().find(motor_id)->drive.target_speed==instrumented.find(motor_id)->drive.target_speed,"Live tuning undo failed");key(sf::Keyboard::Key::Y,true);check(std::abs(builder.document().find(motor_id)->drive.target_speed*60/(2*pi)-1200)<.01f,"Live tuning redo failed");
click(*builder_project(builder.camera(),builder.workspace_viewport(size),builder.document().find(tank_id)->position));const auto before_probe=builder.playback_stats().simulation_seconds;click({ui.inspector_x+ui.inspector_width*.5f,210});check(builder.document().probes.size()==3&&builder.playback_stats().simulation_seconds==before_probe,"Adding a temperature probe live reloaded physics");builder.step_test();check(builder.measurements().back().count==3,"Live instrument setup mixed trace schemas");
click(*builder_project(builder.camera(),builder.workspace_viewport(size),builder.document().find(tank_id)->position)+sf::Vector2f{18,-18});click({ui.inspector_x+ui.inspector_width*.75f,484});
check(builder.document().probes.size()==2&&std::none_of(builder.document().probes.begin(),builder.document().probes.end(),[](const auto& p){return p.quantity==MachineQuantity::Temperature;}),"Overlapping probe markers could not be selected independently");
const auto csv=std::filesystem::temp_directory_path()/("fluidsim-probe-"+std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())+".csv");check(builder.export_measurements(csv),"Instrument CSV export failed");{std::ifstream input(csv);std::string header;std::getline(input,header);check(header.find("time_s")!=std::string::npos&&header.find("Tank pressure [kPa abs]")!=std::string::npos,"CSV lost time or channel units");}std::filesystem::remove(csv);
builder.stop_test();check(std::abs(builder.document().find(motor_id)->drive.target_speed*60/(2*pi)-1200)<.01f&&!builder.measurements().empty(),"Returning to Build lost live calibration or captured data");
{
MachineBuilder sensing;MachineDocument tapped;const auto pipe=tapped.add(MachinePartKind::Pipe,{}).id;tapped.add(MachinePartKind::Shaft,{.5f,0,0});sensing.set_document(tapped);
const auto click_sensor=[&](sf::Vector2f at){const sf::Vector2i point{int(std::round(at.x)),int(std::round(at.y))};sensing.handle_event(sf::Event::MouseButtonPressed{sf::Mouse::Button::Left,point},size);sensing.handle_event(sf::Event::MouseButtonReleased{sf::Mouse::Button::Left,point},size);};
const auto sensor_key=[&](sf::Keyboard::Key code,bool control=false){sensing.handle_event(sf::Event::KeyPressed{.code=code,.control=control},size);};
const auto at=builder_project(sensing.camera(),sensing.workspace_viewport(size),machine_world_point(*tapped.find(pipe),{.09f,0,0}));check(bool(at),"Sensor tap is outside the camera");
sensing.handle_event(sf::Event::MouseButtonPressed{sf::Mouse::Button::Right,{int(std::round(at->x)),int(std::round(at->y))}},size);
const auto menu_x=std::clamp(at->x,sensing.workspace_viewport(size).position.x,float(size.x)-210),menu_y=std::clamp(at->y,92.f,float(size.y)-420);
click_sensor({menu_x+90,menu_y+311});click_sensor({ui.inspector_x+35,210});check(sensing.document().sensors.size()==1,"Add sensor from the pipe context menu failed");
const auto sensor_id=sensing.document().sensors[0].id;check(sensing.document().sensors[0].target.part==pipe&&std::abs(sensing.document().sensors[0].target.position-.8f)<.02f,"Context sensor did not use the clicked pipe arclength");
sensor_key(sf::Keyboard::Key::Z,true);check(sensing.document().sensors.empty(),"Sensor creation did not enter undo history");sensor_key(sf::Keyboard::Key::Y,true);check(sensing.document().sensors[0].id==sensor_id,"Sensor redo changed stable identity");
sensing.start_test();check(sensing.testing(),"Instrumented physical machine did not start");sensing.pause_test();const auto time=sensing.playback_stats().simulation_seconds;
click_sensor({ui.inspector_x+35,210});click_sensor({ui.inspector_x+ui.inspector_width-48,480});check(sensing.document().sensors.size()==1&&sensing.playback_stats().simulation_seconds==time,"Test permitted sensor topology edits or advanced time");sensing.step_test();sensing.stop_test();
sensor_key(sf::Keyboard::Key::Delete);check(sensing.document().find(pipe)==nullptr&&sensing.document().find_sensor(sensor_id)&&sensing.document().find_sensor(sensor_id)->target.part==0,"Deleting a host removed its sensor instead of exposing an unresolved binding");
sensor_key(sf::Keyboard::Key::H);click_sensor({ui.inspector_x+80,240});click_sensor({ui.inspector_x+ui.inspector_width-48,480});check(sensing.document().sensors.empty(),"Unresolved-sensor diagnostic did not navigate to its editable attachment");
}
{
MachineBuilder logic;auto design=machine_logic_example();const auto master=std::find_if(design.logic.nodes.begin(),design.logic.nodes.end(),[](const auto& n){return n.kind==LogicNodeKind::Switch;})->id;
const auto button=design.logic.add(LogicNodeKind::Button,{600,600}).id,lamp=design.logic.add(LogicNodeKind::Lamp,{900,600}).id;design.logic.find(button)->name="Held test button";design.logic.connect({button,"out"},{lamp,"in"});logic.set_document(design);
const auto logic_click=[&](sf::Vector2f at){const sf::Vector2i point{int(std::round(at.x)),int(std::round(at.y))};logic.handle_event(sf::Event::MouseButtonPressed{sf::Mouse::Button::Left,point},size);logic.handle_event(sf::Event::MouseButtonReleased{sf::Mouse::Button::Left,point},size);};
const auto logic_key=[&](sf::Keyboard::Key k,bool control=false){logic.handle_event(sf::Event::KeyPressed{.code=k,.control=control},size);};
const auto output=[&](std::uint32_t id){for(const auto& n:logic.logic_values())if(n.id==id)return n.values.back();throw std::runtime_error("Missing shared live graph values");};
logic_click({float(size.x)-121,68});check(logic.workspace_view()==WorkshopView::Logic,"Logic toolbar tab did not open the graph workspace");const auto original_camera=logic.camera();logic_key(sf::Keyboard::Key::Home);
const auto master_rect=logic.logic_node_rectangle(master,size);logic_click(master_rect.position+sf::Vector2f{20,10});logic_click({ui.inspector_x+40,148});for(char c:std::string("Changed master"))logic.handle_event(sf::Event::TextEntered{std::uint32_t(c)},size);logic_key(sf::Keyboard::Key::Enter);check(logic.document().logic.find(master)->name=="Changed master","Logic name field did not apply");logic_key(sf::Keyboard::Key::Z,true);
const auto speed_node=std::find_if(design.logic.nodes.begin(),design.logic.nodes.end(),[](const auto& n){return n.kind==LogicNodeKind::Slider;})->id,gate_node=std::find_if(design.logic.nodes.begin(),design.logic.nodes.end(),[](const auto& n){return n.kind==LogicNodeKind::And;})->id;const auto old_links=logic.document().logic.links;logic_click(logic.logic_pin_position(speed_node,"out",size));logic_click(logic.logic_pin_position(gate_node,"in1",size));check(logic.document().logic.links==old_links,"Rejected graph edit changed connections");logic_key(sf::Keyboard::Key::Escape);logic_key(sf::Keyboard::Key::Y,true);check(logic.document().logic.find(master)->name=="Changed master","A rejected graph edit destroyed redo history");for(const auto& p:design.parts)if(p.kind==MachinePartKind::Pipe)check(logic.document().find(p.id)->points==p.points,"Logic edits changed physical pipe geometry");
const auto body=logic.logic_node_rectangle(design.logic.nodes.front().id,size);logic_click(body.position+sf::Vector2f{20,40});check(logic.selected_parts()==std::vector<std::uint32_t>{design.sensors[0].target.part},"Graph sensor selection did not share its physical host selection");
logic.start_test();logic.pause_test();const auto paused=logic.playback_stats().simulation_seconds;const auto control=logic.logic_node_rectangle(master,size);const float scale=control.size.x/240;logic_click({control.position.x+control.size.x*.5f,control.position.y+control.size.y-26*scale});logic.step_test();check(!std::get<bool>(output(master).value)&&logic.document().logic.find(master)->boolean,"Live graph control changed its default or did not reach physics");
const auto after_step=logic.playback_stats().simulation_seconds;logic.set_workspace_view(WorkshopView::Split);check(logic.playback_stats().simulation_seconds==after_step&&logic.testing(),"Switching to split view restarted or advanced paused physics");const auto physical=logic.workspace_viewport(size),graph=logic.logic_viewport(size);check(physical.position.x+physical.size.x<graph.position.x,"Split 3D and Logic viewports overlap");check(logic.camera().yaw==original_camera.yaw&&logic.camera().pitch==original_camera.pitch,"Switching workspaces changed the camera");
logic.set_workspace_view(WorkshopView::Logic);logic_click({160,120});logic_key(sf::Keyboard::Key::F,true);for(char c:std::string("Held test button"))logic.handle_event(sf::Event::TextEntered{std::uint32_t(c)},size);logic_key(sf::Keyboard::Key::Enter);logic_click({80,244});logic_key(sf::Keyboard::Key::F);check(logic.selected_logic_node()==button,"Logic Objects did not select its saved node");const auto held=logic.logic_node_rectangle(button,size);const float zoom=held.size.x/240;const sf::Vector2i hold{int(held.position.x+held.size.x*.5f),int(held.position.y+held.size.y-26*zoom)};logic.handle_event(sf::Event::MouseButtonPressed{sf::Mouse::Button::Left,hold},size);logic.step_test();check(std::get<bool>(output(button).value),"Holding a graph button did not survive physics stepping");logic.set_workspace_view(WorkshopView::ThreeD);logic.step_test();check(!std::get<bool>(output(button).value),"Closing Logic left its button pressed");check(logic.playback_stats().simulation_seconds>paused,"Graph/physics steps did not share simulation time");logic.stop_test();
}
// Layout rows, footer actions and physical viewport have disjoint bounds.
for(const sf::Vector2u resolution:{sf::Vector2u{1280,720},sf::Vector2u{1920,1080}})for(float left:{220.f,420.f})for(float right:{280.f,520.f}){
const auto layout=WorkshopLayout::calculate(resolution,left,right);
check(layout.viewport.position.x>=layout.left.position.x+layout.left.size.x+8,"Library overlaps physical viewport");
check(layout.viewport.position.x+layout.viewport.size.x<=layout.right.position.x-8,"Inspector overlaps physical viewport");
check(layout.field_row(layout.field_rows()-1).position.y+48<=float(resolution.y)-164,"Fields overlap inspector footer");
check(layout.library.position.y+layout.library_rows()*36<=float(resolution.y)-238,"Library rows overlap the preview");
}
{
MachineBuilder responsive;responsive.set_document(doc);const auto before=responsive.workspace_viewport(size);const auto layout=WorkshopLayout::calculate(size);
const sf::Vector2i from{int(layout.inspector_x-4),420},to{from.x-100,420};
responsive.handle_event(sf::Event::MouseButtonPressed{sf::Mouse::Button::Left,from},size);responsive.handle_event(sf::Event::MouseMoved{to},size);responsive.handle_event(sf::Event::MouseButtonReleased{sf::Mouse::Button::Left,to},size);
check(responsive.workspace_viewport(size).size.x<before.size.x-90,"Resizing inspector did not update viewport geometry");
MachineBuilder restored;restored.restore_workspace_preferences();check(restored.workspace_viewport(size)==responsive.workspace_viewport(size),"Panel widths did not restore");
responsive.handle_event(sf::Event::MouseButtonPressed{sf::Mouse::Button::Left,{160,68}},size);responsive.handle_event(sf::Event::MouseButtonReleased{sf::Mouse::Button::Left,{160,68}},size);
check(responsive.workspace_viewport(size).size.x>before.size.x,"Collapsing inspector did not return viewport space");
}
// Search captures typing and routes a filtered library selection to placement.
builder.set_document({});click({60,120});key(sf::Keyboard::Key::F,true);for(char c:std::string("Throttle"))builder.handle_event(sf::Event::TextEntered{std::uint32_t(c)},size);key(sf::Keyboard::Key::Enter);click({90,242});click({650,450});
check(builder.document().parts.size()==1&&builder.document().parts.front().kind==MachinePartKind::Throttle,"Filtered library did not place the searched part");
// Ports can be dragged directly without entering the optional Ports tool.
builder.set_document(ports);key(sf::Keyboard::Key::Q);click(screen_part(free_atmosphere_id));
const auto source=screen_part(free_atmosphere_id),target=*builder_project(builder.camera(),builder.workspace_viewport(size),machine_port_position(*ports.find(pipe_id),0));
builder.handle_event(sf::Event::MouseButtonPressed{sf::Mouse::Button::Left,{int(source.x),int(source.y)}},size);builder.handle_event(sf::Event::MouseMoved{{int(target.x),int(target.y)}},size);builder.handle_event(sf::Event::MouseButtonReleased{sf::Mouse::Button::Left,{int(target.x),int(target.y)}},size);
check(builder.document().links.size()==ports.links.size()+1,"Direct port dragging failed");validate_machine(builder.document());
builder.set_document(doc);
if(argc==3&&std::string(argv[1])=="--preview"){
const std::filesystem::path output=argv[2];std::filesystem::create_directories(output);sf::ContextSettings context;context.depthBits=24;context.majorVersion=2;context.minorVersion=1;
sf::RenderWindow window(sf::VideoMode(size),"Machine builder preview",sf::Style::Default,sf::State::Windowed,context);window.setVisible(false);sf::Font font("C:/Windows/Fonts/consola.ttf");
const auto capture=[&](const char* file){builder.draw(window,&font);sf::Texture image(size);image.update(window);check(image.copyToImage().saveToFile(output/file),"Screenshot save failed");};
{
const auto* old_directory=std::getenv("FLUIDSIM_MACHINE_DIR");const std::string previous_directory=old_directory?old_directory:"";machine_test_directory(std::filesystem::absolute(output/"assembly-library").string());
builder.set_document(machine_pump_example());key(sf::Keyboard::Key::A,true);capture("assembly-selection.png");
const auto point=builder_project(builder.camera(),builder.workspace_viewport(size),builder.document().parts.front().position);check(bool(point),"Assembly preview is outside camera");
builder.handle_event(sf::Event::MouseButtonPressed{sf::Mouse::Button::Right,{int(point->x),int(point->y)}},size);capture("assembly-context.png");key(sf::Keyboard::Key::Escape);
click({450,26});const float mx=float(size.x)*.5f-280,my=float(size.y)*.5f-185;click({mx+450,my+30});
for(char c:std::string("Vacuum pump module"))builder.handle_event(sf::Event::TextEntered{std::uint32_t(c)},size);capture("assembly-save.png");key(sf::Keyboard::Key::Enter);
click({450,26});capture("assembly-library.png");click({mx+480,my+93});capture("assembly-inserted.png");
key(sf::Keyboard::Key::C,true);key(sf::Keyboard::Key::V,true);capture("assembly-pasted.png");machine_test_directory(previous_directory);
}
MachineRenderer3D cached;MachineDocument visual;auto& visual_pipe=visual.add(MachinePartKind::Pipe);const auto visual_id=visual_pipe.id;visual_pipe.points=bulge.points;visual_pipe.point_diameters=bulge.point_diameters;OrbitCamera view;
cached.draw(window,visual,nullptr,viewport,view,0);const auto initial_builds=cached.stats().pipe_mesh_builds;check(initial_builds==1,"First pipe mesh was not cached");
const auto check_overlay=[&]{sf::RectangleShape marker({24,24});marker.setPosition({8,8});marker.setFillColor(sf::Color::Magenta);window.draw(marker);sf::Texture pixels(size);pixels.update(window);check(pixels.copyToImage().getPixel({16,16})==sf::Color::Magenta,"Raw 3D rendering left SFML overlays invisible");};check_overlay();
view.orbit({12,8});visual.parts[0].position.x+=.03f;visual.parts[0].rotation.z=15;
cached.draw(window,visual,nullptr,viewport,view,visual_id);check(cached.stats().pipe_mesh_builds==initial_builds,"Camera, transform or selection rebuilt local pipe geometry");
MachineSnapshot colored;MachinePartState ps;ps.id=visual_id;ps.pipe_pressures={95000,105000};colored.parts.push_back(ps);
cached.draw(window,visual,&colored,viewport,view,0);check(cached.stats().pipe_mesh_builds==initial_builds,"Pressure colors rebuilt the pipe mesh");
visual.parts[0].points.back().y+=.05f;cached.draw(window,visual,nullptr,viewport,view,0);check(cached.stats().pipe_mesh_builds==initial_builds+1,"Path edit failed to invalidate the mesh");
visual.parts[0].point_diameters[1]+=.005f;cached.draw(window,visual,nullptr,viewport,view,0);check(cached.stats().pipe_mesh_builds==initial_builds+2,"Interior diameter edit failed to invalidate the mesh");
cached.draw_icon(window,MachinePartKind::Pipe,{{0,0},{35,26}});cached.draw_icon(window,MachinePartKind::Cylinder,{{40,0},{35,26}});check(cached.stats().icon_atlas_builds==1,"Part icons rebuilt their atlas");
check_overlay();
visual.parts.clear();cached.draw(window,visual,nullptr,viewport,view,0);check(cached.stats().cached_pipes==0,"Deleted geometry remained cached");
visual.next_id=visual_id;visual.add(MachinePartKind::Pipe).points={{0,0,0},{0,.2f,0}};cached.draw(window,visual,nullptr,viewport,view,0);check(cached.stats().pipe_mesh_builds==initial_builds+3,"Reused document IDs retained stale geometry");
builder.set_document({});capture("empty.png");builder.set_document(machine_pump_example());click(*builder_project(builder.camera(),builder.workspace_viewport(size),builder.document().parts.front().position));capture("pump-build.png");
key(sf::Keyboard::Key::C);capture("connections.png");builder.start_test();builder.update();capture("pump-test.png");builder.stop_test();builder.set_document(machine_engine_example());capture("engine-build.png");
key(sf::Keyboard::Key::I);capture("import-menu.png");key(sf::Keyboard::Key::Escape);
builder.set_document(machine_from_engine(scenario_example("v8-h")));key(sf::Keyboard::Key::A,true);capture("v8-import.png");
builder.set_document(machine_from_engine(scenario_example("single-450")));capture("single-import.png");
for(auto& p:builder.document().parts)if(p.kind==MachinePartKind::Ecu){key(sf::Keyboard::Key::Q);click(*builder_project(builder.camera(),builder.workspace_viewport(size),p.position));break;}capture("ecu-settings.png");
builder.set_document(dry);key(sf::Keyboard::Key::H);capture("checks-missing-fuel.png");
auto feedback=ports;const auto feedback_ecu=feedback.add(MachinePartKind::Ecu,{.3f,.5f,0}).id;builder.set_document(feedback);
key(sf::Keyboard::Key::C);click(screen_part(free_atmosphere_id));
const auto hover=[&](MachinePort port){const auto point=builder_project(builder.camera(),builder.workspace_viewport(size),machine_port_position(*builder.document().find(port.part),port.port));check(bool(point),"Preview target is outside camera");builder.handle_event(sf::Event::MouseMoved{{int(std::round(point->x)),int(std::round(point->y))}},size);};
hover({pipe_id,0});capture("connect-air-ready.png");hover({feedback_ecu,0});capture("connect-incompatible.png");
builder.set_document(route);key(sf::Keyboard::Key::Q);click(*builder_project(builder.camera(),builder.workspace_viewport(size),machine_world_point(route_part,local)));key(sf::Keyboard::Key::P);
key(sf::Keyboard::Key::LControl);click(*builder_project(builder.camera(),builder.workspace_viewport(size),machine_world_point(route_part,local)));builder.handle_event(sf::Event::KeyReleased{.code=sf::Keyboard::Key::LControl},size);capture("inserted-bend.png");
MachineDocument sculpt;sculpt.add(MachinePartKind::Pipe)=bulge;sculpt.parts[0].id=1;sculpt.parts[0].name="Per-point diameter pipe";builder.set_document(sculpt);key(sf::Keyboard::Key::Q);click(*builder_project(builder.camera(),builder.workspace_viewport(size),Vec3{}));key(sf::Keyboard::Key::P);click(*builder_project(builder.camera(),builder.workspace_viewport(size),Vec3{}));capture("point-diameters.png");
auto gauges=machine_from_engine(scenario_example("single-450"));machine_test::drive(gauges,3000);unsigned cylinder=0,downpipe=0,carb=0;
for(const auto& p:gauges.parts){if(p.kind==MachinePartKind::Cylinder)cylinder=p.id;if(p.kind==MachinePartKind::Carburetor)carb=p.id;if(p.name=="Exhaust atmosphere")for(const auto& link:gauges.links){const auto other=link.a.part==p.id?link.b.part:link.b.part==p.id?link.a.part:0;if(const auto part=gauges.find(other);part&&part->kind==MachinePartKind::Pipe)downpipe=other;}}
gauges.probes={{1,cylinder,"Cylinder pressure",MachineQuantity::Pressure},{2,downpipe,"Downpipe lambda",MachineQuantity::Lambda},{3,cylinder,"Cylinder fresh charge",MachineQuantity::ChargeAfr}};
builder.set_document(gauges);builder.set_test_speed(.1);builder.start_test();builder.pause_test();for(int i=0;i<100;++i)builder.step_test();key(sf::Keyboard::Key::M);capture("instruments-pressure.png");
click({float(size.x)-50,318});capture("instruments-lambda.png");
const auto center=builder_project(builder.camera(),builder.workspace_viewport(size),gauges.find(carb)->position);check(bool(center),"Carburetor is outside camera");bool selected_carb=false;
for(int dy:{0,-8,8,-16,16}){for(int dx:{0,-8,8,-16,16}){click(*center+sf::Vector2f{float(dx),float(dy)});if(builder.selected_parts()==std::vector<std::uint32_t>{carb}){selected_carb=true;break;}}if(selected_carb)break;}
check(selected_carb,"Could not select the carburetor for live tuning");click({ui.inspector_x+40,148});capture("live-carb-tuning.png");builder.stop_test();
}
std::cout<<"Builder selection, transforms, connected copy/paste, assembly library, undo/redo, explicit exit and mode switching passed\n";return 0;
}catch(const std::exception& e){std::cerr<<e.what()<<'\n';return 1;}