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

@@ -8,6 +8,7 @@ add_library(fluidsim_core
src/engine_config.cpp src/engine_config.cpp
src/carburetor.cpp src/carburetor.cpp
src/ecu.cpp src/ecu.cpp
src/fuel_injector.cpp
src/engine.cpp src/engine.cpp
src/crankcase.cpp src/crankcase.cpp
src/two_stroke_cylinder.cpp src/two_stroke_cylinder.cpp
@@ -21,6 +22,19 @@ add_library(fluidsim_core
src/scenario_snapshot.cpp src/scenario_snapshot.cpp
src/scenario_geometry.cpp src/scenario_geometry.cpp
src/physical_geometry.cpp src/physical_geometry.cpp
src/machine.cpp
src/machine_ports.cpp
src/mechanics.cpp
src/machine_mechanics.cpp
src/machine_sensors.cpp
src/machine_logic.cpp
src/logic_runtime.cpp
src/logic_serialization.cpp
src/logic_assembly.cpp
src/logic_example.cpp
src/speed_density_example.cpp
src/machine_simulation.cpp
src/machine_import.cpp
src/solver.cpp src/solver.cpp
src/vehicle.cpp src/vehicle.cpp
src/simulation_ring_buffer.cpp src/simulation_ring_buffer.cpp
@@ -49,6 +63,8 @@ add_executable(fluidsim_parity_trace tools/parity_trace.cpp)
target_link_libraries(fluidsim_parity_trace PRIVATE fluidsim_core) target_link_libraries(fluidsim_parity_trace PRIVATE fluidsim_core)
add_executable(fluidsim_migrate_geometry tools/migrate_geometry.cpp) add_executable(fluidsim_migrate_geometry tools/migrate_geometry.cpp)
target_link_libraries(fluidsim_migrate_geometry PRIVATE fluidsim_core) target_link_libraries(fluidsim_migrate_geometry PRIVATE fluidsim_core)
add_executable(fluidsim_machine_benchmark tools/machine_benchmark.cpp)
target_link_libraries(fluidsim_machine_benchmark PRIVATE fluidsim_core)
if(FLUIDSIM_BUILD_DESKTOP) if(FLUIDSIM_BUILD_DESKTOP)
find_package(SFML 3.0.2 COMPONENTS Graphics Audio QUIET) find_package(SFML 3.0.2 COMPONENTS Graphics Audio QUIET)
@@ -72,15 +88,22 @@ if(FLUIDSIM_BUILD_DESKTOP)
endif() endif()
find_package(OpenGL REQUIRED) find_package(OpenGL REQUIRED)
add_library(fluidsim_desktop_rendering add_library(fluidsim_desktop_rendering
src/desktop_ui.cpp src/scenario_menu.cpp src/engine_schematic.cpp src/engine_renderer_3d.cpp src/dyno_renderer.cpp) src/desktop_ui.cpp src/scenario_menu.cpp src/engine_schematic.cpp src/engine_renderer_3d.cpp src/dyno_renderer.cpp
target_link_libraries(fluidsim_desktop_rendering PUBLIC fluidsim_core SFML::Graphics OpenGL::GL) src/machine_builder.cpp src/builder_geometry.cpp src/machine_renderer_3d.cpp src/window_presentation.cpp src/logic_editor.cpp
src/audio_output_stream.cpp src/sound_engine.cpp)
target_link_libraries(fluidsim_desktop_rendering PUBLIC fluidsim_core SFML::Graphics SFML::Audio OpenGL::GL)
if(WIN32)
target_link_libraries(fluidsim_desktop_rendering PRIVATE user32)
endif()
if(MSVC) if(MSVC)
target_compile_options(fluidsim_desktop_rendering PRIVATE /W4 /permissive-) target_compile_options(fluidsim_desktop_rendering PRIVATE /W4 /permissive-)
else() else()
target_compile_options(fluidsim_desktop_rendering PRIVATE -Wall -Wextra -Wpedantic) target_compile_options(fluidsim_desktop_rendering PRIVATE -Wall -Wextra -Wpedantic)
endif() endif()
add_executable(fluidsim_desktop src/desktop_main.cpp src/audio_output_stream.cpp src/sound_engine.cpp) add_executable(fluidsim_desktop src/desktop_main.cpp)
target_link_libraries(fluidsim_desktop PRIVATE fluidsim_desktop_rendering SFML::Audio) target_link_libraries(fluidsim_desktop PRIVATE fluidsim_desktop_rendering SFML::Audio)
add_executable(fluidsim_machine_render_benchmark tools/machine_render_benchmark.cpp)
target_link_libraries(fluidsim_machine_render_benchmark PRIVATE fluidsim_desktop_rendering)
if(MSVC) if(MSVC)
target_compile_options(fluidsim_desktop PRIVATE /W4 /permissive-) target_compile_options(fluidsim_desktop PRIVATE /W4 /permissive-)
else() else()
@@ -117,6 +140,34 @@ endif()
include(CTest) include(CTest)
if(BUILD_TESTING) if(BUILD_TESTING)
add_executable(fluidsim_speed_density_tests tests/speed_density_tests.cpp)
target_link_libraries(fluidsim_speed_density_tests PRIVATE fluidsim_core)
add_test(NAME fluidsim_speed_density_tests COMMAND fluidsim_speed_density_tests)
add_executable(fluidsim_sensor_ecu_tests tests/sensor_ecu_tests.cpp)
target_link_libraries(fluidsim_sensor_ecu_tests PRIVATE fluidsim_core)
add_test(NAME fluidsim_sensor_ecu_tests COMMAND fluidsim_sensor_ecu_tests)
add_executable(fluidsim_logic_tests tests/logic_tests.cpp)
target_link_libraries(fluidsim_logic_tests PRIVATE fluidsim_core)
add_test(NAME fluidsim_logic_tests COMMAND fluidsim_logic_tests)
add_executable(fluidsim_machine_logic_tests tests/machine_logic_tests.cpp)
target_link_libraries(fluidsim_machine_logic_tests PRIVATE fluidsim_core)
add_test(NAME fluidsim_machine_logic_tests COMMAND fluidsim_machine_logic_tests)
add_executable(fluidsim_machine_tests tests/machine_tests.cpp)
target_link_libraries(fluidsim_machine_tests PRIVATE fluidsim_core)
add_test(NAME fluidsim_machine_tests COMMAND fluidsim_machine_tests)
add_executable(fluidsim_machine_sensor_tests tests/machine_sensor_tests.cpp)
target_link_libraries(fluidsim_machine_sensor_tests PRIVATE fluidsim_core)
target_compile_definitions(fluidsim_machine_sensor_tests PRIVATE FLUIDSIM_SENSOR_EXAMPLE_FILE="${CMAKE_CURRENT_SOURCE_DIR}/machines/sensor-bench.machine.toml")
add_test(NAME fluidsim_machine_sensor_tests COMMAND fluidsim_machine_sensor_tests)
add_executable(fluidsim_machine_port_tests tests/machine_port_tests.cpp)
target_link_libraries(fluidsim_machine_port_tests PRIVATE fluidsim_core)
add_test(NAME fluidsim_machine_port_tests COMMAND fluidsim_machine_port_tests)
add_executable(fluidsim_mechanics_tests tests/mechanics_tests.cpp)
target_link_libraries(fluidsim_mechanics_tests PRIVATE fluidsim_core)
add_test(NAME fluidsim_mechanics_tests COMMAND fluidsim_mechanics_tests)
add_executable(fluidsim_machine_mechanics_tests tests/machine_mechanics_tests.cpp)
target_link_libraries(fluidsim_machine_mechanics_tests PRIVATE fluidsim_core)
add_test(NAME fluidsim_machine_mechanics_tests COMMAND fluidsim_machine_mechanics_tests)
add_executable(fluidsim_physical_geometry_tests tests/physical_geometry_tests.cpp) add_executable(fluidsim_physical_geometry_tests tests/physical_geometry_tests.cpp)
target_link_libraries(fluidsim_physical_geometry_tests PRIVATE fluidsim_core) target_link_libraries(fluidsim_physical_geometry_tests PRIVATE fluidsim_core)
target_include_directories(fluidsim_physical_geometry_tests SYSTEM PRIVATE third_party/tomlplusplus) target_include_directories(fluidsim_physical_geometry_tests SYSTEM PRIVATE third_party/tomlplusplus)
@@ -138,6 +189,16 @@ if(BUILD_TESTING)
add_test(NAME fluidsim_scenario_examples COMMAND fluidsim_scenario_examples) add_test(NAME fluidsim_scenario_examples COMMAND fluidsim_scenario_examples)
endif() endif()
if(BUILD_TESTING AND FLUIDSIM_BUILD_DESKTOP) if(BUILD_TESTING AND FLUIDSIM_BUILD_DESKTOP)
add_executable(fluidsim_logic_editor_tests tests/logic_editor_tests.cpp)
target_link_libraries(fluidsim_logic_editor_tests PRIVATE fluidsim_desktop_rendering)
add_test(NAME fluidsim_logic_editor_tests COMMAND fluidsim_logic_editor_tests)
add_executable(fluidsim_menu_tests tests/menu_tests.cpp)
target_link_libraries(fluidsim_menu_tests PRIVATE fluidsim_desktop_rendering)
add_test(NAME fluidsim_menu_tests COMMAND fluidsim_menu_tests)
add_test(NAME fluidsim_window_tests COMMAND fluidsim_menu_tests --window-check)
add_executable(fluidsim_machine_builder_tests tests/machine_builder_tests.cpp)
target_link_libraries(fluidsim_machine_builder_tests PRIVATE fluidsim_desktop_rendering)
add_test(NAME fluidsim_machine_builder_tests COMMAND fluidsim_machine_builder_tests)
add_executable(fluidsim_desktop_ui_tests tests/desktop_ui_tests.cpp) add_executable(fluidsim_desktop_ui_tests tests/desktop_ui_tests.cpp)
target_link_libraries(fluidsim_desktop_ui_tests PRIVATE fluidsim_desktop_rendering) target_link_libraries(fluidsim_desktop_ui_tests PRIVATE fluidsim_desktop_rendering)
add_test(NAME fluidsim_desktop_ui_tests COMMAND fluidsim_desktop_ui_tests) add_test(NAME fluidsim_desktop_ui_tests COMMAND fluidsim_desktop_ui_tests)

View File

@@ -19,6 +19,54 @@ radiation, thermodynamics and convergence under time/space refinement.
## Build and run (Windows and Linux) ## Build and run (Windows and Linux)
The desktop now includes a [3D machine builder](docs/MACHINE_BUILDER.md). Open
**Machine workshop** in the scenario menu to place and configure pipes, tanks,
valves, engine cylinders and piston pumps. Saved machines support **Edit** and
**Play**, and **Tab** switches between building and testing.
The picker includes 3D previews, carburetors and ECUs. **Import engine layout**
opens editable copies of the 450 single, twin, flat six and V8. Shift selects groups;
blue pins carry ECU control wires, and ideal junctions accept multiple pipes at
one point. Engine cylinders require an explicit connected fuel source.
**Ctrl+C / Ctrl+V** copies and pastes selected groups with their internal air and
ECU connections. **Assemblies** saves selected groups for reuse across machines.
Escape cancels tools or selection; use **Menu** to leave the workshop.
**Meters (M)** adds pressure, temperature, flow, AFR/lambda and shaft instruments
with live traces and CSV export. Test playback slows to 1% speed and supports
paused 10 ms steps. Carb/ECU and other safe settings can be tuned live; geometry
and compiled settings stay locked until returning to Build.
The **Mech** category adds explicit shafts, flywheels, drive motors and brakes.
Cylinders/pumps connect to named shafts; independent engines can run at different
speeds. Try **Shaft, flywheel and brake bench** from saved machines. Mechanical-only
machines need no gas pipes. Older machine saves migrate in memory, and new saves
use version 10 with stable ports, mechanical parameters, sensor attachments,
a separate typed signal graph and explicit sensor-ECU/injector bindings.
The **Sense** inspector adds pressure/MAP, differential pressure, gas temperature,
total pipe mass flow, crank speed/angle and throttle-position pickups. Sensor
channels sample independently of recorded instruments and show validity/time;
deleting a host leaves an unresolved binding. Right-click **Add sensor here**
places a tap at the clicked pipe location.
The saved **Automatic air supply / logic bench** runs pressure feedback through
hysteresis, a master switch and an AND gate to a compressor drive command. Choose
**Logic** to edit its graph or **Split** to view signals beside the 3D machine.
The node library supports search, typed click/drag wiring and live controls;
Boolean wires are red, Number green, with ring inputs and filled outputs.
ECUs now offer **Crank ignition**, **Alpha-N EFI** and **Speed-density EFI** presets alongside the
labeled legacy controller. Their grouped Logic connectors require physical crank
speed/position, plus actual TPS for Alpha-N or absolute MAP/IAT for speed-density.
Intake chambers accept explicit fuel
injector attachments with calibrated capacity and finite/ideal rail supply.
Try `machines/sensor-ignition-bench.machine.toml` and
`machines/sensor-alpha-n-bench.machine.toml` and
`machines/sensor-speed-density-bench.machine.toml`; all have working fuel transport
and combustion. See the [workshop guide](docs/MACHINE_BUILDER.md) for calibration
and the current batch-delivery model.
Launch directly with `fluidsim_desktop --builder`, edit a saved design with
`--machine machines/vacuum-pump.machine.toml`, or run it with
`--play-machine machines/vacuum-pump.machine.toml`.
Both platforms use CMake 3.28 or newer, Git, and a C++20 compiler. The presets Both platforms use CMake 3.28 or newer, Git, and a C++20 compiler. The presets
build with optimizations and debugging symbols (`RelWithDebInfo`). The first build with optimizations and debugging symbols (`RelWithDebInfo`). The first
desktop configure fetches SFML 3.0.2 if it is not installed and needs network desktop configure fetches SFML 3.0.2 if it is not installed and needs network
@@ -166,12 +214,16 @@ in each engine TOML's `[simulation] cells_per_meter`. The desktop engine editor
has been removed; edit engine calibration in TOML. has been removed; edit engine calibration in TOML.
Launching `fluidsim_desktop` without `--scenario` opens a small scenario menu. Launching `fluidsim_desktop` opens a maximized home screen with **Machine
Choose the twin, YZ125, Saab B205, or American V8 card, or select with Up/Down Workshop**, **Engine Scenarios**, and **Quit**. Workshop offers a new design or
and press Enter. saved machines with separate Edit/Play actions. Engine Scenarios opens the engine
collection with category filters, search, a preview and a Start button. Click a
row to select it, then start with the button or Enter; Up/Down also selects rows.
Escape returns from the scenario to the menu, stopping its simulation and Escape returns from the scenario to the menu, stopping its simulation and
audio so another scenario can be selected. Escape closes the menu at the category audio so another scenario can be selected. Escape returns from a browser to the
screen. Simulation and audio start after selection. Passing home screen, and closes the app from Home. Menu, workshop and scenario entry all
maximize the decorated Windows window, retaining its normal restore controls.
Simulation and audio start when an engine is launched. Passing
`--scenario twin`, `--scenario two-stroke`, `--scenario b205`, or `--scenario v8` `--scenario twin`, `--scenario two-stroke`, `--scenario b205`, or `--scenario v8`
bypasses the menu; `--scene` and `--exhaust` options also apply bypasses the menu; `--scene` and `--exhaust` options also apply
when choosing through the menu. when choosing through the menu.
@@ -556,10 +608,10 @@ for units, directory discovery, sections and pipe topology ordering.
### Engine families and inspired builds ### Engine families and inspired builds
The browser opens at engine categories; click a category to see variants, then Choose **Engine Scenarios** on the home screen. Categories are listed beside the
click a variant or press Enter to start. Backspace, Left, Escape or the engine collection; wheel over them to reach more families. Select a variant,
`< Categories` header returns to categories. Both levels support wheel and then press Enter or **Start engine**. Ctrl+F focuses search. Backspace, Escape or
keyboard scrolling. **Back** returns to the main menu. Lists support wheel and keyboard scrolling.
`EngineConfig` supplies cylinder count (up to 12), inline/V/boxer layout, `EngineConfig` supplies cylinder count (up to 12), inline/V/boxer layout,
bank angle, bore/stroke/rod, compression, firing phases, rev limit and fueling. bank angle, bore/stroke/rod, compression, firing phases, rev limit and fueling.

View File

@@ -0,0 +1,354 @@
# Component implementation progress
Active goal started 2026-11-01: implement the full
[machine systems roadmap](MECHANICAL_BUILDER_PLAN.md), in its integrated order.
The scope includes the later components; they have not been dropped because they
need additional physical models. Preserve existing working-tree changes and
user-edited machine files.
## First implementation: typed port foundation
Implemented:
- Shared descriptors for Gas, Rotational, Translational, Boolean, Number and
legacy ECU assignment. Direction, cardinality, quantity, pressure reference,
required/default values and numeric ranges are explicit.
- Typed Boolean/Number values with validity and simulation timestamps. Finite
numeric checks and explicit type matching prevent implicit truth conversions.
- A shared compatibility function used by connection previews, edits and
document validation. Runtime compilation validates the same document.
- Explicit gas-domain filtering in pipe geometry updates, fluid compilation and
fuel-source searches, avoiding assumptions that every non-ECU link carries gas.
- Version 5 stable port/method names, custom physical-port identities and loading
of versions 1 through 4. Zero-storage junctions expose one shared port; old
numeric aliases migrate to it.
- Shared UI port colors and silhouettes: red Boolean, green Number, input rings,
filled outputs, neutral bidirectional gas connectors; legacy assignments blue.
The foundational types are real APIs. Boolean/Number nodes remain subsequent work;
the first mechanical parts are implemented below.
## Mechanical parts milestone
Implemented Shaft / crankshaft, Flywheel, Drive motor and Brake / load with typed
gold attachment ports, real runtime behavior, 3D meshes, Mech picker category,
inspectors, live motor/load/friction tuning and named rotational instruments.
Shaft inertia and initial states require Build. Effective flywheel inertia appears
in the shaft inspector. Cylinders and pumps use their attached shaft; each ECU's
cylinders must share one shaft. Moving a shaft leaves gas geometry unchanged.
The independent-shaft solver tracks kinetic energy, source/drive work and losses.
Its drag integration splits stops and reversals without allowing brakes to create
motion. Ideal speed drives report their supplied/absorbed work, including startup.
Engine-connected shafts currently enforce forward-only timing. The legacy starter
remains explicit shaft calibration until the dedicated starter milestone; new
shafts default to zero starter torque.
Version 6 saves mechanical parameters/connections. Older documents migrate to
visible shafts/drives/loads and keep legacy gauge definitions. Examples/imports,
copy/paste, assembly references, live tuning and benchmark tools use explicit
mechanics. Existing user files are not rewritten on load. The new
`machines/shaft-drive-bench.machine.toml` is a ready-to-run mechanical example.
Validation: nine focused suites pass, including analytic acceleration and damping,
work/energy accounting, brakes/static hold/reversal, two independent engines at
1200/2400 RPM, live tuning one to 3600 RPM, required attachments, mixed-shaft ECU
rejection, migration, save/load, builder/menu interactions and prior physics cases.
Rendered review covers every mechanical inspector and a running bench at
1920x1080 and 1280x720. This is not a global gas-plus-mechanics conservation study;
the existing explicit component-coupling timestep limitations remain relevant.
## Sensor attachment milestone
Implemented six ideal sensor assemblies: absolute pressure / MAP, differential
pressure, gas temperature / IAT, signed total pipe mass flow, crank speed/position
and throttle position. They attach to existing physical hosts without consuming
gas ports, adding storage or changing the fluid mesh. Pipe taps use normalized
arclength. Total mass flow is explicitly distinct from the future fresh-air MAF
sensor; gas temperature is not a head/coolant temperature model.
Sensor outputs use the typed port descriptors, sample timestamps and validity.
Sampling occurs initially and after each audio sample's physics, providing the
next signal-graph boundary's readings. Crank speed, wrapped angle and Boolean
ideal synchronized status share a sample; shaft identity and unwrapped angle are
retained for future timing consumers. A stopped shaft supplies valid zero speed.
Sensor channels are independent of the eight recorded instrument channels.
Version 7 saves stable sensor identities, named kinds, host labels, normalized
locations and pickup offsets, and preserves deleted-ID counters. Versions 1..6
still load. Host deletion leaves a visible unresolved entity; copy/paste and
assemblies remap internal hosts and retain unresolved external bindings.
The **Sense** inspector creates supported sensors, binds hosts/reference taps,
adjusts tap positions and pickup offsets, focuses/removes attachments and shows
live typed outputs. Right-click **Add sensor here** uses the clicked pipe location.
3D markers select their sensor; unresolved-binding checks navigate to it.
Attachments are Build-only and remain inspectable while running/paused.
The new `machines/sensor-bench.machine.toml` demonstrates all six assemblies.
Validation covers sampling/units/signs, coherent timing and reverse crossings,
stopped pickups, remeshing/transforms, no fluid/audio perturbation, more than eight
sensor channels, host deletion, incompatible bindings, copy/paste and save/load,
plus UI placement, undo/redo, diagnostics and Test edit locks. Rendered review
covers pressure, crank and differential-pressure screens at 1920x1080/1280x720.
All ten focused regression suites pass, as does migration/round-trip validation
of the saved machines found in the workspace. The desktop executable is rebuilt.
## Logic runtime milestone
The document now owns a separate typed signal graph with stable node/pin keys,
saved canvas coordinates, shared port compatibility and atomic connection edits.
Version 8 saves that graph; versions 1..7 still load. Physical gas/mechanical
links and legacy blue ECU assignments retain their separate meanings.
Implemented 35 runtime node kinds: Boolean constants, switches/buttons, numeric
constants/sliders/inputs, six Boolean gates with configurable fan-in, comparisons,
windows/hysteresis, arithmetic, clamp/gain/map, number selection, Bool-to-number,
sensor references, command references, lamps/readouts, and Boolean/Number delays.
Polymorphic numeric nodes currently use explicit configured quantities/references.
Multiply/divide use a scalar second operand; pressure subtraction produces a
differential, while absolute-pressure addition requires a differential offset.
Graph evaluation occurs before each physics sample, consuming the previous
boundary's sensor sample and latching commands across gas CFL substeps. Evaluation
uses a compiled order, rejects combinational cycles with a named cycle path and
commits explicit memory next states together. Required unwired inputs block Test;
commands have declared inactive defaults. Invalid math/sensors stay invalid with
runtime diagnostics, and actuator consumers apply inactive fallbacks. Short button
press/release sequences survive between boundaries; fresh Test resets transient
controls and memory. Constants/thresholds can be tuned without restarting physics.
Commands own individual motor enable/speed/torque, brake enable, throttle opening
or legacy shaft starter fields. Duplicate ownership and ECU/opening conflicts are
rejected; dormant signal-owned fields start inactive. A shaft allows only one
potentially enabled ideal speed drive. The physical inspector identifies signal
ownership and the live drive HUD uses the effective enabled state.
Connected harnesses follow physical assembly copy/paste. Internal parts/sensors/
nodes remap independently; omitted physical bindings remain explicitly unresolved.
The new `machines/logic-compressor-bench.machine.toml` runs actual pressure ->
below-threshold hysteresis -> master-enable AND -> compressor motor enable, with
a speed slider, targeted readouts and recorded pressure/RPM/drive-power channels.
Core acceptance covers gate truth tables, unit/reference rules, fan-out/cardinality,
unknown keys/types, ordering/cycles, delayed feedback, math faults, manual event
ordering/reset, live tuning, command ownership/fallbacks, automatic compressor
shutdown, brake/starter commands, sample-vs-CFL timing, saved harnesses and
independent copied compressors. Twelve focused regression suites pass, along
with workspace save migration checks. Rendered review covers signal-owned drive
controls and the running pressure bench at 1920x1080 and 1280x720. The desktop is
rebuilt at this runtime milestone. Timers, edge nodes and latches remain later work.
## Logic editor milestone
Implemented **3D / Logic / Split** workspace tabs with one document, selection
and Test runtime. Canvas pan/zoom and the 3D camera remain independent. Selecting
a sensor/command reference selects its physical host; **Focus in 3D** opens the
split workspace. **Sense -> Show Logic** and physical **Command** buttons create
or reveal references to existing entities without duplicating physical parts.
The node library has search, categories, an Objects list and compatible-node
filtering while a pin is pending. New compatible numeric nodes adopt that pin's
quantity/reference. Nodes drag by their headers, Shift/box selection supports
groups, Home/F fit the graph/selection, and wheel zoom preserves the point under
the pointer. Saved node positions are independent of 3D geometry.
Connections support click/click, dragging, fan-out, input rewiring, right-click
pin disconnection and undo/redo. Rejected edits keep the old wiring and redo
history. Boolean pins/wires are red, Number green; inputs are rings and outputs
filled circles. Live values include units, required/default metadata, ranges,
pressure references and validity. Candidate combinational cycles highlight the
actual cycle nodes and retain the compiler's named path diagnostic.
Inspectors edit names, quantities/references, sensor/command bindings and node
parameters. Invalid numeric text remains editable. Topology, control ranges,
initial states and physical bindings require Build; safe constants/thresholds
and canvas layout can change during Test. Switches, momentary buttons, sliders
and numeric controls operate at simulation boundaries. Paused controls show queued
values separately from frozen pins; slider motion coalesces. Focus loss, leaving
Logic and stopping Test release momentary buttons. Numeric readouts have compact
UI-sampled trends; the recorded meter channels remain separate.
Graph copy/paste preserves internal wires. Same-document sensor references share
their existing sensor; cross-document references detach while retaining labels.
Pasted command nodes require a new explicit target, preventing competing owners.
Physical assembly copy/paste continues to remap complete internal harnesses.
Validation: thirteen focused regression suites pass. Interaction cases cover
placement/search, typed wiring/rewiring, failed-edit rollback/redo, one-entry drag
undo, zoom anchoring, live tuning, paused controls, focus/view-close button release,
shared physical selection, split-view bounds and uninterrupted simulation time.
Rendered review covers 1920x1080/1280x720, including live graphs, split view and the
threshold inspector. Pixel checks confirm red/green filled outputs and hollow
input centers. Updated rendering libraries are linked into
`out/build/windows-gcc/fluidsim_desktop_preview.exe`; the canonical executable is
currently locked by the user's running workshop, which was left intact.
Further graph/UI work remains on the roadmap: pinned operator panels, richer
binding pickers/property help, visual wire waypoints and graph-wide automatic
type inference, in addition to DPI scaling and visibility groups. Basic stage 2
is usable; those refinements and the later component stages are not dropped.
## Sensor ECU and fuel-delivery milestone
Implemented the runtime primitives for the next component stage:
- Physical crank outputs now carry pickup/shaft identity, original acquisition
time, unwrapped angle and measured speed through the signal graph. Unchanged
routing retains that evidence; arithmetic creates ordinary numeric values.
Delays retain their original acquisition time, so retimestamping a delayed
value cannot make it a current timing measurement.
- A crank-only wasted-spark clock validates matching current speed/angle channels,
counts unwrapped crossings, accepts stopped pickups and rejects stale/mixed or
reverse timing. Acquisition/recovery does not replay missed sparks. Changing
advance cannot fire twice for one revolution.
- Separate sensor ECU core with ignition-only and Alpha-N strategies. Ignition
uses measured RPM and an advance map; Alpha-N requires measured TPS and uses a
calibrated two-dimensional RPM/TPS table in kg per cylinder per four-stroke
cycle. It never receives trapped cylinder air mass or global pedal demand.
Required sensor faults cut outputs; the RPM limiter has hysteresis.
- Explicit injector delivery core with capacity limiting, declared ideal or
finite regulated rail supply, depletion and fail-closed command handling.
Delivery adds conserved fuel species, fuel-origin tracer and sensible energy
to an existing gas volume. This is continuous/batch-average delivery, not
sequential pulse-width control or a liquid-pressure circuit.
- Four-stroke cylinders accept consumed external spark pulses and cut commands.
This path requires transported fuel and does not fall back to hidden internal
spark timing. Burn progression follows the received event.
The cores are now integrated into saved machines and the workshop. The ECU
inspector selects labeled Legacy ideal, Crank ignition or Alpha-N EFI presets.
**Show ECU connector in Logic** creates/reveals one grouped connector. Crank
speed/position are required green Number inputs; Alpha-N additionally requires
actual TPS. The connector selects its physical ECU and copied graph-only
connectors detach ownership. Physical assembly copies remap the complete harness.
Blue assignments declare controlled cylinders. Sensor-driven ECUs do not own
throttle opening or read the global pedal; manual or separately commanded
throttles remain available. Each cylinder has an explicit **Pickup TDC deg**
calibration in the crank sensor's coordinate system. Its physical 720-degree
phase is not used as hidden ECU synchronization. Commands and spark events use
the existing simulation-sample boundaries, not UI updates.
An explicit injector attachment can be added to a chamber, bound to an Alpha-N
ECU and configured with capacity, cylinder-dose count, fuel mass/temperature and
finite or ideal regulated supply. A nozzle appears on its host. Total injector
cylinder doses must match assigned cylinders; fuel is added once per physics
sample and then transported by the gas solver. Live inspectors show delivered
flow, remaining fuel, depletion and ECU fault/limiter status. Ignition maps and
RPM limits can be tuned live; strategy, fuel tables, attachments and pickup
calibration require Build.
Version 9 saves ECU strategy/fuel maps, grouped input schemas, pickup calibration
and injector bindings. Versions 1..8 retain their legacy controller behavior.
Deleted/omitted ECU references stay unresolved with labels; IDs remap on assembly
insertion. Existing user files are not rewritten. Ready-to-run examples are
`machines/sensor-ignition-bench.machine.toml` and
`machines/sensor-alpha-n-bench.machine.toml`, both driven at 3000 RPM.
The delivered EFI model is open-loop, continuous/batch-average intake delivery.
It does not claim sequential pulse timing, modeled liquid rail pressure or
evaporation/wall-film dynamics. Those abstractions remain explicitly labeled;
advanced sensor strategies remain in later stages.
The new sensor-ECU acceptance target covers physical sensor -> graph timing,
stale/mixed/arithmetic rejection, delayed acquisition evidence, spark crossings,
RPM/TPS interpolation, fault/recovery and limiter behavior, ECU demand -> gas-host
fuel delivery, finite-supply mass/energy accounting and external spark actuation.
Acceptance also covers actual combustion in both sensor-driven examples,
live loss/recovery of timing, foreign-shaft and constant-number rejection,
depletion in the machine runtime, live ignition tuning without restarting time,
save/load, partial/full assemblies and UI preset/connector/injector controls.
All fourteen focused suites pass after rebuilding affected targets. Workspace
machine migration/round trips pass from the repository root. Rendered review
covers the new graph, ECU and injector screens at 1920x1080 and 1280x720.
Wasted exhaust sparks retain the last trapped-charge AFR reading; valid timing
still samples fresh charge when ignition is cut by the limiter.
The Alpha-N example was opened in Build mode with
`out/build/windows-gcc/fluidsim_desktop_preview.exe`, leaving the original
workshop intact. The final checked binary, including the last charge-instrument
fix, is `out/build/windows-gcc/fluidsim_desktop_ecu_preview.exe`. Open sessions
were not restarted to replace a locked executable; recheck live processes before
linking or launching the next review build.
## Speed-density ECU milestone (stage 4, first slice)
Added a third sensor-driven preset, **Speed-density EFI**. Its grouped connector
requires four Number inputs from three physical sensor assemblies: crank speed,
crank position, manifold **absolute** pressure and intake gas temperature. TPS is
not required in this basic mode. Gauge/differential pressure cannot connect to
MAP; invalid, zero or stale MAP/IAT values cut fuel and new ignition events.
Fuel demand uses a declared dry ideal-gas estimate:
`air_kg_per_cylinder_cycle = MAP * displacement * VE / (R_air * IAT)`, followed
by target AFR and the four-stroke RPM/120 conversion. Displacement, air gas
constant, target AFR and the RPM/MAP volumetric-efficiency table are calibration,
not hidden cylinder measurements. VE interpolates both axes and clamps to table
edges. The gas network still transports the explicitly injected fuel. This
open-loop estimate does not measure fresh-air fraction in diluted intake gas or
provide warm-up, acceleration enrichment, lambda trim or sequential timing.
The preset, required input schema, calibration fields and EFI injector target
picker are integrated into the workshop. **SD** calibration fields display cc,
kPa and VE percent and require Build. Ignition-map/RPM-limit tuning remains live.
Shared strategy names/keys validate the document, graph, runtime and serializer;
unknown strategies fail explicitly. Format 10 saves the new VE/displacement
calibration and connector schema; versions 1..9 still load.
`machines/sensor-speed-density-bench.machine.toml` is a working 3000 RPM example
with physical intake MAP/IAT taps, an explicit injector and actual combustion.
Its calibration is set when constructing the example; runtime fuel estimation
does not query cylinder air mass or geometry.
Validation: fifteen focused suites pass, including ideal-gas charge cases,
pressure/temperature/displacement scaling, two-axis VE interpolation, required
sensor faults, live MAP loss/recovery with combustion, missing IAT host, input
reference types, save/load, assembly remapping and UI preset/connector controls.
Workspace machine migration/round trips pass from the repository root. Rendered
review at 1920x1080 and 1280x720 covers the four-input graph and ECU/injector
inspectors. Pneumatic valves and the pressure-switch supply circuit remain the
next stage-4 delivery; they are not implemented by this ECU milestone.
Checked desktop build: `out/build/windows-gcc/fluidsim_desktop_speed_density_preview.exe`.
Existing workshop windows were left intact. Next session should start with the
physical valve slice below; no valve implementation is currently in progress.
### Next physical component slice
Implement needle, solenoid shutoff, differential relief and non-relieving
pressure-regulator behavior through the existing conservative gas boundaries.
Use explicit inlet/outlet paths and real body/downstream pressure; do not enforce
a setpoint by assigning gas pressure. Needle opening and solenoid coils need
typed signal command ownership; the regulator's pressure demand must declare an
absolute/gauge reference. Relief needs cracking/reseat hysteresis, solenoids need
normally-open/closed rest states and finite travel, and regulator response must
remain bounded when supply falls below demand. Include a pressure-switch preset
and a finite-tank circuit with flow, depletion and conservation acceptance cases.
Integrate picker modes, inspectors, save/load and assemblies before further UI
polishing. Keep this and all later roadmap stages in the active goal.
## Delivery backlog
| Stage | Scope | State |
| --- | --- | --- |
| 0 | Typed ports, validation, stable identities and migration | Complete |
| 1 | Explicit shafts, flywheel, drive motor, brake/load, sensor attachments and shaft instruments | Complete; six basic sensor assemblies implemented |
| 2 | Logic canvas; buttons, switches, constants/sliders; gates, thresholds, arithmetic, mapping, sensors, commands and displays | Basic runtime and editor integrated/verified; advanced graph UX and operator panels remain |
| 3 | Crank sensing, ignition and Alpha-N ECU presets, explicit fuel delivery and ECU connector UI | Basic presets integrated/verified, including physical combustion, saved bindings and workshop controls |
| 4 | MAP/IAT sensing, speed-density ECU, needle/shutoff/regulator/relief valves and pressure-switch control | Speed-density preset integrated/verified; pneumatic valves and pressure-switch circuit remain |
| 5 | Gears, clutch, starter; directional valves, pneumatic cylinders, linear loads/springs/dampers and quick exhaust; latches/timers/memory | Planned |
| 6 | MAF/sequential ECU options; air motors, rotary compressors, filters, nozzles and resonator/silencer assemblies | Planned |
| Later | Belt/pulley drives, heated/cooled chambers, ejectors, turbines/turbochargers and general linkages | Planned |
Each stage needs actual runtime behavior, save/load and assembly support, usable
UI controls and focused acceptance checks. Keep this file current at milestones.
Stage 0 validation: six focused suites pass (machine ports, machine physics and
serialization, builder interactions, menu navigation, native maximization and
desktop UI). Tests cover all current part descriptors, signal types/references,
invalid values/defaults, input direction/cardinality, custom-port reordering,
legacy ECU fan-out, old junction aliases and all saved machines found locally.
Unknown part kinds now fail explicitly instead of inheriting gas ports/storage.
Source whitespace checks pass. Existing user machine files were read and copied
to temporary test locations; they were not migrated in place.

620
docs/MACHINE_BUILDER.md Normal file
View File

@@ -0,0 +1,620 @@
# 3D machine builder
Open **Machine Workshop** from the maximized main menu. Choose **New machine**, or
select a saved machine and choose **Edit** or **Play**. Pressing **B** in the
scenario browser opens a new design directly.
The builder supports general pneumatic circuits as well as engines. The two
example benches provide a four-stroke engine and a piston vacuum pump. The
parts picker uses cached 3D previews of the same meshes that appear in the world.
**Import engine layout** (I) transfers the existing 450 single, parallel twin,
flat six and V8 H-pipe layouts into new editable documents. The importer preserves
pipe routes and diameters, storage dimensions, bore/stroke, firing phases, cam
profiles, valve dynamics, shaft calibration, fuel calibration and ECU settings.
It inserts a visible carburetor or throttle into the final inlet segment and adds
the appropriate ECU with control wires. The metering body and stub replace part
of that inlet segment, preserving its total route length and gas volume.
## Build and configure
The workshop uses a dark editor layout with resizable, collapsible Library and
Inspector panels. Drag their dividers to resize; widths and collapse states are
saved locally. **Reset UI** restores the default panel layout. The **Library**
offers categories, search and part previews; **Objects** lists the current parts.
**Ctrl+F** focuses search. Long control names fit their available width and reveal
their full text on hover. Property labels sit above their editable values.
The inspector tabs are **Part**, **Links**, **Setup**, **Checks**, **Meters** and **Sense**.
The oscilloscope has a separate bottom dock; drag its upper edge to resize it.
The minimum window size is 1280x720, with the layout reviewed at 1920x1080 too.
Entering the workshop maximizes the Windows window; the application no longer
shrinks when moving between the workshop, home menu and engine scenarios.
1. Pick a part on the left and click the world to place it. Placement uses the
horizontal plane set by **Machine → Place Y mm**. Hold Shift for repeated placement.
2. Select a placed part to open its properties. Right click a part for its context
menu. Click the part name to rename it.
3. **G** selects Move. Drag a colored X, Y, or Z handle to translate in world axes,
or drag a selected body to move in the screen plane.
**R** selects Rotate; drag a colored ring to rotate about that world axis.
**X** toggles snapping. Translation uses the configured grid; rotation snaps to
15 degrees. Numeric position and rotation fields also allow precise changes.
4. Click a numeric property, type its value and press Enter. Escape cancels typing.
The minus/plus buttons make incremental changes. Scroll over the inspector to
reach additional properties.
5. Select a part to reveal its ports, then drag between ports or click a port
followed by its target. **C** optionally shows all ports. Neutral markers are
bidirectional gas ports; at least one end of a gas connection must be a pipe.
The pipe endpoint snaps to the other part. Blue pins connect existing ECU
assignment wires; ECU outputs are filled and inputs hollow. The **Links** tab
configures or disconnects connections. Use the Move gizmo to move a small
boundary symbol whose port occupies its center. **Escape** cancels a wire;
**G** returns to Move. Route mode prioritizes editing pipe points.
After choosing the first port, hover the target: a blue preview shows the
snapped pipe route or control wire; a red preview explains why it cannot connect.
Previewing does not change the design. Clicking an invalid target leaves the
first port selected so you can choose another target.
6. **P** selects Route for a selected pipe. Use **Add path point**, select a point,
then drag its X/Y/Z handles. The inspector edits the selected point's local
coordinates. Disconnect an attached endpoint before moving it independently.
Select a numbered point and edit **Point dia mm** to change its bore. Every
point has its own diameter; adjacent values form a linear taper along that
segment. This replaces the former inlet/outlet diameter fields.
**Ctrl-click** near the selected pipe's centerline to insert a bend at that
location, or right click a pipe and choose **Insert bend here**. The new point
starts on the original route, preserving length and gas volume until moved.
Length, the selected point's bore and volume are shown above the viewport while editing.
Alt+left drag or right drag on empty space orbits the view. Middle drag pans;
the wheel zooms; Home fits the design and **F** focuses the selected part or group.
Q selects without a transform tool.
Shift-click adds/removes parts from a selection. Shift-drag on empty space selects
a group; Ctrl+A selects the whole machine. Move and Rotate operate on that group.
Ctrl+C copies the selected parts, their internal air/control connections and
attached probes; Ctrl+V inserts a translated copy. Ctrl+D duplicates immediately.
Connections to parts outside the copied group are not copied. Delete removes the
selection. Ctrl+Z/Ctrl+Y undo/redo. The copied group remains available when loading
another machine during the same workshop session. Right-clicking a selected part
keeps the group selected and opens Copy, Paste, Duplicate and Save as assembly.
Click **Assemblies** to save the selected group for reuse or insert a saved
assembly into the current design. Assemblies are stored in
`machines/assemblies/` as `*.assembly.toml` files and can be copied between
workspaces. Their internal connections, component settings, pipe profiles and
attached probes are preserved. On insertion, part and probe IDs are remapped and
the group is placed near the current selection (or the view center), selected and
focused for positioning. The destination machine's shaft and simulation settings
apply to the inserted components. Shaft gauges remain specific to each machine.
Escape cancels a placement/connection or clears selection; it never leaves the
workshop. Use **Menu** to return to the scenario menu, or close the application
window to exit.
In Move mode, grab an atmosphere symbol directly to select and drag it, or use
its X/Y/Z handles. Its attached pipe endpoint follows the move. Atmosphere
symbols remain selectable at the end of a wide pipe.
## Machine checks
Press **H** or click **Checks** to open the checks panel in Build or Test. Warnings
identify cylinders without a connected fuel source, ECUs without assigned parts,
and fixed throttles that are closed without an assigned ECU. Notes identify
sealed pipe/cylinder ports and remind you to crank a stopped free shaft.
Click a check to select and focus its part. Use the Part or Connections tabs to
configure it. Wheel or Previous/Next browses longer lists. Checks refresh after
edits, undo and redo; they do not change or prevent testing a design. A sealed
port can be intentional. Fuel checks establish wiring/plumbing reachability;
they do not guarantee flow through valves, correct calibration or combustion.
Unresolved sensor checks open the affected attachment in **Sense**.
Ideal junctions support multiple pipes on the same point. Connect free pipe ends
to the junction's center marker. Connecting a third pipe to an occupied
pipe-to-pipe join automatically replaces that join with a shared node. Switching
to stored-volume mode gives branches distinct ports on the visible box; switching
back to an ideal node brings them to its center.
## Physical dimensions
There is one design geometry shared by rendering, picking and simulation:
- **Pipes and reducers:** centerline segment lengths determine the simulated
length. Each route point defines a diameter. Diameter varies linearly between
adjacent points, allowing expansions, necks and reducers within one pipe. The
finite-volume mesh integrates the exact frustum volumes across each cell,
including cells that cross multiple profile segments. This preserves gas
volume across mesh densities. Bends stay at their edited positions.
Visual route points do not force extra simulation cells: the physics mesh follows
total arclength at the selected cell density. A genuinely short pipe remains short
and can require more CFL substeps.
Sharp changes in area also constrain the timestep by the ratio between face
area and the gas volume stored in a cell.
- **Chambers and storage junctions:** the visible internal box dimensions define
capacity as X × Y × Z. Rotation changes the port locations and appearance,
and preserves volume.
- **Cylinders and piston pumps:** bore, stroke and compression ratio define the
gas cavity and clearance. The rendered piston and current simulated volume
use the same displacement. Engine cylinders also expose valve diameters/counts,
cam timing/lift, valve mass, spring, damping, preload, AFR and spark advance.
- **Throttle and check valve bodies:** throttles use their visible elliptical
tube dimensions for gas storage; check valves use their box dimensions.
Diameter and discharge coefficient define the restriction. Throttles
can use the test pedal or a fixed opening. Check valves pass flow from their
local -X inlet to +X outlet above the configured cracking pressure.
- **Ideal junctions:** characteristic and legacy modes have zero stored volume
and are drawn as node symbols. The storage mode is drawn as a physical box.
- **Carburetor:** the elliptical throat stores gas according to its length and
Y/Z diameters. The restriction diameter controls flow. Pilot/main/needle jets
meter liquid fuel from the float bowl into that gas; the solver transports it
through pipes into the cylinders. Jets, needle corrections, fuel density, bowl
capacity/refill, fuel film and discharge settings are editable.
- **ECU:** a controller housing with no gas storage. Blue wires assign injection
and ignition to cylinders, and throttle control to a throttle or carburetor.
One ECU can control multiple parts. An unconnected ECU controls none.
- **Pressure/atmosphere and end-cap symbols:** these represent boundary conditions
and have no storage volume. A pressure boundary is an infinite reservoir with
the configured pressure and temperature; an end cap reflects flow.
Moving, rotating or resizing a connected component updates the attached pipe
endpoints. Its pipes therefore change length when their routes change. There is
no pressure-driven visual expansion in this editor.
The flow equations remain one dimensional in pipes and lumped in chambers.
The 3D layout supplies physical sizes and junction directions; this is not a 3D
CFD mesh. Valve restrictions and zero-volume fittings use the existing boundary
models. Ideal pressure-actuated check valves have no moving reed mechanics.
## Test and return to editing
Press **Tab** or **Test** to compile the design and run it with audio and pressure
colors. Select a part to read its current pressure and volume. W/S or the pedal
bar changes throttle; hold V for starter torque on a free shaft. Space pauses
or resumes both physics and audio. Test offers **1x, 0.5x, 0.25x, 0.1x and 0.01x**
playback; Page Down halves the speed and Page Up doubles it (1% to 100%).
**Step 10 ms** advances a paused machine by exactly 0.01 simulated seconds.
The physics timestep and engine calibration stay the same at every playback speed;
audio pitch slows with playback. Buffered audio scales with speed to keep controls
responsive. The four existing pipe solvers and five audio
environments are selectable under **Machine** before starting a test.
New engine benches use a free shaft at zero initial RPM. Hold **Hold to start**
or V to crank. A cylinder only receives fuel from an explicitly assigned injection
ECU or from fuel physically delivered through a connected carburetor. There is no
implicit cylinder injector. A carburetor can run with mechanical ignition; an
optional ignition/idle ECU can control spark and opening.
For EFI, connect the ECU's blue output to each cylinder's blue input. Connect
that output to the throttle's blue input for idle/throttle control. ECU settings
include idle target/opening limits, PI gains, ignition-map points, cruise/power
AFR, throttle response, overrun cuts, rev limit, resume RPM and minimum cut time.
Fuel and spark cuts are independent. A cut lasts at least its configured time and
resumes only below the resume RPM. Carburetor ECUs use **Ignition / idle only**
so they do not add an invisible second fuel source.
The **Mech** category contains **Shaft / crankshaft**, **Flywheel**, **Drive motor**
and **Brake / load**. Connect their gold mechanical ports. Cylinders and piston
pumps also have a gold shaft port; each must attach to a named shaft before Test.
Moving a shaft changes its attachment lines without moving its cylinders or pipes.
Each shaft has independent angle, initial speed, base inertia and friction.
Flywheels add inertia once; the shaft inspector shows the connected total.
Motors can apply signed torque or impose an ideal target speed. The ideal speed
drive reports the work it supplies or absorbs, including speed changes. A brake
applies opposing torque and drag, dissipates work and cannot drive a stopped shaft
backward. Pure mechanical machines can run without any gas pipes.
Use **Setup** to choose the primary readout shaft, or pin it from a shaft's
inspector. Each ECU's controlled cylinders must share a shaft. Engine timing is
currently forward-only; standalone mechanical shafts support reverse rotation.
The legacy starter calibration remains on migrated shafts until explicit starter
parts are added. New shafts have no hidden starter torque by default.
The saved **Shaft, flywheel and brake bench** demonstrates acceleration, motor
power and absorbed load power. Its file is `machines/shaft-drive-bench.machine.toml`.
The overlay distinguishes **SPEED CONTROLLED**, **TORQUE DRIVEN**, **FREE SHAFT**, **FUEL OR SPARK CUT**,
**VALVE FLOAT**, and **NO FUEL SOURCE**. The model has no fixed 16,000 RPM cap.
With limiting disabled, controlled checks ran at 16,000, 20,000 and 24,000 RPM
without positivity repairs. The unloaded bench settled around 17,500 RPM as net
torque fell toward zero. This depends on geometry, valve dynamics, fuel/spark
calibration and losses; increasing a rev limit does not guarantee engine torque
at that speed.
Unconnected gas ports on pipes and cylinders are sealed; the status line reports
their count. Unused tank faces are simply walls. Unconnected mechanical attachments
receive a navigable check and prevent Test.
Invalid dimensions, duplicate connections and physically separated connected
ports are rejected. Numerical failures stop the test and report an error.
Press **Tab** again to stop playback and return to the same design and camera.
Starting another test initializes a fresh gas and mechanical state.
## Live tuning
Properties are editable during Test unless marked **BUILD** and dimmed. Live changes
are validated and synchronized between physics batches, preserving gas state,
shaft state, simulation time, carburetor fuel film and controller history. Carb
jets, needle corrections, fuel settings, ECU calibration, fixed throttle opening,
check-valve cracking pressure, cylinder combustion settings, shaft losses,
motor torque/target speed and brake loads can be adjusted live. Live edits support undo/redo and saving,
and remain in the design when returning to Build.
Pipe routes/diameters, transforms, part dimensions, bore/stroke, cam profiles and
valve mechanics, connections, initial gas conditions, solver/mesh settings and
compiled boundary coefficients, initial shaft states and shaft/flywheel inertia
require Build. A live undo that would change one
of these properties is rejected without changing the running machine.
## Instruments and traces
Press **M** or **Meters**. Select a part, then choose **Pressure**, **Temp**, **Flow**,
**AFR**, **Lambda** or **Charge AFR**. **RPM**, **Torque** and **Power** target the
selected shaft or attached rotational part; without a compatible selection they
use the declared primary shaft. Up to eight channels can be attached.
Probes add no gas volume or restriction. Colored P1/P2 markers locate probes in
the world; clicking one in Select or Test opens its instrument settings.
Use **< Probe / Probe >** to choose the channel plotted on the scope.
Pipe taps use a fraction of arclength from Start to End, adjustable in the panel.
Measurements use the cell containing the tap, so spatial detail follows mesh
resolution. Lumped-part flow is net flow into the part; pipe flow is signed
Start-to-End. Pressure is absolute kPa, temperature K, flow g/s, torque Nm and
power kW. Motor gauges report drive torque/work, brakes report signed reaction
torque and positive absorbed power, and new shaft gauges report net acceleration
torque and kinetic power. Migrated legacy output gauges retain their running
averages of engine output after shaft friction, excluding bench drive and load;
the inspector identifies that measurement mode.
**Bulk AFR and lambda** track fuel origin as a conserved passive tracer, including
fuel already burned. Exhaust readings therefore travel with the gas through
pipes, chambers and shared junctions and mix according to transported mass.
Put a downpipe probe after a collector to read the combined exhaust there.
The exhaust starts as clean air, so downstream readings can remain very lean
until the original air has been purged by exhaust gas.
Lambda is bulk AFR divided by the probe's stoichiometric reference (default 14.7).
Changing the reference changes the displayed ratio, not the combustion chemistry.
**Charge AFR**, available on cylinders, reads current unburned fresh-air mass
divided by remaining unburned fuel mass. It becomes undefined after that fuel is
used. No fuel is shown as **--**, rather than an invented stoichiometric reading.
These are ideal model instruments; sensor heating, response delay and detailed
oxygen chemistry are not simulated.
The scope samples at 2,756.25 Hz in simulation time and retains the latest 32,768
samples (about 11.9 simulated seconds). Choose a 0.05, 0.2, 1 or 5 second display
window; the graph shows min/max/mean and preserves peaks when compressing samples
onto screen pixels. Frequencies above half the sampling rate cannot be resolved.
The scope stays open while switching to Part settings, allowing live carb/ECU
tuning while watching a trace. X closes the scope. Space freezes the machine for
inspection. The last capture remains available after returning to Build.
**Export captured CSV** writes simulation timestamps and labeled channel units to
`verification/measurements/`; undefined readings are empty cells. **Clear captured
trace** starts a new capture without resetting the machine. Adding/removing a
channel or changing a tap/reference clears the capture to keep its schema
consistent. Instruments can be configured during Test without reloading physics.
## Saving
Ctrl+S saves the design. A new design asks for a file name; Ctrl+Shift+S saves a
separate copy. Files use the versioned `*.machine.toml` format in `machines/`
relative to the working directory, or `FLUIDSIM_MACHINE_DIR` when set. Coordinates
and dimensional values are stored in SI units; inspector fields show their units.
Open/Saved files refreshes the library and provides separate Edit and Play
buttons. The scenario browser discovers saved machines each time it opens.
The document is validated before saving, and the written temporary file is read
back through the same loader before replacement. Unsaved changes prompt before
opening a different design, returning to the menu or closing the window.
Version 2 adds explicit fuel/control parts, control wires, imported port locations,
cam profiles and shaft losses. Version 1 files still load, but cylinders no longer
receive automatic fuel; add a connected carburetor or injection ECU to an older
engine design. The importer currently supports four-stroke layouts with uniform
ducts. Two-stroke crankcase/scavenge assemblies and profiled silencers are not yet
imported. Existing engine TOML files retain their separate schema.
Version 3 stores `point_diameters_m`, one diameter in metres per `points_m` entry,
for each pipe. Version 1 and 2 pipes load with their former linear taper sampled
at each point's arclength, preserving the original shape and volume. Saving writes
version 3. Inserted points inherit the interpolated diameter; moving a point keeps
its assigned diameter. Undo/redo and duplicated assemblies retain the full profile.
Version 4 also saves instrument channels, tap positions and lambda references.
Versions 1 through 3 still load. Recorded samples are exported separately as CSV.
Version 5 saves connection ports and methods by stable names instead of enum
indices. Custom chamber ports retain paired coordinate/key lists. Versions 1
through 4 migrate on load, including legacy aliases of a shared ideal junction.
Saving writes version 5; existing files are not rewritten until explicitly saved.
Port descriptors now identify gas, rotational, translational, Boolean, Number
and legacy ECU-assignment domains. Only implemented parts appear in the picker;
declaring a domain does not create its future components.
Version 6 adds explicit shafts and mechanical configurations. Loading versions 1
through 5 creates a visible shaft and any required speed drive/load, preserves
cylinder phase offsets and retargets legacy shaft instruments. Saving writes
version 6 with named mechanical connections; no existing file is changed merely
by loading it. Copy/paste and assemblies retain internal mechanical attachments
and instrument targets; external attachments remain unresolved.
Version 7 adds sensor attachments with stable IDs and named kinds, host/reference
bindings, normalized pipe taps and crank offsets. Part/sensor ID counters survive
save/load so deleted identities are not reused. Versions 1 through 6 still load.
Version 8 also saves a separate typed logic graph with stable node/pin keys,
parameters, references and independent canvas positions. Saving writes version 8;
loading alone does not rewrite an existing file.
## Sensor attachments
Open `machines/sensor-bench.machine.toml` for a ready-to-run demonstration of all
six sensor assemblies, a filling tank, fixed valve and independently driven shaft.
Select a physical part and open **Sense**. Only supported sensor buttons are
enabled: pressure/MAP and gas temperature/IAT on gas storage, pipes and environment
boundaries; differential pressure on two such taps; signed total mass flow on a
pipe; crank speed/position on an explicit shaft; TPS on a throttle or carburetor.
Right-click a pipe and choose **Add sensor here...** to use the clicked arclength
location. Otherwise a new pipe sensor starts at 50%. Move its tap in Sense.
For differential pressure, add the positive tap first. Select the reference
physical part, return to Sense, switch to **Reference (-) tap** and choose
**Bind reference to selected part**. The output is positive pressure minus
reference pressure, in pascals. Absolute pressure outputs use pascals absolute;
temperature uses kelvin; total mass flow uses kg/s, positive from pipe Start to
End. A volume has no unique through-flow direction, so flow sensors require a pipe.
The ideal crank pickup supplies signed angular speed in rad/s, crank angle in
0..2pi radians and a Boolean synchronized output. The pickup's configurable
reference offset applies to its angle, while the runtime retains the shaft ID
and unwrapped crossings. Zero speed while stopped is valid. This is an ideal
pickup, without tooth acquisition or cam-phase inference. TPS reports mechanism
opening fraction, including the existing ECU throttle response, rather than
substituting pedal demand for a fixed opening.
Sensors sample once per simulation audio sample and display sample time and
validity during Test. Number outputs have filled green dots; Boolean outputs have
filled red dots. Attached sensors introduce no gas storage or flow resistance,
and do not use any of the eight recorded meter channels. Use their Logic
references to wire sensor-driven ECUs; the labeled legacy ECU retains its
compatibility behavior.
Click an S-numbered marker to inspect its sensor. Previous/Next browses all
attachments, including unresolved ones. To rebind, select a compatible physical
part, reopen Sense and choose **Bind host to selected part**. Deleting a host
keeps the sensor and its former host label; missing/incompatible bindings show
invalid outputs rather than ordinary zero readings. Copy/paste and assemblies
remap internal sensor hosts; omitted external references remain unresolved.
Creation, binding, pickup offsets and removal require Build and support undo/redo.
These sensors measure gas temperature and total mass flow. Fresh-air MAF, cam
phase, lambda and material/head temperature sensors follow their own roadmap
stages and physical models.
## Logic editor and runtime
`machines/logic-compressor-bench.machine.toml` demonstrates an automatic air
supply: tank pressure feeds below-threshold hysteresis (120/150 kPa absolute),
an AND gate combines demand with a master switch, and a command node owns the
compressor motor's enable field. A numeric control supplies angular speed. The
saved defaults run the demonstration from Test. Choose **Logic** or **Split**
in the workspace toolbar to inspect its graph beside the physical machine.
The Logic library offers node categories, search and an Objects list. Pick a node,
then click the canvas to place it. Drag node headers to arrange the graph; Shift
and a selection rectangle select groups. Middle drag pans, wheel zooms about the
pointer, Home fits the active view, and F focuses its selection. Switching views
keeps the camera, canvas navigation, selection and running simulation. In Split,
click the desired pane to choose its navigation/inspector context.
Drag between compatible pins, or click a pin and then its target. Inputs are
hollow rings; outputs are filled circles. Boolean is red and Number green. One
output can fan out. Starting from a connected input rewires it atomically;
rejection leaves the original connection intact. Right-click a pin to disconnect
its wires. Escape cancels a pending connection. The library filters compatible
node kinds while a pin is pending, and a newly placed numeric node adopts that
pin's quantity/reference. Other numeric schemas are configured in the inspector.
Select a node to edit its name, parameters, quantity and pressure reference.
Numbers use the units shown on their fields/pins (SI internally). Enter applies
a text edit; Escape cancels it. Invalid text remains editable. Hover pins for
type, direction, units, validity, supported ranges and required/default metadata.
Combinational-cycle candidates highlight their cycle nodes and report the path.
H opens diagnostics; clicking a signal warning reveals the affected node.
Create sensors in 3D **Sense**, then choose **Show Logic** to reveal their reference.
The **Command** button on supported physical parts creates/reveals a command
reference. Its inspector chooses a command function and compatible physical
target. Selecting a sensor/command node selects that physical host; **Focus in
3D** opens Split and focuses the host. Reference nodes do not add gas storage or
duplicate a physical sensor/actuator.
During Test, node topology, schemas, bindings, control ranges and initial memory
require Build. Safe constants, thresholds and canvas layout remain editable.
Click switch controls, hold momentary buttons, drag sliders, or type into numeric
controls on their nodes. Paused control changes queue for the next physics sample;
their control widgets identify queued values while pin values stay frozen. Momentary
buttons release on focus loss, leaving Logic, or stopping Test. Manual Test inputs
do not silently overwrite their configured initial states. Readouts show compact
UI-sampled trends; recorded meter channels and CSV export remain separate.
Ctrl+C/V copies nodes and internal wires; Ctrl+D duplicates, Delete removes, and
Ctrl+Z/Y undo/redo. Same-document sensor copies reference the same sensor entity.
Copies pasted into another document detach external sensor references and keep
their labels. Pasted command nodes require an explicit new target so they cannot
take over an existing actuator field accidentally. Copying physical assemblies
remaps their internal sensors, command targets and connected control harnesses.
The graph runtime supports typed controls, Boolean gates, comparisons, arithmetic,
mapping, selectors, sensors, commands, displays and explicit one-tick delays.
Each audio sample evaluates the graph before physics and holds commands during
fluid substeps. Unknown types/ports, incompatible quantities, multiple drivers
and combinational cycles are rejected. Required unconnected inputs block Test.
Missing measurements and math faults carry invalid status; motor/valve commands
apply their declared inactive fallback rather than retaining a previous command.
The graph is saved in `[logic]` tables, including independent canvas positions.
Physical inspector controls identify signal-owned fields; existing manual or ECU
controls cannot simultaneously own them. Existing engine ECUs retain their
labeled legacy compatibility model; new sensor-driven presets use explicit
measurements as described below.
## Sensor-driven ignition and Alpha-N EFI
Open `sensor-ignition-bench.machine.toml` or `sensor-alpha-n-bench.machine.toml`
from saved machines for a working 3000 RPM test bench. The first uses a physical
carburetor; the second transports fuel from an attached intake injector. Both
have crank-to-ECU wiring visible in **Logic**. Press Test to run, then inspect
inputs and physical parts in Logic/3D or Split.
Select an ECU and cycle its preset between **Legacy ideal**, **Crank ignition**
and **Alpha-N EFI**. Sensor presets assign only cylinders through blue links;
choose **Show ECU connector in Logic** and connect crank speed and angle from
one physical pickup. Alpha-N also requires a throttle-position sensor. No driver
demand wire is needed for a manually adjusted throttle. The cylinder's **Pickup
TDC deg** is its calibrated TDC in the sensor's wrapped crank coordinates; for a
pickup offset of zero, a cylinder physical phase of 90 degrees has TDC at 270.
Crank-only ignition is wasted spark, without hidden cam/cycle synchronization.
For EFI, select an intake chamber and choose **Attach fuel injector**, then
choose its Alpha-N fuel ECU. Scroll its properties to set injector capacity,
initial fuel mass, fuel temperature, finite/ideal rail supply and cylinder-dose
count. A common-plenum injector can deliver several cylinder doses; their total
must match the ECU's assigned cylinders. The ECU's **Fuel RPM**, **Fuel TPS** and
**Fuel cell** fields edit the calibrated table (RPM rows, TPS columns, displayed
mg per cylinder per four-stroke cycle). This is an open-loop calibration, not a
measurement of trapped air mass.
Required input faults, stale or mixed pickups, and pickup/engine shaft mismatch
cut new spark events and EFI delivery. Constants and arithmetic cannot substitute
for physical crank timing. Previously ignited fuel finishes its physical burn.
The ECU inspector reports sensor faults and rev limiting; injector hosts report
flow and remaining fuel. Ignition maps and RPM limits allow live tuning. Fuel
maps, injector setup, strategy and pickup calibration require Build.
Fuel delivery currently uses a declared regulated rail and continuous/batch-average
vaporized fuel addition into the gas volume. There is no liquid rail-pressure
sensor or sequential pulse-width/evaporation model yet. Format 9 saves the new
bindings and calibrations; older machines continue loading without being rewritten.
## Speed-density EFI
Open `sensor-speed-density-bench.machine.toml` for the minimal MAP/IAT engine
example. Select **Speed-density EFI** in the ECU preset cycle and choose **Show
ECU connector in Logic**. Connect crank speed/position, an absolute-pressure MAP
pickup in the intake manifold and a gas-temperature IAT pickup at the intake.
TPS is not required for this basic preset. Missing or invalid measurements cut
fuel and new ignition events; the controller does not read hidden cylinder charge.
In the ECU properties, **SD displacement cc** is the calibrated swept volume per
cylinder. **SD target AFR**, air gas constant and the VE table determine the
open-loop estimate `MAP * displacement * VE / (R_air * IAT)`. **SD VE RPM** gives
the rows and **SD VE MAP kPa** the columns; **SD VE cell %** edits row-major table
values. Values outside the table axes use its edge calibration. These settings
require Build; the ignition map and rev limiter still permit live tuning.
Injector attachments can target Alpha-N or speed-density ECUs. Their explicit
capacity and finite/ideal rail supply continue to limit actual delivered fuel.
The dry ideal-gas estimate does not measure fresh-air fraction under intake-gas
dilution and currently has no warm-up, acceleration or lambda corrections. Format
10 saves this strategy and its calibration while preserving loading of versions
1..9.
## Architecture and further work
The proposed expansion is documented in the
[machine systems roadmap](MECHANICAL_BUILDER_PLAN.md): typed ports and logic,
explicit shafts, pneumatic parts, sensor attachments and sensor-driven ECU
presets, with one integrated delivery order. See the linked implementation
progress for completed parts and the remaining planned features.
`MachineDocument` stores parts with stable IDs, local geometry and explicit port
connections. `MachineSimulation` compiles that document into the existing
`Pipe1D`, `Volume0D`, `FourStrokeCylinder`, `BoundarySystem`, `Solver` and
`MechanicalSystem` components.
Shaft, ECU, cam, combustion and fuel-metering updates run once per audio sample,
as in the original engine scenarios. Gas transport and pressure-operated check
valves retain CFL substeps. Compiled part/gate lists avoid repeated graph scans.
The editor consumes snapshots while testing and owns its document separately.
Pipe meshes are cached until their local route or diameters change; transforms
and pressure colors reuse the mesh. Part icons share a cached texture atlas.
The editor includes fifteen part types, gas and mechanical example circuits, four preset imports,
save/load, group transforms, pipe routing, shared nodes, undo/redo, selection
copy/paste, reusable assemblies, and Build/Test. Remaining workshop work is
focused on port-picking and control-wire visibility in dense assemblies, clearer
group-edit feedback, property help, and adjustable port placement. New machine
types, gears and clutches remain on the component roadmap; explicit shafts are implemented.
### Next session
Continue the active component goal: explicit shafts, flywheels, drives, brakes
and six basic sensor assemblies are implemented and verified. The typed logic
runtime, persistence, physical commands and basic Logic/split editor are integrated
with thirteen focused suites passing at the editor milestone. Sensor-driven
ignition/Alpha-N presets, grouped ECU inputs, pickup calibration and explicit
injector attachments are now integrated, saved and tested with actual combustion
in two new benches. Speed-density EFI now adds a third working bench with actual
MAP/IAT inputs, calibrated VE/displacement, saved bindings and workshop controls.
Fifteen focused suites pass. Continue stage 4 with physical
needle/shutoff/regulator/relief valves and pressure-switch control; see the next
physical component slice in the progress file. Keep advanced graph UX/operator panels on the UI
backlog. See [component progress](COMPONENT_IMPLEMENTATION.md). Preserve the
existing uncommitted working tree and user-edited machine files.
### Historical session notes
The user asked to stop polishing and launch the current build on 2026-10-30.
The updated desktop executable was built and launched with
`machines/single-450-imported.machine.toml`. Changes are saved locally and remain
uncommitted. Preserve the existing working tree.
Continue with small usability refinements, particularly port picking and control
wire visibility in dense assemblies, group-edit feedback, and property help.
The 2026-10-31 usability pass added validated connection previews, click-position
bend insertion, focus selection and navigable machine checks. It also corrected
the OpenGL/SFML state handoff so controls remain visible after raw 3D rendering.
If the reported 16,000 RPM stop persists, reproduce it with the user's exact
machine/settings: controlled tests passed through 24,000 RPM and the free bench
settled around 17,500 RPM, so a universal 16,000 RPM cap was not reproduced.
Legacy version 1 engine files need an explicit carburetor or injection ECU now.
Workshop performance was fixed on 2026-10-30. Arclength meshing, sample-rate
control updates, compiled gate lists and cached rendering reduced flat6 simulation
cost from 104.4 to 8.8 us/sample at 3,000 RPM and 35% pedal. It now uses 98 cells
and approximately one CFL substep/sample, matching the native layout's mesh.
The importer positions its metering body to allow a longer outlet stub while
preserving the inlet endpoints, total route length and gas volume. Untouched stock
imports were refreshed; custom saved designs retain their authored geometry.
Flat6 and V8 audio-driven playback kept real time with no measured underruns.
See the [performance report](../verification/performance/2026-10-30/workshop-report.md)
for timings, validation and reproducible commands. Further work can focus on the
usability items above and closer calibration parity with native engine scenarios;
the workshop still represents its metering hardware as explicit editable parts.
## Checks
`fluidsim_machine_tests` covers centerline/profile volumes, rotated tanks,
connection snapping, malformed graphs, file round trips, closed/open throttle
behavior, shared nodes, group transforms, explicit fuel sources, ECU cut duration,
carburetor-only combustion, preset imports, vacuum pumping and an EFI bench.
`fluidsim_machine_builder_tests` checks projection and picking, world rotations,
mouse-driven translation, Shift selection, assembly duplication/deletion, undo
and Build/Test transitions. Assembly checks cover connected subset copies,
air/control endpoint remapping, geometry and probe persistence, cross-document
paste, library saving/browsing/insertion, and insertion undo/redo. Escape and
explicit Menu/window exit paths are checked separately.
Its `--preview <directory>` option writes editor screenshots for layout review.
It also checks pipe-cache invalidation, transform/pressure cache reuse, deleted
parts and the icon atlas. Physics checks cover route-knot-independent meshing,
real short-pipe CFL limits and pump sample-rate refinement.
Additional checks cover non-mutating connection previews, incompatible air/control
pins, occupied ports and collapsed pipes; shared fuel-source diagnosis; perspective
bend insertion with undo/redo; and check-to-part navigation. Preview mode also
verifies rendered UI pixels after both scene and icon rendering.
Instrument checks cover probe persistence, exhaust AFR/lambda after combustion,
conserved fuel-origin transport through solvers and junctions, pause/step/slow
playback, live tuning without a reload, locked geometry, live undo/redo and CSV
channel units. The [instrument verification report](../verification/performance/2026-10-31/workshop-instruments.md)
records the playback checks with eight channels active.
`fluidsim_machine_tests --rpm-sweep` checks high RPM and a 30-second free-running
acceleration trace with the ECU rev limiter disabled.

View File

@@ -0,0 +1,621 @@
# Machine systems roadmap: mechanics, pneumatics, logic and ECU sensors
Planning draft, 2026-11-01. This describes proposed work, not implemented features.
Implementation has started; see [component progress](COMPONENT_IMPLEMENTATION.md)
for completed foundations and the remaining delivery backlog.
## Goal
Build machines from explicit gas, mechanical and signal connections. Engines,
pumps and actuators exchange physical work; sensors measure named locations;
logic and ECUs control declared targets. The workshop should make those paths
easy to connect, inspect and diagnose.
Originally cylinders and piston pumps used one implicit machine-wide crankshaft.
Explicit shaft, flywheel, drive and brake parts are now implemented, with migrated
legacy layouts and targeted instruments. Six basic sensor assemblies and their
physical bindings/live inspector are also implemented. The basic logic runtime
and 3D/Logic/Split editor, sensor-driven ignition/Alpha-N presets and explicit
intake injector attachments are integrated and verified. Stage 4 now also has a
working speed-density preset using actual MAP/IAT signals and calibrated VE.
Advanced graph UX, the stage-4 pneumatic valves and the later physical systems
remain on the delivery backlog.
## Agreed design rules
- Boolean signals are red; Number signals are green. Directed inputs are hollow
rings and outputs filled circles. Physical ports such as gas/pressure remain
bidirectional and use distinct neutral domain symbols.
- Establish typed ports, stable identities and validation before adding parts
that depend on them. Types must exist in the document and runtime, not only UI.
- Use one machine document across 3D Build, Logic and Test. Canvas references,
sensors and instruments do not create duplicate physical machines.
- Sensor attachments belong at named physical locations. Readouts may fan out
to ECUs, logic and instruments. Controllers use connected measurements and
explicit calibration/defaults rather than hidden access to unrelated state.
- Provide simple starting presets with few mandatory connections; expand their
visible optional inputs as features are enabled. Missing and invalid data
have explicit, different behavior.
- Preserve legacy machines through versioned migrations and labeled compatibility
controllers. Keep their operating behavior reviewable before changing models.
- UI work prioritizes consistent appearance, responsive layout and preventing
text/control overlap at the primary 1920x1080 desktop target. Test starts running
in the existing workspace; panels resize/collapse and retain their widths.
## Integrated delivery order
This is the authoritative implementation order. The track sections below detail
their own parts and acceptance targets; their local numbering is not a separate
release schedule.
| Stage | Deliverable | Completion example |
| --- | --- | --- |
| 0 | Shared port descriptors, quantity/reference types, stable keys, validation and migration scaffolding | Every connection path rejects the same incompatible types; existing machines still load. |
| 1 | Explicit shafts, flywheel, drive/load, basic sensor attachments and targeted instruments | Two independent engines; one shaft driving a loaded piston pump. |
| 2 | Logic canvas, manual controls, gates, thresholds, live values and valve/motor command ownership | Sensor -> threshold -> enable switch -> drive, with red/green signal wiring. |
| 3 | Sensor-driven ignition ECU and Alpha-N EFI, crank sensing and ECU connector UI | Carbureted ignition control and minimal EFI both run from explicit sensors; missing crank stops controlled combustion. |
| 4 | MAP/IAT sensors, speed-density ECU, regulator/relief/shutoff valves and pressure-switch preset | Sensor-driven engine and automatic regulated air supply bench. |
| 5 | Gears, clutch, starter; directional valves, linear actuators and loads; stateful logic | Geared pump, clutched load and timed pneumatic actuator circuits. |
| 6 | MAF/sequential ECU options, air motor, rotary compressor, acoustic assemblies and remaining UI refinement | Stored-air motor and richer sensor/actuator examples. |
| Later | Thermal parts, belt detail, ejector, turbine/turbocharger and general linkages | Add only with a defined physical model and acceptance cases. |
Ship small usable slices within each stage. Stage 3 depends on defining sensor
timing and explicit fuel delivery, not merely drawing extra ECU input pins.
## Proposed parts
| Part | Purpose and editable properties | Delivery |
| --- | --- | --- |
| Shaft / crankshaft | Named rotational node; angle, initial RPM, base inertia and friction. Cylinders and pumps attach with individual phase offsets. | First release |
| Flywheel | Adds inertia to its connected shaft; explicit inertia initially, with geometry-derived inertia as a later option. | First release |
| Drive motor | Ideal speed-controlled bench drive or torque-controlled motor; target speed or torque and direction. Report required drive torque and power in speed mode. | First release |
| Brake / load | Applies a constant opposing torque and optional speed-dependent drag; reports absorbed power. | First release |
| Gear pair | Fixed signed speed ratio between two shafts, with an explicit efficiency/loss model. Tooth counts determine the ratio. | Second release |
| Clutch | Connects two shafts through a torque-limited friction coupling; engagement, torque capacity, slip and dissipated power. | Second release |
| Starter | Torque-limited drive with a one-way coupling and hold-to-start input. Initially uses a specified torque curve. | Second release |
| Belt / pulley drive | Same-direction or crossed drive with pulley ratio; an ideal fixed-ratio model first, slip/tension later. | Later |
| Rotary compressor / pump | Continuous shaft load coupled to gas flow; requires a defined displacement or performance-map model. | Later |
Existing cylinders and piston pumps gain mechanical attachment ports. Their
phase is relative to the attached shaft. A shaft can have multiple attachments;
there is no automatic connection because parts happen to be nearby.
For the initial release, one shaft part represents a rigid rotational node.
Its visible length helps placement; it does not introduce torsional flexibility.
Base shaft inertia and attached flywheel inertia are counted once. Separate
shafts rotate independently until connected by a supported coupling.
## Connection and simulation rules
- Distinguish gas ports, control pins and rotational ports by shape, label and
color. Preview compatibility and explain rejected connections before clicking.
- Mechanical attachment lines identify the shaft association. They do not
silently move cylinders or imply a physically supported bent driveshaft.
- Keep shaft angle/speed per shaft. Route cylinder torque, pump torque, losses,
drives and loads to the correct shaft; sample its RPM for each attached ECU.
- Initially require cylinders controlled by one ECU to share a shaft. Diagnose
mixed-shaft assignments until explicit speed-reference selection is added.
- Ideal gears impose a speed/angle relationship. Solve connected rigid groups
with inertia reflected through their ratios; do not use competing independent
RPM updates or stiff penalty torques to approximate a rigid connection.
- Validate contradictory ratios, incompatible prescribed speeds and unsupported
constraint loops. Start with tree-shaped gear networks and reject loops with
actionable diagnostics until a general constraint solver is available.
- Friction and brake torque oppose motion, and must not push a stopped shaft
into reverse. Drive torque can select direction. Check existing cylinder/cam
assumptions before enabling reverse engine operation.
- Track external work, rotational kinetic energy and coupling losses. Clutch
slip dissipates energy; an ideal prescribed-speed drive supplies or absorbs
whatever work is required and reports it.
- Connection changes and initial state changes require Build. Safe torque,
speed-target and engagement tuning can remain available during Test.
## Workshop UI
The [workshop UI rework](WORKSHOP_UI_PLAN.md) specifies the workspace layout,
interaction conventions and user-selected preferences in more detail.
### First release: connecting and understanding the machine
- Searchable parts picker with Gas, Engine, Mechanical and Control categories.
- Connection filters for Gas, Mechanical and Control; fade unrelated links.
- Larger screen-space port targets, hover labels, endpoint highlighting and
candidate cycling where ports overlap. Preserve existing gas connection rules.
- Mechanical overview listing each shaft, its attached parts and its drive/load.
Selecting a row selects and focuses the corresponding assembly in 3D.
- Shaft inspector groups Initial state, Inertia and losses, and Connections.
Show total connected inertia separately from editable base inertia.
- Cylinder/pump inspector shows the named shaft and phase offset, with an action
to select the shaft. Unattached mechanical parts receive a navigable check.
- RPM, torque and power instruments require a named target. Torque readings
identify what is measured, such as cylinder output, motor input or load torque;
a shaft network has no single universal transmitted-torque value.
- Test overlay shows the selected shaft's RPM and operating state. With several
shafts, let the user pin the main readout instead of silently choosing one.
### Second release: coupled machines and editing feedback
- Gear connection preview shows input/output shafts, ratio and rotation direction.
- Clutch control and instruments show engagement, slip RPM and lost power.
- A compact power-path view highlights the selected shaft's connected network.
- Group inspector shows selection count, pivot and mixed property values;
offer only edits applicable to the selected parts.
- Add concise property help with units, physical meaning and Build/Test status.
- Add named saved visibility groups for dense assemblies. Hiding a part only
affects editing visibility, never simulation membership.
Adjustable gas port placement is a separate follow-up. Ports represent physical
openings, so editing them must update connected geometry and preserve validation;
they should not be freely dragged as purely cosmetic connection markers.
## Save files, imports and assemblies
- Introduce a new machine format version with explicit shaft references and
mechanical connections, using stable IDs.
- Upgrade older files to one visible shaft with their existing inertia, losses,
initial speed, drive and load settings. Create drive/load parts as needed and
attach all existing cylinders and pumps with their original phase offsets.
- Preserve existing starter behavior during migration; replace it with an
explicit starter when the starter model is available.
- Retarget legacy shaft instruments to the migrated shaft. Retain the existing
torque metric's meaning and label it explicitly during the transition.
- Engine imports create explicit mechanical parts. Copy/paste and assemblies
remap internal shaft references and retain external attachments as visibly
unresolved; they must never attach to a destination shaft by coincidence.
## Mechanical track scope and acceptance
Before these implementation stages, deliver the typed port foundation described
in the logic section below. Mechanical and pneumatic additions should use that
shared contract from their first implementation.
1. **Explicit shaft foundation:** document/serialization migration, independent
shaft runtime state, cylinder/pump attachment, rendering and selection.
Acceptance: two disconnected engines can run at different speeds; existing
imported machines retain their operating behavior and phase relationships.
2. **Usable mechanical bench:** flywheel, drive motor and brake/load; connection
filters, port picking, shaft inspector and targeted instruments.
Acceptance: assemble and inspect an engine driving a loaded piston pump;
disconnected attachments and fuel/control problems are easy to locate.
3. **Coupled shafts:** fixed gears, clutch and explicit starter, with ratio and
power-path feedback. Acceptance: accelerate a second inertia through a clutch,
observe slip losses, and drive a pump at a different speed through gears.
4. **Broader machines:** belt drives and a rotary compressor/pump after choosing
and validating their physical models; adjustable gas ports and visibility
groups as workshop follow-ups.
Validation should cover legacy migration, assembly ID remapping, independent
shafts, phase continuity, inertia response, gear direction/ratio and reflected
inertia, prescribed-drive work, clutch losses and stop/reversal behavior.
Use focused simulation and builder checks as each stage is implemented.
The first mechanical slice covers the shaft foundation and usable bench above.
Detailed electrical motors,
batteries, gear contact dynamics, shaft flex and belt tension need separate models
and are outside that milestone.
## Pneumatic and fluid-powered expansion
Extend the current compressible-gas system with useful circuits: store compressed
air, regulate it, switch it, and convert its pressure into linear or shaft work.
Existing chambers already serve as finite tanks, and the piston pump can form a
reciprocating compressor with external check valves. Add useful presets and
assemblies for these before introducing redundant part types.
This roadmap initially targets gas. Liquid hydraulics would need a separately
defined fluid model, including compressibility, cavitation and appropriate
boundary treatment, before the same UI could promise hydraulic behavior.
### Proposed fluid parts
| Part | Behavior and properties | Priority |
| --- | --- | --- |
| Adjustable restriction / needle valve | Two-port metering with editable opening and effective flow area; supports reverse flow and choking through the shared gas boundary model. | A: basic circuits |
| Shutoff / solenoid valve | Two-port on/off valve; normally open/closed state, commanded opening and finite actuation time. | A |
| Pressure relief valve | Opens from a protected node to an explicit outlet above a differential-pressure setting; reseat behavior prevents chatter. | A |
| Pressure regulator | Reduces downstream pressure using a bounded inlet restriction with response dynamics; cannot increase pressure or hold a target without supply capacity. Non-relieving initially. | A |
| Pressure switch | Senses a chosen pressure and emits a Boolean signal with separate on/off thresholds; starts/stops a compressor or operates a valve. | A |
| Directional valve | 3/2 first, then 5/2; named supply, work and exhaust ports, explicit internal paths and transition behavior. | B: actuators |
| Pneumatic actuator | Single-acting spring-return first, then double-acting; bore, rod, stroke, moving mass, friction, dead volumes and end stops. | B |
| Linear load / spring | Opposing force, attached mass, spring and damping for an actuator's translational port. | B |
| Quick-exhaust valve | Vents an actuator locally through its own outlet when the supply path depressurizes. | B |
| Air motor | Positive-displacement gas motor with inlet, outlet and shaft port; displacement, leakage and mechanical losses. | C: shaft power |
| Rotary compressor | Shaft-driven gas compression with displacement/map, leakage and losses; load arises from gas work. | C |
| Nozzle / venturi | Converging/diverging pipe profile preset first; a later ejector adds a suction port and a validated entrainment/mixing model. | C |
| Filter / porous restriction | Calibrated pressure loss and optional gas storage; acoustic attenuation requires an additional defined model. | C |
| Silencer / resonator | Pipe/chamber assemblies first: expansion chamber, side branch and Helmholtz-style resonator. Absorptive silencers need an explicit loss model. | C |
| Cooler / heated chamber | Defined wall temperature or thermal mass and gas-wall heat transfer; report heat flow alongside gas state. | Later |
| Turbine / turbocharger | Map-based turbine and compressor on explicit shaft nodes; requires performance data and operating-range handling. | Later |
Priority letters describe the pneumatic track, not a promise to finish it before
the mechanical milestones. Prioritize A alongside the shaft foundation, B next,
and C once independent shafts and component coupling are established.
### Physical behavior and scope
- Valves transfer conserved mass, species and energy through actual ports.
A regulator must not overwrite a chamber pressure to enforce its setpoint.
- Supply and exhaust are visible connections. An unconnected outlet remains
sealed under the current builder rule; warn when a valve needs an exhaust path.
- Regulator and switch inspectors state whether settings are absolute, gauge or
differential pressure. Store physical pressures in SI and define the reference.
- Actuator force uses the pressures on both piston faces and their effective
areas. Each chamber volume follows piston motion, with positive dead volume;
gas work and mechanical work must balance.
- Add translational mechanical ports for actuators and linear loads. An actuator
is distinct from the existing crank-driven piston pump. A general linkage
mechanism can connect translation to rotation in a later milestone.
- Resolve moving valves, actuator stops and small chambers with appropriate
component timesteps. The existing pipe CFL limit alone is insufficient.
- Air motors and compressors exchange torque/work with the attached shaft.
Their models define starting, stall, reversal and off-design behavior rather
than extrapolating efficiency formulas through zero speed.
- Pressure switches and solenoids use typed signal ports. Add a manual switch
and a simple hysteretic controller; define their update order. Keep ECU
assignments intact while extending the control connection system.
### Fluid UI and instruments
- Add Supply/storage, Valves, Actuators, Rotating machines and Thermal/acoustic
subcategories, plus searchable saved circuit assemblies.
- Label valve ports P (supply), A/B (work), and R/S (exhaust), with tooltips in
plain language. Show connected paths and moving valve state during Test.
- Preview actuator stroke limits and current rod position in 3D. Its inspector
shows chamber pressures, net force, position and velocity.
- Add differential pressure, actuator force/travel/velocity, valve state and
switch output instruments. Flow probes must identify the measured port and
direction; show mass flow by default and label volumetric reference conditions.
- Highlight a selected supply path and its exhaust route; distinguish blocked
flow from disconnected parts. Add checks for missing exhausts, absent supply,
unattached mechanical loads and incompatible signal connections.
- Show regulator target beside actual pressure, tank stored gas state, and
absorbed/delivered shaft power. Keep invalid or out-of-range readings explicit.
### Playable circuit milestones
1. **Air supply bench:** existing piston pump + check valves + tank, pressure
switch, relief valve and regulator. Charge a finite tank, cycle the drive
between pressure thresholds and feed a restricted downstream branch.
2. **Actuator bench:** regulated supply + 3/2 valve + spring-return actuator;
follow with a 5/2 valve and double-acting actuator driving a linear load.
3. **Stored-air motor:** charge the tank, disconnect the compressor drive, and
run an air motor connected to a flywheel and brake until pressure falls.
4. **Acoustic bench:** interchangeable resonator/silencer assemblies with inlet
and outlet pressure traces and source captures. Compare pressure loss and
acoustic response at consistent operating points.
5. **Boost system:** shaft compressor first, then a map-based turbine/compressor
assembly with intake and exhaust connections once those models are ready.
Acceptance checks should include sealed-system mass/energy accounting, choking
and reverse flow, relief opening/reseating, regulator response and insufficient
supply, switch hysteresis, valve port states, actuator force/work and end stops,
and motor/compressor gas-work-to-shaft-work balance. These belong to the future
implementation stages; this document adds planning only.
## Typed signals and logic nodes
### Confirmed current implementation
The shared typed-port foundation, physical sensor attachments and a general
signal-graph runtime are now implemented. `MachineDocument::logic` stores nodes
and named typed wires separately from physical connections. Runtime nodes include
controls, gates, thresholds, numeric operations, sensor/command references,
readouts and explicit delays. See [component progress](COMPONENT_IMPLEMENTATION.md)
for the verified scope. The basic Logic canvas, shared 3D/Logic/split workspace,
typed wire editing, interactive controls and graph inspectors are now integrated.
Advanced graph UX, operator panels and the later component stages remain planned.
`MachineConnectionKind::Control` remains legacy ECU assignment. Those blue wires
assign a controller; the new Boolean/Number graph does not reinterpret them.
### Shared port contract: implement first
- Give each port an explicit domain: Gas, Rotational, Translational, Boolean,
Number, or the existing ECU assignment domain during migration. Domain and
signal direction must be data, not inferred from a part kind or rendered color.
- Define ports through a shared descriptor with a stable key, display name,
domain, input/output direction for signals, cardinality, and typed defaults.
Number ports also declare quantity/unit, supported range and any required
pressure reference. Physical ports retain their domain-specific behavior.
- Use stable serialized port keys, versioned migration for old numeric indices,
and stable part/node kind keys. Adding a port or reordering an enum must not
silently reconnect an existing saved machine to a different function.
- Store signal values as Boolean or finite numeric values with explicit validity.
Do not encode Booleans as arbitrary floating-point values or use NaN as truth.
- Only connect matching signal types from output to input. One output may feed
multiple inputs; one input accepts one driver. No automatic Bool/Number cast.
Explicit conversion nodes handle thresholding and Boolean-to-number selection.
- For numbers, also validate quantity compatibility. Pressure, RPM and opening
fraction must not connect interchangeably because all are numeric. Store SI
values; present convenient units, and require explicit scaling/mapping where
a quantity changes. Enforce arithmetic quantity rules and configure constants
with a quantity. Infer polymorphic node ports consistently when compiling.
- Required unwired inputs produce a navigable check and block Test; optional
inputs show a declared default in the UI. Invalid sensor/math output remains
visibly invalid and follows a documented per-consumer fallback; actuator
command inputs default to a declared inactive state, not a previous command.
- Use the same descriptors and compatibility checks for connection previews,
actual edits, loading, validation, compilation and runtime binding. Keep
signal links out of gas connectivity walks, pipe snapping and fuel searches.
- Preserve existing ECU assignments as their own relation; migrate them through
explicit adapters or dedicated ports when individual ECU commands are exposed.
Never reinterpret old ECU links as Boolean or Number links.
### Color and node editing
- **Boolean ports and wires: red. Number ports and wires: green.** These hues
identify the type consistently in 3D, node view, picker, inspector and legend.
- **Inputs are hollow rings; outputs are filled circles.** Apply this convention
consistently to directed ports on every node and part in both 3D and Logic
views. Shape communicates direction; red/green communicates Boolean/Number
type. Labels and hover text identify both type and direction without color.
- Physical ports without an input/output direction, such as pressure/gas
connections, remain bidirectional and use a distinct neutral physical connector
symbol. Their appearance must not imply one-way flow. A pressure sensor's gas
connection is bidirectional; its numeric measurement output is a filled green
circle. Rotational and translational attachments likewise use their physical
domain symbol rather than suggesting a fixed power-flow direction.
- Bright/dim styling and true/false text show Boolean state while retaining the
red type color and input-ring/output-circle silhouette. Number hover labels
show value, units and validity.
- Replace green as the gas identity with neutral gas markers. Connection validity
uses a separate checkmark or cross/message so success/error feedback cannot be
mistaken for a signal type or input/output direction.
- Add a **Logic** view: a pannable, zoomable node canvas for the same machine.
Logic nodes have saved canvas positions; sensors and actuators appear as
references to their existing physical parts, not duplicated simulated parts.
- Inputs appear on the left, outputs on the right. Drag to connect, branch from
outputs, reroute wires, disconnect and undo. Filter compatible nodes when
dragging from a pin. Label incompatible targets with a rejection reason.
- Keep 3D part placement separate from canvas layout. Selecting a sensor or
actuator reference can focus its physical part, and vice versa. Logic nodes
need no gas storage or physical 3D housing to function.
- Add live pin values, Boolean state and compact number traces during Test.
Pause freezes logic with physics; stepping advances both. A control panel can
pin buttons, switches, sliders and readouts for use without opening the graph.
### Initial node library
| Group | Nodes | Defined behavior |
| --- | --- | --- |
| Manual Boolean controls | Toggle switch, momentary button, Boolean constant | Switch retains its state; button is true while held. Release on focus loss, view close or Test stop. |
| Manual numeric controls | Number constant, slider, numeric input | Quantity, unit, range and initial value are explicit; user edits arrive at simulation boundaries. |
| Boolean gates | AND, OR, NOT, XOR, NAND, NOR | AND/OR start with two inputs and can add inputs; named, deterministic truth tables. |
| Threshold and comparison | Greater than, less than, equal within tolerance, range/window, hysteresis threshold | Number inputs produce Boolean outputs; comparisons use compatible quantities. Hysteresis has separate on/off thresholds and an initial state. |
| Numeric arithmetic | Add, subtract, multiply, divide, min, max, absolute value, clamp | Compatible quantity rules; division by zero produces an invalid value and a diagnostic. |
| Mapping and selection | Gain/offset, map range, Boolean-controlled number selector, Bool-to-number | Selector chooses between two compatible numbers; Bool-to-number emits configured false/true values. Mapping declares quantities and clamping behavior. |
| State and timing | Set/reset latch, rising/falling edge, on-delay, off-delay, pulse timer | Reset wins if set and reset coincide. Timers use simulation time. Edge nodes initialize from their input without emitting a false startup edge. |
| Number memory | One-tick delay, sample-and-hold | Explicit initial value; held quantities remain typed. Delay separates feedback across ticks. |
| Sensors | Pressure, differential pressure, temperature, mass flow, shaft RPM, actuator position, limit switch | Attach to a physical target/port without changing its gas volume. Supply validity and quantity metadata. |
| Outputs and commands | Valve enable, valve opening, motor enable/target, clutch engagement, starter command | Each target exposes its supported type, units/range and fallback. Explicit manual, signal or existing-controller ownership prevents competing commands. |
| Displays | Boolean lamp, numeric readout, trace | Observe a typed signal without changing its evaluation. |
Starter nodes are switches, buttons, constants/sliders, AND/OR/NOT, thresholds
with hysteresis, number selection, pressure/RPM sensors, command inputs and
displays. Add the rest after the core graph and timing semantics are established.
### Evaluation, feedback and persistence
- Initially evaluate the compiled signal graph once per audio sample, using the
configured sample period (currently typically 1/44100 s). Sample physical
sensors at the boundary, evaluate the graph, latch actuator commands, then
advance physics. Fluid CFL substeps hold those commands. Slower playback does
not change control time relative to simulated time.
- Topologically evaluate combinational nodes. Treat memory/delay outputs as
stored state, then commit their next state simultaneously after evaluation.
Reject combinational cycles with a highlighted cycle path; explicit state
permits feedback. Physical sensor/actuator feedback crosses a physics step.
- Consume manual-input events in order at a simulation boundary. Preserve a
press/release pair between boundaries for one tick; edge outputs last one
tick, and delayed output validity/initialization is defined explicitly.
- Save graph topology, port keys, node parameters, canvas layout and configured
initial states. Each fresh Test resets to those initial states; transient
button presses, timer state and latch state do not silently become defaults.
- Topology edits require Build. Allow validated constants, thresholds and manual
controls during Test at synchronized boundaries. Copy/paste and assemblies
remap internal references and expose unresolved external sensor/target bindings.
### Foundation acceptance and delivery order
1. **Typed ports:** shared descriptors, typed links, stable serialization keys,
validation and legacy migration. Confirm all declared domains are represented
before mechanical and pneumatic nodes depend on them.
2. **Basic logic:** red Boolean and green Number pins/wires, node canvas, manual
controls, gates, thresholds, sensors, commands and live values.
3. **Stateful control:** timers, latch, edge detection, memory and numeric mapping,
followed by reusable control assemblies and pinned control panels.
Required implementation checks: Bool-to-Bool and Number-to-compatible-Number
connections succeed; Bool/Number mismatches, quantity mismatches, reversed
direction and multiple drivers fail consistently in preview, load and compile.
Also verify fan-out, defaults, unknown port keys, save/load and copy/paste,
ECU migration, invalid math, graph ordering, cycle rejection, delayed feedback,
pause/step behavior and momentary-button release. Core runtime checks now cover
types, wiring validation, evaluation, persistence, faults, delayed feedback and
button-event release/reset. Canvas interactions, live inspector edits, focus/view-
close release, shared selection, pause/step and split layouts now have interaction
and rendered checks. Pinned operator panels and the remaining UI refinements still
need their own acceptance work.
First demonstration: pressure sensor -> hysteresis threshold -> AND with a
manual enable switch -> compressor drive enable. Show tank pressure as a green
Number wire and compressor demand as a red Boolean wire. Follow with a button
and timer driving a pneumatic valve, and a slider commanding a proportional
valve through an explicit range mapping.
## Sensor-driven ECUs
### Current behavior and intended boundary
The current `Ecu::step` receives pedal and the implicit shaft RPM directly.
`Ecu::fuel_for_air` doses fuel from cylinder air mass, and cylinder code supplies
the timing context. There are no general wired MAP, TPS, crank or IAT inputs.
Existing AFR/lambda instruments are ideal transported fuel-origin measurements,
not oxygen-sensor or heater models.
Retain a labeled **Legacy ideal ECU** for migrated machines until equivalent
sensor-driven examples are established. New ECU presets use explicit sensor
inputs, controller calibration and declared actuator targets. Do not disguise
direct cylinder-air-mass access as speed-density or MAF fuel estimation.
### ECU family and minimum connections
Use one shared ECU runtime with selectable strategy/capability presets. Different
picker entries can provide the desired ECU types without duplicating validation,
sensor binding and fault handling. Keep calibration separate from measurements.
| ECU preset | Required measurements in its basic mode | Optional features and their added requirements |
| --- | --- | --- |
| Ignition / carburetor | Crank speed and crank position | MAP or TPS for load-based spark; TPS/driver demand and a commanded throttle for electronic idle. Fuel remains physically supplied by the carburetor. |
| Alpha-N EFI | Crank speed/position and throttle position (TPS) | IAT, engine temperature and barometric corrections; lambda trim; cam phase for sequential operation. Basic mode uses a calibrated RPM/TPS fuel table. |
| Speed-density EFI | Crank speed/position, manifold absolute pressure (MAP), intake air temperature (IAT) | TPS for acceleration enrichment/overrun logic, temperature warm-up correction, barometric correction, lambda trim and cam phase. Requires displacement and volumetric-efficiency calibration. |
| MAF EFI | Crank speed/position and fresh-air mass flow | TPS, MAP, temperature corrections, lambda trim and cam phase. Requires a stated manifold-filling/charge-allocation model; inlet flow is not automatically trapped cylinder charge. |
| Advanced / custom | Required set of the explicitly selected base strategy | Configure supported feature modules; each declares added sensors, targets and fallback behavior. Initially no arbitrary scripting. |
All presets also need declared controlled cylinders/ignition targets; EFI needs
explicit enabled fuel-delivery targets. These are configuration/actuator bindings,
not extra sensor inputs. Engine geometry, firing order, trigger offset, fuel
properties and maps are calibration, not wires.
Basic crank-only modes use supported batch injection and wasted-spark or
distributor timing. Four-stroke sequential injection/individual-cycle ignition
requires cam phase or another explicit cycle-synchronization source. Do not
silently read the simulated cylinder's 720-degree phase to remove that requirement.
Temperature, lambda, TPS and other optional inputs are not universally mandatory.
Features activate their requirements: no TPS means no TPS-based enrichment;
no lambda means open-loop fueling. Fixed reference values are editable and labeled
where a preset supports them. Required base-strategy inputs cannot silently fall
back to hidden physics values. A manual throttle can be used without driver-demand
or throttle-actuation wires; electronic throttle adds its own demand/feedback needs.
The strategy split follows the Speeduino manual's MAP/speed-density and
TPS/Alpha-N distinction. MegaSquirt documentation provides trigger/cam examples
for batch/wasted-spark versus sequential operation. The exact minimum sets above
are proposed simulation presets, not a claim about a particular hardware ECU.
### Sensor placement and representation
| Sensor | Attachment / physical representation | Outputs and scope |
| --- | --- | --- |
| Crank position/speed | Attach to a named shaft, with visible pickup marker and reference offset | Number speed and crank angle; explicit synchronized/valid status. One sensor assembly supplies both. |
| Cam phase | Attach to an explicit cam drive when available; initially a configured phase pickup associated with the engine shaft | Number cycle phase and Boolean synchronized status. The initial pickup is an ideal cycle-reference abstraction, not simulated cam gearing. |
| MAP / pressure | Tap a pipe at normalized arclength or attach to a chamber/plenum | Number absolute pressure. Select the manifold downstream of the throttle; support per-runner or configured combined readings for individual throttles. |
| Differential pressure | Reference two named gas taps | Number pressure difference with labeled positive/reference sides. |
| IAT / gas temperature | Pipe tap or chamber gas attachment | Number temperature; ideal gas reading first, optional lag later. |
| TPS | Attach to throttle/carburetor mechanism | Number actual opening fraction, distinct from commanded opening or pedal demand. |
| Driver demand / pedal | Standalone manual/control part | Number demand fraction; optional for manual-throttle engines. |
| MAF | Pipe tap for an ideal mass-flow sensor; later an inline body if its restriction/storage is modeled | Number signed fresh-air mass flow, with declared reverse-flow behavior. Preserve total-flow instruments as a separate quantity. |
| Lambda | Exhaust pipe/collector attachment | Number lambda plus validity. Start with a labeled ideal mixture tracer; defer wideband oxygen chemistry, heater and response modeling. Closed-loop use requires explicit opt-in to that approximation. |
| Engine/head temperature | Attach to a cylinder/head thermal node when implemented | Number material temperature. Until then use a declared fixed calibration input; cylinder gas temperature is not coolant/head temperature. |
| Barometric pressure | Standalone ambient sensor or tap of an explicitly selected environment boundary | Number absolute pressure; never substitute manifold pressure during running. |
| Fuel pressure | Attach to a fuel rail/circuit once one exists | Number pressure; until then injector pressure is a declared calibration, not a pretend sensor reading. |
| Knock, oil pressure, battery voltage | Future dedicated physical models | Expose only after their state/model exists; do not synthesize plausible readings from unrelated gas variables. |
Sensors are saved entities with stable IDs and host references; their Logic view
nodes refer to those same entities. Store pipe locations by arclength fraction,
not mesh cell index, and chamber/shaft targets by stable identity. Placement must
survive mesh rebuilds, transforms and valid geometry edits. Invalid/deleted hosts
leave visible unresolved bindings rather than silently retargeting another part.
Use **Add sensor** on the selected part or pipe location, then offer only supported
measurements. Attached taps do not consume a gas connection, create storage or
alter flow. A standalone hose-connected pressure sensor has a bidirectional gas
port and real hose/dead volume if included; its Number output is still a filled
green circle. Flow sensors that add a body must explicitly add its losses/volume.
Each sensor exposes measured quantity, units, location, sample time, validity and
optional response/filter settings. Ideal sensors come first; lag, noise, range
limits and fault injection are later options with deterministic initialization.
Existing probes may share sampling code; ECU inputs must not depend on the
instrument UI's eight-channel limit or its lower-rate recording samples.
### Numeric timing inputs and synchronization
ECU measurement inputs are Number rings, including speed, crank angle, MAP,
TPS and temperature. Crank speed and position are separate typed quantities,
not a single overloaded number. Optional Boolean sync/run/kill inputs are red.
An ideal crank pickup outputs phase in a declared 360-degree reference frame and
speed from the same timestamp. Cam reference resolves the four-stroke cycle.
Keep unwrapped rotation/crossing information in the timing subsystem so wraparound
or a crossing within one simulation step cannot lose an ignition/injection event.
Validate coherent sensor provenance and phase offsets against controlled shafts.
A constant RPM or an unrelated shaft sensor is not sufficient timing authority.
The declared ideal timing channel may supply synchronization metadata alongside
the numeric reading; it is not an invisible additional physics-state lookup.
Raw Hall/VR tooth waveforms and missing-tooth decoders are a later fidelity mode.
They need timestamped edge delivery and a decoder that outputs these same typed
measurements; sampling a Boolean once per audio sample must not be assumed to
capture every tooth at high speed. General logic may process speed and angle,
but transformations used for timing must preserve or explicitly invalidate the
phase/time reference. Ordinary stale or incoherent values cause a visible fault.
### Actuation and fuel estimation
- EFI computes fuel demand from the selected sensor strategy and calibration.
Account explicitly for speed-density charge estimates, Alpha-N table meaning,
or MAF manifold filling before assigning fuel to cylinders.
- Initially allow labeled per-cylinder ideal injector targets with explicit fuel
mass commands and delivery timing. Fuel mass/energy enters through that modeled
injection boundary; do not call the old `fuel_for_air` path behind the new ECU.
Separate injector/rail parts can replace that abstraction later.
- Expose per-cylinder fuel dose, spark advance and enable/cut commands with clear
units and scheduling semantics. Numeric advance is an angle before TDC, not an
arbitrary instantaneous spark pulse. Cylinder phase offsets remain calibrated
mechanical relationships used by the timing scheduler.
- ECU outputs and standalone logic must not simultaneously drive an actuator
input. Use an explicit selector/command owner. Multiple sensor consumers are
allowed. Each ECU initially controls cylinders on one crankshaft.
- Loss of required crank synchronization cuts its controlled fuel and spark;
missing optional lambda disables trim. Define preset-specific responses to MAP,
IAT, TPS and MAF faults. Never automatically switch fuel strategy unless an
explicit configured fallback has the required sensors and calibration.
### ECU connector UI and diagnostics
- Show Required, Connected and Optional input groups, with expandable Timing,
Air/load, Temperatures, Mixture and Auxiliary sections. Keep every connected
pin visible; show more optional pins on demand to avoid a wall of connectors.
- Every input displays required/optional status, quantity/unit, current value,
source sensor and any active fallback. Feature selection updates requirements
immediately; hiding a pin does not disconnect it or disable its validation.
- Add an ECU setup checklist: choose preset, assign cylinders/shaft, place required
sensors, connect measurements, assign actuators, verify timing and enable Test.
Offer sensor creation at a selected physical location; never guess a plenum.
- Highlight a sensor's physical marker and its wire when selecting an ECU input.
Show named ports and connection lists for dense ECU layouts; support reroutes
and saved harness groups without merging distinct signals into an untyped bus.
- Diagnose missing input, invalid measurement, synchronization loss, wrong shaft,
incorrect measurement kind/location, target conflicts and uncalibrated maps.
A valid zero RPM while stopped is not a missing sensor or a sensor failure.
- Provide compact status: stopped/cranking/running, synchronized, fueling strategy,
open/closed-loop mixture, active cuts and fallback reason. Retain ring/filled
circle direction shapes and red Boolean/green Number colors throughout.
### Migration and acceptance
Legacy imports initially retain Legacy ideal ECU behavior. Offer a deliberate
**Convert to sensor ECU** workflow that creates/reuses named shaft sensors and
selected TPS/MAP/IAT attachments, preserves geometry, and reports calibration that
cannot be converted. Do not promise an existing ideal-air-mass fuel calibration
will produce equivalent behavior from a newly invented VE or Alpha-N table.
Demonstrations: minimal crank-driven ignition with carburetor; Alpha-N with crank
and TPS; speed-density with crank, MAP and IAT; optional lambda correction using
the declared ideal tracer; independent ECUs for two shafts. Add sequential and
MAF examples only after their prerequisites are complete.
Acceptance includes required/optional input matrices, sensor positioning after
mesh/geometry changes, assembly remapping and host deletion, zero-RPM startup,
phase wrap and event crossings, cam loss, wrong-shaft rejection, stale input and
fault recovery, deterministic pause/step, explicit fuel-mass accounting, actuator
ownership and optional-feature degradation. Compare controller estimates against
physical state diagnostically without feeding that ground truth back secretly.
Planning references:
- [Speeduino overview: supported strategies and trigger inputs](https://wiki.speeduino.com/en/Overview)
- [Speeduino tuning: MAP and air-density/load estimation](https://wiki.speeduino.com/en/tuning/Tuning)
- [MegaSquirt MS3 hardware guide: crank/cam triggering examples](https://www.msextra.com/doc/pdf/MS3XV30_Hardware-1.4.pdf)

258
docs/WORKSHOP_UI_PLAN.md Normal file
View File

@@ -0,0 +1,258 @@
# Workshop UI rework
Design specification, updated 2026-11-01. Implementation status is listed below.
Physical parts, typed signals and ECU requirements are defined in the
[machine systems roadmap](MECHANICAL_BUILDER_PLAN.md).
## Implementation status
The first UI pass implements the dark theme, shared adaptive panel geometry,
resizing/collapse with saved preferences, library search/categories and previews,
an Objects list, a measured inspector with stacked property fields, fitted control
labels and hover disclosure, direct click/drag connections, persistent Run/Pause/
Step controls, navigable checks, and a separate resizable oscilloscope dock.
Existing ECU assignment ports use input rings and filled output circles; gas
ports retain their physical semantics. The Sense inspector now adds physical
sensor taps, unresolved binding repair and per-sample live outputs, using green
Number and red Boolean output dots. Sensor attachments remain locked during Test.
The builder and desktop UI regressions pass. Rendered review covers empty, ECU,
Checks, Meters, running/paused and collapsed layouts at 1920x1080 and 1280x720.
Logic/split views now share the running machine, with a searchable node library,
typed click/drag wiring, reference selection, live values/controls, parameter
inspectors and compact number trends. Input rings/output circles and red Boolean/
green Number colors are checked in rendered output. Thirteen focused suites pass;
graph and split layouts are reviewed at 1920x1080 and 1280x720.
Grouped ECU connectors follow their runtime systems. Automatic DPI/UI scale
overrides, an object hierarchy with
visibility groups, richer property grouping/help and pinned operator controls
remain follow-up UI work. Their design below remains the intended direction.
## Confirmed direction
- Dark engineering editor with compact toolbars, clear panels and restrained
colors. Reserve strong colors for signal types, selection and relevant state.
- Separate **3D** and **Logic** tabs, with an optional split view. Selection and
running simulation are shared across views; camera and canvas navigation state
remain independent. Switching views does not restart Test.
- Connect by dragging between ports or clicking a port then its target. A
dedicated Connect mode is not required. Escape cancels a pending connection.
- Signal inputs are hollow rings; outputs are filled circles. Boolean is red,
Number green. Bidirectional physical ports use neutral domain symbols.
- Panels remain in predictable positions, resize and collapse, and remember
their widths. Provide a reset-layout action and constrain restored sizes to
the current window so the central workspace stays usable.
- The library uses compact rows with small previews, categories and search.
Selecting an item shows a larger preview with its purpose and essential ports.
- Starting Test preserves the current workspace, selection and navigation state.
Lock structural edits while allowing safe live tuning and logic inspection.
- Test starts running. Pause and Step remain visible; Step is enabled when
paused, with a tooltip explaining that state when disabled.
- Primary design target is a 1920x1080 desktop window. The first priority is
consistent appearance and layout, especially preventing text/control overlap.
- Remaining routine design decisions are made autonomously, per user direction.
## Layout and visual baseline
Use a shared layout/component layer rather than individually positioned controls.
Rendering and hit testing consume the same computed rectangles. Define shared
theme values for spacing, typography, surfaces, borders and interaction states.
Initial dimensions below are logical pixels at 100% UI scale, and may be tuned
through visual review while preserving the agreed structure.
| Element | Default |
| --- | --- |
| Spacing | 4 px base unit; 8 px control gaps; 12 px panel padding; 16 px section gaps |
| Typography | 14 px body/fields, 12 px secondary labels, 16 px section titles; measured line height |
| Controls | 30 px minimum row height, increased when text or UI scale requires it |
| Top command bar | 44 px, with less-used file actions in a menu |
| Workspace toolbar | 36 px; 3D/Logic tabs and split toggle stay discoverable |
| Left panel | 260 px default; resizable between 220 and 420 px |
| Inspector | 340 px default; resizable between 280 and 520 px |
| Bottom area | Collapsed initially; 240 px default when opened, vertically resizable |
| Status bar | 24 px minimum; longer messages go to Checks or a tooltip |
| UI scale | Auto from display scaling, plus 100%, 125%, 150% and 200% overrides |
Use a neutral charcoal palette with clear panel boundaries and one restrained
selection accent. Reuse the same fields, buttons, tabs, badges and menus across
all views. Strong red/green remains reserved for the agreed signal types; add
icons/text to distinguish faults and success from those types.
Support a 1280x720 window at 100% scale through a compact layout. As available
logical space shrinks, move secondary toolbar actions into an overflow menu,
collapse the library/object panel, and offer the inspector as a temporary panel.
Preserve access to required actions and readable text instead of shrinking fonts.
If split view cannot fit two usable canvases, use a single tab and explain why
split is unavailable. Large UI scaling follows the same compact behavior.
Choose a vertical split by default: 3D left, Logic right, with a draggable divider
and a swap action. Remember divider position, panel widths/collapse states and
active view in user preferences; clamp them on restore. Store machine-specific
camera/canvas layout separately from physical geometry and signal values.
### Text and overlap rules
- Measure text before layout. Use content-sized rows and explicit minimum sizes;
never assume every property label fits a fixed width.
- Inspector rows place label, value, unit and optional action in separate layout
cells. Narrow inspectors stack labels above fields; units never cover digits.
- Wrap help and diagnostic text and grow the row. Ellipsize long names only in
compact lists/tabs, with the complete name available on hover and in selection.
- Clip rendering and pointer handling to the same panel scroll region. Fixed
headers and footers reserve their own space so scrolling content cannot cover
buttons, tabs or trace controls.
- Tooltips and menus reposition within the window and respect their anchor.
Keep the focused text field visible when scrolling or opening a panel.
- Lay out ECU ports from descriptor rows with adequate label/value space; nodes
grow vertically as ports expand. Wires route away from pin labels before bends.
- World labels use screen-space placement with collision suppression; prioritize
selected/hovered labels and expose the rest on demand. Hidden labels must not
hide the underlying port's tooltip or named connection-list access.
## Workspace structure
Proposed layout: a slim document/command bar, workspace tabs, a left library/object
panel, central workspace, right inspector, and optional bottom diagnostics/traces.
Use resize handles and collapse controls within this predictable layout.
- Top bar: machine name, unsaved status, file actions, undo/redo and simulation
controls. Show current run state unambiguously even in Logic view.
- 3D toolbar: Select, Move, Rotate, Route, snapping and fit/focus. Connection
gestures work directly on visible ports and do not start a transform drag.
- Logic toolbar: node search/add, frame selection/all, connection filters and
live-value visibility. Node creation targets the canvas under the pointer.
- Library: parts, sensors, logic nodes and saved assemblies with search and
categories, displayed as compact rows with small previews. Show a larger
selected-item preview and compatibility when adding from a signal port.
- Objects: named parts, sensors, shafts, ECUs and assemblies. Search, select,
focus, rename and hide without removing simulation membership.
- Inspector: selection identity, grouped properties, connections and contextual
help. Show units next to editable values and explain locked Build properties.
- Bottom area: navigable checks, traces and event history. Selecting an issue
locates the relevant part, port or connection in the appropriate view.
- Status bar: current gesture, keyboard hints and short feedback. Important
failures remain in Checks rather than disappearing with a transient message.
## Core workflows
### Place and configure
Search for a part, select it, preview placement, then click to place. Shift repeats
placement. Escape cancels. Placed parts immediately expose a name and the small
set of essential properties; advanced groups expand on demand. Preserve current
copy/paste, assembly and undo workflows as the UI changes.
For multiple selections show selection count, pivot and mixed values. Edits apply
only to compatible properties; do not display one arbitrary part's value as a
shared value. Selection/focus is synchronized with the object tree and Logic view.
### Connect
Hover highlights a port with name, domain, direction, units and connection state.
Dragging from it or clicking it begins a preview. Compatible targets stand out;
incompatible targets explain the reason. A separate checkmark/cross communicates
validity without overwriting the red/green signal colors.
Keep ring centers easy to pick and port target size readable at any zoom. For
overlapping ports provide a named candidate chooser. Clicking a part body selects
it; hovering a hidden port must not silently steal a move gesture.
Signal connections do not move physical parts. Gas connections keep their current
geometry/snapping rules and show the proposed pipe path before commit. Physical
port alignment errors are visible. Disconnect/reconnect commands are available
from ports and the inspector; all connection edits are undoable.
### Add sensors and configure ECUs
Use Add sensor on a selected part or a specific pipe location. Offer supported
sensor types, show the tap/pickup marker, and create a matching Logic reference.
Selecting either representation reveals its host and measurement location.
ECU inspector groups Required, Connected and Optional inputs by function. Keep
connected ports visible and let optional groups expand. A guided checklist helps
choose a strategy, attach required sensors and assign actuator targets. Every
measurement displays units, source, validity and fallback state.
### Inspect and test
Simulation controls are independent of 3D/Logic tab selection. Distinguish Run,
Pause, Step and Stop/return-to-edit. Show simulation speed and time. Structural
properties explain why editing is locked during Test; safe live tuning shows
its applied value without resetting the machine.
Entering Test does not switch to a different workspace or change selection.
Keep the current 3D camera, Logic canvas and split-view arrangement. Stopping
returns to editing in that same context; live tuning retains existing persistence
and undo behavior. Start running immediately, with Pause and Step available in
the persistent simulation controls.
Hovering a signal reveals its current value; the inspector can pin it to a trace
or control panel. Physical measurements can also be added from the selected part.
Readouts identify the shaft, sensor or port they measure. Missing and invalid
signals never display as an ordinary zero or false state.
## Readability and interaction requirements
- Text and controls respond to window size and UI scaling. Scroll panels rather
than drawing controls outside their bounds; avoid the current fixed-coordinate
overlap risk as parts and properties grow.
- Focused numeric fields accept keyboard edits, units are visible, and invalid
input remains editable with a local explanation. Escape cancels the edit;
shortcuts do not trigger transforms while typing in a field.
- Preserve useful shortcuts, add visible tooltips, and keep camera gestures
separate from node-canvas gestures. Document both in a compact help overlay.
- Selection, warning and disabled states remain legible in the dark theme.
Signal meaning is also conveyed through labels and direction shapes.
- Keep the viewport stable when changing selection. Fit/focus is explicit.
Unrelated panel refreshes must not jump scroll position or steal text focus.
- Keep raw rendering and UI state ownership explicit so panel rendering remains
reliable after 3D drawing. Rendering optimization must preserve hit testing.
## Delivery slices
1. Appearance and layout foundation: shared controls/theme, responsive panels,
typography, text measurement, clipping and consistent input handling. Rework
the existing workshop first so today's parts already have a coherent UI.
2. Descriptor-driven inspector, connection gestures, named ports and filters.
3. Searchable library/object tree, then Logic tab and optional split view with
shared selection, stable node layout and live values.
4. Sensor placement and grouped ECU connector workflow.
5. Integrated test controls, traces, checks and pinned operator controls.
Coordinate these slices with the systems roadmap: UI can display a type only
after document/runtime descriptors define it. Validate layouts at the agreed
minimum window size and scaling, connection picking in dense assemblies, keyboard
focus, save/restore behavior and the same workflows across 3D, Logic and split view.
## Navigation decisions
Keep the existing familiar 3D controls: Alt+left drag or right drag on empty space
orbits, middle drag pans, wheel zooms, F focuses selection and Home fits all.
An actual drag orbits; right click without dragging opens the context menu.
UI interaction consumes input before viewport handling, and text editing captures
its shortcuts. Escape cancels the active gesture before clearing selection.
Logic uses wheel zoom about the pointer and middle drag to pan. Plain left click
selects, Shift modifies selection, and dragging empty canvas makes a selection
rectangle. Drag nodes by their header, ports by their connector and panel sizes
by their divider; the pointer/hover state identifies the pending action.
## First milestone acceptance
Review the existing single-cylinder, flat-six and V8 imports with long names,
multi-selection, pipe route editing, ECU properties, Checks and instruments.
Exercise empty, selected, hovered, disabled, focused, invalid and running states.
At 1920x1080 and 1280x720 at 100% scale, and at representative larger display
scales, verify that text never overlaps unrelated controls, field units remain
readable, panels scroll within bounds and essential commands stay reachable.
Resize while editing, reopen saved panel layouts, expand long property groups
and open menus near window edges. Appearance/layout review is required before
adding the new systems' more complex panels. Later slices repeat this review for
the dense ECU connector view, Logic and split view as they become available.
No further preference decisions are blocking this plan. Implementation will use
these defaults and adjust details through focused visual and interaction review.

View File

@@ -29,7 +29,7 @@ struct CarburetorConfig {
class Carburetor : public CarburetorConfig { class Carburetor : public CarburetorConfig {
public: public:
explicit Carburetor(float throat_diameter = 0.0381f); explicit Carburetor(float throat_diameter = 0.0381f,const CarburetorConfig& config={});
// air_mass_flow is positive from atmosphere toward the engine. // air_mass_flow is positive from atmosphere toward the engine.
void step(float dt, float pedal, float slide, float rpm, float air_mass_flow, void step(float dt, float pedal, float slide, float rpm, float air_mass_flow,

View File

@@ -13,6 +13,7 @@ struct Port {
float gamma_value{1.4f}, gas_constant_value{287.0f}; float gamma_value{1.4f}, gas_constant_value{287.0f};
float air_fraction{1.0f}; float air_fraction{1.0f};
float fuel_fraction{}; float fuel_fraction{};
float fuel_equivalent_fraction{}; // fuel origin, including fuel already burned
float* owner_energy{}; float* owner_energy{};
float* owner_air_mass{}; float* owner_air_mass{};
float* owner_exhaust_mass{}; float* owner_exhaust_mass{};
@@ -25,6 +26,8 @@ class Component {
public: public:
virtual ~Component() = default; virtual ~Component() = default;
virtual std::vector<Port*> ports() = 0; virtual std::vector<Port*> ports() = 0;
// Optional actuation at each CFL substep, before boundary fluxes are resolved.
virtual void prepare_step(float /*dt*/) {}
virtual void update_state(float dt) = 0; virtual void update_state(float dt) = 0;
}; };

View File

@@ -0,0 +1,45 @@
#pragma once
#include "FluidSim/Machine.hpp"
#include "FluidSim/Desktop/WorkshopLayout.hpp"
#include <SFML/Graphics.hpp>
#include <functional>
#include <memory>
namespace fluidsim::port::desktop {
struct LogicEditorHooks {
MachineDocument& document;
const MachineSnapshot* live{};
bool shift{};
std::function<bool(const std::function<void()>&)> edit;
std::function<void(std::uint32_t,bool)> boolean_control;
std::function<void(std::uint32_t,double)> number_control;
std::function<void()> release_buttons;
std::function<void(std::uint32_t)> select_physical,focus_physical;
std::function<void(const std::string&)> status;
std::function<void()> example;
};
class LogicEditor {
public:
LogicEditor();
~LogicEditor();
void reset_document();
void deactivate(const LogicEditorHooks&);
bool editing_text()const;
bool active_gesture()const;
std::uint32_t selected_node()const;
void select_node(std::uint32_t,const MachineDocument&);
void select_part_reference(std::uint32_t,const MachineDocument&);
void fit(const LogicGraph&,sf::FloatRect,bool selection_only=false);
void reveal(std::uint32_t,const MachineDocument&,sf::FloatRect);
sf::Vector2f project(LogicPosition,sf::FloatRect)const;
LogicPosition unproject(sf::Vector2f,sf::FloatRect)const;
sf::FloatRect node_rect(const LogicNode&,sf::FloatRect)const;
sf::Vector2f pin_position(const LogicNode&,const std::string&,sf::FloatRect)const;
// Panels can be owned independently in split view; the canvas is always live.
bool event(const sf::Event&,const LogicEditorHooks&,const WorkshopLayout&,sf::FloatRect canvas,bool panels);
void draw(sf::RenderWindow&,const sf::Font*,const LogicEditorHooks&,const WorkshopLayout&,sf::FloatRect canvas,bool panels);
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
}

View File

@@ -0,0 +1,57 @@
#pragma once
#include "FluidSim/Machine.hpp"
#include "FluidSim/Desktop/OrbitCamera.hpp"
#include <SFML/Graphics.hpp>
#include <memory>
#include <optional>
namespace fluidsim::port::desktop {
struct BuilderRay {Vec3 origin,direction;};
BuilderRay builder_ray(const OrbitCamera&,sf::FloatRect,sf::Vector2f);
std::optional<sf::Vector2f> builder_project(const OrbitCamera&,sf::FloatRect,Vec3);
// Returns metres along the ray; OBBs and duct centerlines use the rendered dimensions.
std::optional<float> builder_pick_part(const MachinePart&,BuilderRay,float tolerance=0);
Vec3 builder_rotate_world(Vec3 euler,unsigned axis,float degrees);
struct BuilderPipeLocation {std::size_t segment{};Vec3 local_point;float screen_distance{};};
std::optional<BuilderPipeLocation> builder_pipe_location(const OrbitCamera&,sf::FloatRect,const MachinePart&,sf::Vector2f);
struct MachinePlaybackStats {double simulation_seconds{};std::uint64_t underruns{};std::size_t buffered{};double speed{1};bool paused{};};
enum class WorkshopView {ThreeD,Logic,Split};
class MachineBuilder {
public:
MachineBuilder();
~MachineBuilder();
const MachineDocument& document()const;
std::vector<std::uint32_t> selected_parts()const;
void set_document(MachineDocument,std::filesystem::path source={});
bool load_file(const std::filesystem::path&);
void start_test();
void stop_test();
bool testing()const;
MachinePlaybackStats playback_stats()const;
void set_test_speed(double);
void pause_test();
void step_test();
std::vector<MachineMeasurement> measurements()const;
bool export_measurements(const std::filesystem::path&);
bool finished()const;
bool wants_close_window()const;
bool handle_event(const sf::Event&,sf::Vector2u);
void update();
void draw(sf::RenderWindow&,const sf::Font*);
static sf::FloatRect viewport(sf::Vector2u);
sf::FloatRect workspace_viewport(sf::Vector2u)const;
sf::FloatRect logic_viewport(sf::Vector2u)const;
WorkshopView workspace_view()const;
void set_workspace_view(WorkshopView);
std::uint32_t selected_logic_node()const;
std::vector<LogicNodeState> logic_values()const;
sf::FloatRect logic_node_rectangle(std::uint32_t,sf::Vector2u)const;
sf::Vector2f logic_pin_position(std::uint32_t,const std::string&,sf::Vector2u)const;
void restore_workspace_preferences();
const OrbitCamera& camera()const;
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
void run_machine_builder(sf::RenderWindow&,const sf::Font*,const std::filesystem::path& file={},bool play=false);
}

View File

@@ -0,0 +1,21 @@
#pragma once
#include "FluidSim/Machine.hpp"
#include "FluidSim/Desktop/OrbitCamera.hpp"
#include <SFML/Graphics.hpp>
#include <memory>
namespace fluidsim::port::desktop {
struct MachineRenderStats {std::uint64_t pipe_mesh_builds{},icon_atlas_builds{};std::size_t cached_pipes{};};
class MachineRenderer3D {
public:
MachineRenderer3D();
~MachineRenderer3D();
MachineRenderer3D(const MachineRenderer3D&)=delete;
MachineRenderer3D& operator=(const MachineRenderer3D&)=delete;
void draw(sf::RenderWindow&,const MachineDocument&,const MachineSnapshot*,sf::FloatRect,const OrbitCamera&,std::uint32_t selected,const MachinePart* ghost=nullptr,const std::vector<std::uint32_t>& selection={});
void draw_icon(sf::RenderWindow&,MachinePartKind,sf::FloatRect);
MachineRenderStats stats()const;
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
}

View File

@@ -0,0 +1,10 @@
#pragma once
#include "FluidSim/MachinePorts.hpp"
#include <SFML/Graphics/Color.hpp>
namespace fluidsim::port::desktop {
inline sf::Color port_color(PortDomain domain){
switch(domain){case PortDomain::Boolean:return {235,92,102};case PortDomain::Number:return {87,201,128};case PortDomain::Gas:return {189,200,214};case PortDomain::Rotational:return {229,184,91};case PortDomain::Translational:return {177,139,220};case PortDomain::EcuAssignment:return {91,161,242};}
return sf::Color::White;
}
inline bool filled_port(const MachinePortDescriptor& descriptor){return descriptor.direction==PortDirection::Output;}
}

View File

@@ -2,6 +2,26 @@
#include "FluidSim/ScenarioCatalog.hpp" #include "FluidSim/ScenarioCatalog.hpp"
#include <SFML/Graphics.hpp> #include <SFML/Graphics.hpp>
#include <optional> #include <optional>
#include <memory>
namespace fluidsim::port::desktop { namespace fluidsim::port::desktop {
std::optional<std::size_t> choose_scenario(sf::RenderWindow&,const sf::Font*,const std::vector<ScenarioExample>&); struct ScenarioMenuChoice {
std::optional<std::size_t> example;
std::filesystem::path machine;
bool play{};
};
enum class MenuScreen {Home,Workshop,Scenarios};
class ScenarioMenu {
public:
ScenarioMenu(std::vector<ScenarioExample>,std::vector<std::filesystem::path> machines={});
~ScenarioMenu();
bool handle_event(const sf::Event&,sf::Vector2u);
void draw(sf::RenderWindow&,const sf::Font*);
bool finished()const;
MenuScreen screen()const;
std::optional<ScenarioMenuChoice> take_choice();
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
std::optional<ScenarioMenuChoice> choose_scenario(sf::RenderWindow&,const sf::Font*,const std::vector<ScenarioExample>&);
} }

View File

@@ -0,0 +1,8 @@
#pragma once
#include <SFML/Graphics/RenderWindow.hpp>
namespace fluidsim::port::desktop {
// Maximize the decorated OS window; retain normal restore/minimize controls.
void maximize_window(sf::RenderWindow&);
bool window_is_maximized(const sf::RenderWindow&);
}

View File

@@ -0,0 +1,30 @@
#pragma once
#include <SFML/Graphics/Rect.hpp>
#include <SFML/System/Vector2.hpp>
#include <algorithm>
namespace fluidsim::port::desktop {
// One geometry source for drawing, picking and viewport projection.
struct WorkshopLayout {
sf::FloatRect left, right, viewport, library, search;
float inspector_x{}, inspector_width{}, height{}, width{};
static constexpr float field_top=302, field_height=52;
int field_rows()const{return std::max(1,int((height-164-field_top)/field_height));}
int library_rows()const{return std::max(1,int(library.size.y/36));}
sf::FloatRect field_row(int row)const{return {{inspector_x+12,field_top+row*field_height},{inspector_width-24,48}};}
sf::FloatRect field_value(int row)const{auto r=field_row(row);return {{r.position.x,r.position.y+21},{r.size.x-68,26}};}
static WorkshopLayout calculate(sf::Vector2u size,float left_width=260,float right_width=340,bool left_closed=false,bool right_closed=false){
WorkshopLayout l;l.width=float(size.x);l.height=float(size.y);
const float available=std::max(480.f,l.width-440);
float lw=left_closed?28.f:std::clamp(left_width,220.f,420.f),rw=right_closed?28.f:std::clamp(right_width,280.f,520.f);
if(lw+rw>available){const float excess=lw+rw-available;rw=std::max(right_closed?28.f:280.f,rw-excess);lw=std::max(left_closed?28.f:180.f,std::min(lw,available-rw));}
l.left={{8,96},{lw,std::max(100.f,l.height-154)}};
l.right={{l.width-rw-8,96},{rw,std::max(100.f,l.height-154)}};
l.inspector_x=l.right.position.x;l.inspector_width=rw;
l.viewport={{l.left.position.x+lw+8,96},{std::max(100.f,l.right.position.x-lw-24),std::max(100.f,l.height-154)}};
l.search={{20,152},{lw-24,30}};
l.library={{20,230},{lw-24,std::max(36.f,l.height-474)}};
return l;
}
};
}

View File

@@ -1,8 +1,29 @@
#pragma once #pragma once
#include "FluidSim/MachinePorts.hpp"
#include <string_view>
#include <vector> #include <vector>
namespace fluidsim::port { namespace fluidsim::port {
// Accepts only coherent, current channels from one physical crank pickup.
// Signed reverse measurements remain valid sensors, but cannot drive this
// forward-only ignition controller. A stationary crank is valid.
std::optional<SignalTiming> ecu_crank_sample(const SignalValue& speed,
const SignalValue& angle,double time);
class CrankIgnitionClock {
public:
struct Result {bool valid{};unsigned sparks{};};
// Crank-only wasted spark: one event per revolution at the configured
// pickup angle, without consulting a cylinder's hidden 720-degree phase.
Result sample(const SignalValue& speed,const SignalValue& angle,
double time,double advance_degrees,double tdc_degrees=0);
void reset(){previous_.reset();last_event_cycle_.reset();}
private:
std::optional<SignalTiming> previous_;
std::optional<double> last_event_cycle_;
};
// Ideal sensors and injectors; calibration parameters are not engine measurements. // Ideal sensors and injectors; calibration parameters are not engine measurements.
struct EcuConfig { struct EcuConfig {
enum class IdleControl { Feedback, FixedOpening }; enum class IdleControl { Feedback, FixedOpening };
@@ -18,6 +39,8 @@ struct EcuConfig {
float rev_limit_rpm{10000.0f}, rev_resume_rpm{9750.0f}; float rev_limit_rpm{10000.0f}, rev_resume_rpm{9750.0f};
bool rev_limit_enabled{true}; bool rev_limit_enabled{true};
bool rev_limit_fuel_cut_enabled{true}; bool rev_limit_fuel_cut_enabled{true};
bool rev_limit_ignition_cut_enabled{true};
float rev_limit_cut_time{}; // Minimum cut duration in seconds, followed by RPM hysteresis.
float overrun_cut_rpm{3500.0f}, overrun_resume_rpm{2400.0f}; float overrun_cut_rpm{3500.0f}, overrun_resume_rpm{2400.0f};
bool overrun_fuel_cut_enabled{true}; bool overrun_fuel_cut_enabled{true};
bool overrun_ignition_cut_enabled{false}; bool overrun_ignition_cut_enabled{false};
@@ -35,7 +58,7 @@ public:
float target_afr() const { return afr_; } float target_afr() const { return afr_; }
float spark_advance() const { return spark_; } float spark_advance() const { return spark_; }
bool fuel_cut() const { return (limiter_ && rev_limit_fuel_cut_enabled) || (overrun_ && overrun_fuel_cut_enabled) || fault_; } bool fuel_cut() const { return (limiter_ && rev_limit_fuel_cut_enabled) || (overrun_ && overrun_fuel_cut_enabled) || fault_; }
bool ignition_cut() const { return limiter_ || (overrun_ && overrun_ignition_cut_enabled) || fault_; } bool ignition_cut() const { return (limiter_ && rev_limit_ignition_cut_enabled) || (overrun_ && overrun_ignition_cut_enabled) || fault_; }
bool rev_limited() const { return limiter_; } bool rev_limited() const { return limiter_; }
bool idle_active() const { return idle_; } bool idle_active() const { return idle_; }
bool sensor_fault() const { return fault_; } bool sensor_fault() const { return fault_; }
@@ -45,5 +68,51 @@ private:
bool initialized_{}; bool initialized_{};
float opening_{}, target_{}, integral_{}, afr_{14.7f}, spark_{10.0f}; float opening_{}, target_{}, integral_{}, afr_{14.7f}, spark_{10.0f};
bool limiter_{}, overrun_{}, fault_{}, idle_{}; bool limiter_{}, overrun_{}, fault_{}, idle_{};
float limiter_time_{};
};
// Separate from the legacy ECU: measurements are supplied through typed wires.
// Alpha-N cells are calibrated kg per cylinder per four-stroke cycle, indexed
// by RPM rows and measured TPS columns. No trapped-air-mass input is available.
struct SensorEcuConfig {
enum class Strategy {Ignition,AlphaN,SpeedDensity};
Strategy strategy{Strategy::Ignition};
std::vector<EcuConfig::IgnitionPoint> ignition_map{{0,10},{6000,30}};
std::vector<double> fuel_rpm{0,6000},fuel_tps{0,1};
std::vector<double> fuel_per_cycle{0.000002,0.00002,0.000002,0.00002};
// Open-loop dry ideal-gas estimate per cylinder. Geometry, VE and fuel AFR
// are calibration; MAP and IAT must arrive as current wired measurements.
double displacement{.0003},air_gas_constant{287},target_afr{14.7};
std::vector<double> ve_rpm{0,6000},ve_pressure{20000,100000};
std::vector<double> volumetric_efficiency{.9,.9,.9,.9};
bool rev_limit_enabled{true};
double rev_limit_rpm{7000},rev_resume_rpm{6800};
};
inline constexpr int sensor_ecu_strategy_count=3;
const char* sensor_ecu_strategy_key(SensorEcuConfig::Strategy);
const char* sensor_ecu_strategy_name(SensorEcuConfig::Strategy);
SensorEcuConfig::Strategy sensor_ecu_strategy_from_key(std::string_view);
SensorEcuConfig::Strategy next_sensor_ecu_strategy(SensorEcuConfig::Strategy);
struct SensorEcuMeasurements {SignalValue speed,angle,tps,map,iat;};
struct SensorEcuState {
bool sensor_fault{true},rev_limited{};
double spark_advance{},fuel_per_cycle{},fuel_mass_flow{};
double estimated_air_per_cycle{},volumetric_efficiency{};
unsigned spark_events{};
};
class SensorEcu {
public:
explicit SensorEcu(SensorEcuConfig config);
void apply_calibration(SensorEcuConfig config);
const SensorEcuState& sample(const SignalValue& speed,const SignalValue& angle,
const SignalValue& tps,double time);
const SensorEcuState& sample(const SensorEcuMeasurements&,double time);
const SensorEcuState& state()const{return state_;}
void reset(){clock_.reset();state_={};limiter_=false;}
private:
SensorEcuConfig config_;
CrankIgnitionClock clock_;
SensorEcuState state_;
bool limiter_{};
}; };
} // namespace fluidsim::port } // namespace fluidsim::port

View File

@@ -4,6 +4,8 @@
#include "FluidSim/Constants.hpp" #include "FluidSim/Constants.hpp"
#include <array> #include <array>
#include <cstddef> #include <cstddef>
#include <limits>
#include <optional>
#include <random> #include <random>
#include <vector> #include <vector>
@@ -25,6 +27,8 @@ public:
float average_power{}, average_torque{}; float average_power{}, average_torque{};
void add_torque(float torque); void add_torque(float torque);
float take_torque(); // Consume gas torque when an external shaft integrator owns motion.
void record_output(float torque,float power);
void step(float dt); void step(float dt);
private: private:
float external_torque_{}; float external_torque_{};
@@ -51,6 +55,7 @@ public:
float rev_limit_rpm{7500.0f}, rev_limit_cut_time{0.05f}, rev_limit_hysteresis{200.0f}; float rev_limit_rpm{7500.0f}, rev_limit_cut_time{0.05f}, rev_limit_hysteresis{200.0f};
float air_mass{}, exhaust_mass{}, energy{}, volume{}, pressure{101325.0f}; float air_mass{}, exhaust_mass{}, energy{}, volume{}, pressure{101325.0f};
float fuel_mass{}, burn_fraction{}; float fuel_mass{}, burn_fraction{};
float fuel_equivalent_mass{}; // passive fuel-origin tracer, unaffected by combustion
bool combustion_active{}, fuel_injected{}, rev_limit_active{}; bool combustion_active{}, fuel_injected{}, rev_limit_active{};
Port intake{}, exhaust{}; Port intake{}, exhaust{};
@@ -84,6 +89,14 @@ protected:
class FourStrokeCylinder final : public EngineCylinder { class FourStrokeCylinder final : public EngineCylinder {
public: public:
struct IgnitionCommand {bool fire{},cut{true};};
// Present selects external timing exclusively. A fire pulse is consumed
// once; missing subsequent commands cannot recreate internal timed sparks.
// Requires physically transported fuel, including carburetor operation.
std::optional<IgnitionCommand> ignition_command;
// Once per cycle, before ignition consumes fuel. Independent of spark cut.
float charge_afr{std::numeric_limits<float>::quiet_NaN()};
bool charge_sampled{};
struct CamPoint { float angle_degrees{}, lift_metres{}; }; struct CamPoint { float angle_degrees{}, lift_metres{}; };
struct ValveDynamics { struct ValveDynamics {
float mass_kg{0.035f}; float mass_kg{0.035f};
@@ -106,6 +119,8 @@ public:
static float cam_lift_at(const CamProfile& profile,float degrees) { return sample_cam(profile,degrees).lift_metres; } static float cam_lift_at(const CamProfile& profile,float degrees) { return sample_cam(profile,degrees).lift_metres; }
private: private:
float external_burn_angle_{};
bool external_charge_ready_{};
struct ValveState { float lift{},velocity{};bool floating{}; }; struct ValveState { float lift{},velocity{};bool floating{}; };
const CamProfile* intake_cam_{}; const CamProfile* intake_cam_{};
const CamProfile* exhaust_cam_{}; const CamProfile* exhaust_cam_{};

View File

@@ -54,10 +54,12 @@ public:
float air_fraction() const { return air_mass_ / std::max(mass(), 1e-12f); } float air_fraction() const { return air_mass_ / std::max(mass(), 1e-12f); }
float fuel_fraction() const { return fuel_mass_ / std::max(mass(), 1e-12f); } float fuel_fraction() const { return fuel_mass_ / std::max(mass(), 1e-12f); }
float fuel_mass() const { return fuel_mass_; } float fuel_mass() const { return fuel_mass_; }
float fuel_equivalent_fraction()const{return fuel_equivalent_mass_/std::max(mass(),1e-12f);}
void add_fuel_mass(float value, float temperature = 300.0f) { void add_fuel_mass(float value, float temperature = 300.0f) {
if (!std::isfinite(value) || value < 0.0f || !std::isfinite(temperature) || temperature <= 0.0f) if (!std::isfinite(value) || value < 0.0f || !std::isfinite(temperature) || temperature <= 0.0f)
throw std::invalid_argument("invalid fuel addition"); throw std::invalid_argument("invalid fuel addition");
fuel_mass_ += value; fuel_mass_ += value;
fuel_equivalent_mass_ += value;
energy_ += value * gas_constant / (gamma - 1.0f) * temperature; energy_ += value * gas_constant / (gamma - 1.0f) * temperature;
sync_ports(); sync_ports();
} }
@@ -68,9 +70,11 @@ public:
const float total = value * volume_ / (gas_constant * target_t); const float total = value * volume_ / (gas_constant * target_t);
const float fraction = air_fraction(); const float fraction = air_fraction();
const float fuel = fuel_fraction(); const float fuel = fuel_fraction();
const float equivalent=fuel_equivalent_fraction();
air_mass_ = total * fraction; air_mass_ = total * fraction;
fuel_mass_ = total * fuel; fuel_mass_ = total * fuel;
exhaust_mass_ = total * std::max(1.0f - fraction - fuel, 0.0f); exhaust_mass_ = total * std::max(1.0f - fraction - fuel, 0.0f);
fuel_equivalent_mass_=total*equivalent;equivalent_roundoff_=0;
energy_ = value * volume_ / (gamma - 1.0f); energy_ = value * volume_ / (gamma - 1.0f);
air_roundoff_ = exhaust_roundoff_ = fuel_roundoff_ = energy_roundoff_ = 0; air_roundoff_ = exhaust_roundoff_ = fuel_roundoff_ = energy_roundoff_ = 0;
sync_ports(); sync_ports();
@@ -80,7 +84,7 @@ public:
throw std::invalid_argument("invalid volume timestep"); throw std::invalid_argument("invalid volume timestep");
if (dt == 0) if (dt == 0)
return; return;
double d_air = 0, d_exhaust = 0, d_fuel = 0, d_energy = 0; double d_air = 0, d_exhaust = 0, d_fuel = 0, d_energy = 0, d_equivalent=0;
const float fraction = air_fraction(); const float fraction = air_fraction();
const float fuel = fuel_fraction(); const float fuel = fuel_fraction();
for (const Port &p : ports_) { for (const Port &p : ports_) {
@@ -90,11 +94,13 @@ public:
d_fuel += double(p.mass_flow) * flow_fuel; d_fuel += double(p.mass_flow) * flow_fuel;
d_exhaust += double(p.mass_flow) * std::max(1.0f - flow_fraction - flow_fuel, 0.0f); d_exhaust += double(p.mass_flow) * std::max(1.0f - flow_fraction - flow_fuel, 0.0f);
d_energy += double(p.mass_flow) * p.enthalpy; d_energy += double(p.mass_flow) * p.enthalpy;
d_equivalent+=double(p.mass_flow)*(p.mass_flow>=0?std::max(p.fuel_equivalent_fraction,p.fuel_fraction):fuel_equivalent_fraction());
} }
accumulate(air_mass_, air_roundoff_, d_air * dt); accumulate(air_mass_, air_roundoff_, d_air * dt);
accumulate(exhaust_mass_, exhaust_roundoff_, d_exhaust * dt); accumulate(exhaust_mass_, exhaust_roundoff_, d_exhaust * dt);
accumulate(fuel_mass_, fuel_roundoff_, d_fuel * dt); accumulate(fuel_mass_, fuel_roundoff_, d_fuel * dt);
accumulate(energy_, energy_roundoff_, d_energy * dt); accumulate(energy_, energy_roundoff_, d_energy * dt);
accumulate(fuel_equivalent_mass_,equivalent_roundoff_,d_equivalent*dt);
const float new_volume = volume_ + volume_change_rate * dt; const float new_volume = volume_ + volume_change_rate * dt;
if (!(new_volume > 0) || !std::isfinite(new_volume)) if (!(new_volume > 0) || !std::isfinite(new_volume))
throw std::runtime_error("invalid moving volume"); throw std::runtime_error("invalid moving volume");
@@ -118,6 +124,8 @@ public:
air_mass_ = std::max(air_mass_, 0.0f); air_mass_ = std::max(air_mass_, 0.0f);
exhaust_mass_ = std::max(exhaust_mass_, 0.0f); exhaust_mass_ = std::max(exhaust_mass_, 0.0f);
fuel_mass_ = std::max(fuel_mass_, 0.0f); fuel_mass_ = std::max(fuel_mass_, 0.0f);
fuel_equivalent_mass_=std::clamp(fuel_equivalent_mass_,fuel_mass_,mass());
if(fuel_equivalent_mass_<std::numeric_limits<float>::min()){fuel_equivalent_mass_=0;equivalent_roundoff_=0;}
sync_ports(); sync_ports();
} }
void update_state(float dt) override { update(dt); } void update_state(float dt) override { update(dt); }
@@ -132,6 +140,7 @@ public:
private: private:
float volume_, air_mass_{}, exhaust_mass_{}, fuel_mass_{}, energy_; float volume_, air_mass_{}, exhaust_mass_{}, fuel_mass_{}, energy_;
float fuel_equivalent_mass_{};double equivalent_roundoff_{};
// Preserve sub-ulp transfers without changing the float port/budget ABI. // Preserve sub-ulp transfers without changing the float port/budget ABI.
double air_roundoff_{}, exhaust_roundoff_{}, fuel_roundoff_{}, energy_roundoff_{}; double air_roundoff_{}, exhaust_roundoff_{}, fuel_roundoff_{}, energy_roundoff_{};
static void accumulate(float& value, double& roundoff, double increment) { static void accumulate(float& value, double& roundoff, double increment) {
@@ -149,6 +158,7 @@ private:
p.enthalpy = gamma / (gamma - 1.0f) * p.pressure / std::max(p.density, 1e-12f); p.enthalpy = gamma / (gamma - 1.0f) * p.pressure / std::max(p.density, 1e-12f);
p.air_fraction = air_fraction(); p.air_fraction = air_fraction();
p.fuel_fraction = fuel_fraction(); p.fuel_fraction = fuel_fraction();
p.fuel_equivalent_fraction=fuel_equivalent_fraction();
} }
void sync_ports() { void sync_ports() {
for (Port &p : ports_) for (Port &p : ports_)
@@ -161,10 +171,12 @@ struct GasCell {
float energy{1.225f * (101325.0f / (pipe_gm1 * 1.225f))}; float energy{1.225f * (101325.0f / (pipe_gm1 * 1.225f))};
float air{1.0f}; float air{1.0f};
float fuel{}; float fuel{};
float fuel_equivalent{};
}; };
struct GasState { struct GasState {
float rho{1.225f}, velocity{}, pressure{101325.0f}, air{1.0f}; float rho{1.225f}, velocity{}, pressure{101325.0f}, air{1.0f};
float fuel{}; float fuel{};
float fuel_equivalent{};
}; };
class Pipe1D { class Pipe1D {
@@ -172,7 +184,7 @@ public:
std::vector<float> linear_resistance, quadratic_resistance; std::vector<float> linear_resistance, quadratic_resistance;
// Integrated face fluxes: kg/s, N, W, kg/s of fresh air and fuel, positive along +x. // Integrated face fluxes: kg/s, N, W, kg/s of fresh air and fuel, positive along +x.
struct Flux { struct Flux {
float m{}, p{}, e{}, y{}, fuel{}; float m{}, p{}, e{}, y{}, fuel{}, fuel_equivalent{};
}; };
struct RoeState { double root_density{}, enthalpy{}; }; struct RoeState { double root_density{}, enthalpy{}; };
PipeSolver solver{PipeSolver::Rusanov}; PipeSolver solver{PipeSolver::Rusanov};
@@ -200,6 +212,11 @@ public:
if (!std::isfinite(cell_areas[i]) || !std::isfinite(cell_lengths[i]) || cell_areas[i] <= 0 || if (!std::isfinite(cell_areas[i]) || !std::isfinite(cell_lengths[i]) || cell_areas[i] <= 0 ||
cell_lengths[i] <= 0) cell_lengths[i] <= 0)
throw std::invalid_argument("pipe geometry must be finite and positive"); throw std::invalid_argument("pipe geometry must be finite and positive");
if(std::any_of(cell_areas.begin(),cell_areas.end(),[&](float a){return a!=cell_areas.front();})){
geometry_cfl_lengths_.resize(cells.size());
for(std::size_t i=0;i<cells.size();++i){const float a=cell_areas[i],left=i?.5f*(cell_areas[i-1]+a):a,right=i+1<cells.size()?.5f*(a+cell_areas[i+1]):a;
geometry_cfl_lengths_[i]=cell_lengths[i]*(a/std::max({a,left,right}));}
}
} }
GasState state(int i) const { return primitive(cells.at(static_cast<std::size_t>(i))); } GasState state(int i) const { return primitive(cells.at(static_cast<std::size_t>(i))); }
float cell_mass(int i) const { return cells.at(i).rho * cell_areas.at(i) * cell_lengths.at(i); } float cell_mass(int i) const { return cells.at(i).rho * cell_areas.at(i) * cell_lengths.at(i); }
@@ -212,7 +229,9 @@ public:
auto s = primitive(cells[i]); auto s = primitive(cells[i]);
if (!(s.rho > 0) || !(s.pressure > 0) || !std::isfinite(s.velocity) || !std::isfinite(s.pressure)) if (!(s.rho > 0) || !(s.pressure > 0) || !std::isfinite(s.velocity) || !std::isfinite(s.pressure))
throw std::runtime_error("nonphysical pipe state in CFL calculation"); throw std::runtime_error("nonphysical pipe state in CFL calculation");
dt = std::min(dt, cfl * cell_lengths[i] / wave(s)); float speed=wave(s),length=cell_lengths[i];
if(!geometry_cfl_lengths_.empty()){length=geometry_cfl_lengths_[i];if(i)speed=std::max(speed,wave(primitive(cells[i-1])));if(i+1<cells.size())speed=std::max(speed,wave(primitive(cells[i+1])));}
dt = std::min(dt, cfl * length / speed);
} }
return dt; return dt;
} }
@@ -242,6 +261,13 @@ public:
throw std::runtime_error("nonphysical pipe state in preparation"); throw std::runtime_error("nonphysical pipe state in preparation");
dt = std::min(dt, cfl * cell_lengths[i] / waves_[i]); dt = std::min(dt, cfl * cell_lengths[i] / waves_[i]);
} }
// A narrow cell exchanges flux through faces larger than its own area.
// Limit that face/volume ratio and use each face's fastest adjacent wave.
// Uniform pipes retain the original fast preparation path.
if(!geometry_cfl_lengths_.empty())for(std::size_t i=0;i<cells.size();++i){
float speed=waves_[i];if(i)speed=std::max(speed,waves_[i-1]);if(i+1<cells.size())speed=std::max(speed,waves_[i+1]);
dt=std::min(dt,cfl*geometry_cfl_lengths_[i]/speed);
}
if (solver == PipeSolver::Hlle || solver == PipeSolver::Hllc) if (solver == PipeSolver::Hlle || solver == PipeSolver::Hllc)
for (std::size_t i=0; i<cells.size(); ++i) { for (std::size_t i=0; i<cells.size(); ++i) {
const auto& s=states_[i]; const auto& s=states_[i];
@@ -272,7 +298,7 @@ public:
const float u = c.momentum / std::max(c.rho, 1e-12f); const float u = c.momentum / std::max(c.rho, 1e-12f);
const float air = std::clamp(c.air, 0.0f, 1.0f); const float air = std::clamp(c.air, 0.0f, 1.0f);
return {c.rho, u, pipe_gm1 * (c.energy - .5f * c.momentum * u), air, return {c.rho, u, pipe_gm1 * (c.energy - .5f * c.momentum * u), air,
std::clamp(c.fuel, 0.0f, 1.0f - air)}; std::clamp(c.fuel, 0.0f, 1.0f - air),std::clamp(std::max(c.fuel,c.fuel_equivalent),0.f,1.f)};
} }
static float wave(const GasState &s) { static float wave(const GasState &s) {
return std::abs(s.velocity) + std::sqrt(gamma * s.pressure / s.rho); return std::abs(s.velocity) + std::sqrt(gamma * s.pressure / s.rho);
@@ -301,7 +327,7 @@ public:
const double sr=std::max({ua+ca,ub+cb,uroe+croe}); const double sr=std::max({ua+ca,ub+cb,uroe+croe});
const auto physical=[](const GasState& q,double energy) { const auto physical=[](const GasState& q,double energy) {
const float m=q.rho*q.velocity; const float m=q.rho*q.velocity;
return Flux{m,m*q.velocity+q.pressure,float((energy+q.pressure)*q.velocity),m*q.air,m*q.fuel}; return Flux{m,m*q.velocity+q.pressure,float((energy+q.pressure)*q.velocity),m*q.air,m*q.fuel,m*q.fuel_equivalent};
}; };
const Flux fa=physical(a,ea), fb=physical(b,eb); const Flux fa=physical(a,ea), fb=physical(b,eb);
if(sl>=0) return fa; if(sl>=0) return fa;
@@ -312,7 +338,7 @@ public:
float((sr*fa.p-sl*fb.p+diff*(b.rho*ub-a.rho*ua))*inv), float((sr*fa.p-sl*fb.p+diff*(b.rho*ub-a.rho*ua))*inv),
float((sr*fa.e-sl*fb.e+diff*(eb-ea))*inv), float((sr*fa.e-sl*fb.e+diff*(eb-ea))*inv),
float((sr*fa.y-sl*fb.y+diff*(b.rho*b.air-a.rho*a.air))*inv), float((sr*fa.y-sl*fb.y+diff*(b.rho*b.air-a.rho*a.air))*inv),
float((sr*fa.fuel-sl*fb.fuel+diff*(b.rho*b.fuel-a.rho*a.fuel))*inv)}; float((sr*fa.fuel-sl*fb.fuel+diff*(b.rho*b.fuel-a.rho*a.fuel))*inv),float((sr*fa.fuel_equivalent-sl*fb.fuel_equivalent+diff*(b.rho*b.fuel_equivalent-a.rho*a.fuel_equivalent))*inv)};
}; };
if constexpr(method==PipeSolver::Hlle) return hlle(); if constexpr(method==PipeSolver::Hlle) return hlle();
const double dl=a.rho*(sl-ua), dr=b.rho*(sr-ub); const double dl=a.rho*(sl-ua), dr=b.rho*(sr-ub);
@@ -333,7 +359,7 @@ public:
} }
const double mass=f.m+speed*(density-q.rho); const double mass=f.m+speed*(density-q.rho);
return {float(mass),float(f.p+speed*(density*sm-q.rho*q.velocity)), return {float(mass),float(f.p+speed*(density*sm-q.rho*q.velocity)),
float(f.e+speed*(energy-(left?ea:eb))),float(mass*q.air),float(mass*q.fuel)}; float(f.e+speed*(energy-(left?ea:eb))),float(mass*q.air),float(mass*q.fuel),float(mass*q.fuel_equivalent)};
} }
static Flux numerical_flux(const GasState& a,const GasState& b,PipeSolver solver, static Flux numerical_flux(const GasState& a,const GasState& b,PipeSolver solver,
float ca,float cb,bool* fallback=nullptr) { float ca,float cb,bool* fallback=nullptr) {
@@ -407,6 +433,7 @@ public:
float en = cells[i].energy - factor * (fr.e - fl.e); float en = cells[i].energy - factor * (fr.e - fl.e);
const float air = cells[i].rho * cells[i].air - factor * (fr.y - fl.y); const float air = cells[i].rho * cells[i].air - factor * (fr.y - fl.y);
const float fuel = cells[i].rho * cells[i].fuel - factor * (fr.fuel - fl.fuel); const float fuel = cells[i].rho * cells[i].fuel - factor * (fr.fuel - fl.fuel);
const float equivalent=cells[i].rho*std::max(cells[i].fuel_equivalent,cells[i].fuel)-factor*(fr.fuel_equivalent-fl.fuel_equivalent);
if (!std::isfinite(rho) || !std::isfinite(mom) || !std::isfinite(en) || if (!std::isfinite(rho) || !std::isfinite(mom) || !std::isfinite(en) ||
!std::isfinite(air) || !std::isfinite(fuel)) !std::isfinite(air) || !std::isfinite(fuel))
throw std::runtime_error("nonfinite finite-volume update"); throw std::runtime_error("nonfinite finite-volume update");
@@ -434,9 +461,10 @@ public:
// subnormal concentrations; total gas mass and energy are unchanged. // subnormal concentrations; total gas mass and energy are unchanged.
const float fraction = std::clamp(air / rho, 0.0f, 1.0f); const float fraction = std::clamp(air / rho, 0.0f, 1.0f);
const float fuel_fraction = std::clamp(fuel / rho, 0.0f, 1.0f - fraction); const float fuel_fraction = std::clamp(fuel / rho, 0.0f, 1.0f - fraction);
const float equivalent_fraction=std::clamp(equivalent/rho,fuel_fraction,1.f);
next_cells_[i] = {rho, mom, en, next_cells_[i] = {rho, mom, en,
fraction < std::numeric_limits<float>::min() ? 0.0f : fraction, fraction < std::numeric_limits<float>::min() ? 0.0f : fraction,
fuel_fraction < std::numeric_limits<float>::min() ? 0.0f : fuel_fraction}; fuel_fraction < std::numeric_limits<float>::min() ? 0.0f : fuel_fraction,equivalent_fraction<std::numeric_limits<float>::min()?0.f:equivalent_fraction};
} }
cells.swap(next_cells_); cells.swap(next_cells_);
prepared_ = false; prepared_ = false;
@@ -444,11 +472,12 @@ public:
left_override_ = right_override_ = false; left_override_ = right_override_ = false;
} }
static Flux scaled(Flux f, float a) { static Flux scaled(Flux f, float a) {
return {f.m * a, f.p * a, f.e * a, f.y * a, f.fuel * a}; return {f.m * a, f.p * a, f.e * a, f.y * a, f.fuel * a,f.fuel_equivalent*a};
} }
private: private:
std::vector<GasCell> next_cells_; std::vector<GasCell> next_cells_;
std::vector<float> geometry_cfl_lengths_;
std::vector<GasState> states_; std::vector<GasState> states_;
std::vector<GasState> reconstructed_left_, reconstructed_right_; std::vector<GasState> reconstructed_left_, reconstructed_right_;
std::vector<float> waves_; std::vector<float> waves_;
@@ -480,13 +509,14 @@ private:
const float dp=minmod(q.pressure-a.pressure,b.pressure-q.pressure); const float dp=minmod(q.pressure-a.pressure,b.pressure-q.pressure);
const float da=minmod(q.air-a.air,b.air-q.air); const float da=minmod(q.air-a.air,b.air-q.air);
const float df=minmod(q.fuel-a.fuel,b.fuel-q.fuel); const float df=minmod(q.fuel-a.fuel,b.fuel-q.fuel);
const float de=minmod(q.fuel_equivalent-a.fuel_equivalent,b.fuel_equivalent-q.fuel_equivalent);
const float h=.5f*dt/cell_lengths[i]; const float h=.5f*dt/cell_lengths[i];
const GasState half{q.rho-h*(q.velocity*dr+q.rho*du), const GasState half{q.rho-h*(q.velocity*dr+q.rho*du),
q.velocity-h*(q.velocity*du+dp/q.rho), q.velocity-h*(q.velocity*du+dp/q.rho),
q.pressure-h*(q.velocity*dp+gamma*q.pressure*du), q.pressure-h*(q.velocity*dp+gamma*q.pressure*du),
q.air-h*q.velocity*da,q.fuel-h*q.velocity*df}; q.air-h*q.velocity*da,q.fuel-h*q.velocity*df,q.fuel_equivalent-h*q.velocity*de};
const auto edge=[&](float sign) { return GasState{half.rho+sign*dr, const auto edge=[&](float sign) { return GasState{half.rho+sign*dr,
half.velocity+sign*du,half.pressure+sign*dp,half.air+sign*da,half.fuel+sign*df}; }; half.velocity+sign*du,half.pressure+sign*dp,half.air+sign*da,half.fuel+sign*df,half.fuel_equivalent+sign*de}; };
const auto left=edge(-.5f), right=edge(.5f); const auto left=edge(-.5f), right=edge(.5f);
const auto physical=[](const GasState& v) { const auto physical=[](const GasState& v) {
return v.rho>1e-12f && v.pressure>1e-6f && std::isfinite(v.pressure) && return v.rho>1e-12f && v.pressure>1e-6f && std::isfinite(v.pressure) &&
@@ -510,7 +540,7 @@ private:
.5f * (ma * a.velocity + a.pressure + mb * b.velocity + b.pressure - c * (mb - ma)), .5f * (ma * a.velocity + a.pressure + mb * b.velocity + b.pressure - c * (mb - ma)),
.5f * ((ea + a.pressure) * a.velocity + (eb + b.pressure) * b.velocity - c * (eb - ea)), .5f * ((ea + a.pressure) * a.velocity + (eb + b.pressure) * b.velocity - c * (eb - ea)),
.5f * (ma * a.air + mb * b.air - c * (b.rho * b.air - a.rho * a.air)), .5f * (ma * a.air + mb * b.air - c * (b.rho * b.air - a.rho * a.air)),
.5f * (ma * a.fuel + mb * b.fuel - c * (b.rho * b.fuel - a.rho * a.fuel))}; .5f * (ma * a.fuel + mb * b.fuel - c * (b.rho * b.fuel - a.rho * a.fuel)),.5f*(ma*a.fuel_equivalent+mb*b.fuel_equivalent-c*(b.rho*b.fuel_equivalent-a.rho*a.fuel_equivalent))};
} }
}; };
@@ -668,7 +698,7 @@ private:
struct JunctionNode { struct JunctionNode {
std::vector<JunctionFace> ends; std::vector<JunctionFace> ends;
std::vector<GasState> states; std::vector<GasState> states;
struct Face { double pressure{},rho{},velocity{},mass{},enthalpy{},air{},fuel{},loss{}; }; struct Face { double pressure{},rho{},velocity{},mass{},enthalpy{},air{},fuel{},loss{},equivalent{}; };
std::vector<Face> faces; std::vector<Face> faces;
JunctionMethod method{JunctionMethod::Characteristic}; JunctionMethod method{JunctionMethod::Characteristic};
double turning_loss{}; double turning_loss{};
@@ -689,7 +719,7 @@ private:
s.velocity*=e.left?-1:1;node.states[k]=s; s.velocity*=e.left?-1:1;node.states[k]=s;
speed=std::max(speed,2*(std::sqrt(gamma*s.pressure/s.rho)+std::abs(double(s.velocity)))); speed=std::max(speed,2*(std::sqrt(gamma*s.pressure/s.rho)+std::abs(double(s.velocity))));
} }
double area_sum=0,rho=0,energy=0,air=0,fuel=0; double area_sum=0,rho=0,energy=0,air=0,fuel=0,equivalent=0;
for(std::size_t k=0;k<node.ends.size();++k) { for(std::size_t k=0;k<node.ends.size();++k) {
auto e=node.ends[k];auto& p=pipes_[e.pipe];const auto s=node.states[k]; auto e=node.ends[k];auto& p=pipes_[e.pipe];const auto s=node.states[k];
const double a=p.cell_area(end(p,e.left)), u=s.velocity; const double a=p.cell_area(end(p,e.left)), u=s.velocity;
@@ -697,8 +727,10 @@ private:
area_sum+=a;rho+=a*s.rho*(1+u/speed); area_sum+=a;rho+=a*s.rho*(1+u/speed);
energy+=a*(en+(en+s.pressure)*u/speed); energy+=a*(en+(en+s.pressure)*u/speed);
air+=a*s.rho*s.air*(1+u/speed);fuel+=a*s.rho*s.fuel*(1+u/speed); air+=a*s.rho*s.air*(1+u/speed);fuel+=a*s.rho*s.fuel*(1+u/speed);
equivalent+=a*s.rho*s.fuel_equivalent*(1+u/speed);
} }
rho/=area_sum;energy/=area_sum;air/=area_sum;fuel/=area_sum; rho/=area_sum;energy/=area_sum;air/=area_sum;fuel/=area_sum;
equivalent/=area_sum;
const double pressure=energy*pipe_gm1; const double pressure=energy*pipe_gm1;
for(std::size_t k=0;k<node.ends.size();++k) { for(std::size_t k=0;k<node.ends.size();++k) {
auto e=node.ends[k];auto& p=pipes_[e.pipe];const auto s=node.states[k]; auto e=node.ends[k];auto& p=pipes_[e.pipe];const auto s=node.states[k];
@@ -710,7 +742,8 @@ private:
const double y=.5*a*(s.rho*s.air*u-speed*(air-s.rho*s.air)); const double y=.5*a*(s.rho*s.air*u-speed*(air-s.rho*s.air));
const double f=.5*a*(s.rho*s.fuel*u-speed*(fuel-s.rho*s.fuel)); const double f=.5*a*(s.rho*s.fuel*u-speed*(fuel-s.rho*s.fuel));
const double momentum=.5*a*(s.rho*u*u+s.pressure+pressure+speed*s.rho*u); const double momentum=.5*a*(s.rho*u*u+s.pressure+pressure+speed*s.rho*u);
p.set_boundary_flux(e.left,{float(orientation*m),float(momentum),float(orientation*heat),float(orientation*y),float(orientation*f)}); const double marker=.5*a*(s.rho*s.fuel_equivalent*u-speed*(equivalent-s.rho*s.fuel_equivalent));
p.set_boundary_flux(e.left,{float(orientation*m),float(momentum),float(orientation*heat),float(orientation*y),float(orientation*f),float(orientation*marker)});
} }
} }
// Nonlinear outgoing acoustic-wave curves (shock or rarefaction), with // Nonlinear outgoing acoustic-wave curves (shock or rarefaction), with
@@ -743,7 +776,7 @@ private:
}; };
// Each pressure evaluation writes scratch face states only, never pipes. // Each pressure evaluation writes scratch face states only, never pipes.
const auto residual=[&](double pressure) { const auto residual=[&](double pressure) {
double incoming=0, heat=0, air=0, fuel=0; double incoming=0, heat=0, air=0, fuel=0,equivalent=0;
for(std::size_t k=0;k<node.ends.size();++k) { for(std::size_t k=0;k<node.ends.size();++k) {
const auto q=node.states[k]; auto& f=node.faces[k]; const auto q=node.states[k]; auto& f=node.faces[k];
double p=pressure; double p=pressure;
@@ -757,6 +790,7 @@ private:
f.mass=pipe.cell_area(end(pipe,e.left))*f.rho*f.velocity; f.mass=pipe.cell_area(end(pipe,e.left))*f.rho*f.velocity;
f.enthalpy=gamma/(gamma-1)*p/f.rho+.5*f.velocity*f.velocity; f.enthalpy=gamma/(gamma-1)*p/f.rho+.5*f.velocity*f.velocity;
f.air=q.air; f.fuel=q.fuel; f.air=q.air; f.fuel=q.fuel;
f.equivalent=q.fuel_equivalent;if(f.mass>0)equivalent+=f.mass*f.equivalent;
if(f.mass>0) { incoming+=f.mass; heat+=f.mass*f.enthalpy; air+=f.mass*q.air; fuel+=f.mass*q.fuel; } if(f.mass>0) { incoming+=f.mass; heat+=f.mass*f.enthalpy; air+=f.mass*q.air; fuel+=f.mass*q.fuel; }
} }
double balance=0; double balance=0;
@@ -769,6 +803,7 @@ private:
const auto e=node.ends[k]; const auto& pipe=pipes_[e.pipe]; const auto e=node.ends[k]; const auto& pipe=pipes_[e.pipe];
f.mass=pipe.cell_area(end(pipe,e.left))*f.rho*f.velocity; f.mass=pipe.cell_area(end(pipe,e.left))*f.rho*f.velocity;
f.enthalpy=h; f.air=air/incoming; f.fuel=fuel/incoming; f.enthalpy=h; f.air=air/incoming; f.fuel=fuel/incoming;
f.equivalent=equivalent/incoming;
} }
balance+=f.mass; balance+=f.mass;
} }
@@ -808,12 +843,13 @@ private:
} }
} }
} }
double incoming=0, outgoing=0, heat=0, air=0, fuel=0, scale=1; double incoming=0, outgoing=0, heat=0, air=0, fuel=0, scale=1,equivalent=0;
for(std::size_t k=0;k<node.ends.size();++k) { for(std::size_t k=0;k<node.ends.size();++k) {
const auto& f=node.faces[k]; const auto& f=node.faces[k];
if(!std::isfinite(f.mass) || !std::isfinite(f.enthalpy)) return false; if(!std::isfinite(f.mass) || !std::isfinite(f.enthalpy)) return false;
if(std::abs(f.velocity)>=.98*std::sqrt(gamma*f.pressure/f.rho)) return false; if(std::abs(f.velocity)>=.98*std::sqrt(gamma*f.pressure/f.rho)) return false;
if(f.mass>0) { incoming+=f.mass;heat+=f.mass*f.enthalpy;air+=f.mass*f.air;fuel+=f.mass*f.fuel; } if(f.mass>0) { incoming+=f.mass;heat+=f.mass*f.enthalpy;air+=f.mass*f.air;fuel+=f.mass*f.fuel; }
if(f.mass>0)equivalent+=f.mass*f.equivalent;
else outgoing-=f.mass; else outgoing-=f.mass;
} }
if(incoming>1e-16 && outgoing<=1e-16) { if(incoming>1e-16 && outgoing<=1e-16) {
@@ -821,12 +857,14 @@ private:
// Residual tolerance at rest must not create a one-sided mass source. // Residual tolerance at rest must not create a one-sided mass source.
for(auto& face:node.faces) face.mass=0; for(auto& face:node.faces) face.mass=0;
incoming=heat=air=fuel=0; incoming=heat=air=fuel=0;
equivalent=0;
} }
// Remove root roundoff by scaling all outflows together. A shared donor // Remove root roundoff by scaling all outflows together. A shared donor
// budget scales advective fluxes together, preserving node conservation. // budget scales advective fluxes together, preserving node conservation.
for(std::size_t k=0;k<node.ends.size();++k) { for(std::size_t k=0;k<node.ends.size();++k) {
auto& f=node.faces[k]; auto& f=node.faces[k];
if(f.mass<0) { f.mass*=outgoing>0?incoming/outgoing:0; f.enthalpy=incoming>0?heat/incoming:0; f.air=incoming>0?air/incoming:0; f.fuel=incoming>0?fuel/incoming:0; } if(f.mass<0) { f.mass*=outgoing>0?incoming/outgoing:0; f.enthalpy=incoming>0?heat/incoming:0; f.air=incoming>0?air/incoming:0; f.fuel=incoming>0?fuel/incoming:0; }
if(f.mass<0)f.equivalent=incoming>0?equivalent/incoming:0;
if(f.mass>0) { if(f.mass>0) {
const auto e=node.ends[k]; const auto& pipe=pipes_[e.pipe];const int i=end(pipe,e.left); const auto e=node.ends[k]; const auto& pipe=pipes_[e.pipe];const int i=end(pipe,e.left);
scale=std::min(scale,.25*pipe.cell_mass(i)/(f.mass*dt)); scale=std::min(scale,.25*pipe.cell_mass(i)/(f.mass*dt));
@@ -838,7 +876,7 @@ private:
const auto e=node.ends[k]; auto& pipe=pipes_[e.pipe]; const auto& f=node.faces[k]; const auto e=node.ends[k]; auto& pipe=pipes_[e.pipe]; const auto& f=node.faces[k];
const double orientation=e.left?-1:1, m=f.mass*scale; const double orientation=e.left?-1:1, m=f.mass*scale;
const double momentum=m*f.velocity+f.pressure*pipe.cell_area(end(pipe,e.left)); const double momentum=m*f.velocity+f.pressure*pipe.cell_area(end(pipe,e.left));
pipe.set_boundary_flux(e.left,{float(orientation*m),float(momentum),float(orientation*m*f.enthalpy),float(orientation*m*f.air),float(orientation*m*f.fuel)}); pipe.set_boundary_flux(e.left,{float(orientation*m),float(momentum),float(orientation*m*f.enthalpy),float(orientation*m*f.air),float(orientation*m*f.fuel),float(orientation*m*f.equivalent)});
} }
return true; return true;
} }
@@ -1036,17 +1074,19 @@ private:
const float direction = o.left ? 1.f : -1.f; const float direction = o.left ? 1.f : -1.f;
const float species = from ? v.air_fraction : s.air; const float species = from ? v.air_fraction : s.air;
const float fuel = from ? v.fuel_fraction : s.fuel; const float fuel = from ? v.fuel_fraction : s.fuel;
const float equivalent=from?std::max(v.fuel_equivalent_fraction,v.fuel_fraction):s.fuel_equivalent;
const float signed_mass = direction * mdot; const float signed_mass = direction * mdot;
p.set_boundary_flux(o.left, {signed_mass, p.set_boundary_flux(o.left, {signed_mass,
mdot * velocity + face_pressure * area + s.pressure * (face_area - area), mdot * velocity + face_pressure * area + s.pressure * (face_area - area),
signed_mass * h, signed_mass * species, signed_mass * fuel}); signed_mass * h, signed_mass * species, signed_mass * fuel,signed_mass*equivalent});
ghost(p, o.left, {density, direction * velocity, face_pressure, species, fuel}); ghost(p, o.left, {density, direction * velocity, face_pressure, species, fuel,equivalent});
v.mass_flow = -mdot; v.mass_flow = -mdot;
v.enthalpy = h; v.enthalpy = h;
if (!from) if (!from)
v.air_fraction = s.air; v.air_fraction = s.air;
if (!from) if (!from)
v.fuel_fraction = s.fuel; v.fuel_fraction = s.fuel;
if(!from)v.fuel_equivalent_fraction=s.fuel_equivalent;
} }
void resolve_open(Open &o, float dt) { void resolve_open(Open &o, float dt) {
auto &p = pipes_[o.pipe]; auto &p = pipes_[o.pipe];
@@ -1078,13 +1118,14 @@ private:
if (!(temperature > 0)) throw std::runtime_error("ambient inflow exceeds stagnation enthalpy"); if (!(temperature > 0)) throw std::runtime_error("ambient inflow exceeds stagnation enthalpy");
face.rho = face.pressure/(gas_constant*temperature); face.rho = face.pressure/(gas_constant*temperature);
face.air = 1; face.fuel = 0; face.air = 1; face.fuel = 0;
face.fuel_equivalent=0;
} }
} }
ghost(p, o.left, face); ghost(p, o.left, face);
const float mass_flux = face.rho*face.velocity*o.area; const float mass_flux = face.rho*face.velocity*o.area;
const float enthalpy = gamma/(gamma-1)*face.pressure/face.rho + .5f*face.velocity*face.velocity; const float enthalpy = gamma/(gamma-1)*face.pressure/face.rho + .5f*face.velocity*face.velocity;
p.set_boundary_flux(o.left, {mass_flux, mass_flux*face.velocity+face.pressure*o.area, p.set_boundary_flux(o.left, {mass_flux, mass_flux*face.velocity+face.pressure*o.area,
mass_flux*enthalpy, mass_flux*face.air, mass_flux*face.fuel}); mass_flux*enthalpy, mass_flux*face.air, mass_flux*face.fuel,mass_flux*face.fuel_equivalent});
o.last_volume_flow = direction*face.velocity*o.area; o.last_volume_flow = direction*face.velocity*o.area;
o.last_mass_flow = direction*mass_flux; o.last_mass_flow = direction*mass_flux;
o.last_face_pressure = face.pressure; o.last_face_pressure = face.pressure;
@@ -1098,7 +1139,7 @@ private:
const bool out = o.left ? s.velocity <= -c : s.velocity >= c; const bool out = o.left ? s.velocity <= -c : s.velocity >= c;
const bool ambient_inflow = o.left ? u >= 0 : u <= 0; const bool ambient_inflow = o.left ? u >= 0 : u <= 0;
GasState g = out ? s : GasState{rho, u, o.pressure, ambient_inflow ? 1.f : s.air, GasState g = out ? s : GasState{rho, u, o.pressure, ambient_inflow ? 1.f : s.air,
ambient_inflow ? 0.f : s.fuel}; ambient_inflow ? 0.f : s.fuel,ambient_inflow?0.f:s.fuel_equivalent};
ghost(p, o.left, g); ghost(p, o.left, g);
const float a = std::min(o.area, p.cell_area(i)); const float a = std::min(o.area, p.cell_area(i));
auto f = Pipe1D::scaled(o.left ? Pipe1D::riemann(g, s) : Pipe1D::riemann(s, g), a); auto f = Pipe1D::scaled(o.left ? Pipe1D::riemann(g, s) : Pipe1D::riemann(s, g), a);
@@ -1123,10 +1164,10 @@ private:
auto f = Pipe1D::scaled(Pipe1D::numerical_flux(sa, sb, a.solver == b.solver ? a.solver : PipeSolver::Rusanov), area); auto f = Pipe1D::scaled(Pipe1D::numerical_flux(sa, sb, a.solver == b.solver ? a.solver : PipeSolver::Rusanov), area);
a.set_boundary_flux(o.left_a, a.set_boundary_flux(o.left_a,
{da * f.m, f.p + sa.pressure * (a.cell_area(ia) - area), da * f.e, {da * f.m, f.p + sa.pressure * (a.cell_area(ia) - area), da * f.e,
da * f.y, da * f.fuel}); da * f.y, da * f.fuel,da*f.fuel_equivalent});
b.set_boundary_flux(o.left_b, b.set_boundary_flux(o.left_b,
{db * f.m, f.p + sb.pressure * (b.cell_area(ib) - area), db * f.e, {db * f.m, f.p + sb.pressure * (b.cell_area(ib) - area), db * f.e,
db * f.y, db * f.fuel}); db * f.y, db * f.fuel,db*f.fuel_equivalent});
} }
}; };

View File

@@ -0,0 +1,31 @@
#pragma once
#include "FluidSim/MachinePorts.hpp"
namespace fluidsim::port {
class Volume0D;
// Initial delivery model: calibrated regulated rail, with declared ideal or
// finite supply. Rail pressure dynamics and liquid plumbing are separate work.
struct FuelInjectorConfig {
enum class Supply {IdealRail,FiniteRail};
Supply supply{Supply::FiniteRail};
double maximum_mass_flow{.001}; // kg/s at the calibrated rail pressure
double initial_fuel_mass{.1}; // kg, used only by FiniteRail
float fuel_temperature{300}; // sensible energy of the vaporized fuel
};
struct FuelInjectorState {
bool command_fault{},saturated{},empty{};
double mass_flow{},delivered_mass{},remaining_mass{};
};
class FuelInjector {
public:
explicit FuelInjector(FuelInjectorConfig config={});
// Continuous/batch-average delivery, not a sequential pulse-width model.
// Adds conserved fuel species, tracer and sensible energy to its host.
const FuelInjectorState& deliver(Volume0D& host,const SignalValue& command,
double time,double dt);
const FuelInjectorState& state()const{return state_;}
private:
FuelInjectorConfig config_;
FuelInjectorState state_;
};
}

View File

@@ -0,0 +1,288 @@
#pragma once
#include "FluidSim/Engine.hpp"
#include "FluidSim/PhysicalGeometry.hpp"
#include "FluidSim/PipeSolver.hpp"
#include "FluidSim/OutdoorAudio.hpp"
#include "FluidSim/Ecu.hpp"
#include "FluidSim/Carburetor.hpp"
#include "FluidSim/FuelInjector.hpp"
#include "FluidSim/PipeProfile.hpp"
#include "FluidSim/MachinePorts.hpp"
#include "FluidSim/Mechanics.hpp"
#include "FluidSim/MachineSensors.hpp"
#include "FluidSim/MachineLogic.hpp"
#include <cstdint>
#include <array>
#include <filesystem>
#include <memory>
#include <optional>
#include <string>
#include <vector>
namespace fluidsim::port {
namespace machine_math {
Vec3 add(Vec3,Vec3);
Vec3 subtract(Vec3,Vec3);
Vec3 multiply(Vec3,float);
float dot(Vec3,Vec3);
Vec3 cross(Vec3,Vec3);
float length(Vec3);
Vec3 unit(Vec3);
// Intrinsic XYZ Euler angles, in degrees. Rendering and picking use this transform.
Vec3 rotate(Vec3,Vec3 degrees);
Vec3 inverse_rotate(Vec3,Vec3 degrees);
Vec3 rotate_euler_world(Vec3,unsigned axis,float degrees);
}
enum class MachinePartKind { Pipe, Chamber, Junction, Throttle, CheckValve, Cylinder, PistonPump, Reservoir, Cap, Carburetor, Ecu, Shaft, Flywheel, DriveMotor, Brake };
inline constexpr int machine_part_kind_count=15;
enum class MachineConnectionKind { Orifice, Inertance, Characteristic, Legacy, Control, Mechanical };
inline constexpr int machine_connection_kind_count=6;
const char* machine_part_name(MachinePartKind);
const char* machine_part_key(MachinePartKind);
const char* machine_connection_name(MachineConnectionKind);
const char* machine_connection_key(MachineConnectionKind);
struct MachineShaftConfig {
float inertia{.15f},initial_speed{},initial_angle{};
float friction_constant{},friction_viscous{},friction_quadratic{},starter_torque{};
};
struct MachineDriveConfig {float target_speed{100},torque{1};bool speed_control{true},enabled{true};};
struct MachineBrakeConfig {float constant{1},viscous{},quadratic{};bool enabled{true};};
struct MachineSensorEcuConfig {
bool enabled{}; // False preserves the explicitly labeled legacy ECU.
SensorEcuConfig::Strategy strategy{SensorEcuConfig::Strategy::Ignition};
std::vector<float> fuel_rpm{0,6000},fuel_tps{0,1};
std::vector<float> fuel_per_cycle{.000002f,.00002f,.000002f,.00002f};
float displacement{.0003f},air_gas_constant{287},target_afr{14.7f};
std::vector<float> ve_rpm{0,6000},ve_pressure{20000,100000},volumetric_efficiency{.9f,.9f,.9f,.9f};
bool operator==(const MachineSensorEcuConfig&)const=default;
};
struct MachineInjectorConfig {
std::uint32_t ecu{};
std::string ecu_label;
int cylinders{1}; // Number of calibrated cylinder doses delivered here.
float maximum_flow{.001f},initial_mass{.1f},temperature{300};
bool finite_supply{true};
bool operator==(const MachineInjectorConfig&)const=default;
};
struct MachinePart {
std::uint32_t id{};
MachinePartKind kind{MachinePartKind::Pipe};
std::string name;
Vec3 position{0,.25f,0}, rotation{}, size{.12f,.12f,.12f};
// Pipe centerline in part-local metres. Length is always derived from it.
std::vector<Vec3> points{{-.15f,0,0},{.15f,0,0}};
// One diameter per route point, linearly interpolated along each segment.
// Empty profiles are legacy/programmatic pipes using the two scalar values.
std::vector<float> point_diameters;
float diameter{.04f}, outlet_diameter{.04f};
float pressure{101325}, temperature{300}, opening{1}, discharge{.8f};
bool pedal_control{true};
float cracking_pressure{100}, phase_degrees{}, maximum_lift{.008f};
float intake_open{350}, intake_close{580}, exhaust_open{130}, exhaust_close{370};
CylinderConfig cylinder{};
FourStrokeCylinder::ValveDynamics valve{};
FourStrokeCylinder::ValveDynamics exhaust_valve{};
FourStrokeCylinder::CamProfile intake_profile,exhaust_profile;
// Normalized body coordinates preserve imported port locations when resized.
std::vector<Vec3> port_coordinates;
// Stable identities paired with custom chamber/junction port coordinates.
std::vector<std::string> port_keys;
EcuConfig ecu;
MachineSensorEcuConfig sensor_ecu;
std::optional<MachineInjectorConfig> injector; // Explicit chamber attachment.
float ignition_tdc_degrees{}; // Calibrated crank pickup angle, 0..360.
CarburetorConfig carburetor;
bool injection_enabled{true};
// Junction method 0 stores gas in its visible box; 1/2 are zero-volume nodes.
int junction_method{};
float turning_loss{};
MachineShaftConfig shaft;
MachineDriveConfig drive;
MachineBrakeConfig brake;
};
struct MachinePort {
std::uint32_t part{};
unsigned port{};
bool operator==(const MachinePort&) const = default;
};
SensorEcuConfig machine_sensor_ecu_config(const MachinePart&);
struct MachineLink {
MachinePort a,b;
MachineConnectionKind kind{MachineConnectionKind::Orifice};
float discharge{1}, inertance_length{.01f}, loss{};
};
enum class MachineQuantity {Pressure,Temperature,MassFlow,AirFuelRatio,Lambda,ChargeAfr,Rpm,Torque,Power};
inline constexpr std::size_t machine_probe_limit=8;
inline constexpr float machine_afr_reading_limit=1000.f;
enum class MachineReadingState {Valid,NoFuel,LeanLimit,FuelCut,AwaitingCharge};
const char* machine_reading_state_name(MachineReadingState);
const char* machine_quantity_name(MachineQuantity);
const char* machine_quantity_unit(MachineQuantity);
float machine_quantity_scale(MachineQuantity);
bool machine_quantity_supported(const MachinePart&,MachineQuantity);
struct MachineProbe {
std::uint32_t id{},part{};
std::string name;
MachineQuantity quantity{MachineQuantity::Pressure};
float position{.5f}; // fraction of pipe arclength; lumped parts ignore it
float reference_afr{14.7f}; // stoichiometric AFR for the selected fuel
bool generated_output{}; // Migrated legacy shaft torque/power excludes bench motor/load.
bool operator==(const MachineProbe&)const=default;
};
struct MachineMeasurement {
double time{};
std::array<float,machine_probe_limit> values{}; // SI; undefined readings are NaN
std::array<MachineReadingState,machine_probe_limit> states{};
std::size_t count{};
};
struct MachineDocument {
std::string name{"Untitled machine"};
float cells_per_meter{40}, drive_rpm{}, shaft_inertia{.15f}, load_torque{};
bool driven{};
// Deprecated calibration fields above are read only by legacy migration.
std::uint32_t primary_shaft{};
float friction_constant{.15f},friction_viscous{.002f},friction_quadratic{},starter_torque{15};
PipeSolver solver{PipeSolver::Rusanov};
OutdoorScene scene{OutdoorScene::Wall};
std::uint32_t next_id{1};
std::vector<MachinePart> parts;
std::vector<MachineLink> links;
std::vector<MachineProbe> probes;
std::vector<MachineSensor> sensors;
std::uint32_t next_sensor_id{1};
LogicGraph logic;
MachinePart* find(std::uint32_t);
const MachinePart* find(std::uint32_t) const;
MachinePart& add(MachinePartKind,Vec3 position={0,.25f,0});
MachineSensor& add_sensor(MachineSensorKind,std::uint32_t host,float position=.5f);
MachineSensor* find_sensor(std::uint32_t);
const MachineSensor* find_sensor(std::uint32_t)const;
void remove_sensor(std::uint32_t);
void remove(std::uint32_t);
void disconnect(MachinePort);
// At least one endpoint must be a pipe. Its centerline snaps to the other port.
void connect(MachinePort,MachinePort,MachineConnectionKind=MachineConnectionKind::Orifice);
void sync_connections(std::uint32_t moved_part=0);
void transform_selection(const std::vector<std::uint32_t>& ids,Vec3 pivot,Vec3 translation,unsigned rotation_axis=0,float degrees=0);
};
bool machine_sensor_supported(const MachinePart&,MachineSensorKind,bool reference=false);
MachineSensorStatus machine_sensor_binding(const MachineDocument&,const MachineSensor&);
std::optional<Vec3> machine_sensor_position(const MachineDocument&,const MachineSensorTap&);
void validate_machine_sensors(const MachineDocument&);
bool machine_control_port(const MachinePart&,unsigned);
MachinePortDescriptor machine_port_descriptor(const MachinePart&,unsigned);
unsigned machine_port_index(const MachinePart&,const std::string& key);
PortDomain machine_link_domain(const MachineDocument&,const MachineLink&);
bool machine_rotational_part(MachinePartKind);
std::uint32_t machine_shaft_for(const MachineDocument&,std::uint32_t part);
std::uint32_t machine_primary_shaft(const MachineDocument&);
bool machine_shaft_driven(const MachineDocument&,std::uint32_t shaft);
// Convert an old shared-shaft document to visible mechanical parts once.
void upgrade_machine_mechanics(MachineDocument&);
bool machine_port_available(const MachineDocument&,MachinePort);
unsigned machine_port_count(const MachinePart&);
std::string machine_port_name(const MachinePart&,unsigned);
Vec3 machine_local_port(const MachinePart&,unsigned);
Vec3 machine_world_point(const MachinePart&,Vec3);
Vec3 machine_local_point(const MachinePart&,Vec3);
Vec3 machine_port_position(const MachinePart&,unsigned);
std::vector<Vec3> machine_pipe_path(const MachinePart&);
float machine_pipe_length(const MachinePart&);
std::vector<float> machine_pipe_diameters(const MachinePart&);
std::vector<PipeSection> machine_pipe_sections(const MachinePart&);
float machine_pipe_diameter(const MachinePart&,float distance);
void machine_insert_pipe_point(MachinePart&,std::size_t segment,Vec3 local_point);
void machine_remove_pipe_point(MachinePart&,std::size_t point);
float machine_part_volume(const MachinePart&);
Vec3 machine_part_size(const MachinePart&);
// Drafts may contain unconnected ports. Unconnected duct ends are explicitly capped at test time.
void validate_machine(const MachineDocument&);
std::vector<std::string> machine_diagnostics(const MachineDocument&);
struct MachineConnectionPreview {
bool allowed{};
std::string message;
std::optional<MachinePart> pipe;
};
// Dry-run the actual connection and validation without changing the design.
MachineConnectionPreview machine_connection_preview(const MachineDocument&,MachinePort,MachinePort);
bool machine_has_fuel_source(const MachineDocument&,std::uint32_t cylinder);
struct MachineCheck {
std::uint32_t part{};
bool warning{};
std::string title,advice;
std::uint32_t sensor{};
std::uint32_t logic_node{};
};
std::vector<MachineCheck> machine_checks(const MachineDocument&);
std::filesystem::path machine_directory();
std::vector<std::filesystem::path> machine_files();
std::filesystem::path machine_assembly_directory();
std::vector<std::filesystem::path> machine_assembly_files();
// Center of the selected world geometry, including routed pipes.
Vec3 machine_selection_center(const MachineDocument&,const std::vector<std::uint32_t>&);
// Stores only selected parts, internal links and their probes, relative to the center.
MachineDocument machine_extract_assembly(const MachineDocument&,const std::vector<std::uint32_t>&,std::string name={});
// Inserts an independent copy atomically; returned IDs follow assembly.parts order.
std::vector<std::uint32_t> machine_insert_assembly(MachineDocument&,const MachineDocument&,Vec3 center);
MachineDocument load_machine(const std::filesystem::path&);
void save_machine(const MachineDocument&,const std::filesystem::path&);
MachineDocument machine_pump_example();
MachineDocument machine_engine_example();
MachineDocument machine_logic_example();
MachineDocument machine_sensor_engine_example(bool injection=false);
MachineDocument machine_speed_density_example();
struct ScenarioExample;
MachineDocument machine_from_engine(const ScenarioExample&);
struct MachinePartState {
std::uint32_t id{};
float pressure{101325}, temperature{300}, volume{}, flow{}, piston_fraction{}, intake_lift{}, exhaust_lift{}, opening{};
std::vector<float> pipe_pressures;
float fuel_mass{},fuel_flow{},afr{},burn_fraction{};
bool combustion{},fuel_cut{},ignition_cut{},idle_control{},valve_float{},fuel_source{};
bool actuator_enabled{},signal_controlled{};
double command_target{};
double injector_remaining{},injector_delivered{};
bool sensor_fault{},rev_limited{},injector_fault{},injector_empty{};
float spark_advance{};
std::uint32_t shaft{};
double shaft_angle{},shaft_speed{},rotational_inertia{},torque{},power{},kinetic_energy{},mechanical_work{},mechanical_loss{};
};
struct MachineSnapshot {
double time{};
float rpm{}, torque{}, power{};
std::uint64_t cells{},substeps{},positivity_repairs{};
float minimum_cell_length{};
std::uint32_t minimum_cell_part{};
std::vector<MachinePartState> parts;
std::uint32_t primary_shaft{};
std::vector<ShaftState> shafts;
std::vector<MachineSensorState> sensors;
std::vector<LogicNodeState> logic;
};
// Owns the compiled graph. Editing never mutates a running solver; Test compiles a fresh graph.
class MachineSimulation {
public:
explicit MachineSimulation(const MachineDocument&,int sample_rate=44100);
~MachineSimulation();
MachineSimulation(const MachineSimulation&)=delete;
MachineSimulation& operator=(const MachineSimulation&)=delete;
AudioFrame process();
void set_controls(float pedal,bool starter=false);
void set_listener(AudioListener);
MachineSnapshot snapshot() const;
MachineMeasurement measure() const;
// Latest sample-boundary values, independent of recorded instrument channels.
MachineSensorState sense(std::uint32_t sensor)const;
void set_logic_control(std::uint32_t node,bool);
void set_logic_control(std::uint32_t node,double);
void release_logic_buttons();
// Applies safe tuning and instrument changes between samples, preserving state.
// Geometry, topology, initial conditions and compiled boundary settings must match.
void apply_tuning(const MachineDocument&);
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
}

View File

@@ -0,0 +1,111 @@
#pragma once
#include "FluidSim/MachineSensors.hpp"
#include "FluidSim/Ecu.hpp"
#include <cstdint>
#include <functional>
#include <memory>
#include <string>
#include <vector>
namespace fluidsim::port {
struct MachineDocument;
enum class LogicNodeKind {
BooleanConstant,Switch,Button,NumberConstant,Slider,NumberInput,
And,Or,Not,Xor,Nand,Nor,Greater,Less,Equal,Window,Hysteresis,
Add,Subtract,Multiply,Divide,Minimum,Maximum,Absolute,Clamp,GainOffset,MapRange,Select,BooleanToNumber,
Sensor,Command,Lamp,Readout,BooleanDelay,NumberDelay,Ecu
};
inline constexpr int logic_node_kind_count=36;
enum class LogicCommand {MotorEnable,MotorSpeed,MotorTorque,BrakeEnable,ThrottleOpening,Starter};
inline constexpr int logic_command_count=6;
struct LogicPosition {float x{},y{};bool operator==(const LogicPosition&)const=default;};
struct LogicNode {
std::uint32_t id{};
LogicNodeKind kind{LogicNodeKind::Switch};
std::string name;
LogicPosition position;
SignalQuantity quantity{SignalQuantity::Scalar},output_quantity{SignalQuantity::Scalar};
PressureReference pressure_reference{PressureReference::None},output_pressure_reference{PressureReference::None};
double value{},minimum{},maximum{1},low{.25},high{.75},tolerance{1e-6};
double gain{1},offset{},false_value{},true_value{1},input_low{},input_high{1},output_low{},output_high{1};
bool boolean{},initial{},below{},clamp{true};
unsigned input_count{2};
std::uint32_t sensor{},part{};
MachineSensorKind sensor_kind{MachineSensorKind::Pressure}; // Schema survives an unresolved reference.
SensorEcuConfig::Strategy ecu_strategy{SensorEcuConfig::Strategy::Ignition};
LogicCommand command{LogicCommand::MotorEnable};
std::string binding_label;
bool operator==(const LogicNode&)const=default;
};
struct LogicEndpoint {
std::uint32_t node{};
std::string port;
bool operator==(const LogicEndpoint&)const=default;
};
struct LogicLink {
LogicEndpoint from,to;
bool operator==(const LogicLink&)const=default;
};
struct LogicGraph {
std::uint32_t next_id{1};
std::vector<LogicNode> nodes;
std::vector<LogicLink> links;
LogicNode* find(std::uint32_t);
const LogicNode* find(std::uint32_t)const;
LogicNode& add(LogicNodeKind,LogicPosition={});
void remove(std::uint32_t);
void disconnect(const LogicEndpoint&);
void connect(LogicEndpoint,LogicEndpoint); // Canonical output -> input; validates without mutation on failure.
bool operator==(const LogicGraph&)const=default;
};
const char* logic_node_key(LogicNodeKind);
const char* logic_node_name(LogicNodeKind);
const char* logic_command_key(LogicCommand);
const char* logic_command_name(LogicCommand);
bool logic_memory_node(LogicNodeKind);
std::vector<MachinePortDescriptor> logic_node_ports(const LogicNode&,const MachineDocument* context=nullptr);
MachinePortDescriptor logic_port(const LogicGraph&,const LogicEndpoint&);
PortCompatibility logic_connection_preview(const LogicGraph&,LogicEndpoint,LogicEndpoint);
void validate_logic_graph(const LogicGraph&); // Drafts may have required unconnected inputs.
std::vector<std::uint32_t> logic_evaluation_order(const LogicGraph&,std::vector<std::uint32_t>* cycle_path=nullptr);
std::vector<std::string> logic_required_inputs(const LogicGraph&);
bool logic_topology_equal(const LogicGraph&,const LogicGraph&);
// Empty means a compatible physical binding. Missing targets remain unresolved.
std::string logic_binding_problem(const MachineDocument&,const LogicNode&);
const LogicNode* logic_command_owner(const MachineDocument&,std::uint32_t part,LogicCommand);
void validate_machine_logic(const MachineDocument&);
struct LogicNodeState {
std::uint32_t id{};
std::vector<SignalValue> values; // Same order as logic_node_ports, including inputs.
std::string problem;
};
struct LogicCommandState {
std::uint32_t node{},part{};
LogicCommand command{};
SignalValue value; // Invalid values require the declared inactive consumer fallback.
};
struct LogicEcuInputs {
std::uint32_t node{},part{};
SignalValue speed,angle,tps,map,iat;
};
class MachineLogicRuntime {
public:
using SensorReader=std::function<MachineSensorState(std::uint32_t)>;
explicit MachineLogicRuntime(const MachineDocument&,SensorReader={});
~MachineLogicRuntime();
MachineLogicRuntime(const MachineLogicRuntime&)=delete;
MachineLogicRuntime& operator=(const MachineLogicRuntime&)=delete;
void evaluate(double time,double dt);
SignalValue value(const LogicEndpoint&)const;
std::vector<LogicNodeState> snapshot()const;
const std::vector<LogicCommandState>& commands()const;
const std::vector<LogicEcuInputs>& ecu_inputs()const;
void set_manual(std::uint32_t,bool);
void set_manual(std::uint32_t,double);
void release_buttons();
void apply_tuning(const MachineDocument&);
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
}

View File

@@ -0,0 +1,51 @@
#pragma once
#include <cstdint>
#include <optional>
#include <string>
#include <variant>
namespace fluidsim::port {
enum class PortDomain {Gas,Rotational,Translational,Boolean,Number,EcuAssignment};
enum class PortDirection {Bidirectional,Input,Output};
enum class PortCardinality {Single,Multiple};
// Quantities carry meaning as well as dimensions. Opening and scalar, or crank
// angle and cycle phase, require an explicit conversion even when units match.
enum class SignalQuantity {None,Scalar,Fraction,Pressure,Temperature,MassFlow,FreshAirMassFlow,AngularSpeed,Angle,CyclePhase,Torque,Power,Position,Velocity,Force,Time,Inertia};
enum class PressureReference {None,Absolute,Gauge,Differential};
enum class SignalValidity {Valid,Missing,Fault};
// Runtime-only pickup evidence. Arithmetic creates new values without timing
// authority; routing retains the original acquisition time, including delays.
struct SignalTiming {
std::uint32_t sensor{},shaft{};
double sampled_at{},unwrapped_angle{},angular_speed{};
bool operator==(const SignalTiming&)const=default;
};
struct SignalValue {
std::variant<bool,double> value{false};
SignalValidity validity{SignalValidity::Missing};
double timestamp{};
std::optional<SignalTiming> timing;
static SignalValue boolean(bool,double timestamp=0);
static SignalValue number(double,double timestamp=0);
};
struct MachinePortDescriptor {
std::string key,name;
PortDomain domain{PortDomain::Gas};
PortDirection direction{PortDirection::Bidirectional};
PortCardinality cardinality{PortCardinality::Single};
SignalQuantity quantity{SignalQuantity::None};
PressureReference pressure_reference{PressureReference::None};
bool required{};
std::variant<std::monostate,bool,double> default_value;
std::optional<double> minimum,maximum;
bool operator==(const MachinePortDescriptor&)const=default;
};
struct PortCompatibility {bool allowed{};std::string message;};
const char* port_domain_key(PortDomain);
const char* signal_quantity_key(SignalQuantity);
const char* signal_quantity_unit(SignalQuantity); // Internal SI units.
bool signal_domain(PortDomain);
void validate_port_descriptor(const MachinePortDescriptor&);
PortCompatibility port_compatibility(const MachinePortDescriptor&,const MachinePortDescriptor&);
bool signal_value_valid_for(const SignalValue&,const MachinePortDescriptor&);
}

View File

@@ -0,0 +1,40 @@
#pragma once
#include "FluidSim/MachinePorts.hpp"
#include <array>
#include <cstdint>
#include <string>
namespace fluidsim::port {
enum class MachineSensorKind {Pressure,DifferentialPressure,GasTemperature,MassFlow,Crank,ThrottlePosition};
inline constexpr int machine_sensor_kind_count=6;
struct MachineSensorTap {
std::uint32_t part{};
float position{.5f}; // Normalized pipe arclength, independent of fluid cells.
std::string label; // Retains the intended host when a binding is unresolved.
bool operator==(const MachineSensorTap&)const=default;
};
struct MachineSensor {
std::uint32_t id{};
MachineSensorKind kind{MachineSensorKind::Pressure};
std::string name;
MachineSensorTap target,reference;
float angle_offset{}; // Radians, added to the physical shaft angle.
bool operator==(const MachineSensor&)const=default;
};
enum class MachineSensorStatus {Valid,MissingHost,IncompatibleHost,Fault};
struct MachineSensorState {
std::uint32_t id{};
std::array<SignalValue,3> outputs;
unsigned count{};
MachineSensorStatus status{MachineSensorStatus::MissingHost};
// An ideal crank pickup preserves provenance and unwrapped crossings. An
// arbitrary numeric RPM signal must never acquire timing authority from it.
std::uint32_t timing_shaft{};
double unwrapped_angle{};
};
const char* machine_sensor_key(MachineSensorKind);
const char* machine_sensor_name(MachineSensorKind);
const char* machine_sensor_status_name(MachineSensorStatus);
unsigned machine_sensor_output_count(MachineSensorKind);
MachinePortDescriptor machine_sensor_output(const MachineSensor&,unsigned);
}

View File

@@ -0,0 +1,54 @@
#pragma once
#include <cstdint>
#include <memory>
namespace fluidsim::port {
// Rotational quantities use SI: rad, rad/s, N m, kg m2, J and W.
enum class DriveMode {Torque,Speed};
struct ShaftParameters {
double inertia{.15},initial_speed{},initial_angle{};
double friction_constant{},friction_viscous{},friction_quadratic{};
bool forward_only{};
};
struct DriveParameters {DriveMode mode{DriveMode::Torque};double command{};bool enabled{true};};
struct BrakeParameters {double constant{},viscous{},quadratic{};bool enabled{true};};
struct ShaftState {
std::uint32_t id{};
double angle{},previous_angle{},speed{},inertia{},kinetic_energy{};
double net_torque{},source_torque{},external_torque{},friction_torque{},brake_torque{},constraint_torque{};
double external_work{},drive_work{},friction_loss{},brake_loss{},initial_energy{};
// initial_energy + external_work + drive_work = kinetic_energy + losses.
};
struct DriveState {
std::uint32_t id{},shaft{};
double torque{},power{},work{};
};
struct BrakeState {
std::uint32_t id{},shaft{};
double torque{},power{},loss{}; // Torque on shaft signed; absorbed power >= 0.
};
// Independent rigid shaft nodes. Gears and clutches will couple these nodes;
// direct attachment adds torque or inertia to one named node exactly once.
class MechanicalSystem {
public:
MechanicalSystem();
~MechanicalSystem();
MechanicalSystem(const MechanicalSystem&)=delete;
MechanicalSystem& operator=(const MechanicalSystem&)=delete;
void add_shaft(std::uint32_t,const ShaftParameters&);
void add_flywheel(std::uint32_t id,std::uint32_t shaft,double inertia);
void add_drive(std::uint32_t id,std::uint32_t shaft,const DriveParameters&);
void add_brake(std::uint32_t id,std::uint32_t shaft,const BrakeParameters&);
void set_drive(std::uint32_t,const DriveParameters&);
void set_brake(std::uint32_t,const BrakeParameters&);
void set_friction(std::uint32_t shaft,double constant,double viscous,double quadratic);
void add_torque(std::uint32_t shaft,double torque);
void step(double dt);
const ShaftState& shaft(std::uint32_t)const;
const DriveState& drive(std::uint32_t)const;
const BrakeState& brake(std::uint32_t)const;
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
}

View File

@@ -35,4 +35,31 @@ inline Pipe1D make_profiled_pipe(std::span<const PipeSection> sections, float ma
} }
return Pipe1D(std::move(areas), std::move(lengths)); return Pipe1D(std::move(areas), std::move(lengths));
} }
// Uniform cells over the full arclength, integrating every profile section that
// a cell crosses. Route/profile knots do not force short cells; gas volume is
// still the exact sum of the piecewise frustums at any mesh density.
inline Pipe1D make_uniform_profiled_pipe(std::span<const PipeSection> sections,float maximum_cell_length=.01f){
if(!std::isfinite(maximum_cell_length)||maximum_cell_length<=0||sections.empty())throw std::invalid_argument("invalid pipe profile mesh");
double total=0;float previous=sections.front().inlet_diameter;
for(const auto& s:sections){
if(!std::isfinite(s.length)||!std::isfinite(s.inlet_diameter)||!std::isfinite(s.outlet_diameter)||s.length<=0||s.inlet_diameter<=0||s.outlet_diameter<=0||std::abs(s.inlet_diameter-previous)>1e-6f)throw std::invalid_argument("invalid/discontinuous pipe profile");
total+=s.length;previous=s.outlet_diameter;
}
const int count=profile_section_cells(float(total),maximum_cell_length);const double dx=total/count;
std::vector<float> areas;areas.reserve(count);std::vector<float> lengths(count,float(dx));
std::size_t section=0;double section_start=0,section_end=sections.front().length;
for(int i=0;i<count;++i){
double cursor=i*dx,volume=0;const double end=i+1==count?total:(i+1)*dx;
while(cursor<end){
while(section+1<sections.size()&&cursor>=section_end){section_start=section_end;section_end+=sections[++section].length;}
const auto& s=sections[section];const double stop=std::min(end,section_end);
const double d0=std::lerp(double(s.inlet_diameter),double(s.outlet_diameter),std::clamp((cursor-section_start)/s.length,0.,1.));
const double d1=std::lerp(double(s.inlet_diameter),double(s.outlet_diameter),std::clamp((stop-section_start)/s.length,0.,1.));
volume+=double(pi)/12*(stop-cursor)*(d0*d0+d0*d1+d1*d1);cursor=stop;
}
areas.push_back(float(volume/dx));
}
return Pipe1D(std::move(areas),std::move(lengths));
}
} // namespace fluidsim::port } // namespace fluidsim::port

View File

@@ -19,6 +19,8 @@ public:
bool write(float left, float right, std::stop_token stop = {}); bool write(float left, float right, std::stop_token stop = {});
bool wait_for_space(std::size_t target_fill, std::stop_token stop); bool wait_for_space(std::size_t target_fill, std::stop_token stop);
void reset_consumer(); void reset_consumer();
// Only while both producer and audio consumer are stopped.
void discard();
std::size_t read_interpolated(std::span<float> left, std::span<float> right, double speed); std::size_t read_interpolated(std::span<float> left, std::span<float> right, double speed);
private: private:

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,422 @@
cells_per_meter = 10.0
drive_rpm = 0.0
driven = false
format = 4
friction_constant = 0.15000000596046448
friction_quadratic = 0.0
friction_viscous = 0.0020000000949949026
load_torque = 0.0
name = '3-cyl-header'
scene = 2
shaft_inertia = 0.15000000596046448
solver = 'rusanov'
starter_torque = 15.0
[[connections]]
a_part = 31
a_port = 1
b_part = 30
b_port = 0
discharge = 1.0
inertance_length_m = 0.0099999997764825821
loss = 0.0
method = 0
[[connections]]
a_part = 32
a_port = 0
b_part = 30
b_port = 0
discharge = 1.0
inertance_length_m = 0.0099999997764825821
loss = 0.0
method = 0
[[connections]]
a_part = 33
a_port = 1
b_part = 30
b_port = 0
discharge = 1.0
inertance_length_m = 0.0099999997764825821
loss = 0.0
method = 0
[[connections]]
a_part = 50
a_port = 0
b_part = 30
b_port = 0
discharge = 1.0
inertance_length_m = 0.0099999997764825821
loss = 0.0
method = 0
[[parts]]
afr = 14.699999809265137
ambient_temperature = 300.0
bore_m = 0.075000002980232239
burn_duration_deg = 60.0
burn_start_deg = 5.0
compression = 11.5
cracking_pressure_pa = 100.0
diameter_m = 0.039999999105930328
discharge = 0.80000001192092896
energy_variation = 0.05000000074505806
exhaust_close_deg = 370.0
exhaust_open_deg = 130.0
exhaust_profile = []
exhaust_valve_count = 1
exhaust_valve_m = 0.028000000864267349
fuel_lhv = 44000000.0
heat_transfer = 100.0
id = 30
injection_enabled = true
intake_close_deg = 580.0
intake_open_deg = 350.0
intake_profile = []
intake_valve_count = 1
intake_valve_m = 0.029999999329447746
junction_method = 1
kind = 'junction'
maximum_lift_m = 0.0080000003799796104
misfire_probability = 0.0
name = 'Junction 30'
opening = 1.0
outlet_diameter_m = 0.039999999105930328
pedal_control = true
phase_deg = 0.0
points_m = [ [ -0.15000000596046448, 0.0, 0.0 ], [ 0.15000000596046448, 0.0, 0.0 ] ]
port_coordinates = []
position_m = [ -0.01974998414516449, -0.032500006258487701, 0.042499996721744537 ]
pressure_pa = 101325.0
rod_m = 0.1120000034570694
rotation_deg = [ 0.0, 0.0, 0.0 ]
size_m = [ 0.059999998658895493, 0.059999998658895493, 0.059999998658895493 ]
spark_advance_deg = 20.0
stroke_m = 0.068000003695487976
temperature_k = 300.0
turning_loss = 0.0
valve_damping = 24.0
valve_mass_kg = 0.035000000149011612
valve_preload_n = 100.0
valve_spring_n_m = 30000.0
wall_area_m2 = 0.019999999552965164
wiebe_a = 5.0
wiebe_m = 2.0
[parts.exhaust_dynamics]
cam_restitution = 0.0099999997764825821
damping = 24.0
mass_kg = 0.035000000149011612
maximum_lift = 0.014999999664723873
seat_force = 100.0
seat_restitution = 0.004999999888241291
spring_rate = 30000.0
stop_restitution = 0.0099999997764825821
[parts.intake_dynamics]
cam_restitution = 0.0099999997764825821
damping = 24.0
mass_kg = 0.035000000149011612
maximum_lift = 0.014999999664723873
seat_force = 100.0
seat_restitution = 0.004999999888241291
spring_rate = 30000.0
stop_restitution = 0.0099999997764825821
[[parts]]
afr = 14.699999809265137
ambient_temperature = 300.0
bore_m = 0.075000002980232239
burn_duration_deg = 60.0
burn_start_deg = 5.0
compression = 11.5
cracking_pressure_pa = 100.0
discharge = 0.80000001192092896
energy_variation = 0.05000000074505806
exhaust_close_deg = 370.0
exhaust_open_deg = 130.0
exhaust_profile = []
exhaust_valve_count = 1
exhaust_valve_m = 0.028000000864267349
fuel_lhv = 44000000.0
heat_transfer = 100.0
id = 31
injection_enabled = true
intake_close_deg = 580.0
intake_open_deg = 350.0
intake_profile = []
intake_valve_count = 1
intake_valve_m = 0.029999999329447746
junction_method = 0
kind = 'pipe'
maximum_lift_m = 0.0080000003799796104
misfire_probability = 0.0
name = 'Pipe / reducer 31'
opening = 1.0
pedal_control = true
phase_deg = 0.0
point_diameters_m = [ 0.02500000037252903, 0.02500000037252903, 0.02500000037252903 ]
points_m = [ [ 0.13300000131130219, -0.29750001430511475, -0.59825778007507324 ], [ 0.15052753686904907, -0.33734250068664551, -0.59909915924072266 ], [ 0.18999999761581421, -0.37000000476837158, -0.60000002384185791 ] ]
port_coordinates = []
position_m = [ -0.2097499817609787, 0.33749997615814209, 0.64249998331069946 ]
pressure_pa = 101325.0
rod_m = 0.1120000034570694
rotation_deg = [ 0.0, 0.0, 0.0 ]
size_m = [ 0.11999999731779099, 0.11999999731779099, 0.11999999731779099 ]
spark_advance_deg = 20.0
stroke_m = 0.068000003695487976
temperature_k = 300.0
turning_loss = 0.0
valve_damping = 24.0
valve_mass_kg = 0.035000000149011612
valve_preload_n = 100.0
valve_spring_n_m = 30000.0
wall_area_m2 = 0.019999999552965164
wiebe_a = 5.0
wiebe_m = 2.0
[parts.exhaust_dynamics]
cam_restitution = 0.0099999997764825821
damping = 24.0
mass_kg = 0.035000000149011612
maximum_lift = 0.014999999664723873
seat_force = 100.0
seat_restitution = 0.004999999888241291
spring_rate = 30000.0
stop_restitution = 0.0099999997764825821
[parts.intake_dynamics]
cam_restitution = 0.0099999997764825821
damping = 24.0
mass_kg = 0.035000000149011612
maximum_lift = 0.014999999664723873
seat_force = 100.0
seat_restitution = 0.004999999888241291
spring_rate = 30000.0
stop_restitution = 0.0099999997764825821
[[parts]]
afr = 14.699999809265137
ambient_temperature = 300.0
bore_m = 0.075000002980232239
burn_duration_deg = 60.0
burn_start_deg = 5.0
compression = 11.5
cracking_pressure_pa = 100.0
discharge = 0.80000001192092896
energy_variation = 0.05000000074505806
exhaust_close_deg = 370.0
exhaust_open_deg = 130.0
exhaust_profile = []
exhaust_valve_count = 1
exhaust_valve_m = 0.028000000864267349
fuel_lhv = 44000000.0
heat_transfer = 100.0
id = 32
injection_enabled = true
intake_close_deg = 580.0
intake_open_deg = 350.0
intake_profile = []
intake_valve_count = 1
intake_valve_m = 0.029999999329447746
junction_method = 0
kind = 'pipe'
maximum_lift_m = 0.0080000003799796104
misfire_probability = 0.0
name = 'Pipe / reducer 32'
opening = 1.0
pedal_control = true
phase_deg = 0.0
point_diameters_m = [ 0.02500000037252903, 0.02500000037252903, 0.02500000037252903, 0.02500000037252903 ]
points_m = [ [ -0.36999994516372681, -0.37000000476837158, -0.52000004053115845 ], [ -0.4761640727519989, -0.34520614147186279, -0.51906377077102661 ], [ -0.52133440971374512, -0.32712483406066895, -0.5185772180557251 ], [ -0.53999996185302734, -0.29750001430511475, -0.51825779676437378 ] ]
port_coordinates = []
position_m = [ 0.35024997591972351, 0.33749997615814209, 0.5625 ]
pressure_pa = 101325.0
rod_m = 0.1120000034570694
rotation_deg = [ 0.0, 0.0, 0.0 ]
size_m = [ 0.11999999731779099, 0.11999999731779099, 0.11999999731779099 ]
spark_advance_deg = 20.0
stroke_m = 0.068000003695487976
temperature_k = 300.0
turning_loss = 0.0
valve_damping = 24.0
valve_mass_kg = 0.035000000149011612
valve_preload_n = 100.0
valve_spring_n_m = 30000.0
wall_area_m2 = 0.019999999552965164
wiebe_a = 5.0
wiebe_m = 2.0
[parts.exhaust_dynamics]
cam_restitution = 0.0099999997764825821
damping = 24.0
mass_kg = 0.035000000149011612
maximum_lift = 0.014999999664723873
seat_force = 100.0
seat_restitution = 0.004999999888241291
spring_rate = 30000.0
stop_restitution = 0.0099999997764825821
[parts.intake_dynamics]
cam_restitution = 0.0099999997764825821
damping = 24.0
mass_kg = 0.035000000149011612
maximum_lift = 0.014999999664723873
seat_force = 100.0
seat_restitution = 0.004999999888241291
spring_rate = 30000.0
stop_restitution = 0.0099999997764825821
[[parts]]
afr = 14.699999809265137
ambient_temperature = 300.0
bore_m = 0.075000002980232239
burn_duration_deg = 60.0
burn_start_deg = 5.0
compression = 11.5
cracking_pressure_pa = 100.0
discharge = 0.80000001192092896
energy_variation = 0.05000000074505806
exhaust_close_deg = 370.0
exhaust_open_deg = 130.0
exhaust_profile = []
exhaust_valve_count = 1
exhaust_valve_m = 0.028000000864267349
fuel_lhv = 44000000.0
heat_transfer = 100.0
id = 33
injection_enabled = true
intake_close_deg = 580.0
intake_open_deg = 350.0
intake_profile = []
intake_valve_count = 1
intake_valve_m = 0.029999999329447746
junction_method = 0
kind = 'pipe'
maximum_lift_m = 0.0080000003799796104
misfire_probability = 0.0
name = 'Pipe / reducer 33'
opening = 1.0
pedal_control = true
phase_deg = 0.0
point_diameters_m = [ 0.02500000037252903, 0.02500000037252903, 0.02500000037252903, 0.02500000037252903 ]
points_m = [ [ 0.16699999570846558, -0.29750001430511475, 0.14174219965934753 ], [ 0.21503587067127228, -0.35698139667510986, 0.14158914983272552 ], [ 0.25817635655403137, -0.36491745710372925, 0.14149604737758636 ], [ 0.44999998807907104, -0.37000000476837158, 0.13999998569488525 ] ]
port_coordinates = []
position_m = [ -0.46974998712539673, 0.33749997615814209, -0.09749998152256012 ]
pressure_pa = 101325.0
rod_m = 0.1120000034570694
rotation_deg = [ 0.0, 0.0, 0.0 ]
size_m = [ 0.11999999731779099, 0.11999999731779099, 0.11999999731779099 ]
spark_advance_deg = 20.0
stroke_m = 0.068000003695487976
temperature_k = 300.0
turning_loss = 0.0
valve_damping = 24.0
valve_mass_kg = 0.035000000149011612
valve_preload_n = 100.0
valve_spring_n_m = 30000.0
wall_area_m2 = 0.019999999552965164
wiebe_a = 5.0
wiebe_m = 2.0
[parts.exhaust_dynamics]
cam_restitution = 0.0099999997764825821
damping = 24.0
mass_kg = 0.035000000149011612
maximum_lift = 0.014999999664723873
seat_force = 100.0
seat_restitution = 0.004999999888241291
spring_rate = 30000.0
stop_restitution = 0.0099999997764825821
[parts.intake_dynamics]
cam_restitution = 0.0099999997764825821
damping = 24.0
mass_kg = 0.035000000149011612
maximum_lift = 0.014999999664723873
seat_force = 100.0
seat_restitution = 0.004999999888241291
spring_rate = 30000.0
stop_restitution = 0.0099999997764825821
[[parts]]
afr = 14.699999809265137
ambient_temperature = 300.0
bore_m = 0.075000002980232239
burn_duration_deg = 60.0
burn_start_deg = 5.0
compression = 11.5
cracking_pressure_pa = 100.0
discharge = 0.80000001192092896
energy_variation = 0.05000000074505806
exhaust_close_deg = 370.0
exhaust_open_deg = 130.0
exhaust_profile = []
exhaust_valve_count = 1
exhaust_valve_m = 0.028000000864267349
fuel_lhv = 44000000.0
heat_transfer = 100.0
id = 50
injection_enabled = true
intake_close_deg = 580.0
intake_open_deg = 350.0
intake_profile = []
intake_valve_count = 1
intake_valve_m = 0.029999999329447746
junction_method = 0
kind = 'pipe'
maximum_lift_m = 0.0080000003799796104
misfire_probability = 0.0
name = 'Pipe / reducer 50'
opening = 1.0
pedal_control = true
phase_deg = 0.0
point_diameters_m = [ 0.029999999329447746, 0.029999999329447746 ]
points_m = [ [ -0.46999996900558472, -0.37000000476837158, -0.2200000137090683 ], [ -0.15000000596046448, -0.29999998211860657, -0.31000000238418579 ] ]
port_coordinates = []
position_m = [ 0.45024999976158142, 0.33749997615814209, 0.26250001788139343 ]
pressure_pa = 101325.0
rod_m = 0.1120000034570694
rotation_deg = [ 0.0, 0.0, 0.0 ]
size_m = [ 0.11999999731779099, 0.11999999731779099, 0.11999999731779099 ]
spark_advance_deg = 20.0
stroke_m = 0.068000003695487976
temperature_k = 300.0
turning_loss = 0.0
valve_damping = 24.0
valve_mass_kg = 0.035000000149011612
valve_preload_n = 100.0
valve_spring_n_m = 30000.0
wall_area_m2 = 0.019999999552965164
wiebe_a = 5.0
wiebe_m = 2.0
[parts.exhaust_dynamics]
cam_restitution = 0.0099999997764825821
damping = 24.0
mass_kg = 0.035000000149011612
maximum_lift = 0.014999999664723873
seat_force = 100.0
seat_restitution = 0.004999999888241291
spring_rate = 30000.0
stop_restitution = 0.0099999997764825821
[parts.intake_dynamics]
cam_restitution = 0.0099999997764825821
damping = 24.0
mass_kg = 0.035000000149011612
maximum_lift = 0.014999999664723873
seat_force = 100.0
seat_restitution = 0.004999999888241291
spring_rate = 30000.0
stop_restitution = 0.0099999997764825821
[[probes]]
id = 1
name = 'Lambda / Pipe / reducer 50'
part = 50
position = 0.5
quantity = 4
reference_afr = 14.699999809265137

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,165 @@
format = 7
name = 'Ideal sensor bench'
primary_shaft = 6
next_id = 8
next_sensor_id = 7
cells_per_meter = 40.0
solver = 'rusanov'
scene = 0
[[parts]]
id = 1
kind = 'reservoir'
name = 'Compressed air supply'
position_m = [-0.5, 0.25, 0.0]
pressure_pa = 200000.0
temperature_k = 300.0
[[parts]]
id = 2
kind = 'throttle'
name = 'Fixed opening valve'
position_m = [-0.1, 0.25, 0.0]
size_m = [0.04, 0.05, 0.05]
pedal_control = false
opening = 0.45
[[parts]]
id = 3
kind = 'chamber'
name = 'Receiving tank'
position_m = [0.3, 0.25, 0.0]
size_m = [0.12, 0.12, 0.12]
[[parts]]
id = 4
kind = 'pipe'
name = 'Supply pipe'
position_m = [0.0, 0.0, 0.0]
points_m = [[-0.5, 0.25, 0.0], [-0.12, 0.25, 0.0]]
point_diameters_m = [0.04, 0.04]
[[parts]]
id = 5
kind = 'pipe'
name = 'Measured filling pipe'
position_m = [0.0, 0.0, 0.0]
points_m = [[-0.08, 0.25, 0.0], [0.24, 0.25, 0.0]]
point_diameters_m = [0.04, 0.04]
[[parts]]
id = 6
kind = 'shaft'
name = 'Pickup demonstration shaft'
position_m = [0.1, 0.2, 0.3]
size_m = [0.25, 0.035, 0.035]
[parts.shaft]
inertia_kg_m2 = 0.15
[[parts]]
id = 7
kind = 'drive_motor'
name = 'Ideal speed drive'
position_m = [-0.25, 0.2, 0.3]
size_m = [0.12, 0.09, 0.09]
[parts.drive]
speed_control = true
target_speed_rad_s = 188.49556
enabled = true
[[connections]]
a_part = 4
a_port = 'start'
b_part = 1
b_port = 'gas'
method = 'orifice'
[[connections]]
a_part = 4
a_port = 'end'
b_part = 2
b_port = 'inlet'
method = 'orifice'
[[connections]]
a_part = 5
a_port = 'start'
b_part = 2
b_port = 'outlet'
method = 'orifice'
[[connections]]
a_part = 5
a_port = 'end'
b_part = 3
b_port = 'face_x_negative'
method = 'orifice'
[[connections]]
a_part = 6
a_port = 'shaft'
b_part = 7
b_port = 'shaft'
method = 'mechanical'
[[sensors]]
id = 1
kind = 'pressure'
name = 'Tank absolute pressure'
target = {part = 3, position = 0.5, label = 'Receiving tank'}
reference = {part = 0, position = 0.5, label = ''}
[[sensors]]
id = 2
kind = 'gas_temperature'
name = 'Tank gas temperature'
target = {part = 3, position = 0.5, label = 'Receiving tank'}
reference = {part = 0, position = 0.5, label = ''}
[[sensors]]
id = 3
kind = 'differential_pressure'
name = 'Supply minus tank pressure'
target = {part = 1, position = 0.5, label = 'Compressed air supply'}
reference = {part = 3, position = 0.5, label = 'Receiving tank'}
[[sensors]]
id = 4
kind = 'mass_flow'
name = 'Signed total filling flow'
target = {part = 5, position = 0.5, label = 'Measured filling pipe'}
reference = {part = 0, position = 0.5, label = ''}
[[sensors]]
id = 5
kind = 'crank'
name = 'Shaft speed and crank position'
angle_offset_rad = 0.0
target = {part = 6, position = 0.5, label = 'Pickup demonstration shaft'}
reference = {part = 0, position = 0.5, label = ''}
[[sensors]]
id = 6
kind = 'throttle_position'
name = 'Valve mechanism opening'
target = {part = 2, position = 0.5, label = 'Fixed opening valve'}
reference = {part = 0, position = 0.5, label = ''}
[[probes]]
id = 1
part = 3
name = 'Tank pressure'
quantity = 0
[[probes]]
id = 2
part = 5
name = 'Total filling flow'
quantity = 2
position = 0.5
[[probes]]
id = 3
part = 6
name = 'Demonstration shaft RPM'
quantity = 6

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,93 @@
format = 6
name = 'Shaft, flywheel and brake bench'
primary_shaft = 1
cells_per_meter = 40.0
solver = 'rusanov'
scene = 0
[[parts]]
id = 1
kind = 'shaft'
name = 'Bench shaft'
position_m = [0.0, 0.22, 0.0]
size_m = [0.25, 0.035, 0.035]
[parts.shaft]
inertia_kg_m2 = 0.15
friction_nm = 0.15
viscous_nm_s = 0.002
[[parts]]
id = 2
kind = 'flywheel'
name = 'Flywheel'
position_m = [0.32, 0.22, 0.0]
size_m = [0.025, 0.16, 0.16]
[parts.shaft]
inertia_kg_m2 = 0.35
[[parts]]
id = 3
kind = 'drive_motor'
name = 'Torque motor'
position_m = [-0.32, 0.22, 0.0]
size_m = [0.12, 0.09, 0.09]
[parts.drive]
speed_control = false
torque_nm = 10.0
enabled = true
[[parts]]
id = 4
kind = 'brake'
name = 'Adjustable brake'
position_m = [0.0, 0.22, 0.24]
size_m = [0.07, 0.1, 0.1]
[parts.brake]
constant_nm = 2.0
viscous_nm_s = 0.01
enabled = true
[[connections]]
a_part = 1
a_port = 'shaft'
b_part = 2
b_port = 'shaft'
method = 'mechanical'
[[connections]]
a_part = 1
a_port = 'shaft'
b_part = 3
b_port = 'shaft'
method = 'mechanical'
[[connections]]
a_part = 1
a_port = 'shaft'
b_part = 4
b_port = 'shaft'
method = 'mechanical'
[[probes]]
id = 1
part = 1
name = 'Bench RPM'
quantity = 6
[[probes]]
id = 2
part = 3
name = 'Motor input power'
quantity = 8
[[probes]]
id = 3
part = 4
name = 'Brake absorbed power'
quantity = 8
[[probes]]
id = 4
part = 1
name = 'Shaft net torque'
quantity = 7

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,560 @@
cells_per_meter = 40.0
drive_rpm = 1800.0
driven = true
format = 1
load_torque = 0.0
name = 'Single cylinder engine bench'
scene = 2
shaft_inertia = 0.15000000596046448
solver = 'rusanov'
[[connections]]
a_part = 7
a_port = 0
b_part = 1
b_port = 0
discharge = 1.0
inertance_length_m = 0.0099999997764825821
loss = 0.0
method = 0
[[connections]]
a_part = 7
a_port = 1
b_part = 2
b_port = 0
discharge = 1.0
inertance_length_m = 0.0099999997764825821
loss = 0.0
method = 0
[[connections]]
a_part = 8
a_port = 0
b_part = 2
b_port = 1
discharge = 1.0
inertance_length_m = 0.0099999997764825821
loss = 0.0
method = 0
[[connections]]
a_part = 8
a_port = 1
b_part = 3
b_port = 0
discharge = 1.0
inertance_length_m = 0.0099999997764825821
loss = 0.0
method = 0
[[connections]]
a_part = 9
a_port = 0
b_part = 3
b_port = 1
discharge = 1.0
inertance_length_m = 0.0099999997764825821
loss = 0.0
method = 0
[[connections]]
a_part = 9
a_port = 1
b_part = 4
b_port = 0
discharge = 1.0
inertance_length_m = 0.0099999997764825821
loss = 0.0
method = 0
[[connections]]
a_part = 10
a_port = 0
b_part = 4
b_port = 1
discharge = 1.0
inertance_length_m = 0.0099999997764825821
loss = 0.0
method = 0
[[connections]]
a_part = 10
a_port = 1
b_part = 5
b_port = 0
discharge = 1.0
inertance_length_m = 0.0099999997764825821
loss = 0.0
method = 0
[[connections]]
a_part = 11
a_port = 0
b_part = 5
b_port = 1
discharge = 1.0
inertance_length_m = 0.0099999997764825821
loss = 0.0
method = 0
[[connections]]
a_part = 11
a_port = 1
b_part = 6
b_port = 0
discharge = 1.0
inertance_length_m = 0.0099999997764825821
loss = 0.0
method = 0
[[parts]]
afr = 14.699999809265137
bore_m = 0.075000002980232239
compression = 11.5
cracking_pressure_pa = 100.0
diameter_m = 0.039999999105930328
discharge = 0.80000001192092896
exhaust_close_deg = 370.0
exhaust_open_deg = 130.0
exhaust_valve_count = 1
exhaust_valve_m = 0.028000000864267349
heat_transfer = 100.0
id = 1
intake_close_deg = 580.0
intake_open_deg = 350.0
intake_valve_count = 1
intake_valve_m = 0.029999999329447746
junction_method = 0
kind = 'reservoir'
maximum_lift_m = 0.0080000003799796104
name = 'Pressure / atmosphere 1'
opening = 1.0
outlet_diameter_m = 0.039999999105930328
pedal_control = true
phase_deg = 0.0
points_m = [ [ -0.15000000596046448, 0.0, 0.0 ], [ 0.15000000596046448, 0.0, 0.0 ] ]
position_m = [ -0.64999997615814209, 0.30000001192092896, 0.0 ]
pressure_pa = 101325.0
rod_m = 0.1120000034570694
rotation_deg = [ 0.0, 0.0, 0.0 ]
size_m = [ 0.039999999105930328, 0.039999999105930328, 0.039999999105930328 ]
spark_advance_deg = 20.0
stroke_m = 0.068000003695487976
temperature_k = 300.0
turning_loss = 0.0
valve_damping = 24.0
valve_mass_kg = 0.035000000149011612
valve_preload_n = 100.0
valve_spring_n_m = 30000.0
wall_area_m2 = 0.019999999552965164
[[parts]]
afr = 14.699999809265137
bore_m = 0.075000002980232239
compression = 11.5
cracking_pressure_pa = 100.0
diameter_m = 0.039999999105930328
discharge = 0.80000001192092896
exhaust_close_deg = 370.0
exhaust_open_deg = 130.0
exhaust_valve_count = 1
exhaust_valve_m = 0.028000000864267349
heat_transfer = 100.0
id = 2
intake_close_deg = 580.0
intake_open_deg = 350.0
intake_valve_count = 1
intake_valve_m = 0.029999999329447746
junction_method = 0
kind = 'throttle'
maximum_lift_m = 0.0080000003799796104
name = 'Throttle valve 2'
opening = 1.0
outlet_diameter_m = 0.039999999105930328
pedal_control = true
phase_deg = 0.0
points_m = [ [ -0.15000000596046448, 0.0, 0.0 ], [ 0.15000000596046448, 0.0, 0.0 ] ]
position_m = [ -0.40000000596046448, 0.30000001192092896, 0.0 ]
pressure_pa = 101325.0
rod_m = 0.1120000034570694
rotation_deg = [ 0.0, 0.0, 0.0 ]
size_m = [ 0.039999999105930328, 0.05000000074505806, 0.05000000074505806 ]
spark_advance_deg = 20.0
stroke_m = 0.068000003695487976
temperature_k = 300.0
turning_loss = 0.0
valve_damping = 24.0
valve_mass_kg = 0.035000000149011612
valve_preload_n = 100.0
valve_spring_n_m = 30000.0
wall_area_m2 = 0.019999999552965164
[[parts]]
afr = 14.699999809265137
bore_m = 0.075000002980232239
compression = 11.5
cracking_pressure_pa = 100.0
diameter_m = 0.039999999105930328
discharge = 0.80000001192092896
exhaust_close_deg = 370.0
exhaust_open_deg = 130.0
exhaust_valve_count = 1
exhaust_valve_m = 0.028000000864267349
heat_transfer = 100.0
id = 3
intake_close_deg = 580.0
intake_open_deg = 350.0
intake_valve_count = 1
intake_valve_m = 0.029999999329447746
junction_method = 0
kind = 'chamber'
maximum_lift_m = 0.0080000003799796104
name = 'Chamber / tank 3'
opening = 1.0
outlet_diameter_m = 0.039999999105930328
pedal_control = true
phase_deg = 0.0
points_m = [ [ -0.15000000596046448, 0.0, 0.0 ], [ 0.15000000596046448, 0.0, 0.0 ] ]
position_m = [ -0.20000000298023224, 0.30000001192092896, 0.0 ]
pressure_pa = 101325.0
rod_m = 0.1120000034570694
rotation_deg = [ 0.0, 0.0, 0.0 ]
size_m = [ 0.10000000149011612, 0.079999998211860657, 0.079999998211860657 ]
spark_advance_deg = 20.0
stroke_m = 0.068000003695487976
temperature_k = 300.0
turning_loss = 0.0
valve_damping = 24.0
valve_mass_kg = 0.035000000149011612
valve_preload_n = 100.0
valve_spring_n_m = 30000.0
wall_area_m2 = 0.019999999552965164
[[parts]]
afr = 14.699999809265137
bore_m = 0.075000002980232239
compression = 11.5
cracking_pressure_pa = 100.0
diameter_m = 0.039999999105930328
discharge = 0.80000001192092896
exhaust_close_deg = 370.0
exhaust_open_deg = 130.0
exhaust_valve_count = 1
exhaust_valve_m = 0.028000000864267349
heat_transfer = 100.0
id = 4
intake_close_deg = 580.0
intake_open_deg = 350.0
intake_valve_count = 1
intake_valve_m = 0.029999999329447746
junction_method = 0
kind = 'cylinder'
maximum_lift_m = 0.0080000003799796104
name = 'Engine cylinder 4'
opening = 1.0
outlet_diameter_m = 0.039999999105930328
pedal_control = true
phase_deg = 0.0
points_m = [ [ -0.15000000596046448, 0.0, 0.0 ], [ 0.15000000596046448, 0.0, 0.0 ] ]
position_m = [ 0.0, 0.20000000298023224, 0.0 ]
pressure_pa = 101325.0
rod_m = 0.1120000034570694
rotation_deg = [ 0.0, 0.0, 0.0 ]
size_m = [ 0.11999999731779099, 0.11999999731779099, 0.11999999731779099 ]
spark_advance_deg = 20.0
stroke_m = 0.068000003695487976
temperature_k = 300.0
turning_loss = 0.0
valve_damping = 24.0
valve_mass_kg = 0.035000000149011612
valve_preload_n = 100.0
valve_spring_n_m = 30000.0
wall_area_m2 = 0.019999999552965164
[[parts]]
afr = 14.699999809265137
bore_m = 0.075000002980232239
compression = 11.5
cracking_pressure_pa = 100.0
diameter_m = 0.039999999105930328
discharge = 0.80000001192092896
exhaust_close_deg = 370.0
exhaust_open_deg = 130.0
exhaust_valve_count = 1
exhaust_valve_m = 0.028000000864267349
heat_transfer = 100.0
id = 5
intake_close_deg = 580.0
intake_open_deg = 350.0
intake_valve_count = 1
intake_valve_m = 0.029999999329447746
junction_method = 0
kind = 'chamber'
maximum_lift_m = 0.0080000003799796104
name = 'Chamber / tank 5'
opening = 1.0
outlet_diameter_m = 0.039999999105930328
pedal_control = true
phase_deg = 0.0
points_m = [ [ -0.15000000596046448, 0.0, 0.0 ], [ 0.15000000596046448, 0.0, 0.0 ] ]
position_m = [ 0.20000000298023224, 0.30000001192092896, 0.0 ]
pressure_pa = 101325.0
rod_m = 0.1120000034570694
rotation_deg = [ 0.0, 0.0, 0.0 ]
size_m = [ 0.079999998211860657, 0.079999998211860657, 0.079999998211860657 ]
spark_advance_deg = 20.0
stroke_m = 0.068000003695487976
temperature_k = 300.0
turning_loss = 0.0
valve_damping = 24.0
valve_mass_kg = 0.035000000149011612
valve_preload_n = 100.0
valve_spring_n_m = 30000.0
wall_area_m2 = 0.019999999552965164
[[parts]]
afr = 14.699999809265137
bore_m = 0.075000002980232239
compression = 11.5
cracking_pressure_pa = 100.0
diameter_m = 0.039999999105930328
discharge = 0.80000001192092896
exhaust_close_deg = 370.0
exhaust_open_deg = 130.0
exhaust_valve_count = 1
exhaust_valve_m = 0.028000000864267349
heat_transfer = 100.0
id = 6
intake_close_deg = 580.0
intake_open_deg = 350.0
intake_valve_count = 1
intake_valve_m = 0.029999999329447746
junction_method = 0
kind = 'reservoir'
maximum_lift_m = 0.0080000003799796104
name = 'Pressure / atmosphere 6'
opening = 1.0
outlet_diameter_m = 0.039999999105930328
pedal_control = true
phase_deg = 0.0
points_m = [ [ -0.15000000596046448, 0.0, 0.0 ], [ 0.15000000596046448, 0.0, 0.0 ] ]
position_m = [ 0.64999997615814209, 0.30000001192092896, 0.0 ]
pressure_pa = 101325.0
rod_m = 0.1120000034570694
rotation_deg = [ 0.0, 0.0, 0.0 ]
size_m = [ 0.039999999105930328, 0.039999999105930328, 0.039999999105930328 ]
spark_advance_deg = 20.0
stroke_m = 0.068000003695487976
temperature_k = 300.0
turning_loss = 0.0
valve_damping = 24.0
valve_mass_kg = 0.035000000149011612
valve_preload_n = 100.0
valve_spring_n_m = 30000.0
wall_area_m2 = 0.019999999552965164
[[parts]]
afr = 14.699999809265137
bore_m = 0.075000002980232239
compression = 11.5
cracking_pressure_pa = 100.0
diameter_m = 0.032000001519918442
discharge = 0.80000001192092896
exhaust_close_deg = 370.0
exhaust_open_deg = 130.0
exhaust_valve_count = 1
exhaust_valve_m = 0.028000000864267349
heat_transfer = 100.0
id = 7
intake_close_deg = 580.0
intake_open_deg = 350.0
intake_valve_count = 1
intake_valve_m = 0.029999999329447746
junction_method = 0
kind = 'pipe'
maximum_lift_m = 0.0080000003799796104
name = 'Pipe / reducer 7'
opening = 1.0
outlet_diameter_m = 0.032000001519918442
pedal_control = true
phase_deg = 0.0
points_m = [ [ -0.11500000953674316, 0.0, 0.0 ], [ 0.11499994993209839, 0.0, 0.0 ] ]
position_m = [ -0.53499996662139893, 0.30000001192092896, 0.0 ]
pressure_pa = 101325.0
rod_m = 0.1120000034570694
rotation_deg = [ 0.0, 0.0, 0.0 ]
size_m = [ 0.11999999731779099, 0.11999999731779099, 0.11999999731779099 ]
spark_advance_deg = 20.0
stroke_m = 0.068000003695487976
temperature_k = 300.0
turning_loss = 0.0
valve_damping = 24.0
valve_mass_kg = 0.035000000149011612
valve_preload_n = 100.0
valve_spring_n_m = 30000.0
wall_area_m2 = 0.019999999552965164
[[parts]]
afr = 14.699999809265137
bore_m = 0.075000002980232239
compression = 11.5
cracking_pressure_pa = 100.0
diameter_m = 0.032000001519918442
discharge = 0.80000001192092896
exhaust_close_deg = 370.0
exhaust_open_deg = 130.0
exhaust_valve_count = 1
exhaust_valve_m = 0.028000000864267349
heat_transfer = 100.0
id = 8
intake_close_deg = 580.0
intake_open_deg = 350.0
intake_valve_count = 1
intake_valve_m = 0.029999999329447746
junction_method = 0
kind = 'pipe'
maximum_lift_m = 0.0080000003799796104
name = 'Pipe / reducer 8'
opening = 1.0
outlet_diameter_m = 0.032000001519918442
pedal_control = true
phase_deg = 0.0
points_m = [ [ -0.064999997615814209, 0.0, 0.0 ], [ 0.064999997615814209, 0.0, 0.0 ] ]
position_m = [ -0.31499999761581421, 0.30000001192092896, 0.0 ]
pressure_pa = 101325.0
rod_m = 0.1120000034570694
rotation_deg = [ 0.0, 0.0, 0.0 ]
size_m = [ 0.11999999731779099, 0.11999999731779099, 0.11999999731779099 ]
spark_advance_deg = 20.0
stroke_m = 0.068000003695487976
temperature_k = 300.0
turning_loss = 0.0
valve_damping = 24.0
valve_mass_kg = 0.035000000149011612
valve_preload_n = 100.0
valve_spring_n_m = 30000.0
wall_area_m2 = 0.019999999552965164
[[parts]]
afr = 14.699999809265137
bore_m = 0.075000002980232239
compression = 11.5
cracking_pressure_pa = 100.0
diameter_m = 0.032000001519918442
discharge = 0.80000001192092896
exhaust_close_deg = 370.0
exhaust_open_deg = 130.0
exhaust_valve_count = 1
exhaust_valve_m = 0.028000000864267349
heat_transfer = 100.0
id = 9
intake_close_deg = 580.0
intake_open_deg = 350.0
intake_valve_count = 1
intake_valve_m = 0.029999999329447746
junction_method = 0
kind = 'pipe'
maximum_lift_m = 0.0080000003799796104
name = 'Pipe / reducer 9'
opening = 1.0
outlet_diameter_m = 0.032000001519918442
pedal_control = true
phase_deg = 0.0
points_m = [ [ -0.066375002264976501, 0.031380951404571533, 0.0 ], [ 0.066375002264976501, -0.031380951404571533, 0.0 ] ]
position_m = [ -0.083625003695487976, 0.26861906051635742, 0.0 ]
pressure_pa = 101325.0
rod_m = 0.1120000034570694
rotation_deg = [ 0.0, 0.0, 0.0 ]
size_m = [ 0.11999999731779099, 0.11999999731779099, 0.11999999731779099 ]
spark_advance_deg = 20.0
stroke_m = 0.068000003695487976
temperature_k = 300.0
turning_loss = 0.0
valve_damping = 24.0
valve_mass_kg = 0.035000000149011612
valve_preload_n = 100.0
valve_spring_n_m = 30000.0
wall_area_m2 = 0.019999999552965164
[[parts]]
afr = 14.699999809265137
bore_m = 0.075000002980232239
compression = 11.5
cracking_pressure_pa = 100.0
diameter_m = 0.032000001519918442
discharge = 0.80000001192092896
exhaust_close_deg = 370.0
exhaust_open_deg = 130.0
exhaust_valve_count = 1
exhaust_valve_m = 0.028000000864267349
heat_transfer = 100.0
id = 10
intake_close_deg = 580.0
intake_open_deg = 350.0
intake_valve_count = 1
intake_valve_m = 0.029999999329447746
junction_method = 0
kind = 'pipe'
maximum_lift_m = 0.0080000003799796104
name = 'Pipe / reducer 10'
opening = 1.0
outlet_diameter_m = 0.032000001519918442
pedal_control = true
phase_deg = 0.0
points_m = [ [ -0.071374997496604919, -0.031380951404571533, 0.0 ], [ 0.071374997496604919, 0.031380951404571533, 0.0 ] ]
position_m = [ 0.088624998927116394, 0.26861906051635742, 0.0 ]
pressure_pa = 101325.0
rod_m = 0.1120000034570694
rotation_deg = [ 0.0, 0.0, 0.0 ]
size_m = [ 0.11999999731779099, 0.11999999731779099, 0.11999999731779099 ]
spark_advance_deg = 20.0
stroke_m = 0.068000003695487976
temperature_k = 300.0
turning_loss = 0.0
valve_damping = 24.0
valve_mass_kg = 0.035000000149011612
valve_preload_n = 100.0
valve_spring_n_m = 30000.0
wall_area_m2 = 0.019999999552965164
[[parts]]
afr = 14.699999809265137
bore_m = 0.075000002980232239
compression = 11.5
cracking_pressure_pa = 100.0
diameter_m = 0.032000001519918442
discharge = 0.80000001192092896
exhaust_close_deg = 370.0
exhaust_open_deg = 130.0
exhaust_valve_count = 1
exhaust_valve_m = 0.028000000864267349
heat_transfer = 100.0
id = 11
intake_close_deg = 580.0
intake_open_deg = 350.0
intake_valve_count = 1
intake_valve_m = 0.029999999329447746
junction_method = 0
kind = 'pipe'
maximum_lift_m = 0.0080000003799796104
name = 'Pipe / reducer 11'
opening = 1.0
outlet_diameter_m = 0.032000001519918442
pedal_control = true
phase_deg = 0.0
points_m = [ [ -0.20499998331069946, 0.0, 0.0 ], [ 0.20499998331069946, 0.0, 0.0 ] ]
position_m = [ 0.44499999284744263, 0.30000001192092896, 0.0 ]
pressure_pa = 101325.0
rod_m = 0.1120000034570694
rotation_deg = [ 0.0, 0.0, 0.0 ]
size_m = [ 0.11999999731779099, 0.11999999731779099, 0.11999999731779099 ]
spark_advance_deg = 20.0
stroke_m = 0.068000003695487976
temperature_k = 300.0
turning_loss = 0.0
valve_damping = 24.0
valve_mass_kg = 0.035000000149011612
valve_preload_n = 100.0
valve_spring_n_m = 30000.0
wall_area_m2 = 0.019999999552965164

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,254 @@
cells_per_meter = 40.0
drive_rpm = 900.0
driven = true
format = 1
load_torque = 0.0
name = 'Vacuum pump bench'
scene = 2
shaft_inertia = 0.15000000596046448
solver = 'rusanov'
[[connections]]
a_part = 4
a_port = 0
b_part = 1
b_port = 1
discharge = 1.0
inertance_length_m = 0.0099999997764825821
loss = 0.0
method = 0
[[connections]]
a_part = 4
a_port = 1
b_part = 2
b_port = 0
discharge = 1.0
inertance_length_m = 0.0099999997764825821
loss = 0.0
method = 0
[[connections]]
a_part = 5
a_port = 0
b_part = 2
b_port = 1
discharge = 1.0
inertance_length_m = 0.0099999997764825821
loss = 0.0
method = 0
[[connections]]
a_part = 5
a_port = 1
b_part = 3
b_port = 0
discharge = 1.0
inertance_length_m = 0.0099999997764825821
loss = 0.0
method = 0
[[parts]]
afr = 14.699999809265137
bore_m = 0.075000002980232239
compression = 11.5
cracking_pressure_pa = 100.0
diameter_m = 0.039999999105930328
discharge = 0.80000001192092896
exhaust_close_deg = 370.0
exhaust_open_deg = 130.0
exhaust_valve_count = 1
exhaust_valve_m = 0.028000000864267349
heat_transfer = 100.0
id = 1
intake_close_deg = 580.0
intake_open_deg = 350.0
intake_valve_count = 1
intake_valve_m = 0.029999999329447746
junction_method = 0
kind = 'chamber'
maximum_lift_m = 0.0080000003799796104
name = 'Chamber / tank 1'
opening = 1.0
outlet_diameter_m = 0.039999999105930328
pedal_control = true
phase_deg = 0.0
points_m = [ [ -0.15000000596046448, 0.0, 0.0 ], [ 0.15000000596046448, 0.0, 0.0 ] ]
position_m = [ -0.40000000596046448, 0.2800000011920929, 0.0 ]
pressure_pa = 101325.0
rod_m = 0.1120000034570694
rotation_deg = [ 0.0, 0.0, 0.0 ]
size_m = [ 0.20000000298023224, 0.18000000715255737, 0.18000000715255737 ]
spark_advance_deg = 20.0
stroke_m = 0.068000003695487976
temperature_k = 300.0
turning_loss = 0.0
valve_damping = 24.0
valve_mass_kg = 0.035000000149011612
valve_preload_n = 100.0
valve_spring_n_m = 30000.0
wall_area_m2 = 0.019999999552965164
[[parts]]
afr = 14.699999809265137
bore_m = 0.075000002980232239
compression = 11.5
cracking_pressure_pa = 100.0
diameter_m = 0.039999999105930328
discharge = 0.80000001192092896
exhaust_close_deg = 370.0
exhaust_open_deg = 130.0
exhaust_valve_count = 1
exhaust_valve_m = 0.028000000864267349
heat_transfer = 100.0
id = 2
intake_close_deg = 580.0
intake_open_deg = 350.0
intake_valve_count = 1
intake_valve_m = 0.029999999329447746
junction_method = 0
kind = 'piston_pump'
maximum_lift_m = 0.0080000003799796104
name = 'Piston pump 2'
opening = 1.0
outlet_diameter_m = 0.039999999105930328
pedal_control = true
phase_deg = 0.0
points_m = [ [ -0.15000000596046448, 0.0, 0.0 ], [ 0.15000000596046448, 0.0, 0.0 ] ]
position_m = [ 0.0, 0.20000000298023224, 0.0 ]
pressure_pa = 101325.0
rod_m = 0.1120000034570694
rotation_deg = [ 0.0, 0.0, 0.0 ]
size_m = [ 0.11999999731779099, 0.11999999731779099, 0.11999999731779099 ]
spark_advance_deg = 20.0
stroke_m = 0.068000003695487976
temperature_k = 300.0
turning_loss = 0.0
valve_damping = 24.0
valve_mass_kg = 0.035000000149011612
valve_preload_n = 100.0
valve_spring_n_m = 30000.0
wall_area_m2 = 0.019999999552965164
[[parts]]
afr = 14.699999809265137
bore_m = 0.075000002980232239
compression = 11.5
cracking_pressure_pa = 100.0
diameter_m = 0.039999999105930328
discharge = 0.80000001192092896
exhaust_close_deg = 370.0
exhaust_open_deg = 130.0
exhaust_valve_count = 1
exhaust_valve_m = 0.028000000864267349
heat_transfer = 100.0
id = 3
intake_close_deg = 580.0
intake_open_deg = 350.0
intake_valve_count = 1
intake_valve_m = 0.029999999329447746
junction_method = 0
kind = 'reservoir'
maximum_lift_m = 0.0080000003799796104
name = 'Pressure / atmosphere 3'
opening = 1.0
outlet_diameter_m = 0.039999999105930328
pedal_control = true
phase_deg = 0.0
points_m = [ [ -0.15000000596046448, 0.0, 0.0 ], [ 0.15000000596046448, 0.0, 0.0 ] ]
position_m = [ 0.40000000596046448, 0.31999999284744263, 0.0 ]
pressure_pa = 101325.0
rod_m = 0.1120000034570694
rotation_deg = [ 0.0, 0.0, 0.0 ]
size_m = [ 0.039999999105930328, 0.039999999105930328, 0.039999999105930328 ]
spark_advance_deg = 20.0
stroke_m = 0.068000003695487976
temperature_k = 300.0
turning_loss = 0.0
valve_damping = 24.0
valve_mass_kg = 0.035000000149011612
valve_preload_n = 100.0
valve_spring_n_m = 30000.0
wall_area_m2 = 0.019999999552965164
[[parts]]
afr = 14.699999809265137
bore_m = 0.075000002980232239
compression = 11.5
cracking_pressure_pa = 100.0
diameter_m = 0.032000001519918442
discharge = 0.80000001192092896
exhaust_close_deg = 370.0
exhaust_open_deg = 130.0
exhaust_valve_count = 1
exhaust_valve_m = 0.028000000864267349
heat_transfer = 100.0
id = 4
intake_close_deg = 580.0
intake_open_deg = 350.0
intake_valve_count = 1
intake_valve_m = 0.029999999329447746
junction_method = 0
kind = 'pipe'
maximum_lift_m = 0.0080000003799796104
name = 'Pipe / reducer 4'
opening = 1.0
outlet_diameter_m = 0.032000001519918442
pedal_control = true
phase_deg = 0.0
points_m = [ [ -0.14137500524520874, 0.02138093113899231, 0.0 ], [ 0.14137500524520874, -0.021380960941314697, 0.0 ] ]
position_m = [ -0.15862500667572021, 0.25861907005310059, 0.0 ]
pressure_pa = 101325.0
rod_m = 0.1120000034570694
rotation_deg = [ 0.0, 0.0, 0.0 ]
size_m = [ 0.11999999731779099, 0.11999999731779099, 0.11999999731779099 ]
spark_advance_deg = 20.0
stroke_m = 0.068000003695487976
temperature_k = 300.0
turning_loss = 0.0
valve_damping = 24.0
valve_mass_kg = 0.035000000149011612
valve_preload_n = 100.0
valve_spring_n_m = 30000.0
wall_area_m2 = 0.019999999552965164
[[parts]]
afr = 14.699999809265137
bore_m = 0.075000002980232239
compression = 11.5
cracking_pressure_pa = 100.0
diameter_m = 0.032000001519918442
discharge = 0.80000001192092896
exhaust_close_deg = 370.0
exhaust_open_deg = 130.0
exhaust_valve_count = 1
exhaust_valve_m = 0.028000000864267349
heat_transfer = 100.0
id = 5
intake_close_deg = 580.0
intake_open_deg = 350.0
intake_valve_count = 1
intake_valve_m = 0.029999999329447746
junction_method = 0
kind = 'pipe'
maximum_lift_m = 0.0080000003799796104
name = 'Pipe / reducer 5'
opening = 1.0
outlet_diameter_m = 0.032000001519918442
pedal_control = true
phase_deg = 0.0
points_m = [ [ -0.1913750022649765, -0.041380941867828369, 0.0 ], [ 0.1913750022649765, 0.041380941867828369, 0.0 ] ]
position_m = [ 0.20862500369548798, 0.27861905097961426, 0.0 ]
pressure_pa = 101325.0
rod_m = 0.1120000034570694
rotation_deg = [ 0.0, 0.0, 0.0 ]
size_m = [ 0.11999999731779099, 0.11999999731779099, 0.11999999731779099 ]
spark_advance_deg = 20.0
stroke_m = 0.068000003695487976
temperature_k = 300.0
turning_loss = 0.0
valve_damping = 24.0
valve_mass_kg = 0.035000000149011612
valve_preload_n = 100.0
valve_spring_n_m = 30000.0
wall_area_m2 = 0.019999999552965164

61
src/builder_geometry.cpp Normal file
View File

@@ -0,0 +1,61 @@
#include "FluidSim/Desktop/MachineBuilder.hpp"
#include "FluidSim/Constants.hpp"
#include <algorithm>
#include <limits>
namespace fluidsim::port::desktop {
using namespace machine_math;
BuilderRay builder_ray(const OrbitCamera& c,sf::FloatRect v,sf::Vector2f screen){
const float x=(2*(screen.x-v.position.x)/v.size.x-1)*.41421356f*v.size.x/v.size.y;
const float y=(1-2*(screen.y-v.position.y)/v.size.y)*.41421356f;
const float yaw=c.yaw*pi/180,pitch=c.pitch*pi/180;
const Vec3 right{std::cos(yaw),0,std::sin(yaw)},up{std::sin(pitch)*std::sin(yaw),std::cos(pitch),-std::sin(pitch)*std::cos(yaw)},back{-std::cos(pitch)*std::sin(yaw),std::sin(pitch),std::cos(pitch)*std::cos(yaw)};
return {c.listener().position,unit(add(add(multiply(right,x),multiply(up,y)),multiply(back,-1)))};
}
std::optional<sf::Vector2f> builder_project(const OrbitCamera& c,sf::FloatRect v,Vec3 world){
const auto d=subtract(world,c.target);const float yaw=c.yaw*pi/180,pitch=c.pitch*pi/180;
const float x=std::cos(yaw)*d.x+std::sin(yaw)*d.z,z=-std::sin(yaw)*d.x+std::cos(yaw)*d.z;
const float y=std::cos(pitch)*d.y-std::sin(pitch)*z,depth=c.distance-(std::sin(pitch)*d.y+std::cos(pitch)*z);
if(depth<.03f)return std::nullopt;
const float scale=v.size.y/(2*.41421356f*depth);
return sf::Vector2f{v.position.x+v.size.x*.5f+x*scale,v.position.y+v.size.y*.5f-y*scale};
}
std::optional<BuilderPipeLocation> builder_pipe_location(const OrbitCamera& c,sf::FloatRect viewport,const MachinePart& p,sf::Vector2f screen){
if(p.kind!=MachinePartKind::Pipe)return std::nullopt;
std::optional<BuilderPipeLocation> result;
const auto depth=[&](Vec3 world){const auto d=subtract(world,c.target);const float yaw=c.yaw*pi/180,pitch=c.pitch*pi/180;return c.distance-(std::sin(pitch)*d.y+std::cos(pitch)*(-std::sin(yaw)*d.x+std::cos(yaw)*d.z));};
for(std::size_t i=1;i<p.points.size();++i){
const auto wa=machine_world_point(p,p.points[i-1]),wb=machine_world_point(p,p.points[i]);const auto a=builder_project(c,viewport,wa),b=builder_project(c,viewport,wb);if(!a||!b)continue;
const auto edge=*b-*a;const float squared=edge.x*edge.x+edge.y*edge.y;if(squared<1e-5f)continue;
const auto delta=screen-*a;const float u=std::clamp((delta.x*edge.x+delta.y*edge.y)/squared,0.f,1.f);const auto offset=screen-(*a+edge*u);const float distance=std::sqrt(offset.x*offset.x+offset.y*offset.y);
const float t=u*depth(wa)/((1-u)*depth(wb)+u*depth(wa));
if(!result||distance<result->screen_distance)result=BuilderPipeLocation{i-1,add(p.points[i-1],multiply(subtract(p.points[i],p.points[i-1]),t)),distance};
}
return result;
}
std::optional<float> builder_pick_part(const MachinePart& p,BuilderRay ray,float tolerance){
ray.origin=machine_local_point(p,ray.origin);ray.direction=inverse_rotate(ray.direction,p.rotation);
if(p.kind==MachinePartKind::Pipe){
float best=std::numeric_limits<float>::max();const auto diameters=machine_pipe_diameters(p);
for(std::size_t i=1;i<p.points.size();++i){auto a=p.points[i-1],b=p.points[i],edge=subtract(b,a),q=subtract(ray.origin,a);const float ee=dot(edge,edge),de=dot(ray.direction,edge),dq=dot(ray.direction,q),eq=dot(edge,q),den=ee-de*de;
float u=den>1e-10f?(eq-de*dq)/den:0;u=std::clamp(u,0.f,1.f);float t=std::max(0.f,dot(subtract(add(a,multiply(edge,u)),ray.origin),ray.direction));
// Re-clamp the finite segment after solving for the half ray.
u=std::clamp(dot(subtract(add(ray.origin,multiply(ray.direction,t)),a),edge)/ee,0.f,1.f);
const float radius=std::lerp(diameters[i-1],diameters[i],u)*.5f+tolerance;
if(length(subtract(add(ray.origin,multiply(ray.direction,t)),add(a,multiply(edge,u))))<=radius)best=std::min(best,std::max(0.f,t-radius));
}return best==std::numeric_limits<float>::max()?std::nullopt:std::optional<float>(best);
}
Vec3 size=machine_part_size(p);if(p.kind==MachinePartKind::Reservoir||p.kind==MachinePartKind::Cap)size={.032f,.032f,.032f};
if(p.kind==MachinePartKind::Junction&&p.junction_method)size={.035f,.035f,.035f};
const float origin[]{ray.origin.x,ray.origin.y,ray.origin.z},dir[]{ray.direction.x,ray.direction.y,ray.direction.z},half[]{size.x*.5f+tolerance,size.y*.5f+tolerance,size.z*.5f+tolerance};float enter=0,exit=1e9f;
for(int i=0;i<3;++i){if(std::abs(dir[i])<1e-8f){if(std::abs(origin[i])>half[i])return std::nullopt;}else{float a=(-half[i]-origin[i])/dir[i],b=(half[i]-origin[i])/dir[i];if(a>b)std::swap(a,b);enter=std::max(enter,a);exit=std::min(exit,b);if(exit<enter)return std::nullopt;}}
return enter;
}
Vec3 builder_rotate_world(Vec3 e,unsigned axis,float degrees){
const Vec3 a=axis==0?Vec3{1,0,0}:axis==1?Vec3{0,1,0}:Vec3{0,0,1};const float c=std::cos(degrees*pi/180),s=std::sin(degrees*pi/180);
const auto turn=[&](Vec3 v){return add(add(multiply(v,c),multiply(cross(a,v),s)),multiply(a,dot(a,v)*(1-c)));};
const auto x=turn(rotate({1,0,0},e)),y=turn(rotate({0,1,0},e)),z=turn(rotate({0,0,1},e));
const float pitch=std::asin(std::clamp(-x.z,-1.f,1.f));
if(std::abs(std::cos(pitch))<1e-5f)return {0,pitch*180/pi,std::atan2(-y.x,y.y)*180/pi};
return {std::atan2(y.z,z.z)*180/pi,pitch*180/pi,std::atan2(x.y,x.x)*180/pi};
}
}

View File

@@ -13,8 +13,8 @@ float smoothstep(float value) {
} }
} }
Carburetor::Carburetor(float throat_diameter) Carburetor::Carburetor(float throat_diameter,const CarburetorConfig& config)
: bowl_mass_(bowl_capacity), throat_area_(circle_area(std::max(throat_diameter, 1e-4f))) {} : CarburetorConfig(config),bowl_mass_(bowl_capacity), throat_area_(circle_area(std::max(throat_diameter, 1e-4f))) {}
void Carburetor::step(float dt, float pedal, float slide, float rpm, float air_mass_flow, void Carburetor::step(float dt, float pedal, float slide, float rpm, float air_mass_flow,
float air_density, float upstream_pressure, float downstream_pressure) { float air_density, float upstream_pressure, float downstream_pressure) {

View File

@@ -3,6 +3,8 @@
#include "FluidSim/Desktop/DesktopUi.hpp" #include "FluidSim/Desktop/DesktopUi.hpp"
#include "FluidSim/Desktop/ScenarioMenu.hpp" #include "FluidSim/Desktop/ScenarioMenu.hpp"
#include "FluidSim/Desktop/PlaybackControl.hpp" #include "FluidSim/Desktop/PlaybackControl.hpp"
#include "FluidSim/Desktop/MachineBuilder.hpp"
#include "FluidSim/Desktop/WindowPresentation.hpp"
#include <SFML/Graphics.hpp> #include <SFML/Graphics.hpp>
#include <algorithm> #include <algorithm>
@@ -34,6 +36,8 @@ constexpr float throttle_lerp_rate = 10.0f;
int main(int argc, char** argv) try { int main(int argc, char** argv) try {
using namespace fluidsim::port; using namespace fluidsim::port;
bool scenario_from_args=false; bool scenario_from_args=false;
bool builder_from_args=false,builder_play=false;
std::filesystem::path machine_file;
std::string junction_model; std::string junction_model;
std::optional<float> junction_loss; std::optional<float> junction_loss;
auto examples=scenario_examples(); auto examples=scenario_examples();
@@ -42,6 +46,8 @@ int main(int argc, char** argv) try {
OutdoorScene selected_scene=OutdoorScene::Wall; OutdoorScene selected_scene=OutdoorScene::Wall;
for(int i=1;i<argc;++i){ for(int i=1;i<argc;++i){
const std::string option=argv[i]; const std::string option=argv[i];
if(option=="--builder"){builder_from_args=true;continue;}
if((option=="--machine"||option=="--play-machine")&&i+1<argc){builder_from_args=true;builder_play=option=="--play-machine";machine_file=argv[++i];continue;}
if((option=="--example"||option=="--scenario"||option=="--scene"||option=="--exhaust"||option=="--solver"||option=="--junctions"||option=="--junction-loss")&&i+1<argc){ if((option=="--example"||option=="--scenario"||option=="--scene"||option=="--exhaust"||option=="--solver"||option=="--junctions"||option=="--junction-loss")&&i+1<argc){
const std::string value=argv[++i]; const std::string value=argv[++i];
if(option=="--example") { if(option=="--example") {
@@ -69,11 +75,11 @@ int main(int argc, char** argv) try {
} }
}else{std::cerr<<"Usage: fluidsim_desktop [--example catalog-id] [--scenario twin|two-stroke|b205|v8] [--scene dry|field|wall|woodland|tunnel] [--exhaust racing|straight] [--solver rusanov|hlle|hllc|muscl] [--junctions volume|characteristic|legacy] [--junction-loss 0..10]\n";return 2;} }else{std::cerr<<"Usage: fluidsim_desktop [--example catalog-id] [--scenario twin|two-stroke|b205|v8] [--scene dry|field|wall|woodland|tunnel] [--exhaust racing|straight] [--solver rusanov|hlle|hllc|muscl] [--junctions volume|characteristic|legacy] [--junction-loss 0..10]\n";return 2;}
} }
const sf::Vector2u initial_size = scenario_from_args ? sf::Vector2u{window_width, window_height} const sf::Vector2u initial_size{window_width,window_height};
: sf::Vector2u{720, 606};
sf::ContextSettings context; sf::ContextSettings context;
context.depthBits=24;context.antiAliasingLevel=4;context.majorVersion=2;context.minorVersion=1; context.depthBits=24;context.antiAliasingLevel=4;context.majorVersion=2;context.minorVersion=1;
sf::RenderWindow window(sf::VideoMode(initial_size), "FluidSim",sf::Style::Default,sf::State::Windowed,context); sf::RenderWindow window(sf::VideoMode(initial_size), "FluidSim",sf::Style::Default,sf::State::Windowed,context);
desktop::maximize_window(window);
window.setFramerateLimit(0); window.setFramerateLimit(0);
window.setVerticalSyncEnabled(true); window.setVerticalSyncEnabled(true);
window.setKeyRepeatEnabled(false); window.setKeyRepeatEnabled(false);
@@ -89,22 +95,23 @@ int main(int argc, char** argv) try {
for(const auto& path:font_paths)if(std::filesystem::exists(path)&&font.openFromFile(path)){has_font=true;break;} 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"; if(!has_font)std::cerr<<"No UI font available; configure FLUIDSIM_FONT_FILE.\n";
const sf::Font* font_ptr=has_font?&font:nullptr; const sf::Font* font_ptr=has_font?&font:nullptr;
if(builder_from_args){desktop::run_machine_builder(window,font_ptr,machine_file,builder_play);return 0;}
std::unordered_map<std::string,float> scenario_resolution; std::unordered_map<std::string,float> scenario_resolution;
while (window.isOpen()) { while (window.isOpen()) {
if (!scenario_from_args) { if (!scenario_from_args) {
window.setTitle("FluidSim - Examples"); window.setTitle("FluidSim - Main menu");
window.setMinimumSize(sf::Vector2u{720,606}); window.setMinimumSize(sf::Vector2u{1280,720});
window.setSize({720,606}); desktop::maximize_window(window);
const auto choice = desktop::choose_scenario(window, font_ptr,examples); const auto choice = desktop::choose_scenario(window, font_ptr,examples);
if (!choice) return 0; if (!choice) return 0;
selected=examples[*choice]; if(!choice->example){desktop::run_machine_builder(window,font_ptr,choice->machine,choice->play);continue;}
window.setSize({window_width, window_height}); selected=examples[*choice->example];
window.setView(sf::View(sf::FloatRect({0, 0}, {float(window_width), float(window_height)})));
} }
scenario_from_args=false; scenario_from_args=false;
window.setTitle("FluidSim - "+selected.title); window.setTitle("FluidSim - "+selected.title);
window.setMinimumSize(sf::Vector2u{1040,800}); window.setMinimumSize(sf::Vector2u{1040,800});
desktop::maximize_window(window);
if(!junction_model.empty()) selected.junctions.model=junction_model; if(!junction_model.empty()) selected.junctions.model=junction_model;
if(junction_loss) selected.junctions.turning_loss=*junction_loss; if(junction_loss) selected.junctions.turning_loss=*junction_loss;
if(selected.kind==ScenarioKind::TwoStroke && (!junction_model.empty() || junction_loss)) if(selected.kind==ScenarioKind::TwoStroke && (!junction_model.empty() || junction_loss))

View File

@@ -1,15 +1,65 @@
#include "FluidSim/Ecu.hpp" #include "FluidSim/Ecu.hpp"
#include "FluidSim/Constants.hpp"
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
#include <limits>
#include <stdexcept>
namespace fluidsim::port { namespace fluidsim::port {
const char* sensor_ecu_strategy_key(SensorEcuConfig::Strategy strategy){
switch(strategy){case SensorEcuConfig::Strategy::Ignition:return "ignition";case SensorEcuConfig::Strategy::AlphaN:return "alpha_n";case SensorEcuConfig::Strategy::SpeedDensity:return "speed_density";}throw std::invalid_argument("Unknown sensor ECU strategy");
}
const char* sensor_ecu_strategy_name(SensorEcuConfig::Strategy strategy){
switch(strategy){case SensorEcuConfig::Strategy::Ignition:return "Crank ignition";case SensorEcuConfig::Strategy::AlphaN:return "Alpha-N EFI";case SensorEcuConfig::Strategy::SpeedDensity:return "Speed-density EFI";}throw std::invalid_argument("Unknown sensor ECU strategy");
}
SensorEcuConfig::Strategy sensor_ecu_strategy_from_key(std::string_view key){
for(int i=0;i<sensor_ecu_strategy_count;++i){if(key==sensor_ecu_strategy_key(SensorEcuConfig::Strategy(i)))return SensorEcuConfig::Strategy(i);}
throw std::invalid_argument("Unknown sensor ECU strategy");
}
SensorEcuConfig::Strategy next_sensor_ecu_strategy(SensorEcuConfig::Strategy strategy){sensor_ecu_strategy_key(strategy);return SensorEcuConfig::Strategy((int(strategy)+1)%sensor_ecu_strategy_count);}
std::optional<SignalTiming> ecu_crank_sample(const SignalValue& speed,const SignalValue& angle,double time){
if(!std::isfinite(time)||speed.validity!=SignalValidity::Valid||angle.validity!=SignalValidity::Valid||
speed.timestamp!=time||angle.timestamp!=time||!speed.timing||!angle.timing||*speed.timing!=*angle.timing||
!std::holds_alternative<double>(speed.value)||!std::holds_alternative<double>(angle.value))return {};
const auto& pickup=*speed.timing;
if(!pickup.sensor||!pickup.shaft||pickup.sampled_at!=time||!std::isfinite(pickup.unwrapped_angle)||
!std::isfinite(pickup.angular_speed)||pickup.angular_speed<0||std::get<double>(speed.value)!=pickup.angular_speed)return {};
// Sensors use the solver's float pi convention; tolerate its rounding while
// rejecting changed angle values that no longer describe the pickup.
constexpr double period=2*double(pi);
double wrapped=std::fmod(pickup.unwrapped_angle,period);if(wrapped<0)wrapped+=period;
const double measured=std::get<double>(angle.value);
if(!std::isfinite(measured)||measured<0||measured>=period||std::abs(measured-wrapped)>1e-9)return {};
return pickup;
}
CrankIgnitionClock::Result CrankIgnitionClock::sample(const SignalValue& speed,const SignalValue& angle,double time,double advance,double tdc){
const auto current=ecu_crank_sample(speed,angle,time);
if(!current||!std::isfinite(advance)||advance< -90||advance>90||!std::isfinite(tdc)||tdc<0||tdc>=360){reset();return {};}
constexpr double period=2*double(pi);
const double offset=(advance-tdc)*period/360;
const double current_cycle=std::floor((current->unwrapped_angle+offset)/period);
if(!previous_){previous_=current;last_event_cycle_=current_cycle;return {true,0};}
const auto before=*previous_;
if(current->sensor!=before.sensor||current->shaft!=before.shaft||time<=before.sampled_at||current->unwrapped_angle<before.unwrapped_angle){reset();return {};}
const double previous_cycle=std::floor((before.unwrapped_angle+offset)/period);
// A live advance change must not spark twice in one revolution by moving
// the trigger across a previously fired cycle's TDC reference.
const double crossed=std::max(0.,current_cycle-std::max(previous_cycle,*last_event_cycle_));
if(!std::isfinite(crossed)||crossed>std::numeric_limits<unsigned>::max()){reset();return {};}
previous_=current;
if(crossed>0)last_event_cycle_=current_cycle;
return {true,static_cast<unsigned>(crossed)};
}
void Ecu::step(float dt, float pedal, float rpm) { void Ecu::step(float dt, float pedal, float rpm) {
fault_ = !std::isfinite(dt) || dt <= 0.0f || !std::isfinite(pedal) || !std::isfinite(rpm) || rpm < 0.0f; fault_ = !std::isfinite(dt) || dt <= 0.0f || !std::isfinite(pedal) || !std::isfinite(rpm) || rpm < 0.0f;
if (fault_) { target_ = 0.0f; idle_ = false; return; } if (fault_) { target_ = 0.0f; idle_ = false; return; }
pedal = std::clamp(pedal, 0.0f, 1.0f); pedal = std::clamp(pedal, 0.0f, 1.0f);
if (!rev_limit_enabled) limiter_ = false; if (!rev_limit_enabled) {limiter_ = false;limiter_time_=0;}
else if (rpm >= rev_limit_rpm) limiter_ = true; else if (!limiter_ && rpm >= rev_limit_rpm) {limiter_=true;limiter_time_=std::max(0.f,rev_limit_cut_time);}
else if (rpm <= rev_resume_rpm) limiter_ = false; else if (limiter_) {limiter_time_=std::max(0.f,limiter_time_-dt);if(rpm<=rev_resume_rpm&&limiter_time_<=0)limiter_=false;}
if (pedal > 0.005f || rpm < overrun_resume_rpm) overrun_ = false; if (pedal > 0.005f || rpm < overrun_resume_rpm) overrun_ = false;
else if (rpm > overrun_cut_rpm) overrun_ = true; else if (rpm > overrun_cut_rpm) overrun_ = true;
@@ -58,4 +108,85 @@ float Ecu::fuel_for_air(float air) const {
if (fuel_cut() || !std::isfinite(air) || air <= 0.0f) return 0.0f; if (fuel_cut() || !std::isfinite(air) || air <= 0.0f) return 0.0f;
return air / std::max(afr_, 1.0f); return air / std::max(afr_, 1.0f);
} }
namespace {
void require_calibration(bool ok,const char* why){if(!ok)throw std::invalid_argument(why);}
void validate_axis(const std::vector<double>& axis,const char* why){
require_calibration(axis.size()>=2&&axis.size()<=256,why);
for(std::size_t i=0;i<axis.size();++i)require_calibration(std::isfinite(axis[i])&&axis[i]>=0&&(!i||axis[i]>axis[i-1]),why);
}
struct TableInterval {std::size_t lower;double fraction;};
TableInterval interval(const std::vector<double>& axis,double value){
const auto high=std::upper_bound(axis.begin(),axis.end(),value);
const auto lower=high==axis.begin()?0:high==axis.end()?axis.size()-2:std::size_t(high-axis.begin()-1);
return {lower,std::clamp((value-axis[lower])/(axis[lower+1]-axis[lower]),0.,1.)};
}
}
SensorEcu::SensorEcu(SensorEcuConfig config):config_(std::move(config)){
sensor_ecu_strategy_key(config_.strategy);
require_calibration(!config_.ignition_map.empty()&&config_.ignition_map.size()<=256,"Ignition map needs 1..256 points");
for(std::size_t i=0;i<config_.ignition_map.size();++i){const auto p=config_.ignition_map[i];require_calibration(std::isfinite(p.rpm)&&p.rpm>=0&&(!i||p.rpm>config_.ignition_map[i-1].rpm)&&std::isfinite(p.degrees_btdc)&&p.degrees_btdc>=-90&&p.degrees_btdc<=90,"Invalid ignition map calibration");}
require_calibration(std::isfinite(config_.rev_limit_rpm)&&std::isfinite(config_.rev_resume_rpm)&&config_.rev_limit_rpm>0&&config_.rev_resume_rpm>=0&&config_.rev_resume_rpm<config_.rev_limit_rpm,"Invalid sensor ECU rev limiter calibration");
if(config_.strategy==SensorEcuConfig::Strategy::AlphaN){
validate_axis(config_.fuel_rpm,"Fuel RPM axis must be finite, nonnegative and increasing");validate_axis(config_.fuel_tps,"Fuel TPS axis must be finite and increasing");
require_calibration(config_.fuel_tps.front()==0&&config_.fuel_tps.back()==1,"Fuel TPS axis must span 0..1");
require_calibration(config_.fuel_per_cycle.size()==config_.fuel_rpm.size()*config_.fuel_tps.size(),"Fuel table dimensions do not match axes");
for(const auto mass:config_.fuel_per_cycle)require_calibration(std::isfinite(mass)&&mass>=0,"Fuel table requires finite nonnegative kg/cycle");
}
if(config_.strategy==SensorEcuConfig::Strategy::SpeedDensity){
require_calibration(std::isfinite(config_.displacement)&&config_.displacement>0&&std::isfinite(config_.air_gas_constant)&&config_.air_gas_constant>0&&std::isfinite(config_.target_afr)&&config_.target_afr>0,"Speed-density displacement, air gas constant and target AFR must be positive and finite");
validate_axis(config_.ve_rpm,"VE RPM axis must be finite, nonnegative and increasing");validate_axis(config_.ve_pressure,"VE absolute pressure axis must be finite, nonnegative and increasing");
require_calibration(config_.volumetric_efficiency.size()==config_.ve_rpm.size()*config_.ve_pressure.size(),"VE table dimensions do not match axes");
for(const auto ve:config_.volumetric_efficiency)require_calibration(std::isfinite(ve)&&ve>=0&&ve<=3,"VE cells must be finite fractions in 0..3");
}
}
void SensorEcu::apply_calibration(SensorEcuConfig config){
SensorEcu checked(std::move(config));if(checked.config_.strategy!=config_.strategy)throw std::invalid_argument("ECU strategy requires Build");config_=std::move(checked.config_);
}
const SensorEcuState& SensorEcu::sample(const SignalValue& speed,const SignalValue& angle,const SignalValue& tps,double time){
return sample(SensorEcuMeasurements{speed,angle,tps,{},{}},time);
}
const SensorEcuState& SensorEcu::sample(const SensorEcuMeasurements& measured,double time){
const auto& speed=measured.speed;const auto& angle=measured.angle;const auto& tps=measured.tps;
state_={};const auto crank=ecu_crank_sample(speed,angle,time);
if(!crank){clock_.reset();return state_;}
double opening=0;
if(config_.strategy==SensorEcuConfig::Strategy::AlphaN){
if(tps.validity!=SignalValidity::Valid||tps.timestamp!=time||!std::holds_alternative<double>(tps.value)){clock_.reset();return state_;}
opening=std::get<double>(tps.value);if(!std::isfinite(opening)||opening<0||opening>1){clock_.reset();return state_;}
}
double pressure=0,temperature=0;
if(config_.strategy==SensorEcuConfig::Strategy::SpeedDensity){
const auto current=[&](const SignalValue& value){return value.validity==SignalValidity::Valid&&value.timestamp==time&&std::holds_alternative<double>(value.value)&&std::isfinite(std::get<double>(value.value))&&std::get<double>(value.value)>0;};
if(!current(measured.map)||!current(measured.iat)){clock_.reset();return state_;}
pressure=std::get<double>(measured.map.value);temperature=std::get<double>(measured.iat.value);
}
const double rpm=crank->angular_speed*60/(2*double(pi));
if(!std::isfinite(rpm)){clock_.reset();return state_;}
double advance=config_.ignition_map.front().degrees_btdc;
for(std::size_t i=1;i<config_.ignition_map.size();++i){const auto a=config_.ignition_map[i-1],b=config_.ignition_map[i];advance=std::lerp(double(a.degrees_btdc),double(b.degrees_btdc),std::clamp((rpm-a.rpm)/(b.rpm-a.rpm),0.,1.));if(rpm<=b.rpm)break;}
const auto timing=clock_.sample(speed,angle,time,advance);if(!timing.valid)return state_;
if(!config_.rev_limit_enabled)limiter_=false;
else if(rpm>=config_.rev_limit_rpm)limiter_=true;
else if(rpm<=config_.rev_resume_rpm)limiter_=false;
state_.sensor_fault=false;state_.rev_limited=limiter_;state_.spark_advance=advance;
if(limiter_)return state_;
state_.spark_events=timing.sparks;
if(config_.strategy==SensorEcuConfig::Strategy::AlphaN){
const auto row=interval(config_.fuel_rpm,rpm),column=interval(config_.fuel_tps,opening);const auto width=config_.fuel_tps.size();
const auto a=row.lower*width+column.lower,b=a+width;
state_.fuel_per_cycle=std::lerp(std::lerp(config_.fuel_per_cycle[a],config_.fuel_per_cycle[a+1],column.fraction),std::lerp(config_.fuel_per_cycle[b],config_.fuel_per_cycle[b+1],column.fraction),row.fraction);
}
if(config_.strategy==SensorEcuConfig::Strategy::SpeedDensity){
const auto row=interval(config_.ve_rpm,rpm),column=interval(config_.ve_pressure,pressure);const auto width=config_.ve_pressure.size();const auto a=row.lower*width+column.lower,b=a+width;
state_.volumetric_efficiency=std::lerp(std::lerp(config_.volumetric_efficiency[a],config_.volumetric_efficiency[a+1],column.fraction),std::lerp(config_.volumetric_efficiency[b],config_.volumetric_efficiency[b+1],column.fraction),row.fraction);
// Divide first so finite high P/T pairs do not overflow R*T and silently
// become a valid zero-fuel estimate.
const double density=pressure/config_.air_gas_constant/temperature;
state_.estimated_air_per_cycle=density*config_.displacement*state_.volumetric_efficiency;
state_.fuel_per_cycle=state_.estimated_air_per_cycle/config_.target_afr;
}
if(config_.strategy!=SensorEcuConfig::Strategy::Ignition){state_.fuel_mass_flow=state_.fuel_per_cycle*rpm/120;if(!std::isfinite(state_.fuel_mass_flow)||!std::isfinite(state_.fuel_per_cycle)){state_={};clock_.reset();}}
return state_;
}
} // namespace fluidsim::port } // namespace fluidsim::port

View File

@@ -3,12 +3,14 @@
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
#include <stdexcept>
namespace fluidsim::port { namespace fluidsim::port {
void Crankshaft::add_torque(float torque) { void Crankshaft::add_torque(float torque) {
external_torque_ += torque; external_torque_ += torque;
} }
float Crankshaft::take_torque(){const float torque=external_torque_;external_torque_=0;return torque;}
void Crankshaft::step(float dt) { void Crankshaft::step(float dt) {
if (!std::isfinite(omega)) omega = 0.0f; if (!std::isfinite(omega)) omega = 0.0f;
@@ -24,6 +26,11 @@ void Crankshaft::step(float dt) {
// Brake shaft output, before external test load/drivetrain reaction. Using // Brake shaft output, before external test load/drivetrain reaction. Using
// acceleration torque reports zero dyno power at a loaded steady speed. // acceleration torque reports zero dyno power at a loaded steady speed.
const float instant_power = net_torque * omega; const float instant_power = net_torque * omega;
record_output(net_torque,instant_power);
external_torque_ = 0.0f;
drivetrain_torque = 0.0f;
}
void Crankshaft::record_output(float net_torque,float instant_power){
if (power_count_ == power_buffer_.size()) power_sum_ -= power_buffer_[power_index_]; if (power_count_ == power_buffer_.size()) power_sum_ -= power_buffer_[power_index_];
else ++power_count_; else ++power_count_;
power_buffer_[power_index_] = instant_power; power_buffer_[power_index_] = instant_power;
@@ -37,8 +44,6 @@ void Crankshaft::step(float dt) {
torque_sum_ += net_torque; torque_sum_ += net_torque;
torque_index_ = (torque_index_ + 1) % torque_buffer_.size(); torque_index_ = (torque_index_ + 1) % torque_buffer_.size();
average_torque = torque_sum_ / static_cast<float>(torque_count_); average_torque = torque_sum_ / static_cast<float>(torque_count_);
external_torque_ = 0.0f;
drivetrain_torque = 0.0f;
} }
float EngineCylinder::swept_volume() const { float EngineCylinder::swept_volume() const {
@@ -80,6 +85,7 @@ void EngineCylinder::initialize(const Crankshaft& /*crank*/) {
air_mass = 1.225f * volume; air_mass = 1.225f * volume;
exhaust_mass = 0.0f; exhaust_mass = 0.0f;
fuel_mass = 0.0f; fuel_mass = 0.0f;
fuel_equivalent_mass=0;
energy = 101325.0f * volume / (gamma - 1.0f); energy = 101325.0f * volume / (gamma - 1.0f);
pressure = 101325.0f; pressure = 101325.0f;
intake.owner_energy = exhaust.owner_energy = &energy; intake.owner_energy = exhaust.owner_energy = &energy;
@@ -146,8 +152,11 @@ float FourStrokeCylinder::advance_valve(ValveState& state,const CamProfile& cam,
} }
void FourStrokeCylinder::pre_step(Crankshaft& crank, float dt, float /*throttle*/) { void FourStrokeCylinder::pre_step(Crankshaft& crank, float dt, float /*throttle*/) {
if(ignition_command&&!transported_fuel)throw std::invalid_argument("External ignition requires transported fuel delivery");
const float rpm = crank.omega * 60.0f / (2.0f * pi); const float rpm = crank.omega * 60.0f / (2.0f * pi);
if (ecu) { if (ignition_command) {
rev_limit_active=false;
} else if (ecu) {
spark_advance = ecu->spark_advance(); spark_advance = ecu->spark_advance();
rev_limit_active = ecu->rev_limited(); rev_limit_active = ecu->rev_limited();
} else { } else {
@@ -175,16 +184,23 @@ void FourStrokeCylinder::pre_step(Crankshaft& crank, float dt, float /*throttle*
const float current_degrees = wrap_degrees(theta * 180.0f / pi); const float current_degrees = wrap_degrees(theta * 180.0f / pi);
const bool intake_closed_this_step=intake_cam_ && sample_cam(*intake_cam_,previous_degrees).lift_metres>0 && sample_cam(*intake_cam_,current_degrees).lift_metres<=0; const bool intake_closed_this_step=intake_cam_ && sample_cam(*intake_cam_,previous_degrees).lift_metres>0 && sample_cam(*intake_cam_,current_degrees).lift_metres<=0;
if (intake_closed_this_step) { if (intake_closed_this_step) {
if (!transported_fuel) external_charge_ready_=true;
fuel_mass = ecu ? ecu->fuel_for_air(air_mass) : air_mass / std::max(afr, 1e-6f); if (!transported_fuel){const float injected=ecu?ecu->fuel_for_air(air_mass):air_mass/std::max(afr,1e-6f);fuel_equivalent_mass=std::max(0.f,fuel_equivalent_mass+injected-fuel_mass);fuel_mass=injected;}
const float charge_afr = air_mass / std::max(fuel_mass, 1e-12f); const float charge_afr = air_mass / std::max(fuel_mass, 1e-12f);
fuel_injected = fuel_mass > 0.0f && (!transported_fuel || (charge_afr >= 7 && charge_afr <= 24)); fuel_injected = fuel_mass > 0.0f && (!transported_fuel || (charge_afr >= 7 && charge_afr <= 24));
} }
const float spark_angle = wrap_degrees(720.0f - spark_advance); const float spark_angle = wrap_degrees(720.0f - spark_advance);
const bool crossed_spark = crossed_angle(previous_degrees,current_degrees,spark_angle); const bool crossed_spark = ignition_command?ignition_command->fire:crossed_angle(previous_degrees,current_degrees,spark_angle);
if (crossed_spark && fuel_injected && !combustion_active && !(ecu ? ecu->ignition_cut() : rev_limit_active) && random_unit() >= misfire_probability) { const bool ignition_cut=ignition_command?ignition_command->cut:ecu?ecu->ignition_cut():rev_limit_active;
if(ignition_command)ignition_command->fire=false;
if(ignition_command&&combustion_active){float travel=current_degrees-previous_degrees;if(travel<0)travel+=720;external_burn_angle_+=travel;}
// Wasted sparks on the exhaust revolution must not overwrite the most
// recent trapped-charge measurement with already burned exhaust gas.
if(crossed_spark&&(!ignition_command||external_charge_ready_||fuel_injected)){charge_sampled=true;charge_afr=fuel_mass>1e-12f?air_mass/fuel_mass:std::numeric_limits<float>::quiet_NaN();external_charge_ready_=false;}
if (crossed_spark && fuel_injected && !combustion_active && !ignition_cut && random_unit() >= misfire_probability) {
combustion_active = true; combustion_active = true;
external_burn_angle_=0;
burn_fraction = 0.0f; burn_fraction = 0.0f;
active_fuel_mass_ = transported_fuel ? std::min(fuel_mass, air_mass / 14.7f) : fuel_mass; active_fuel_mass_ = transported_fuel ? std::min(fuel_mass, air_mass / 14.7f) : fuel_mass;
energy_factor_ = 1.0f + energy_variation * (2.0f * random_unit() - 1.0f); energy_factor_ = 1.0f + energy_variation * (2.0f * random_unit() - 1.0f);
@@ -193,10 +209,11 @@ void FourStrokeCylinder::pre_step(Crankshaft& crank, float dt, float /*throttle*
if (combustion_active) { if (combustion_active) {
float angle_since_spark = current_degrees - spark_angle; float angle_since_spark = current_degrees - spark_angle;
if (angle_since_spark < 0.0f) angle_since_spark += 720.0f; if (angle_since_spark < 0.0f) angle_since_spark += 720.0f;
if(ignition_command)angle_since_spark=external_burn_angle_;
const float phi = (angle_since_spark - burn_start) / std::max(burn_duration, 1e-6f); const float phi = (angle_since_spark - burn_start) / std::max(burn_duration, 1e-6f);
const float burned = phi <= 0.0f ? 0.0f : 1.0f - std::exp(-wiebe_a * std::pow(phi, wiebe_m + 1.0f)); const float burned = phi <= 0.0f ? 0.0f : 1.0f - std::exp(-wiebe_a * std::pow(phi, wiebe_m + 1.0f));
float fraction = std::max(burn_fraction, std::clamp(burned, 0.0f, 1.0f)); float fraction = std::max(burn_fraction, std::clamp(burned, 0.0f, 1.0f));
if (fraction >= 1.0f || angle_since_spark > burn_duration + burn_start + spark_advance) { if (fraction >= 1.0f || angle_since_spark > burn_duration + burn_start + (ignition_command?0:spark_advance)) {
fraction = 1.0f; fraction = 1.0f;
combustion_active = false; combustion_active = false;
if (!transported_fuel) { if (!transported_fuel) {
@@ -221,6 +238,7 @@ void FourStrokeCylinder::pre_step(Crankshaft& crank, float dt, float /*throttle*
} }
if (!ecu && rev_limit_active) { if (!ecu && rev_limit_active) {
fuel_equivalent_mass=std::max(0.f,fuel_equivalent_mass-fuel_mass);
fuel_mass = 0.0f; fuel_mass = 0.0f;
fuel_injected = false; fuel_injected = false;
combustion_active = false; combustion_active = false;
@@ -232,6 +250,7 @@ void FourStrokeCylinder::pre_step(Crankshaft& crank, float dt, float /*throttle*
void EngineCylinder::update_state(float dt) { void EngineCylinder::update_state(float dt) {
float delta_air = 0.0f, delta_exhaust = 0.0f, delta_fuel = 0.0f, delta_energy = 0.0f; float delta_air = 0.0f, delta_exhaust = 0.0f, delta_fuel = 0.0f, delta_energy = 0.0f;
float delta_equivalent=0;const float equivalent=std::clamp(std::max(fuel_mass,fuel_equivalent_mass)/std::max(total_mass(),1e-12f),0.f,1.f);
for (Port* port : {&intake, &exhaust}) { for (Port* port : {&intake, &exhaust}) {
const float mass_flow = port->mass_flow; const float mass_flow = port->mass_flow;
const float fraction = mass_flow >= 0.0f ? port->air_fraction : air_fraction(); const float fraction = mass_flow >= 0.0f ? port->air_fraction : air_fraction();
@@ -241,11 +260,14 @@ void EngineCylinder::update_state(float dt) {
delta_fuel += mass_flow * fuel_fraction * dt; delta_fuel += mass_flow * fuel_fraction * dt;
delta_exhaust += mass_flow * std::max(1.0f - fraction - fuel_fraction, 0.0f) * dt; delta_exhaust += mass_flow * std::max(1.0f - fraction - fuel_fraction, 0.0f) * dt;
delta_energy += mass_flow * port->enthalpy * dt; delta_energy += mass_flow * port->enthalpy * dt;
delta_equivalent+=mass_flow*(mass_flow>=0?std::max(port->fuel_equivalent_fraction,port->fuel_fraction):equivalent)*dt;
} }
air_mass += delta_air; air_mass += delta_air;
exhaust_mass += delta_exhaust; exhaust_mass += delta_exhaust;
fuel_mass += delta_fuel; fuel_mass += delta_fuel;
energy += delta_energy; energy += delta_energy;
fuel_equivalent_mass=std::clamp(fuel_equivalent_mass+delta_equivalent,0.f,std::max(total_mass(),0.f));
if(fuel_equivalent_mass<std::numeric_limits<float>::min())fuel_equivalent_mass=0;
const float current_volume = std::max(volume, 1e-12f); const float current_volume = std::max(volume, 1e-12f);
float current_pressure = (gamma - 1.0f) * energy / current_volume; float current_pressure = (gamma - 1.0f) * energy / current_volume;
@@ -263,6 +285,7 @@ void EngineCylinder::update_state(float dt) {
air_mass = 1e-9f; air_mass = 1e-9f;
exhaust_mass = 0.0f; exhaust_mass = 0.0f;
fuel_mass = 0.0f; fuel_mass = 0.0f;
fuel_equivalent_mass=0;
energy = 101325.0f * current_volume / (gamma - 1.0f); energy = 101325.0f * current_volume / (gamma - 1.0f);
} else if (energy < 0.0f) { } else if (energy < 0.0f) {
energy = 101325.0f * current_volume / (gamma - 1.0f); energy = 101325.0f * current_volume / (gamma - 1.0f);
@@ -314,6 +337,7 @@ void EngineCylinder::sync_ports() {
port->temperature = temp; port->temperature = temp;
port->air_fraction = fraction; port->air_fraction = fraction;
port->fuel_fraction = fuel_fraction; port->fuel_fraction = fuel_fraction;
port->fuel_equivalent_fraction=std::clamp(std::max(fuel_mass,fuel_equivalent_mass)/std::max(total_mass(),1e-12f),0.f,1.f);
port->enthalpy = enthalpy; port->enthalpy = enthalpy;
} }
} }

View File

@@ -193,6 +193,8 @@ void read_ecu(Reader r,EcuConfig& c) {
r.field("power_afr",c.power_afr); r.field("power_afr",c.power_afr);
r.field("rev_limit_enabled",c.rev_limit_enabled); r.field("rev_limit_enabled",c.rev_limit_enabled);
r.field("rev_limit_fuel_cut_enabled",c.rev_limit_fuel_cut_enabled); r.field("rev_limit_fuel_cut_enabled",c.rev_limit_fuel_cut_enabled);
r.field("rev_limit_ignition_cut_enabled",c.rev_limit_ignition_cut_enabled);
r.field("rev_limit_cut_time_s",c.rev_limit_cut_time);
r.field("rev_limit_rpm",c.rev_limit_rpm); r.field("rev_limit_rpm",c.rev_limit_rpm);
r.field("rev_resume_rpm",c.rev_resume_rpm); r.field("rev_resume_rpm",c.rev_resume_rpm);
r.field("stoichiometric_afr",c.stoichiometric_afr); r.field("stoichiometric_afr",c.stoichiometric_afr);
@@ -377,6 +379,7 @@ void validate_ecu(const EcuConfig& c,const std::string& path) {
require(c.throttle_time_constant>0 && c.throttle_rate>0 && c.idle_spark_gain>=0 && require(c.throttle_time_constant>0 && c.throttle_rate>0 && c.idle_spark_gain>=0 &&
c.throttle_closing_time_constant>=0 && c.throttle_closing_rate>=0,path,"ecu: invalid actuator calibration"); c.throttle_closing_time_constant>=0 && c.throttle_closing_rate>=0,path,"ecu: invalid actuator calibration");
require(c.stoichiometric_afr>0 && c.power_afr>0,path,"ecu: AFR targets must be positive"); require(c.stoichiometric_afr>0 && c.power_afr>0,path,"ecu: AFR targets must be positive");
require(c.rev_limit_cut_time>=0&&c.rev_limit_cut_time<=10,path,"ecu: rev_limit_cut_time_s must be in 0..10");
} }
void validate_vehicle(const VehicleConfig& c,const std::string& path) { void validate_vehicle(const VehicleConfig& c,const std::string& path) {
require(c.mass>0 && c.wheel_radius>0 && c.rotational_inertia>=0 && require(c.mass>0 && c.wheel_radius>0 && c.rotational_inertia>=0 &&

45
src/fuel_injector.cpp Normal file
View File

@@ -0,0 +1,45 @@
#include "FluidSim/FuelInjector.hpp"
#include "FluidSim/Fluid.hpp"
#include <algorithm>
#include <cmath>
#include <limits>
#include <stdexcept>
namespace fluidsim::port {
FuelInjector::FuelInjector(FuelInjectorConfig config):config_(config){
if((config.supply!=FuelInjectorConfig::Supply::IdealRail&&config.supply!=FuelInjectorConfig::Supply::FiniteRail)||
!std::isfinite(config.maximum_mass_flow)||config.maximum_mass_flow<=0||
!std::isfinite(config.initial_fuel_mass)||config.initial_fuel_mass<0||
!std::isfinite(config.fuel_temperature)||config.fuel_temperature<=0)
throw std::invalid_argument("Invalid injector rail, flow capacity or fuel temperature");
state_.remaining_mass=config.supply==FuelInjectorConfig::Supply::FiniteRail?config.initial_fuel_mass:0;
state_.empty=config.supply==FuelInjectorConfig::Supply::FiniteRail&&state_.remaining_mass==0;
}
const FuelInjectorState& FuelInjector::deliver(Volume0D& host,const SignalValue& command,double time,double dt){
if(!std::isfinite(time)||!std::isfinite(dt)||dt<=0)throw std::invalid_argument("Injector requires finite time and positive period");
state_.mass_flow=0;state_.saturated=false;state_.command_fault=false;
if(command.validity!=SignalValidity::Valid||command.timestamp!=time||!std::holds_alternative<double>(command.value)){
state_.command_fault=true;return state_;
}
const auto flow=std::get<double>(command.value);
if(!std::isfinite(flow)||flow<0){state_.command_fault=true;return state_;}
state_.saturated=flow>config_.maximum_mass_flow;
auto requested=std::min(flow,config_.maximum_mass_flow)*dt;
if(config_.supply==FuelInjectorConfig::Supply::FiniteRail)requested=std::min(requested,state_.remaining_mass);
if(!std::isfinite(requested)||requested>std::numeric_limits<float>::max()){state_.command_fault=true;return state_;}
float mass=static_cast<float>(requested);
// Never remove more fuel from a finite supply than it actually contains.
if(double(mass)>requested)mass=std::nextafter(mass,0.f);
const double energy=double(mass)*host.gas_constant/(host.gamma-1)*config_.fuel_temperature;
if(!std::isfinite(energy)||double(host.internal_energy())+energy>std::numeric_limits<float>::max()||double(host.mass())+mass>std::numeric_limits<float>::max()){
state_.command_fault=true;return state_;
}
host.add_fuel_mass(mass,config_.fuel_temperature);
state_.mass_flow=double(mass)/dt;state_.delivered_mass+=mass;
if(config_.supply==FuelInjectorConfig::Supply::FiniteRail){
state_.remaining_mass=std::max(0.,state_.remaining_mass-mass);
state_.empty=state_.remaining_mass<std::numeric_limits<float>::denorm_min();
}
return state_;
}
}

25
src/logic_assembly.cpp Normal file
View File

@@ -0,0 +1,25 @@
#include "logic_assembly.hpp"
#include <algorithm>
namespace fluidsim::port {
void extract_machine_logic(const MachineDocument& source,const std::set<std::uint32_t>& selected,MachineDocument& assembly){
std::set<std::uint32_t> chosen;std::unordered_map<std::uint32_t,std::vector<std::uint32_t>> neighbours;
for(const auto& node:source.logic.nodes)if(((node.kind==LogicNodeKind::Command||node.kind==LogicNodeKind::Ecu)&&selected.contains(node.part))||(node.kind==LogicNodeKind::Sensor&&assembly.find_sensor(node.sensor)))chosen.insert(node.id);
for(const auto& wire:source.logic.links){neighbours[wire.from.node].push_back(wire.to.node);neighbours[wire.to.node].push_back(wire.from.node);}
std::vector<std::uint32_t> pending(chosen.begin(),chosen.end());for(std::size_t i=0;i<pending.size();++i)for(const auto id:neighbours[pending[i]])if(chosen.insert(id).second)pending.push_back(id);
for(auto node:source.logic.nodes)if(chosen.contains(node.id)){
if((node.kind==LogicNodeKind::Command||node.kind==LogicNodeKind::Ecu)&&!selected.contains(node.part)){if(const auto host=source.find(node.part))node.binding_label=host->name;node.part=0;}
if(node.kind==LogicNodeKind::Sensor){if(const auto sensor=source.find_sensor(node.sensor)){
if(!assembly.find_sensor(sensor->id)){auto copy=*sensor;for(auto* tap:{&copy.target,&copy.reference})if(!selected.contains(tap->part)){if(const auto host=source.find(tap->part))tap->label=host->name;tap->part=0;}assembly.next_sensor_id=std::max(assembly.next_sensor_id,copy.id+1);assembly.sensors.push_back(std::move(copy));}
}else node.sensor=0;
}
assembly.logic.next_id=std::max(assembly.logic.next_id,node.id+1);assembly.logic.nodes.push_back(std::move(node));
}
for(const auto& wire:source.logic.links)if(chosen.contains(wire.from.node)&&chosen.contains(wire.to.node))assembly.logic.links.push_back(wire);
}
void insert_machine_logic(MachineDocument& destination,const MachineDocument& assembly,const std::unordered_map<std::uint32_t,std::uint32_t>& parts,const std::unordered_map<std::uint32_t,std::uint32_t>& sensors){
std::unordered_map<std::uint32_t,std::uint32_t> nodes;float shift=0;if(!destination.logic.nodes.empty()){float right=-1e6f,left=1e6f;for(const auto& n:destination.logic.nodes)right=std::max(right,n.position.x);for(const auto& n:assembly.logic.nodes)left=std::min(left,n.position.x);if(!assembly.logic.nodes.empty())shift=right-left+320;}
for(auto node:assembly.logic.nodes){const auto original=node.id;node.id=destination.logic.next_id++;node.position.x+=shift;if((node.kind==LogicNodeKind::Command||node.kind==LogicNodeKind::Ecu))node.part=parts.contains(node.part)?parts.at(node.part):0;if(node.kind==LogicNodeKind::Sensor)node.sensor=sensors.contains(node.sensor)?sensors.at(node.sensor):0;nodes.emplace(original,node.id);destination.logic.nodes.push_back(std::move(node));}
for(auto wire:assembly.logic.links){wire.from.node=nodes.at(wire.from.node);wire.to.node=nodes.at(wire.to.node);destination.logic.links.push_back(std::move(wire));}
}
}

8
src/logic_assembly.hpp Normal file
View File

@@ -0,0 +1,8 @@
#pragma once
#include "FluidSim/Machine.hpp"
#include <set>
#include <unordered_map>
namespace fluidsim::port {
void extract_machine_logic(const MachineDocument&,const std::set<std::uint32_t>&,MachineDocument&);
void insert_machine_logic(MachineDocument&,const MachineDocument&,const std::unordered_map<std::uint32_t,std::uint32_t>& parts,const std::unordered_map<std::uint32_t,std::uint32_t>& sensors);
}

309
src/logic_editor.cpp Normal file
View File

@@ -0,0 +1,309 @@
#include "FluidSim/Desktop/LogicEditor.hpp"
#include "FluidSim/Desktop/PortAppearance.hpp"
#include <algorithm>
#include <cmath>
#include <cctype>
#include <deque>
#include <iomanip>
#include <limits>
#include <map>
#include <set>
#include <sstream>
namespace fluidsim::port::desktop {
namespace {
const sf::Color background{18,21,26},panel{27,31,38},border{54,62,75},text_color{229,233,240},muted{158,168,183},accent{116,170,235},amber{234,187,106};
constexpr float node_width=240,header_height=34,pins_top=60,pin_height=40;
void box(sf::RenderTarget& target,sf::FloatRect r,sf::Color fill,sf::Color outline=sf::Color::Transparent){sf::RectangleShape shape(r.size);shape.setPosition(r.position);shape.setFillColor(fill);shape.setOutlineColor(outline);shape.setOutlineThickness(1);target.draw(shape);}
void line(sf::RenderTarget& target,sf::Vector2f a,sf::Vector2f b,sf::Color color){const sf::Vertex vertices[]{{a,color},{b,color}};target.draw(vertices,2,sf::PrimitiveType::Lines);}
void circle(sf::RenderTarget& target,sf::Vector2f p,float radius,sf::Color fill,sf::Color outline){sf::CircleShape shape(radius);shape.setOrigin({radius,radius});shape.setPosition(p);shape.setFillColor(fill);shape.setOutlineColor(outline);shape.setOutlineThickness(1.5f);target.draw(shape);}
std::string fitted(const sf::Font* font,std::string s,float width,unsigned size){if(!font)return {};sf::Text text(*font,s,size);if(text.getLocalBounds().size.x<=width)return s;while(!s.empty()){s.pop_back();text.setString(s+"...");if(text.getLocalBounds().size.x<=width)return s+"...";}return {};}
void label(sf::RenderTarget& target,const sf::Font* font,const std::string& s,float x,float y,float width,unsigned size=12,sf::Color color=text_color){if(!font||width<=0)return;sf::Text text(*font,fitted(font,s,width,size),size);text.setPosition({x,y});text.setFillColor(color);target.draw(text);}
void wrapped(sf::RenderTarget& target,const sf::Font* font,std::string text,float x,float y,float width,unsigned size,int rows,sf::Color color){if(!font)return;for(int row=0;row<rows&&!text.empty();++row){std::size_t count=text.size();sf::Text measured(*font,text,size);while(count&&measured.getLocalBounds().size.x>width){--count;measured.setString(text.substr(0,count));}if(!count)return;if(count<text.size()){const auto space=text.rfind(' ',count);if(space!=std::string::npos&&space>0)count=space;}label(target,font,text.substr(0,count)+(row==rows-1&&count<text.size()?"...":""),x,y+row*(size+4),width,size,color);text.erase(0,count);while(!text.empty()&&text.front()==' ')text.erase(text.begin());}}
std::string number(double value,int precision=4){std::ostringstream out;out<<std::setprecision(precision)<<value;return out.str();}
std::string reading(const SignalValue* value,const MachinePortDescriptor& port){if(!value)return "--";if(value->validity!=SignalValidity::Valid)return value->validity==SignalValidity::Fault?"FAULT":"MISSING";if(std::holds_alternative<bool>(value->value))return std::get<bool>(value->value)?"true":"false";return number(std::get<double>(value->value))+" "+signal_quantity_unit(port.quantity);}
int category(LogicNodeKind k){if(k<=LogicNodeKind::NumberInput)return 1;if(k<=LogicNodeKind::Hysteresis)return 2;if(k<=LogicNodeKind::BooleanToNumber)return 3;if(logic_memory_node(k))return 5;return 4;}
bool manual_boolean(LogicNodeKind k){return k==LogicNodeKind::Switch||k==LogicNodeKind::Button;}
bool manual_number(LogicNodeKind k){return k==LogicNodeKind::Slider||k==LogicNodeKind::NumberInput;}
bool has_control(LogicNodeKind k){return manual_boolean(k)||manual_number(k);}
bool configurable_quantity(LogicNodeKind k){return k==LogicNodeKind::NumberConstant||manual_number(k)||(k>=LogicNodeKind::Greater&&k<=LogicNodeKind::BooleanToNumber)||k==LogicNodeKind::Readout||k==LogicNodeKind::NumberDelay;}
std::uint32_t physical_host(const MachineDocument& doc,const LogicNode& n){if(n.kind==LogicNodeKind::Command||n.kind==LogicNodeKind::Ecu)return n.part;if(n.kind==LogicNodeKind::Sensor)if(const auto sensor=doc.find_sensor(n.sensor))return sensor->target.part;return 0;}
const LogicNodeState* live_node(const LogicEditorHooks& hooks,std::uint32_t id){if(hooks.live)for(const auto& state:hooks.live->logic)if(state.id==id)return &state;return nullptr;}
const SignalValue* live_value(const LogicEditorHooks& hooks,const LogicNode& n,const std::string& key){const auto state=live_node(hooks,n.id);if(!state)return nullptr;const auto ports=logic_node_ports(n);for(std::size_t i=0;i<ports.size()&&i<state->values.size();++i)if(ports[i].key==key)return &state->values[i];return nullptr;}
float body_height(const LogicNode& n){unsigned inputs=0,outputs=0;for(const auto& p:logic_node_ports(n)){if(p.direction==PortDirection::Input)++inputs;else ++outputs;}return pins_top+pin_height*std::max(inputs,outputs)+12+(has_control(n.kind)||n.kind==LogicNodeKind::Readout?40:0);}
struct Field {
std::string key,label;
double* value{};bool* boolean{};unsigned* integer{};
double step{1},minimum{-1e15},maximum{1e15};bool restart{};
double get()const{return value?*value:boolean?double(*boolean):double(*integer);}
void set(double v)const{if(value)*value=v;else if(boolean)*boolean=v>=.5;else *integer=unsigned(std::round(v));}
};
std::vector<Field> fields(LogicNode& n){
std::vector<Field> result;
const auto num=[&](const char* key,const std::string& name,double& v,double step=1,bool restart=false,double minimum=-1e15,double maximum=1e15){result.push_back({key,name,&v,nullptr,nullptr,step,minimum,maximum,restart});};
const auto bit=[&](const char* key,const char* name,bool& v,bool restart=false){result.push_back({key,name,nullptr,&v,nullptr,1,0,1,restart});};
const auto unit=std::string(signal_quantity_unit(n.quantity));
if(n.kind==LogicNodeKind::BooleanConstant)bit("boolean","Value",n.boolean);
if(n.kind==LogicNodeKind::Switch||n.kind==LogicNodeKind::BooleanDelay)bit("boolean","Initial state",n.boolean,true);
if(n.kind==LogicNodeKind::NumberConstant||manual_number(n.kind)||n.kind==LogicNodeKind::NumberDelay)num("value",n.kind==LogicNodeKind::NumberConstant?"Value ["+unit+"]":"Initial value ["+unit+"]",n.value,1,n.kind!=LogicNodeKind::NumberConstant);
if(manual_number(n.kind)){num("minimum","Minimum ["+unit+"]",n.minimum,1,true);num("maximum","Maximum ["+unit+"]",n.maximum,1,true);}
if(n.kind==LogicNodeKind::And||n.kind==LogicNodeKind::Or||n.kind==LogicNodeKind::Xor||n.kind==LogicNodeKind::Nand||n.kind==LogicNodeKind::Nor)result.push_back({"input_count","Number of inputs",nullptr,nullptr,&n.input_count,1,2,16,true});
if(n.kind==LogicNodeKind::Window||n.kind==LogicNodeKind::Hysteresis||n.kind==LogicNodeKind::Clamp){num("low","Low ["+unit+"]",n.low);num("high","High ["+unit+"]",n.high);}
if(n.kind==LogicNodeKind::Hysteresis){bit("below","Activate below low",n.below);bit("initial","Initial output",n.initial,true);}
if(n.kind==LogicNodeKind::Equal)num("tolerance","Tolerance ["+unit+"]",n.tolerance,.001,false,0);
if(n.kind==LogicNodeKind::GainOffset){num("gain","Gain [scalar]",n.gain,.1);num("offset","Offset ["+unit+"]",n.offset);}
if(n.kind==LogicNodeKind::MapRange){num("input_low","Input low ["+unit+"]",n.input_low);num("input_high","Input high ["+unit+"]",n.input_high);const auto out=std::string(signal_quantity_unit(n.output_quantity));num("output_low","Output low ["+out+"]",n.output_low);num("output_high","Output high ["+out+"]",n.output_high);bit("clamp","Clamp mapped range",n.clamp);}
if(n.kind==LogicNodeKind::BooleanToNumber){num("false_value","False value ["+unit+"]",n.false_value);num("true_value","True value ["+unit+"]",n.true_value);}
return result;
}
}
struct LogicEditor::Impl {
LogicPosition center{};float zoom{1};bool needs_fit{true};
std::uint32_t selected{};std::set<std::uint32_t> selection;
std::optional<LogicNodeKind> placing;std::optional<LogicEndpoint> pending;bool rewiring{},port_drag{};
sf::Vector2f mouse{},press{},previous{},drag_offset{};bool panning{},dragging{},boxing{},slider_drag{};std::uint32_t held_button{},slider_node{};double slider_preview{};
int library_tab{},library_category{},library_scroll{},field_scroll{};
std::string search,edit_key,buffer;bool search_focus{},search_replace{},replace_text{true};
std::optional<LogicGraph> clipboard;std::uint64_t epoch{},clipboard_epoch{};
std::map<std::uint32_t,std::deque<std::pair<double,double>>> traces;
std::map<std::uint32_t,std::variant<bool,double>> manual_values;
struct Action {sf::FloatRect rect;std::string title;std::function<void()> run;bool enabled{true},active{};};
sf::Vector2f project(LogicPosition p,sf::FloatRect canvas)const{return canvas.position+canvas.size*.5f+sf::Vector2f{p.x-center.x,p.y-center.y}*zoom;}
LogicPosition unproject(sf::Vector2f p,sf::FloatRect canvas)const{const auto q=(p-canvas.position-canvas.size*.5f)/zoom;return {center.x+q.x,center.y+q.y};}
sf::FloatRect rect(const LogicNode& n,sf::FloatRect canvas)const{auto at=project(n.position,canvas);if(dragging&&selection.contains(n.id))at+=drag_offset;return {at,{node_width*zoom,body_height(n)*zoom}};}
sf::Vector2f pin(const LogicNode& n,const std::string& key,sf::FloatRect canvas)const{unsigned in=0,out=0;const auto r=rect(n,canvas);for(const auto& p:logic_node_ports(n)){const bool input=p.direction==PortDirection::Input;const auto row=input?in++:out++;if(p.key==key)return r.position+sf::Vector2f{input?0:node_width,(pins_top+row*pin_height+7)}*zoom;}throw std::invalid_argument("Unknown logic editor pin");}
std::optional<LogicEndpoint> pick_pin(const LogicGraph& graph,sf::Vector2f point,sf::FloatRect canvas)const{for(auto it=graph.nodes.rbegin();it!=graph.nodes.rend();++it)for(const auto& p:logic_node_ports(*it))if((point-pin(*it,p.key,canvas)).length()<=std::max(7.f,7*zoom))return LogicEndpoint{it->id,p.key};return {};}
const LogicNode* pick(const LogicGraph& graph,sf::Vector2f point,sf::FloatRect canvas)const{for(auto it=graph.nodes.rbegin();it!=graph.nodes.rend();++it)if(rect(*it,canvas).contains(point))return &*it;return nullptr;}
void select(std::uint32_t id,const LogicEditorHooks& h,bool extend=false){if(!extend)selection.clear();if(id){if(extend&&selection.contains(id))selection.erase(id);else selection.insert(id);}selected=selection.contains(id)?id:selection.empty()?0:*selection.rbegin();field_scroll=0;edit_key.clear();if(h.select_physical)h.select_physical(selected?physical_host(h.document,*h.document.logic.find(selected)):0);}
void fit(const LogicGraph& graph,sf::FloatRect canvas,bool only){float x=1e9f,y=1e9f,r=-1e9f,b=-1e9f;for(const auto& n:graph.nodes)if(!only||selection.contains(n.id)){x=std::min(x,n.position.x);y=std::min(y,n.position.y);r=std::max(r,n.position.x+node_width);b=std::max(b,n.position.y+body_height(n));}if(x>r){center={};zoom=1;}else{center={(x+r)*.5f,(y+b)*.5f};zoom=std::clamp(std::min((canvas.size.x-64)/std::max(1.f,r-x),(canvas.size.y-64)/std::max(1.f,b-y)),.25f,1.4f);}needs_fit=false;}
void deactivate(const LogicEditorHooks& h){if(h.release_buttons)h.release_buttons();if(!h.live){manual_values.clear();traces.clear();}held_button=0;slider_drag=panning=dragging=boxing=port_drag=false;pending.reset();placing.reset();edit_key.clear();search_focus=false;}
bool control(const LogicEditorHooks& h,std::uint32_t id,bool value){try{h.boolean_control(id,value);manual_values[id]=value;return true;}catch(const std::exception& e){h.status(e.what());return false;}}
bool control(const LogicEditorHooks& h,std::uint32_t id,double value){try{h.number_control(id,value);manual_values[id]=value;return true;}catch(const std::exception& e){h.status(e.what());return false;}}
bool connect(const LogicEditorHooks& h,const LogicEndpoint& target){if(!pending)return false;const auto source=*pending;const bool rewire=rewiring;const bool result=h.edit([&]{if(rewire)h.document.logic.disconnect(source);h.document.logic.connect(source,target);});if(result){pending.reset();port_drag=false;h.status("Signal connected. Red Boolean / green Number; Ctrl+Z undoes.");}return result;}
PortCompatibility preview(const LogicGraph& graph,const LogicEndpoint& target)const{try{auto copy=graph;if(rewiring&&pending)copy.disconnect(*pending);if(pending)return logic_connection_preview(copy,*pending,target);return {false,"Choose a pin first"};}catch(const std::exception& e){return {false,e.what()};}}
void configure(LogicNode& node,const MachinePortDescriptor& descriptor)const{if(descriptor.domain==PortDomain::Number){if(configurable_quantity(node.kind)){node.quantity=descriptor.quantity;node.pressure_reference=descriptor.pressure_reference;node.output_quantity=descriptor.quantity;node.output_pressure_reference=descriptor.pressure_reference;}if(node.kind==LogicNodeKind::Command){if(descriptor.quantity==SignalQuantity::AngularSpeed)node.command=LogicCommand::MotorSpeed;else if(descriptor.quantity==SignalQuantity::Torque)node.command=LogicCommand::MotorTorque;else if(descriptor.quantity==SignalQuantity::Fraction)node.command=LogicCommand::ThrottleOpening;}if(node.kind==LogicNodeKind::Sensor){if(descriptor.quantity==SignalQuantity::AngularSpeed||descriptor.quantity==SignalQuantity::Angle)node.sensor_kind=MachineSensorKind::Crank;else if(descriptor.quantity==SignalQuantity::Temperature)node.sensor_kind=MachineSensorKind::GasTemperature;else if(descriptor.quantity==SignalQuantity::MassFlow)node.sensor_kind=MachineSensorKind::MassFlow;else if(descriptor.quantity==SignalQuantity::Fraction)node.sensor_kind=MachineSensorKind::ThrottlePosition;else if(descriptor.quantity==SignalQuantity::Pressure&&descriptor.pressure_reference==PressureReference::Differential)node.sensor_kind=MachineSensorKind::DifferentialPressure;}}}
std::vector<int> items(const LogicEditorHooks& h)const{std::vector<int> result;std::string query=search;for(auto& c:query)c=char(std::tolower(static_cast<unsigned char>(c)));const int count=library_tab?int(h.document.logic.nodes.size()):logic_node_kind_count;for(int i=0;i<count;++i){const auto kind=library_tab?h.document.logic.nodes[i].kind:LogicNodeKind(i);if(!library_tab&&library_category&&category(kind)!=library_category)continue;std::string title=library_tab?h.document.logic.nodes[i].name:logic_node_name(kind);for(auto& c:title)c=char(std::tolower(static_cast<unsigned char>(c)));if(!query.empty()&&title.find(query)==std::string::npos)continue;
if(!library_tab&&pending){const auto source=logic_port(h.document.logic,*pending);LogicNode candidate;candidate.kind=kind;configure(candidate,source);bool compatible=false;for(const auto& pin:logic_node_ports(candidate))if(port_compatibility(source,pin).allowed)compatible=true;if(!compatible)continue;}
result.push_back(i);
}return result;}
void create(const LogicEditorHooks& h,sf::FloatRect canvas){if(!placing||h.live)return;const auto kind=*placing;const auto at=unproject(mouse,canvas);std::uint32_t id=0;const auto source=pending;const bool rewire=rewiring;if(h.edit([&]{auto& graph=h.document.logic;id=graph.add(kind,at).id;if(source)configure(*graph.find(id),logic_port(graph,*source));if(source){const auto descriptor=logic_port(graph,*source);for(const auto& pin:logic_node_ports(*graph.find(id)))if(port_compatibility(descriptor,pin).allowed){if(rewire)graph.disconnect(*source);graph.connect(*source,{id,pin.key});break;}}})){select(id,h);pending.reset();placing.reset();}}
void copy(const LogicEditorHooks& h){if(selection.empty())return;LogicGraph fragment;for(auto n:h.document.logic.nodes)if(selection.contains(n.id)){if(n.kind==LogicNodeKind::Sensor){if(const auto s=h.document.find_sensor(n.sensor))n.binding_label=s->name;}else if(n.kind==LogicNodeKind::Command||n.kind==LogicNodeKind::Ecu){if(const auto p=h.document.find(n.part))n.binding_label=p->name;}fragment.nodes.push_back(n);fragment.next_id=std::max(fragment.next_id,n.id+1);}for(const auto& l:h.document.logic.links)if(selection.contains(l.from.node)&&selection.contains(l.to.node))fragment.links.push_back(l);clipboard=std::move(fragment);clipboard_epoch=epoch;h.status("Copied logic selection with internal wires. Commands paste with unresolved ownership.");}
void paste(const LogicEditorHooks& h){
if(!clipboard||h.live)return;
std::vector<std::uint32_t> ids;const auto fragment=*clipboard;const bool same=clipboard_epoch==epoch;
if(h.edit([&]{
std::map<std::uint32_t,std::uint32_t> map;
for(auto n:fragment.nodes){const auto old=n.id;n.id=h.document.logic.next_id++;n.position.x+=32;n.position.y+=32;
if(n.kind==LogicNodeKind::Command||n.kind==LogicNodeKind::Ecu){if(same)if(const auto p=h.document.find(n.part))n.binding_label=p->name;n.part=0;}
if(n.kind==LogicNodeKind::Sensor&&!same)n.sensor=0;
map[old]=n.id;ids.push_back(n.id);h.document.logic.nodes.push_back(std::move(n));
}
for(auto l:fragment.links){l.from.node=map.at(l.from.node);l.to.node=map.at(l.to.node);h.document.logic.links.push_back(std::move(l));}
})){selection=std::set<std::uint32_t>(ids.begin(),ids.end());selected=ids.empty()?0:ids.back();}
}
void remove(const LogicEditorHooks& h){if(h.live||selection.empty())return;const auto ids=selection;if(h.edit([&]{for(const auto id:ids)h.document.logic.remove(id);})){selection.clear();selected=0;pending.reset();}}
std::vector<Action> actions(const LogicEditorHooks&,const WorkshopLayout&,sf::FloatRect,bool);
bool commit(const LogicEditorHooks&);
bool event(const sf::Event&,const LogicEditorHooks&,const WorkshopLayout&,sf::FloatRect,bool);
void draw(sf::RenderWindow&,const sf::Font*,const LogicEditorHooks&,const WorkshopLayout&,sf::FloatRect,bool);
};
std::vector<LogicEditor::Impl::Action> LogicEditor::Impl::actions(const LogicEditorHooks& h,const WorkshopLayout& ui,sf::FloatRect canvas,bool panels){
std::vector<Action> result;if(!panels)return result;
const auto button=[&](sf::FloatRect rect,std::string title,std::function<void()> action,bool enabled=true,bool active=false){result.push_back({rect,std::move(title),std::move(action),enabled,active});};
if(ui.left.size.x>40){const float half=(ui.left.size.x-32)/2;
button({{20,108},{half,28}},"Nodes",[this]{library_tab=0;library_scroll=0;},true,!library_tab);button({{28+half,108},{half,28}},"Objects",[this]{library_tab=1;library_scroll=0;},true,library_tab);
if(!library_tab){const char* names[]{"All","Ctl","Bool","Num","I/O","Mem"};const float width=(ui.left.size.x-44)/6;for(int i=0;i<6;++i)button({{20+i*(width+4),192},{width,28}},names[i],[this,i]{library_category=i;library_scroll=0;},true,library_category==i);}
const auto entries=items(h);const int rows=ui.library_rows();library_scroll=std::clamp(library_scroll,0,std::max(0,int(entries.size())-rows));
for(int row=0;row<rows&&row+library_scroll<int(entries.size());++row){const auto index=entries[row+library_scroll];const auto kind=library_tab?h.document.logic.nodes[index].kind:LogicNodeKind(index);const auto id=library_tab?h.document.logic.nodes[index].id:0;
button({{20,ui.library.position.y+row*36},{ui.left.size.x-24,30}},library_tab?h.document.logic.nodes[index].name:logic_node_name(kind),[this,&h,kind,id]{search_focus=false;if(id)select(id,h);else{placing=kind;h.status("Click the Logic canvas to place "+std::string(logic_node_name(kind))+". Escape cancels.");}},library_tab||!h.live,library_tab?selection.contains(id):placing&&*placing==kind);
}
button({{20,ui.height-126},{ui.left.size.x-24,28}},"Fit graph [Home]",[this,&h,canvas]{fit(h.document.logic,canvas,false);});
button({{20,ui.height-90},{ui.left.size.x-24,28}},"Compressor logic bench",[&h]{if(h.example)h.example();},!h.live);
}
if(ui.right.size.x<=40)return result;
const auto n=h.document.logic.find(selected);if(!n)return result;const float x=ui.inspector_x+12,width=ui.inspector_width-24;
button({{x,136},{width,30}},edit_key=="name"?buffer+"|":n->name,[this,n]{edit_key="name";buffer=n->name;replace_text=true;});
if(n->kind==LogicNodeKind::Sensor){const auto sensor=h.document.find_sensor(n->sensor);button({{x,194},{width,28}},sensor?"Sensor: "+sensor->name:"Choose sensor attachment",[this,&h]{const auto id=selected;h.edit([&]{auto& n=*h.document.logic.find(id);std::vector<std::uint32_t> ids{0};for(const auto& s:h.document.sensors)ids.push_back(s.id);auto at=std::find(ids.begin(),ids.end(),n.sensor);n.sensor=at==ids.end()||++at==ids.end()?0:*at;if(const auto sensor=h.document.find_sensor(n.sensor)){n.sensor_kind=sensor->kind;n.binding_label=sensor->name;}});},!h.live);
}else if(n->kind==LogicNodeKind::Command||n->kind==LogicNodeKind::Ecu){
if(n->kind==LogicNodeKind::Ecu)button({{x,194},{width,28}},std::string("Preset: ")+sensor_ecu_strategy_name(n->ecu_strategy),[this,&h]{h.edit([&]{auto& n=*h.document.logic.find(selected);n.ecu_strategy=next_sensor_ecu_strategy(n.ecu_strategy);std::erase_if(h.document.logic.links,[&](const auto& wire){return wire.to.node==n.id&&(wire.to.port=="tps"||wire.to.port=="map"||wire.to.port=="iat");});});},!h.live);
if(n->kind==LogicNodeKind::Command)button({{x,194},{width,28}},std::string("Command: ")+logic_command_name(n->command),[this,&h]{h.edit([&]{auto& n=*h.document.logic.find(selected);n.command=LogicCommand((int(n.command)+1)%logic_command_count);});},!h.live);
const auto target=h.document.find(n->part);button({{x,228},{width,28}},target?"Target: "+target->name:"Choose physical target",[this,&h]{h.edit([&]{auto& n=*h.document.logic.find(selected);std::vector<std::uint32_t> ids{0};for(const auto& p:h.document.parts){auto candidate=n;candidate.part=p.id;if(logic_binding_problem(h.document,candidate).empty())ids.push_back(p.id);}auto at=std::find(ids.begin(),ids.end(),n.part);n.part=at==ids.end()||++at==ids.end()?0:*at;if(const auto target=h.document.find(n.part))n.binding_label=target->name;});},!h.live);
}else if(configurable_quantity(n->kind)){
const auto quantity=[this,&h](bool output){h.edit([&]{auto& n=*h.document.logic.find(selected);auto& q=output?n.output_quantity:n.quantity;auto& ref=output?n.output_pressure_reference:n.pressure_reference;q=SignalQuantity(int(q)%int(SignalQuantity::Inertia)+1);ref=q==SignalQuantity::Pressure?PressureReference::Absolute:PressureReference::None;});};
button({{x,194},{width,28}},std::string("Quantity: ")+signal_quantity_key(n->quantity)+" ["+signal_quantity_unit(n->quantity)+"]",[quantity]{quantity(false);},!h.live);
const auto pressure=[this,&h](bool output){h.edit([&]{auto& n=*h.document.logic.find(selected);auto& ref=output?n.output_pressure_reference:n.pressure_reference;ref=PressureReference(int(ref)%3+1);});};
const char* references[]{"none","absolute","gauge","differential"};
if(n->quantity==SignalQuantity::Pressure)button({{x,228},{width,28}},std::string("Pressure: ")+references[int(n->pressure_reference)],[pressure]{pressure(false);},!h.live);
if(n->kind==LogicNodeKind::MapRange){const float half=(width-8)/2;button({{x,264},{half,28}},std::string("Out: ")+signal_quantity_key(n->output_quantity),[quantity]{quantity(true);},!h.live);if(n->output_quantity==SignalQuantity::Pressure)button({{x+half+8,264},{half,28}},references[int(n->output_pressure_reference)],[pressure]{pressure(true);},!h.live);}
}
const auto f=fields(*h.document.logic.find(selected));const int rows=std::max(1,int((ui.height-190-310)/52));field_scroll=std::clamp(field_scroll,0,std::max(0,int(f.size())-rows));
for(int row=0;row<rows&&row+field_scroll<int(f.size());++row){const auto field=f[row+field_scroll];const float y=310+row*52.f;const bool enabled=!h.live||!field.restart;
button({{x,y+20},{width-68,26}},edit_key==field.key?buffer+"|":field.boolean?(field.get()?"true":"false"):number(field.get(),10),[this,&h,field]{if(field.boolean)h.edit([&]{field.set(field.get()?0:1);});else{edit_key=field.key;buffer=number(field.get(),12);replace_text=true;}},enabled);
if(!field.boolean){for(const int direction:{-1,1})button({{x+width+(direction<0?-62.f:-30.f),y+20},{26,26}},direction<0?"-":"+",[&h,field,direction]{h.edit([&]{field.set(std::clamp(field.get()+field.step*direction,field.minimum,field.maximum));});},enabled);}
}
button({{x,ui.height-142},{width,28}},"Disconnect node wires",[this,&h]{const auto id=selected;h.edit([&]{std::erase_if(h.document.logic.links,[&](const auto& l){return l.from.node==id||l.to.node==id;});});},!h.live);
const auto host=physical_host(h.document,*n);const float half=(width-8)/2;button({{x,ui.height-106},{half,28}},"Focus in 3D",[&h,host]{if(h.focus_physical)h.focus_physical(host);},host&&h.document.find(host));button({{x+half+8,ui.height-106},{half,28}},"Delete node [Del]",[this,&h]{remove(h);},!h.live);
return result;
}
bool LogicEditor::Impl::commit(const LogicEditorHooks& h){
if(edit_key.empty())return true;
const auto key=edit_key,text=buffer;const auto id=selected;
if(!h.document.logic.find(id)){edit_key.clear();return false;}
try{double value=0;if(key!="name"){std::size_t used=0;value=std::stod(text,&used);if(used!=text.size()||!std::isfinite(value))throw std::invalid_argument("Enter a finite number");}
if(key=="manual"&&h.live){if(!control(h,id,value))return false;edit_key.clear();return true;}
const bool applied=h.edit([&]{auto& n=*h.document.logic.find(id);if(key=="name")n.name=text;else{auto f=fields(n);const auto at=std::find_if(f.begin(),f.end(),[&](const auto& f){return f.key==key;});if(at==f.end())throw std::invalid_argument("Property no longer exists");if(h.live&&at->restart)throw std::invalid_argument("This initial/schema property requires Build");if(value<at->minimum||value>at->maximum)throw std::invalid_argument("Value is outside the property range");at->set(value);}});
if(applied)edit_key.clear();
return applied;
}catch(const std::exception& e){h.status(e.what());return false;}
}
bool LogicEditor::Impl::event(const sf::Event& event,const LogicEditorHooks& h,const WorkshopLayout& ui,sf::FloatRect canvas,bool panels){
auto& graph=h.document.logic;std::erase_if(selection,[&](auto id){return !graph.find(id);});if(!selection.contains(selected))selected=selection.empty()?0:*selection.rbegin();
if(pending)try{logic_port(graph,*pending);}catch(...){pending.reset();port_drag=false;}
if(event.is<sf::Event::FocusLost>()){deactivate(h);return false;}
if(!edit_key.empty()){
if(const auto key=event.getIf<sf::Event::KeyPressed>()){if(key->code==sf::Keyboard::Key::Escape){edit_key.clear();return true;}if(key->code==sf::Keyboard::Key::Enter||key->code==sf::Keyboard::Key::Tab){commit(h);return true;}if(key->code==sf::Keyboard::Key::Backspace){if(replace_text)buffer.clear();else if(!buffer.empty())buffer.pop_back();replace_text=false;}return true;}
if(const auto text=event.getIf<sf::Event::TextEntered>()){const auto c=text->unicode;const bool valid=edit_key=="name"?c>=32&&c<127:(c>='0'&&c<='9')||c=='-'||c=='+'||c=='.'||c=='e'||c=='E';if(valid){if(replace_text)buffer.clear();replace_text=false;if(buffer.size()<(edit_key=="name"?120:32))buffer+=char(c);}return true;}
if(event.is<sf::Event::MouseButtonPressed>()&&!commit(h))return true;
}
if(search_focus){if(const auto key=event.getIf<sf::Event::KeyPressed>()){if(key->code==sf::Keyboard::Key::Escape||key->code==sf::Keyboard::Key::Enter)search_focus=false;else if(key->code==sf::Keyboard::Key::Backspace){if(search_replace)search.clear();else if(!search.empty())search.pop_back();search_replace=false;library_scroll=0;}return true;}if(const auto text=event.getIf<sf::Event::TextEntered>()){if(text->unicode>=32&&text->unicode<127){if(search_replace)search.clear();search_replace=false;if(search.size()<80)search+=char(text->unicode);library_scroll=0;}return true;}}
if(const auto key=event.getIf<sf::Event::KeyPressed>()){
using K=sf::Keyboard::Key;
if(key->control){if(key->code==K::F&&panels){search_focus=true;search_replace=true;return true;}if(key->code==K::A){selection.clear();for(const auto& node:graph.nodes)selection.insert(node.id);selected=selection.empty()?0:*selection.rbegin();return true;}if(key->code==K::C){copy(h);return true;}if(key->code==K::V){paste(h);return true;}if(key->code==K::D){copy(h);paste(h);return true;}return false;}
if(key->code==K::Home){fit(graph,canvas,false);return true;}if(key->code==K::F){fit(graph,canvas,true);return true;}
if(key->code==K::Escape){if(held_button){if(h.release_buttons)h.release_buttons();held_button=0;}if(pending){pending.reset();port_drag=false;}else if(placing)placing.reset();else if(dragging||boxing||slider_drag){dragging=boxing=slider_drag=false;drag_offset={};}else select(0,h);return true;}
if(key->code==K::Delete||key->code==K::Backspace){if(pending&&!h.live){const auto pin=*pending;h.edit([&]{graph.disconnect(pin);});pending.reset();}else remove(h);return true;}
if(key->code==K::Q||key->code==K::G||key->code==K::R||key->code==K::C||key->code==K::P||key->code==K::X)return true;
return false;
}
if(const auto moved=event.getIf<sf::Event::MouseMoved>()){
mouse=sf::Vector2f(moved->position);const auto delta=mouse-previous;previous=mouse;
if(panning){center.x-=delta.x/zoom;center.y-=delta.y/zoom;return true;}
if(dragging){drag_offset=mouse-press;return true;}
if(slider_drag){if(const auto n=graph.find(slider_node)){const auto r=rect(*n,canvas);const auto ratio=std::clamp((mouse.x-r.position.x-12*zoom)/((node_width-24)*zoom),0.f,1.f);slider_preview=std::lerp(n->minimum,n->maximum,double(ratio));if(h.live)control(h,n->id,slider_preview);}return true;}
return boxing||port_drag||canvas.contains(mouse)||(panels&&(ui.left.contains(mouse)||ui.right.contains(mouse)));
}
if(const auto released=event.getIf<sf::Event::MouseButtonReleased>()){
mouse=sf::Vector2f(released->position);if(released->button==sf::Mouse::Button::Middle&&panning){panning=false;return true;}
if(released->button!=sf::Mouse::Button::Left)return false;
if(held_button){const auto id=held_button;held_button=0;if(h.live)control(h,id,false);return true;}
if(slider_drag){slider_drag=false;if(!h.live){const auto id=slider_node;const auto v=slider_preview;h.edit([&]{if(auto n=graph.find(id))n->value=v;});}return true;}
if(dragging){dragging=false;const auto delta=drag_offset/zoom;drag_offset={};if(delta.length()>1){const auto ids=selection;h.edit([&]{for(const auto id:ids)if(auto n=graph.find(id)){n->position.x+=delta.x;n->position.y+=delta.y;}});}return true;}
if(boxing){boxing=false;const sf::FloatRect region{{std::min(press.x,mouse.x),std::min(press.y,mouse.y)},{std::abs(mouse.x-press.x),std::abs(mouse.y-press.y)}};for(const auto& n:graph.nodes)if(region.findIntersection(rect(n,canvas)))selection.insert(n.id);selected=selection.empty()?0:*selection.rbegin();return true;}
if(port_drag){port_drag=false;if((mouse-press).length()>5&&canvas.contains(mouse))if(const auto target=pick_pin(graph,mouse,canvas);target&&pending&&*target!=*pending)connect(h,*target);return true;}
return canvas.contains(mouse);
}
if(const auto wheel=event.getIf<sf::Event::MouseWheelScrolled>()){
const auto point=sf::Vector2f(wheel->position);
if(canvas.contains(point)){const auto before=unproject(point,canvas);zoom=std::clamp(zoom*std::exp(wheel->delta*.12f),.25f,2.f);const auto after=unproject(point,canvas);center.x+=before.x-after.x;center.y+=before.y-after.y;return true;}
if(panels&&ui.left.contains(point)){library_scroll=std::max(0,library_scroll+(wheel->delta<0?1:-1));return true;}
if(panels&&ui.right.contains(point)){field_scroll=std::max(0,field_scroll+(wheel->delta<0?1:-1));return true;}return false;
}
if(const auto pressed=event.getIf<sf::Event::MouseButtonPressed>()){
mouse=previous=sf::Vector2f(pressed->position);
if(panels&&ui.left.size.x>40&&ui.search.contains(mouse)&&pressed->button==sf::Mouse::Button::Left){search_focus=true;search_replace=false;return true;}search_focus=false;
if(panels&&pressed->button==sf::Mouse::Button::Left){auto list=actions(h,ui,canvas,panels);for(auto i=list.rbegin();i!=list.rend();++i)if(i->rect.contains(mouse)){if(i->enabled)i->run();else h.status(h.live?"This schema or initial-state property requires Build.":"Choose a compatible physical binding first.");return true;}}
if(!canvas.contains(mouse))return panels&&(ui.left.contains(mouse)||ui.right.contains(mouse));
if(pressed->button==sf::Mouse::Button::Middle){panning=true;return true;}
if(pressed->button==sf::Mouse::Button::Right){if(const auto pin=pick_pin(graph,mouse,canvas);pin&&!h.live){const auto endpoint=*pin;h.edit([&]{graph.disconnect(endpoint);});pending.reset();}else if(const auto n=pick(graph,mouse,canvas))select(n->id,h);return true;}
if(pressed->button!=sf::Mouse::Button::Left)return true;
if(placing){create(h,canvas);return true;}
if(const auto hit=pick_pin(graph,mouse,canvas);hit){if(h.live){select(hit->node,h);return true;}if(pending&&*pending!=*hit){connect(h,*hit);return true;}pending=*hit;rewiring=logic_port(graph,*hit).direction==PortDirection::Input;port_drag=true;press=mouse;return true;}
if(const auto n=pick(graph,mouse,canvas)){
const auto id=n->id;const auto r=rect(*n,canvas);const float control_y=r.position.y+r.size.y-42*zoom;
if(has_control(n->kind)&&mouse.y>=control_y){select(id,h);if(manual_boolean(n->kind)){if(n->kind==LogicNodeKind::Button){if(h.live){if(control(h,id,true))held_button=id;}else h.status("Momentary buttons are active during Test; their initial state is released.");}else{bool current=n->boolean;if(const auto live=live_value(h,*n,"out");live&&live->validity==SignalValidity::Valid)current=std::get<bool>(live->value);if(h.live&&manual_values.contains(id))current=std::get<bool>(manual_values.at(id));if(h.live)control(h,id,!current);else h.edit([&]{graph.find(id)->boolean=!current;});}}
else if(n->kind==LogicNodeKind::Slider){slider_drag=true;slider_node=id;slider_preview=n->value;sf::Event moved=sf::Event::MouseMoved{pressed->position};this->event(moved,h,ui,canvas,panels);}
else {edit_key=h.live?"manual":"value";if(const auto live=live_value(h,*n,"out");h.live&&live&&live->validity==SignalValidity::Valid)buffer=number(std::get<double>(live->value),12);else buffer=number(n->value,12);replace_text=true;}
return true;
}
if(!selection.contains(id)||h.shift)select(id,h,h.shift);
else {selected=id;if(h.select_physical)h.select_physical(physical_host(h.document,*n));}
if(mouse.y<r.position.y+header_height*zoom){dragging=true;press=mouse;drag_offset={};}
return true;
}
if(pending){h.status("Choose a compatible pin, or place a compatible node from the filtered library. Escape cancels.");return true;}
if(!h.shift)select(0,h);
boxing=true;press=mouse;return true;
}
return false;
}
namespace {
sf::Vector2f curve(sf::Vector2f a,sf::Vector2f b,float t){const auto span=std::max(60.f,std::abs(b.x-a.x)*.45f);const auto c=a+sf::Vector2f{span,0},d=b-sf::Vector2f{span,0};const float u=1-t;return a*(u*u*u)+c*(3*u*u*t)+d*(3*u*t*t)+b*(t*t*t);}
void wire(sf::RenderTarget& target,sf::Vector2f a,sf::Vector2f b,sf::Color color,bool dashed=false){auto previous=a;for(int i=1;i<=40;++i){const auto next=curve(a,b,float(i)/40);if(!dashed||i%4<2)line(target,previous,next,color);previous=next;}}
}
void LogicEditor::Impl::draw(sf::RenderWindow& window,const sf::Font* font,const LogicEditorHooks& h,const WorkshopLayout& ui,sf::FloatRect canvas,bool panels){
if(needs_fit)fit(h.document.logic,canvas,false);
const auto original=window.getView();const auto size=window.getSize();
sf::View clipped(canvas);clipped.setViewport({{canvas.position.x/size.x,canvas.position.y/size.y},{canvas.size.x/size.x,canvas.size.y/size.y}});window.setView(clipped);box(window,canvas,background,border);
const auto zero=project({},canvas);const float grid=40*zoom;for(float x=canvas.position.x+std::fmod(zero.x-canvas.position.x,grid);x<canvas.position.x+canvas.size.x;x+=grid)line(window,{x,canvas.position.y},{x,canvas.position.y+canvas.size.y},sf::Color{29,35,42});for(float y=canvas.position.y+std::fmod(zero.y-canvas.position.y,grid);y<canvas.position.y+canvas.size.y;y+=grid)line(window,{canvas.position.x,y},{canvas.position.x+canvas.size.x,y},sf::Color{29,35,42});
std::string hover,hover_detail;std::set<std::uint32_t> cycle;
if(pending)if(const auto target=pick_pin(h.document.logic,mouse,canvas);target&&*target!=*pending){const auto result=preview(h.document.logic,*target);if(!result.allowed&&result.message.starts_with("Combinational cycle:")){auto graph=h.document.logic;if(rewiring)graph.disconnect(*pending);auto from=*pending,to=*target;if(logic_port(graph,from).direction==PortDirection::Input)std::swap(from,to);graph.links.push_back({from,to});std::vector<std::uint32_t> path;try{logic_evaluation_order(graph,&path);}catch(const std::exception&){}cycle.insert(path.begin(),path.end());}}
for(const auto& link:h.document.logic.links){const auto from=h.document.logic.find(link.from.node),to=h.document.logic.find(link.to.node);if(!from||!to)continue;const auto descriptor=logic_port(h.document.logic,link.from);auto color=port_color(descriptor.domain);const auto value=live_value(h,*from,link.from.port);const bool invalid=value&&value->validity!=SignalValidity::Valid;if(value&&value->validity==SignalValidity::Valid&&descriptor.domain==PortDomain::Boolean&&!std::get<bool>(value->value))color=sf::Color(color.r/2,color.g/2,color.b/2);wire(window,pin(*from,link.from.port,canvas),pin(*to,link.to.port,canvas),color,invalid);}
for(const auto& node:h.document.logic.nodes){const auto r=rect(node,canvas);if(!r.findIntersection(canvas))continue;const bool active=selection.contains(node.id);const auto state=live_node(h,node.id);const bool fault=state&&!state->problem.empty();
box(window,r,sf::Color{29,34,42},cycle.contains(node.id)?amber:active?accent:fault?amber:border);box(window,{r.position,{r.size.x,header_height*zoom}},cycle.contains(node.id)?sf::Color{75,56,35}:active?sf::Color{39,57,78}:sf::Color{37,43,52});
const auto title_size=unsigned(std::clamp(12*zoom,8.f,14.f));label(window,font,node.name,r.position.x+10*zoom,r.position.y+8*zoom,r.size.x-20*zoom,title_size,active?accent:text_color);
if(zoom>=.65f){std::string description=logic_node_name(node.kind);if(node.kind==LogicNodeKind::Sensor){const auto sensor=h.document.find_sensor(node.sensor);description=sensor?"S"+std::to_string(sensor->id)+" / "+sensor->name:"Unresolved sensor / "+node.binding_label;}else if(node.kind==LogicNodeKind::Command||node.kind==LogicNodeKind::Ecu){const auto part=h.document.find(node.part);description=part?std::string(node.kind==LogicNodeKind::Ecu?"ECU inputs":logic_command_name(node.command))+" / "+part->name:"Unresolved target / "+node.binding_label;}
label(window,font,description,r.position.x+10*zoom,r.position.y+40*zoom,r.size.x-20*zoom,unsigned(std::max(9.f,10*zoom)),muted);
}
const auto ports=logic_node_ports(node,&h.document);for(std::size_t index=0;index<ports.size();++index){const auto& port=ports[index];const auto at=pin(node,port.key,canvas);const bool input=port.direction==PortDirection::Input;auto color=port_color(port.domain);const auto value=state&&index<state->values.size()?&state->values[index]:nullptr;
if(value&&value->validity==SignalValidity::Valid&&port.domain==PortDomain::Boolean&&!std::get<bool>(value->value))color=sf::Color(color.r/2,color.g/2,color.b/2);
const auto type_color=port_color(port.domain);circle(window,at,std::max(3.f,5.f*zoom),filled_port(port)?color:background,type_color);
if(pending&&pending->node==node.id&&pending->port==port.key)circle(window,at,std::max(6.f,8*zoom),sf::Color::Transparent,accent);
if(zoom>=.65f){const float x=input?r.position.x+12*zoom:r.position.x+126*zoom;const float width=102*zoom;label(window,font,port.name,x,at.y-6*zoom,width,unsigned(std::max(9.f,11*zoom)),type_color);if(h.live)label(window,font,reading(value,port),x,at.y+9*zoom,width,unsigned(std::max(8.f,10*zoom)),value&&value->validity!=SignalValidity::Valid?amber:muted);}
if(canvas.contains(mouse)&&(mouse-at).length()<10){hover=port.name+" / "+port_domain_key(port.domain)+(input?(port.required?" required input":" optional input"):" output");hover_detail=port.domain==PortDomain::Number?std::string(signal_quantity_key(port.quantity))+" ["+signal_quantity_unit(port.quantity)+"]":"Boolean";if(port.quantity==SignalQuantity::Pressure){const char* reference[]{"none","absolute","gauge","differential"};hover_detail+=" / "+std::string(reference[int(port.pressure_reference)]);}if(port.minimum||port.maximum)hover_detail+=" / "+(port.minimum?number(*port.minimum):"open")+".."+(port.maximum?number(*port.maximum):"open");if(std::holds_alternative<bool>(port.default_value))hover_detail+=std::get<bool>(port.default_value)?" / default true":" / default false";else if(std::holds_alternative<double>(port.default_value))hover_detail+=" / default "+number(std::get<double>(port.default_value));if(h.live)hover_detail+=" / "+reading(value,port);if(pending&&*pending!=LogicEndpoint{node.id,port.key})hover_detail=preview(h.document.logic,{node.id,port.key}).message;}
}
if(has_control(node.kind)){
const sf::FloatRect control{{r.position.x+12*zoom,r.position.y+r.size.y-40*zoom},{r.size.x-24*zoom,28*zoom}};box(window,control,background,border);std::string title;
const auto value=live_value(h,node,"out");
if(h.live&&manual_values.contains(node.id)&&value&&value->validity==SignalValidity::Valid&&manual_values.at(node.id)==value->value)manual_values.erase(node.id);
const bool queued=h.live&&manual_values.contains(node.id);
if(manual_boolean(node.kind)){bool active=node.kind==LogicNodeKind::Switch?node.boolean:false;if(value&&value->validity==SignalValidity::Valid)active=std::get<bool>(value->value);if(queued)active=std::get<bool>(manual_values.at(node.id));title=node.kind==LogicNodeKind::Button?(held_button==node.id?"HELD":"Hold button"):std::string(queued?"Queued ":"")+(active?"ON / click to toggle":"OFF / click to toggle");label(window,font,title,control.position.x+6*zoom,control.position.y+6*zoom,control.size.x-12*zoom,unsigned(std::max(8.f,11*zoom)),port_color(PortDomain::Boolean));}
else {double current=node.value;if(value&&value->validity==SignalValidity::Valid)current=std::get<double>(value->value);if(queued)current=std::get<double>(manual_values.at(node.id));if(slider_drag&&slider_node==node.id)current=slider_preview;
if(node.kind==LogicNodeKind::Slider&&node.maximum>node.minimum){const float fraction=float(std::clamp((current-node.minimum)/(node.maximum-node.minimum),0.,1.));box(window,{control.position,{control.size.x*fraction,control.size.y}},sf::Color{32,72,53});}
title=(edit_key=="manual"||(edit_key=="value"&&node.kind==LogicNodeKind::NumberInput))&&selected==node.id?buffer+"|":number(current,6)+" "+signal_quantity_unit(node.quantity)+(queued?" (queued)":"");label(window,font,title,control.position.x+6*zoom,control.position.y+6*zoom,control.size.x-12*zoom,unsigned(std::max(8.f,11*zoom)),port_color(PortDomain::Number));
}
}
if(node.kind==LogicNodeKind::Readout&&h.live){const auto value=live_value(h,node,"in");auto& trace=traces[node.id];if(value&&(trace.empty()||trace.back().first!=value->timestamp)){trace.push_back({value->timestamp,value->validity==SignalValidity::Valid?std::get<double>(value->value):std::numeric_limits<double>::quiet_NaN()});if(trace.size()>128)trace.pop_front();}
double lo=std::numeric_limits<double>::infinity(),hi=-lo;for(const auto& point:trace)if(std::isfinite(point.second)){lo=std::min(lo,point.second);hi=std::max(hi,point.second);}const auto area=sf::FloatRect({r.position.x+12*zoom,r.position.y+r.size.y-36*zoom},{r.size.x-24*zoom,24*zoom});box(window,area,background);
if(trace.size()>1&&std::isfinite(lo)){const double range=std::max(1e-12,hi-lo);for(std::size_t i=1;i<trace.size();++i)if(std::isfinite(trace[i-1].second)&&std::isfinite(trace[i].second)){const auto p=[&](std::size_t at){return area.position+sf::Vector2f{area.size.x*float(at)/float(trace.size()-1),area.size.y*(1-float((trace[at].second-lo)/range))};};line(window,p(i-1),p(i),port_color(PortDomain::Number));}}
}
}
if(pending){const auto n=h.document.logic.find(pending->node);if(n){const auto port=logic_port(h.document.logic,*pending);const auto origin=pin(*n,pending->port,canvas);wire(window,port.direction==PortDirection::Output?origin:mouse,port.direction==PortDirection::Output?mouse:origin,port_color(port.domain));}}
if(placing){LogicNode ghost;ghost.kind=*placing;const auto at=unproject(mouse,canvas);ghost.position=at;if(pending)configure(ghost,logic_port(h.document.logic,*pending));const auto r=rect(ghost,canvas);box(window,r,sf::Color{27,31,38,190},accent);label(window,font,logic_node_name(ghost.kind),r.position.x+8,r.position.y+8,r.size.x-16,11,accent);}
if(boxing)box(window,{{std::min(press.x,mouse.x),std::min(press.y,mouse.y)},{std::abs(press.x-mouse.x),std::abs(press.y-mouse.y)}},sf::Color{116,170,235,28},accent);
label(window,font,std::string("LOGIC / ")+(h.live?number(h.live->time,4)+" s / ":"")+std::to_string(h.document.logic.nodes.size())+" nodes / "+number(zoom*100,3)+"%",canvas.position.x+12,canvas.position.y+10,canvas.size.x-24,11,accent);
if(h.document.logic.nodes.empty()){label(window,font,"Build a signal graph",canvas.position.x+26,canvas.position.y+64,canvas.size.x-52,18);label(window,font,"Pick a node on the left. Drag pins or click a pin then its target.",canvas.position.x+26,canvas.position.y+100,canvas.size.x-52,11,muted);}
if(canvas.contains(mouse)&&!hover.empty()){const auto width=std::min(460.f,canvas.size.x-24),x=std::clamp(mouse.x+12,canvas.position.x+8,canvas.position.x+canvas.size.x-width-8),y=std::clamp(mouse.y+24,canvas.position.y+36,canvas.position.y+canvas.size.y-82);box(window,{{x,y},{width,68}},background,accent);label(window,font,hover,x+8,y+8,width-16,10,text_color);label(window,font,hover_detail,x+8,y+26,width-16,10,muted);label(window,font,pending?"Escape cancels / connected inputs can be rewired":"Input = ring / output = filled circle",x+8,y+44,width-16,10,muted);}
window.setView(original);
if(!panels)return;
window.setView(sf::View(sf::FloatRect({0,0},{float(size.x),float(size.y)})));
if(ui.left.size.x>40){box(window,ui.left,panel);box(window,ui.search,background,search_focus?accent:border);label(window,font,search.empty()&&!search_focus?"Search logic nodes...":search+(search_focus?"|":""),ui.search.position.x+8,ui.search.position.y+7,ui.search.size.x-16,12,search_focus?text_color:muted);const auto entries=items(h);if(library_tab)label(window,font,std::to_string(entries.size())+" nodes / F focuses selection",20,198,ui.left.size.x-24,10,muted);if(entries.empty())label(window,font,"No compatible matching nodes",20,236,ui.left.size.x-24,11,muted);
label(window,font,pending?"Library filtered by pending pin":"RED Boolean / GREEN Number",20,ui.height-220,ui.left.size.x-24,11,muted);label(window,font,"Inputs: rings / outputs: filled",20,ui.height-196,ui.left.size.x-24,10,muted);label(window,font,"Wheel: zoom / middle drag: pan",20,ui.height-172,ui.left.size.x-24,10,muted);
}
if(ui.right.size.x>40){box(window,ui.right,panel);const float x=ui.inspector_x+12,width=ui.inspector_width-24;label(window,font,"LOGIC INSPECTOR",x,109,width,13,muted);const auto n=h.document.logic.find(selected);
if(n){label(window,font,logic_node_name(n->kind),x,175,width,11,muted);const auto f=fields(*h.document.logic.find(selected));const int rows=std::max(1,int((ui.height-190-310)/52));for(int row=0;row<rows&&row+field_scroll<int(f.size());++row){const auto& field=f[row+field_scroll];label(window,font,field.label+(h.live&&field.restart?" / BUILD":""),x,310+row*52.f,width,11,h.live&&field.restart?muted:text_color);}
std::string problem=logic_binding_problem(h.document,*n);if(const auto state=live_node(h,n->id);state){if(!state->problem.empty())problem=state->problem;if(!state->values.empty())label(window,font,"Sample "+number(state->values[0].timestamp,8)+" s",x,ui.height-196,width,10,muted);}label(window,font,problem.empty()?(h.live?"Live tuning / initial fields require Build":"Click values to edit. Enter applies."):problem,x,ui.height-178,width,10,problem.empty()?muted:amber);
if(int(f.size())>rows)label(window,font,"Scroll properties: "+std::to_string(field_scroll+1)+" / "+std::to_string(f.size()),x,ui.height-158,width,10,accent);
}else{label(window,font,"Select a node to inspect",x,148,width,13);label(window,font,"Shift selects several / drag headers",x,184,width,11,muted);label(window,font,"Delete removes / Ctrl+Z undoes",x,210,width,11,muted);label(window,font,"Right-click a pin to disconnect it",x,236,width,11,muted);}
}
std::string action_help;for(const auto& action:actions(h,ui,canvas,panels)){const bool over=action.rect.contains(mouse),small=action.rect.size.x<44;const float inset=small?3.f:8.f;const unsigned text_size=small?9:11;box(window,action.rect,action.active?sf::Color{38,57,79}:over&&action.enabled?sf::Color{40,46,57}:panel,action.active?accent:border);label(window,font,action.title,action.rect.position.x+inset,action.rect.position.y+6,action.rect.size.x-inset*2,text_size,action.enabled?text_color:sf::Color{100,110,125});if(over&&(fitted(font,action.title,action.rect.size.x-inset*2,text_size)!=action.title||!action.enabled))action_help=action.title+(action.enabled?"":action.title=="Focus in 3D"?" / This node has no physical host.":" / Return to Build for this edit.");}
if(!action_help.empty()){const auto area=ui.right.contains(mouse)?ui.right:ui.left;const float x=area.position.x+12,width=area.size.x-24,y=std::clamp(mouse.y+32,area.position.y+10,area.position.y+area.size.y-80);box(window,{{x,y},{width,70}},background,accent);wrapped(window,font,action_help,x+8,y+8,width-16,10,3,text_color);}
window.setView(original);
}
LogicEditor::LogicEditor():impl_(std::make_unique<Impl>()){}
LogicEditor::~LogicEditor()=default;
void LogicEditor::reset_document(){auto next=std::make_unique<Impl>();next->epoch=impl_->epoch+1;next->clipboard_epoch=impl_->clipboard_epoch;next->clipboard=std::move(impl_->clipboard);impl_=std::move(next);}
void LogicEditor::deactivate(const LogicEditorHooks& hooks){impl_->deactivate(hooks);}
bool LogicEditor::editing_text()const{return !impl_->edit_key.empty()||impl_->search_focus;}
bool LogicEditor::active_gesture()const{return impl_->dragging||impl_->boxing||impl_->panning||impl_->slider_drag||impl_->port_drag||impl_->held_button;}
std::uint32_t LogicEditor::selected_node()const{return impl_->selected;}
void LogicEditor::select_node(std::uint32_t id,const MachineDocument& doc){impl_->selection.clear();if(doc.logic.find(id))impl_->selection.insert(id);impl_->selected=impl_->selection.empty()?0:id;impl_->field_scroll=0;impl_->edit_key.clear();}
void LogicEditor::select_part_reference(std::uint32_t part,const MachineDocument& doc){if(!part)return;for(const auto& n:doc.logic.nodes)if(physical_host(doc,n)==part){select_node(n.id,doc);return;}}
void LogicEditor::fit(const LogicGraph& graph,sf::FloatRect canvas,bool only){impl_->fit(graph,canvas,only);}
void LogicEditor::reveal(std::uint32_t id,const MachineDocument& doc,sf::FloatRect canvas){select_node(id,doc);impl_->fit(doc.logic,canvas,true);}
sf::Vector2f LogicEditor::project(LogicPosition point,sf::FloatRect canvas)const{return impl_->project(point,canvas);}
LogicPosition LogicEditor::unproject(sf::Vector2f point,sf::FloatRect canvas)const{return impl_->unproject(point,canvas);}
sf::FloatRect LogicEditor::node_rect(const LogicNode& node,sf::FloatRect canvas)const{return impl_->rect(node,canvas);}
sf::Vector2f LogicEditor::pin_position(const LogicNode& node,const std::string& port,sf::FloatRect canvas)const{return impl_->pin(node,port,canvas);}
bool LogicEditor::event(const sf::Event& event,const LogicEditorHooks& hooks,const WorkshopLayout& ui,sf::FloatRect canvas,bool panels){if(impl_->needs_fit)impl_->fit(hooks.document.logic,canvas,false);return impl_->event(event,hooks,ui,canvas,panels);}
void LogicEditor::draw(sf::RenderWindow& window,const sf::Font* font,const LogicEditorHooks& hooks,const WorkshopLayout& ui,sf::FloatRect canvas,bool panels){impl_->draw(window,font,hooks,ui,canvas,panels);}
}

52
src/logic_example.cpp Normal file
View File

@@ -0,0 +1,52 @@
#include "FluidSim/Machine.hpp"
#include <algorithm>
namespace fluidsim::port {
MachineDocument machine_logic_example(){
auto doc=machine_pump_example();doc.name="Automatic air supply / logic bench";
const auto tank=doc.parts[0].id,pump=doc.parts[1].id,air=doc.parts[2].id;
doc.find(tank)->position={.4f,.28f,0};doc.find(tank)->size={.12f,.12f,.12f};doc.find(tank)->name="Compressed air tank";
doc.find(air)->position={-.4f,.32f,0};doc.find(air)->name="Intake atmosphere";
for(auto& link:doc.links)for(auto* endpoint:{&link.a,&link.b}){if(endpoint->part==tank){endpoint->part=air;endpoint->port=0;}else if(endpoint->part==air){endpoint->part=tank;endpoint->port=0;}}
doc.sync_connections();const auto shaft=machine_shaft_for(doc,pump);doc.find(shaft)->shaft.inertia=.01f;doc.find(shaft)->shaft.initial_speed=0;doc.find(shaft)->shaft.starter_torque=0;
std::uint32_t motor=0;for(const auto& part:doc.parts)if(part.kind==MachinePartKind::DriveMotor)motor=part.id;
const auto pressure=doc.add_sensor(MachineSensorKind::Pressure,tank).id;doc.find_sensor(pressure)->name="Tank absolute pressure";
const auto crank=doc.add_sensor(MachineSensorKind::Crank,shaft).id;
auto& graph=doc.logic;
const auto tap=graph.add(LogicNodeKind::Sensor,{0,0}).id;graph.find(tap)->sensor=pressure;graph.find(tap)->name="Tank pressure";
const auto threshold=graph.add(LogicNodeKind::Hysteresis,{300,0}).id;auto& h=*graph.find(threshold);h.name="Compressor demand";h.quantity=SignalQuantity::Pressure;h.pressure_reference=PressureReference::Absolute;h.low=120000;h.high=150000;h.below=true;h.initial=true;
const auto enable=graph.add(LogicNodeKind::Switch,{300,240}).id;graph.find(enable)->name="Master enable";graph.find(enable)->boolean=true;
const auto gate=graph.add(LogicNodeKind::And,{600,0}).id;graph.find(gate)->name="Enabled demand";
const auto command=graph.add(LogicNodeKind::Command,{900,0}).id;graph.find(command)->name="Compressor motor enable";graph.find(command)->part=motor;graph.find(command)->binding_label=doc.find(motor)->name;
const auto speed=graph.add(LogicNodeKind::Slider,{600,260}).id;auto& slider=*graph.find(speed);slider.name="Compressor speed (rad/s)";slider.quantity=SignalQuantity::AngularSpeed;slider.minimum=0;slider.maximum=300;slider.value=900*2*double(pi)/60;
const auto target=graph.add(LogicNodeKind::Command,{900,260}).id;graph.find(target)->name="Motor speed target";graph.find(target)->command=LogicCommand::MotorSpeed;graph.find(target)->part=motor;
const auto display=graph.add(LogicNodeKind::Readout,{300,-180}).id;graph.find(display)->name="Tank pressure readout";graph.find(display)->quantity=SignalQuantity::Pressure;graph.find(display)->pressure_reference=PressureReference::Absolute;
const auto lamp=graph.add(LogicNodeKind::Lamp,{900,-180}).id;graph.find(lamp)->name="Compressor running demand";
const auto pickup=graph.add(LogicNodeKind::Sensor,{0,380}).id;graph.find(pickup)->sensor=crank;graph.find(pickup)->sensor_kind=MachineSensorKind::Crank;graph.find(pickup)->name="Compressor crank pickup";
const auto rpm=graph.add(LogicNodeKind::Readout,{300,420}).id;graph.find(rpm)->quantity=SignalQuantity::AngularSpeed;graph.find(rpm)->name="Measured angular speed";
graph.connect({tap,"value"},{threshold,"in"});graph.connect({threshold,"out"},{gate,"in1"});graph.connect({enable,"out"},{gate,"in2"});graph.connect({gate,"out"},{command,"in"});graph.connect({speed,"out"},{target,"in"});graph.connect({tap,"value"},{display,"in"});graph.connect({gate,"out"},{lamp,"in"});graph.connect({pickup,"speed"},{rpm,"in"});
doc.probes={{1,tank,"Tank absolute pressure",MachineQuantity::Pressure},{2,shaft,"Compressor shaft RPM",MachineQuantity::Rpm},{3,motor,"Drive power",MachineQuantity::Power}};
validate_machine(doc);return doc;
}
MachineDocument machine_sensor_engine_example(bool injection){
auto doc=machine_engine_example();doc.name=injection?"Sensor Alpha-N EFI bench":"Sensor crank ignition / carburetor bench";
std::uint32_t ecu=0,cylinder=0,throttle=0,intake=0;
for(auto& part:doc.parts){if(part.kind==MachinePartKind::Ecu){ecu=part.id;part.sensor_ecu.enabled=true;part.sensor_ecu.strategy=injection?SensorEcuConfig::Strategy::AlphaN:SensorEcuConfig::Strategy::Ignition;part.injection_enabled=false;part.ecu.rev_limit_enabled=false;part.name=injection?"Alpha-N ECU":"Crank ignition ECU";}
if(part.kind==MachinePartKind::Cylinder)cylinder=part.id;
if(part.kind==MachinePartKind::Throttle){throttle=part.id;if(!injection){part.kind=MachinePartKind::Carburetor;part.name="Physical carburetor";}part.pedal_control=false;part.opening=.5f;}
if(part.kind==MachinePartKind::Chamber&&part.position.x<0)intake=part.id;
if(part.kind==MachinePartKind::DriveMotor)part.drive.target_speed=3000*2*pi/60;
}
if(injection)doc.find(ecu)->sensor_ecu.fuel_per_cycle={.000002f,.00004f,.000002f,.00004f};
const auto shaft=machine_primary_shaft(doc);bool driven=false;for(const auto& part:doc.parts)if(part.kind==MachinePartKind::DriveMotor)driven=true;
if(!driven){const auto motor=doc.add(MachinePartKind::DriveMotor,machine_math::add(doc.find(shaft)->position,{-.3f,0,0})).id;doc.find(motor)->name="3000 RPM test drive";doc.find(motor)->drive.target_speed=3000*2*pi/60;doc.connect({shaft,0},{motor,0});}
std::erase_if(doc.links,[&](const auto& link){return link.kind==MachineConnectionKind::Control&&link.b.part!=cylinder;});
const auto sensor=doc.add_sensor(MachineSensorKind::Crank,machine_primary_shaft(doc)).id;
auto& graph=doc.logic;const auto pickup=graph.add(LogicNodeKind::Sensor,{0,0}).id;graph.find(pickup)->sensor=sensor;graph.find(pickup)->sensor_kind=MachineSensorKind::Crank;graph.find(pickup)->name="Crank pickup";
const auto connector=graph.add(LogicNodeKind::Ecu,{340,0}).id;graph.find(connector)->part=ecu;graph.find(connector)->ecu_strategy=doc.find(ecu)->sensor_ecu.strategy;graph.find(connector)->name="Engine ECU inputs";graph.find(connector)->binding_label=doc.find(ecu)->name;
graph.connect({pickup,"speed"},{connector,"speed"});graph.connect({pickup,"angle"},{connector,"angle"});
if(injection){const auto tps=doc.add_sensor(MachineSensorKind::ThrottlePosition,throttle).id;const auto node=graph.add(LogicNodeKind::Sensor,{0,230}).id;graph.find(node)->sensor=tps;graph.find(node)->sensor_kind=MachineSensorKind::ThrottlePosition;graph.find(node)->name="Actual throttle position";graph.connect({node,"value"},{connector,"tps"});doc.find(intake)->injector=MachineInjectorConfig{};doc.find(intake)->injector->ecu=ecu;doc.find(intake)->injector->ecu_label=doc.find(ecu)->name;}
doc.probes={{1,cylinder,"Cylinder pressure",MachineQuantity::Pressure},{2,machine_primary_shaft(doc),"Crank RPM",MachineQuantity::Rpm},{3,cylinder,"Charge AFR",MachineQuantity::ChargeAfr}};
validate_machine(doc);return doc;
}
}

143
src/logic_runtime.cpp Normal file
View File

@@ -0,0 +1,143 @@
#include "FluidSim/Machine.hpp"
#include <algorithm>
#include <cmath>
#include <deque>
#include <limits>
#include <stdexcept>
#include <unordered_map>
namespace fluidsim::port {
struct MachineLogicRuntime::Impl {
struct Source {int node{-1},port{-1};};
struct Node {
std::vector<MachinePortDescriptor> ports;
std::vector<Source> sources;
std::vector<SignalValue> values;
std::vector<unsigned> inputs,outputs;
SignalValue manual,stored,next;
bool hysteresis{};
bool sensor_bound{};
std::uint32_t ecu_shaft{};
const char* problem{};
std::deque<SignalValue> events;
};
LogicGraph graph;
SensorReader reader;
std::vector<Node> nodes;
std::unordered_map<std::uint32_t,std::size_t> by_id;
std::vector<std::size_t> order;
std::vector<LogicCommandState> commands;
std::vector<LogicEcuInputs> ecu_inputs;
explicit Impl(const MachineDocument& document,SensorReader source):graph(document.logic),reader(std::move(source)){
validate_machine_logic(document);const auto missing=logic_required_inputs(graph);if(!missing.empty())throw std::invalid_argument(missing.front());
nodes.reserve(graph.nodes.size());
for(std::size_t i=0;i<graph.nodes.size();++i){const auto& definition=graph.nodes[i];if(definition.kind==LogicNodeKind::Command||definition.kind==LogicNodeKind::Ecu){const auto problem=logic_binding_problem(document,definition);if(!problem.empty())throw std::invalid_argument(definition.name+": "+problem);}
Node node;node.ports=logic_node_ports(definition,&document);node.sources.resize(node.ports.size());node.values.resize(node.ports.size());node.hysteresis=definition.initial;
node.sensor_bound=definition.kind==LogicNodeKind::Sensor&&logic_binding_problem(document,definition).empty();
if(definition.kind==LogicNodeKind::Ecu)for(const auto& link:document.links)if(link.kind==MachineConnectionKind::Control&&link.a.part==definition.part)if(const auto target=document.find(link.b.part);target&&target->kind==MachinePartKind::Cylinder)node.ecu_shaft=machine_shaft_for(document,target->id);
for(unsigned p=0;p<node.ports.size();++p){node.values[p]=invalid(node.ports[p],SignalValidity::Missing,0);if(node.ports[p].direction==PortDirection::Input)node.inputs.push_back(p);else node.outputs.push_back(p);}
node.manual=definition.kind==LogicNodeKind::Switch?SignalValue::boolean(definition.boolean):definition.kind==LogicNodeKind::Button?SignalValue::boolean(false):SignalValue::number(definition.value);
node.stored=definition.kind==LogicNodeKind::BooleanDelay?SignalValue::boolean(definition.boolean):SignalValue::number(definition.value);node.next=node.stored;
by_id.emplace(definition.id,i);nodes.push_back(std::move(node));
}
const auto port_index=[&](std::size_t node,const std::string& key){const auto& ports=nodes[node].ports;for(std::size_t i=0;i<ports.size();++i)if(ports[i].key==key)return int(i);throw std::invalid_argument("Unknown compiled logic port");};
for(const auto& link:graph.links){const auto to=by_id.at(link.to.node),from=by_id.at(link.from.node);nodes[to].sources[port_index(to,link.to.port)]={int(from),port_index(from,link.from.port)};}
for(const auto id:logic_evaluation_order(graph))order.push_back(by_id.at(id));
for(const auto& n:graph.nodes)if(n.kind==LogicNodeKind::Command)commands.push_back({n.id,n.part,n.command,{}});
for(const auto& n:graph.nodes)if(n.kind==LogicNodeKind::Ecu)ecu_inputs.push_back({n.id,n.part,{},{},{},{},{}});
}
static SignalValue invalid(const MachinePortDescriptor& port,SignalValidity validity,double time){auto result=port.domain==PortDomain::Boolean?SignalValue::boolean(false,time):SignalValue::number(0,time);result.validity=validity;return result;}
void inputs(Node& node,double time){for(const auto p:node.inputs){const auto source=node.sources[p];SignalValue value;
if(source.node>=0)value=nodes[source.node].values[source.port];
else if(std::holds_alternative<bool>(node.ports[p].default_value))value=SignalValue::boolean(std::get<bool>(node.ports[p].default_value),time);
else if(std::holds_alternative<double>(node.ports[p].default_value))value=SignalValue::number(std::get<double>(node.ports[p].default_value),time);
else value=invalid(node.ports[p],SignalValidity::Missing,time);
if(value.validity==SignalValidity::Valid&&(!signal_value_valid_for(value,node.ports[p])||value.timestamp!=time)){value=invalid(node.ports[p],SignalValidity::Fault,time);node.problem="Input type, range or sample time does not match";}
if(value.validity!=SignalValidity::Valid&&!node.problem)node.problem=value.validity==SignalValidity::Missing?"Input signal is missing":"Input signal is invalid";
node.values[p]=value;
}}
void emit(Node& node,unsigned output,SignalValue value,double time){const auto p=node.outputs[output];value.timestamp=time;if(value.validity==SignalValidity::Valid&&!signal_value_valid_for(value,node.ports[p])){value=invalid(node.ports[p],SignalValidity::Fault,time);node.problem="Output type or declared range does not match";}if(value.validity!=SignalValidity::Valid&&!node.problem)node.problem=value.validity==SignalValidity::Missing?"Signal is missing":"Non-finite or invalid signal";node.values[p]=value;}
void evaluate(double time,double dt){
if(!std::isfinite(time)||!std::isfinite(dt)||dt<=0)throw std::invalid_argument("Logic requires a finite sample time and positive period");
for(auto& node:nodes)node.problem=nullptr;
// Memory outputs are visible before any combinational evaluation. Every
// memory input is then sampled before any stored value is committed.
for(std::size_t i=0;i<nodes.size();++i)if(logic_memory_node(graph.nodes[i].kind))emit(nodes[i],0,nodes[i].stored,time);
for(const auto index:order){auto& node=nodes[index];const auto& n=graph.nodes[index];inputs(node,time);
const auto number=[&](unsigned i){return std::get<double>(node.values[node.inputs[i]].value);};
const auto bit=[&](unsigned i){return std::get<bool>(node.values[node.inputs[i]].value);};
const auto numeric=[&](double value){emit(node,0,SignalValue::number(value,time),time);};
const auto boolean=[&](bool value){emit(node,0,SignalValue::boolean(value,time),time);};
if(n.kind==LogicNodeKind::Sensor){MachineSensorState sample;if(reader&&node.sensor_bound)sample=reader(n.sensor);for(unsigned i=0;i<node.outputs.size();++i){const auto valid=sample.id==n.sensor&&i<sample.count&&sample.outputs[i].timestamp==time;if(!valid)node.problem="Sensor reference or current sample is unavailable";auto value=valid?sample.outputs[i]:invalid(node.ports[node.outputs[i]],SignalValidity::Missing,time);emit(node,i,value,time);}continue;}
if(n.kind==LogicNodeKind::Select){const auto& select=node.values[node.inputs[0]];if(select.validity!=SignalValidity::Valid){emit(node,0,invalid(node.ports[node.outputs[0]],select.validity,time),time);continue;}const auto selected=node.values[node.inputs[std::get<bool>(select.value)?2:1]];node.problem=selected.validity==SignalValidity::Valid?nullptr:selected.validity==SignalValidity::Missing?"Selected input is missing":"Selected input is invalid";emit(node,0,selected,time);continue;}
SignalValidity validity=SignalValidity::Valid;for(const auto p:node.inputs){if(node.values[p].validity==SignalValidity::Fault){validity=SignalValidity::Fault;break;}if(node.values[p].validity==SignalValidity::Missing)validity=SignalValidity::Missing;}
if(validity!=SignalValidity::Valid){for(unsigned o=0;o<node.outputs.size();++o)emit(node,o,invalid(node.ports[node.outputs[o]],validity,time),time);continue;}
switch(n.kind){
case LogicNodeKind::BooleanConstant:boolean(n.boolean);break;
case LogicNodeKind::NumberConstant:numeric(n.value);break;
case LogicNodeKind::Switch:case LogicNodeKind::Slider:case LogicNodeKind::NumberInput:
while(!node.events.empty()){node.manual=node.events.front();node.events.pop_front();}emit(node,0,node.manual,time);break;
case LogicNodeKind::Button:
// One transition per boundary preserves a short press/release
// pair and keeps repeated presses distinguishable by edges.
if(!node.events.empty()){node.manual=node.events.front();node.events.pop_front();}emit(node,0,node.manual,time);break;
case LogicNodeKind::And:case LogicNodeKind::Nand:{bool result=true;for(unsigned i=0;i<node.inputs.size();++i)result&=bit(i);boolean(n.kind==LogicNodeKind::Nand?!result:result);break;}
case LogicNodeKind::Or:case LogicNodeKind::Nor:{bool result=false;for(unsigned i=0;i<node.inputs.size();++i)result|=bit(i);boolean(n.kind==LogicNodeKind::Nor?!result:result);break;}
case LogicNodeKind::Xor:{bool result=false;for(unsigned i=0;i<node.inputs.size();++i)result^=bit(i);boolean(result);break;}
case LogicNodeKind::Not:boolean(!bit(0));break;
case LogicNodeKind::Greater:boolean(number(0)>number(1));break;
case LogicNodeKind::Less:boolean(number(0)<number(1));break;
case LogicNodeKind::Equal:boolean(std::abs(number(0)-number(1))<=n.tolerance);break;
case LogicNodeKind::Window:boolean(number(0)>=n.low&&number(0)<=n.high);break;
case LogicNodeKind::Hysteresis:
if(n.below){if(number(0)<=n.low)node.hysteresis=true;else if(number(0)>=n.high)node.hysteresis=false;}
else {if(number(0)>=n.high)node.hysteresis=true;else if(number(0)<=n.low)node.hysteresis=false;}
boolean(node.hysteresis);break;
case LogicNodeKind::Add:numeric(number(0)+number(1));break;
case LogicNodeKind::Subtract:numeric(number(0)-number(1));break;
case LogicNodeKind::Multiply:numeric(number(0)*number(1));break;
case LogicNodeKind::Divide:
if(number(1)==0){node.problem="Division by zero";emit(node,0,invalid(node.ports[node.outputs[0]],SignalValidity::Fault,time),time);}else numeric(number(0)/number(1));break;
case LogicNodeKind::Minimum:numeric(std::min(number(0),number(1)));break;
case LogicNodeKind::Maximum:numeric(std::max(number(0),number(1)));break;
case LogicNodeKind::Absolute:numeric(std::abs(number(0)));break;
case LogicNodeKind::Clamp:numeric(std::clamp(number(0),n.low,n.high));break;
case LogicNodeKind::GainOffset:numeric(number(0)*n.gain+n.offset);break;
case LogicNodeKind::MapRange:{auto f=(number(0)-n.input_low)/(n.input_high-n.input_low);if(n.clamp)f=std::clamp(f,0.,1.);numeric(std::lerp(n.output_low,n.output_high,f));break;}
case LogicNodeKind::BooleanToNumber:numeric(bit(0)?n.true_value:n.false_value);break;
case LogicNodeKind::Command:case LogicNodeKind::Ecu:case LogicNodeKind::Lamp:case LogicNodeKind::Readout:break;
case LogicNodeKind::Sensor:case LogicNodeKind::Select:case LogicNodeKind::BooleanDelay:case LogicNodeKind::NumberDelay:break;
}
}
for(std::size_t i=0;i<nodes.size();++i)if(logic_memory_node(graph.nodes[i].kind)){auto& node=nodes[i];inputs(node,time);node.next=node.values[node.inputs[0]];}
for(std::size_t i=0;i<nodes.size();++i)if(logic_memory_node(graph.nodes[i].kind))nodes[i].stored=nodes[i].next;
for(auto& command:commands)command.value=nodes[by_id.at(command.node)].values[0];
for(auto& input:ecu_inputs){const auto index=by_id.at(input.node);auto& node=nodes[index];const auto& values=node.values;input.speed=values[0];input.angle=values[1];input.tps=input.map=input.iat=SignalValue{};
if(graph.nodes[index].ecu_strategy==SensorEcuConfig::Strategy::AlphaN)input.tps=values[2];
if(graph.nodes[index].ecu_strategy==SensorEcuConfig::Strategy::SpeedDensity){input.map=values[2];input.iat=values[3];}
const auto crank=ecu_crank_sample(input.speed,input.angle,time);if(!crank)node.problem="ECU needs current crank channels from one physical pickup";else if(node.ecu_shaft&&crank->shaft!=node.ecu_shaft)node.problem="Crank pickup belongs to a different engine shaft";
}
}
void manual(std::uint32_t id,SignalValue value){const auto found=by_id.find(id);if(found==by_id.end())throw std::invalid_argument("Unknown logic control");auto& node=nodes[found->second];const auto& definition=graph.nodes[found->second];const bool bit=definition.kind==LogicNodeKind::Switch||definition.kind==LogicNodeKind::Button;
if(!bit&&definition.kind!=LogicNodeKind::Slider&&definition.kind!=LogicNodeKind::NumberInput)throw std::invalid_argument("This node is not a manual control");
if(!signal_value_valid_for(value,node.ports[node.outputs[0]]))throw std::invalid_argument("Manual control type or range does not match its output");
const auto& previous=node.events.empty()?node.manual:node.events.back();if(previous.value==value.value)return;
// Numeric controls set the next boundary's value. Coalesce pointer
// motion while paused; momentary button transitions retain their queue.
if(definition.kind==LogicNodeKind::Slider||definition.kind==LogicNodeKind::NumberInput)node.events.clear();
if(node.events.size()>=4096)throw std::invalid_argument("Too many pending logic control events");
node.events.push_back(value);
}
};
MachineLogicRuntime::MachineLogicRuntime(const MachineDocument& document,SensorReader reader):impl_(std::make_unique<Impl>(document,std::move(reader))){}
MachineLogicRuntime::~MachineLogicRuntime()=default;
void MachineLogicRuntime::evaluate(double time,double dt){impl_->evaluate(time,dt);}
SignalValue MachineLogicRuntime::value(const LogicEndpoint& endpoint)const{const auto found=impl_->by_id.find(endpoint.node);if(found==impl_->by_id.end())throw std::out_of_range("Unknown logic node");const auto& node=impl_->nodes[found->second];for(std::size_t i=0;i<node.ports.size();++i)if(node.ports[i].key==endpoint.port)return node.values[i];throw std::out_of_range("Unknown logic port");}
std::vector<LogicNodeState> MachineLogicRuntime::snapshot()const{std::vector<LogicNodeState> result;result.reserve(impl_->nodes.size());for(std::size_t i=0;i<impl_->nodes.size();++i)result.push_back({impl_->graph.nodes[i].id,impl_->nodes[i].values,impl_->nodes[i].problem?impl_->nodes[i].problem:""});return result;}
const std::vector<LogicCommandState>& MachineLogicRuntime::commands()const{return impl_->commands;}
const std::vector<LogicEcuInputs>& MachineLogicRuntime::ecu_inputs()const{return impl_->ecu_inputs;}
void MachineLogicRuntime::set_manual(std::uint32_t node,bool value){impl_->manual(node,SignalValue::boolean(value));}
void MachineLogicRuntime::set_manual(std::uint32_t node,double value){impl_->manual(node,SignalValue::number(value));}
void MachineLogicRuntime::release_buttons(){for(std::size_t i=0;i<impl_->nodes.size();++i)if(impl_->graph.nodes[i].kind==LogicNodeKind::Button){auto& node=impl_->nodes[i];node.events.clear();node.events.push_back(SignalValue::boolean(false));}}
void MachineLogicRuntime::apply_tuning(const MachineDocument& document){validate_machine_logic(document);if(!logic_topology_equal(impl_->graph,document.logic))throw std::invalid_argument("Logic topology, quantities, bindings, control ranges and initial memory require Build");for(std::size_t i=0;i<document.logic.nodes.size();++i){const auto& node=document.logic.nodes[i];if(node.kind==LogicNodeKind::Command||node.kind==LogicNodeKind::Ecu){const auto problem=logic_binding_problem(document,node);if(!problem.empty())throw std::invalid_argument(node.name+": "+problem);}if(logic_node_ports(node,&document)!=impl_->nodes[i].ports)throw std::invalid_argument("A changed port contract requires Build");}impl_->graph=document.logic;}
}

View File

@@ -0,0 +1,47 @@
#include "logic_serialization.hpp"
#include <algorithm>
#include <stdexcept>
namespace fluidsim::port {
namespace {
void require(bool value,const std::string& message){if(!value)throw std::invalid_argument(message);}
std::string text(const toml::table& table,const char* key,std::string fallback){if(!table.contains(key))return fallback;const auto value=table[key].value<std::string>();require(bool(value),std::string("Logic ")+key+" must be text");return *value;}
std::uint32_t id(const toml::table& table,const char* key,std::uint32_t fallback,bool zero=true){if(!table.contains(key))return fallback;const auto value=table[key].value<std::int64_t>();require(value&&*value>=(zero?0:1)&&*value<1000000000,std::string("Invalid logic ")+key);return std::uint32_t(*value);}
const char* reference_key(PressureReference reference){switch(reference){case PressureReference::None:return "none";case PressureReference::Absolute:return "absolute";case PressureReference::Gauge:return "gauge";case PressureReference::Differential:return "differential";}throw std::invalid_argument("Unknown pressure reference");}
template<class T,class Key>T enumeration(const toml::table& table,const char* field,T fallback,int count,Key key){const auto name=text(table,field,key(fallback));for(int i=0;i<count;++i)if(name==key(T(i)))return T(i);throw std::invalid_argument(std::string("Unknown logic ")+field+": "+name);}
using Numbers=std::vector<std::pair<const char*,double*>>;
Numbers numbers(LogicNode& n){return {{"value",&n.value},{"minimum",&n.minimum},{"maximum",&n.maximum},{"low",&n.low},{"high",&n.high},{"tolerance",&n.tolerance},{"gain",&n.gain},{"offset",&n.offset},{"false_value",&n.false_value},{"true_value",&n.true_value},{"input_low",&n.input_low},{"input_high",&n.input_high},{"output_low",&n.output_low},{"output_high",&n.output_high}};}
using Booleans=std::vector<std::pair<const char*,bool*>>;
Booleans booleans(LogicNode& n){return {{"boolean",&n.boolean},{"initial",&n.initial},{"below",&n.below},{"clamp",&n.clamp}};}
}
LogicGraph read_logic_graph(const toml::table& machine){
LogicGraph graph;if(!machine.contains("logic"))return graph;const auto logic=machine["logic"].as_table();require(logic,"Logic must be a table");
if(logic->contains("nodes")){const auto nodes=(*logic)["nodes"].as_array();require(nodes&&nodes->size()<=2048,"Invalid logic node array");
for(const auto& item:*nodes){const auto table=item.as_table();require(table,"Logic node must be a table");LogicNode n;n.id=id(*table,"id",0,false);require(n.id>0,"Logic node needs an ID");
n.kind=enumeration(*table,"kind",n.kind,logic_node_kind_count,logic_node_key);n.name=text(*table,"name",logic_node_name(n.kind));
n.quantity=enumeration(*table,"quantity",n.quantity,int(SignalQuantity::Inertia)+1,signal_quantity_key);n.output_quantity=enumeration(*table,"output_quantity",n.output_quantity,int(SignalQuantity::Inertia)+1,signal_quantity_key);
n.pressure_reference=enumeration(*table,"pressure_reference",n.pressure_reference,4,reference_key);n.output_pressure_reference=enumeration(*table,"output_pressure_reference",n.output_pressure_reference,4,reference_key);
n.command=enumeration(*table,"command",n.command,logic_command_count,logic_command_key);n.sensor_kind=enumeration(*table,"sensor_kind",n.sensor_kind,machine_sensor_kind_count,machine_sensor_key);
n.part=id(*table,"part",0);n.sensor=id(*table,"sensor",0);n.binding_label=text(*table,"binding_label",{});n.input_count=id(*table,"input_count",2);
n.ecu_strategy=sensor_ecu_strategy_from_key(text(*table,"ecu_strategy","ignition"));
for(auto [key,target]:numbers(n))if(table->contains(key)){const auto value=(*table)[key].value<double>();require(bool(value),std::string("Logic ")+key+" must be numeric");*target=*value;}
for(auto [key,target]:booleans(n))if(table->contains(key)){const auto value=(*table)[key].value<bool>();require(bool(value),std::string("Logic ")+key+" must be Boolean");*target=*value;}
if(table->contains("position")){const auto position=(*table)["position"].as_array();require(position&&position->size()==2,"Logic position needs two coordinates");const auto x=(*position)[0].value<double>(),y=(*position)[1].value<double>();require(x&&y,"Logic position must be numeric");n.position={float(*x),float(*y)};}
graph.next_id=std::max(graph.next_id,n.id+1);graph.nodes.push_back(std::move(n));
}
}
if(logic->contains("wires")){const auto wires=(*logic)["wires"].as_array();require(wires&&wires->size()<=8192,"Invalid logic wire array");for(const auto& item:*wires){const auto t=item.as_table();require(t,"Logic wire must be a table");graph.links.push_back({{id(*t,"from_node",0,false),text(*t,"from_port",{})},{id(*t,"to_node",0,false),text(*t,"to_port",{})}});}}
const auto next=id(*logic,"next_id",graph.next_id,false);require(next>=graph.next_id,"Invalid next logic ID");graph.next_id=next;validate_logic_graph(graph);return graph;
}
toml::table write_logic_graph(const LogicGraph& graph){
validate_logic_graph(graph);toml::table result{{"next_id",int64_t(graph.next_id)}};toml::array nodes,wires;
for(auto n:graph.nodes){toml::table t{{"id",int64_t(n.id)},{"kind",logic_node_key(n.kind)},{"name",n.name},{"position",toml::array{double(n.position.x),double(n.position.y)}},{"quantity",signal_quantity_key(n.quantity)},{"pressure_reference",reference_key(n.pressure_reference)},{"output_quantity",signal_quantity_key(n.output_quantity)},{"output_pressure_reference",reference_key(n.output_pressure_reference)},{"sensor",int64_t(n.sensor)},{"sensor_kind",machine_sensor_key(n.sensor_kind)},{"part",int64_t(n.part)},{"command",logic_command_key(n.command)},{"binding_label",n.binding_label},{"input_count",int64_t(n.input_count)}};
for(auto [key,value]:numbers(n))t.insert(key,*value);
t.insert("ecu_strategy",sensor_ecu_strategy_key(n.ecu_strategy));
for(auto [key,value]:booleans(n))t.insert(key,*value);
nodes.push_back(std::move(t));
}
for(const auto& wire:graph.links)wires.push_back(toml::table{{"from_node",int64_t(wire.from.node)},{"from_port",wire.from.port},{"to_node",int64_t(wire.to.node)},{"to_port",wire.to.port}});
result.insert("nodes",std::move(nodes));result.insert("wires",std::move(wires));return result;
}
}

View File

@@ -0,0 +1,7 @@
#pragma once
#include "FluidSim/MachineLogic.hpp"
#include <toml.hpp>
namespace fluidsim::port {
LogicGraph read_logic_graph(const toml::table& machine);
toml::table write_logic_graph(const LogicGraph&);
}

621
src/machine.cpp Normal file
View File

@@ -0,0 +1,621 @@
#include "FluidSim/Machine.hpp"
#include "logic_serialization.hpp"
#include "logic_assembly.hpp"
#include "FluidSim/Constants.hpp"
#include "FluidSim/Fluid.hpp"
#include "FluidSim/PipeProfile.hpp"
#include "FluidSim/Solver.hpp"
#include <toml.hpp>
#include <algorithm>
#include <cmath>
#include <cstdlib>
#include <fstream>
#include <set>
#include <sstream>
#include <unordered_map>
namespace fluidsim::port {
namespace machine_math {
Vec3 add(Vec3 a,Vec3 b){return {a.x+b.x,a.y+b.y,a.z+b.z};}
Vec3 subtract(Vec3 a,Vec3 b){return {a.x-b.x,a.y-b.y,a.z-b.z};}
Vec3 multiply(Vec3 a,float s){return {a.x*s,a.y*s,a.z*s};}
float dot(Vec3 a,Vec3 b){return a.x*b.x+a.y*b.y+a.z*b.z;}
Vec3 cross(Vec3 a,Vec3 b){return {a.y*b.z-a.z*b.y,a.z*b.x-a.x*b.z,a.x*b.y-a.y*b.x};}
float length(Vec3 a){return std::sqrt(dot(a,a));}
Vec3 unit(Vec3 a){const float l=length(a);return l>1e-9f?multiply(a,1/l):Vec3{1,0,0};}
Vec3 rotate(Vec3 v,Vec3 r){
r=multiply(r,pi/180);float c=std::cos(r.x),s=std::sin(r.x);v={v.x,c*v.y-s*v.z,s*v.y+c*v.z};
c=std::cos(r.y);s=std::sin(r.y);v={c*v.x+s*v.z,v.y,-s*v.x+c*v.z};
c=std::cos(r.z);s=std::sin(r.z);return {c*v.x-s*v.y,s*v.x+c*v.y,v.z};
}
Vec3 inverse_rotate(Vec3 v,Vec3 r){
r=multiply(r,-pi/180);float c=std::cos(r.z),s=std::sin(r.z);v={c*v.x-s*v.y,s*v.x+c*v.y,v.z};
c=std::cos(r.y);s=std::sin(r.y);v={c*v.x+s*v.z,v.y,-s*v.x+c*v.z};
c=std::cos(r.x);s=std::sin(r.x);return {v.x,c*v.y-s*v.z,s*v.y+c*v.z};
}
Vec3 rotate_euler_world(Vec3 e,unsigned axis,float degrees){
const Vec3 a=axis==0?Vec3{1,0,0}:axis==1?Vec3{0,1,0}:Vec3{0,0,1};const float c=std::cos(degrees*pi/180),s=std::sin(degrees*pi/180);
const auto turn=[&](Vec3 v){return add(add(multiply(v,c),multiply(cross(a,v),s)),multiply(a,dot(a,v)*(1-c)));};
const auto x=turn(rotate({1,0,0},e)),y=turn(rotate({0,1,0},e)),z=turn(rotate({0,0,1},e));const float pitch=std::asin(std::clamp(-x.z,-1.f,1.f));
if(std::abs(std::cos(pitch))<1e-5f)return {0,pitch*180/pi,std::atan2(-y.x,y.y)*180/pi};
return {std::atan2(y.z,z.z)*180/pi,pitch*180/pi,std::atan2(x.y,x.x)*180/pi};
}
}
using namespace machine_math;
namespace {
constexpr const char* keys[]{"pipe","chamber","junction","throttle","check_valve","cylinder","piston_pump","reservoir","cap","carburetor","ecu","shaft","flywheel","drive_motor","brake"};
constexpr const char* names[]{"Pipe / reducer","Chamber / tank","Junction","Throttle valve","Check valve","Engine cylinder","Piston pump","Pressure / atmosphere","End cap","Carburetor","ECU / fuel injection","Shaft / crankshaft","Flywheel","Drive motor","Brake / load"};
static_assert(std::size(keys)==machine_part_kind_count&&std::size(names)==machine_part_kind_count,"Every machine part needs a stable key and name");
void require(bool condition,const std::string& message){if(!condition)throw std::invalid_argument(message);}
bool finite(Vec3 v){return std::isfinite(v.x)&&std::isfinite(v.y)&&std::isfinite(v.z);}
bool range(float v,float lo,float hi){return std::isfinite(v)&&v>=lo&&v<=hi;}
bool cylinder_kind(MachinePartKind k){return k==MachinePartKind::Cylinder||k==MachinePartKind::PistonPump;}
bool occupied(const MachineDocument& d,MachinePort e){return std::any_of(d.links.begin(),d.links.end(),[&](auto& l){return l.a==e||l.b==e;});}
toml::array xyz(Vec3 v){return toml::array{double(v.x),double(v.y),double(v.z)};}
Vec3 read_xyz(const toml::node_view<const toml::node>& n,Vec3 fallback={}){
if(!n)return fallback;
auto a=n.as_array();require(a&&a->size()==3,"Expected a vector of three numbers");
Vec3 v;float* values[]{&v.x,&v.y,&v.z};
for(int i=0;i<3;++i){const auto value=(*a)[i].value<double>();require(bool(value),"Vector needs numeric coordinates");*values[i]=float(*value);}
return v;
}
float read_number(const toml::table& t,const char* key,float fallback){
const auto node=t[key];if(!node)return fallback;auto n=node.value<double>();require(bool(n),std::string(key)+" must be numeric");return float(*n);
}
unsigned read_id(const toml::node_view<const toml::node>& node){auto n=node.value<std::int64_t>();require(n&&*n>0&&*n<1000000000,"Invalid part ID");return unsigned(*n);}
MachinePartKind read_kind(const std::string& k){for(int i=0;i<machine_part_kind_count;++i)if(k==keys[i])return MachinePartKind(i);throw std::invalid_argument("Unknown machine part: "+k);}
using Numbers=std::vector<std::pair<const char*,float*>>;
Numbers ecu_values(EcuConfig& e){return {{"idle_rpm",&e.idle_rpm},{"idle_base_opening",&e.idle_base_opening},{"idle_max_opening",&e.idle_max_opening},{"idle_kp",&e.idle_kp},{"idle_ki",&e.idle_ki},{"throttle_time_constant",&e.throttle_time_constant},{"throttle_rate",&e.throttle_rate},{"throttle_closing_time_constant",&e.throttle_closing_time_constant},{"throttle_closing_rate",&e.throttle_closing_rate},{"rev_limit_rpm",&e.rev_limit_rpm},{"rev_resume_rpm",&e.rev_resume_rpm},{"rev_limit_cut_time",&e.rev_limit_cut_time},{"overrun_cut_rpm",&e.overrun_cut_rpm},{"overrun_resume_rpm",&e.overrun_resume_rpm},{"idle_spark_gain",&e.idle_spark_gain},{"idle_minimum_advance",&e.idle_minimum_advance},{"stoichiometric_afr",&e.stoichiometric_afr},{"power_afr",&e.power_afr}};}
Numbers carb_values(CarburetorConfig& c){return {{"fuel_density",&c.fuel_density},{"pilot_jet_diameter",&c.pilot_jet_diameter},{"main_jet_diameter",&c.main_jet_diameter},{"needle_jet_diameter",&c.needle_jet_diameter},{"needle_low_scale",&c.needle_low_scale},{"needle_mid_scale",&c.needle_mid_scale},{"needle_off_idle_scale",&c.needle_off_idle_scale},{"needle_transition_scale",&c.needle_transition_scale},{"needle_tip_scale",&c.needle_tip_scale},{"needle_quarter_scale",&c.needle_quarter_scale},{"needle_upper_scale",&c.needle_upper_scale},{"pilot_transition_scale",&c.pilot_transition_scale},{"pilot_tip_scale",&c.pilot_tip_scale},{"pilot_discharge_coefficient",&c.pilot_discharge_coefficient},{"main_discharge_coefficient",&c.main_discharge_coefficient},{"bowl_head_pressure",&c.bowl_head_pressure},{"film_fraction",&c.film_fraction},{"film_time_constant",&c.film_time_constant},{"bowl_capacity",&c.bowl_capacity},{"bowl_refill_rate",&c.bowl_refill_rate},{"overrun_rpm",&c.overrun_rpm},{"overrun_exit_rpm",&c.overrun_exit_rpm}};}
Numbers valve_values(FourStrokeCylinder::ValveDynamics& v){return {{"mass_kg",&v.mass_kg},{"spring_rate",&v.spring_rate_n_per_m},{"damping",&v.damping_n_s_per_m},{"seat_force",&v.seat_force_n},{"maximum_lift",&v.maximum_lift_m},{"seat_restitution",&v.seat_restitution},{"cam_restitution",&v.cam_restitution},{"stop_restitution",&v.stop_restitution}};}
void read_numbers(const toml::table& t,const Numbers& numbers){for(auto [key,value]:numbers)*value=read_number(t,key,*value);}
toml::table write_numbers(const Numbers& numbers){toml::table t;for(auto [key,value]:numbers)t.insert(key,double(*value));return t;}
FourStrokeCylinder::CamProfile read_cam(const toml::array& points){FourStrokeCylinder::CamProfile profile;require(points.size()<=2048,"Too many cam points");for(auto& point:points){auto pair=point.as_array();require(pair&&pair->size()==2,"Cam point needs angle and lift");auto angle=(*pair)[0].value<double>(),lift=(*pair)[1].value<double>();require(angle&&lift,"Cam point must be numeric");profile.points.push_back({float(*angle),float(*lift)});}return profile;}
toml::array write_cam(const FourStrokeCylinder::CamProfile& c){toml::array points;for(auto p:c.points)points.push_back(toml::array{double(p.angle_degrees),double(p.lift_metres)});return points;}
}
const char* machine_part_name(MachinePartKind k){require(int(k)>=0&&int(k)<machine_part_kind_count,"Unknown machine part kind");return names[int(k)];}
const char* machine_part_key(MachinePartKind k){require(int(k)>=0&&int(k)<machine_part_kind_count,"Unknown machine part kind");return keys[int(k)];}
SensorEcuConfig machine_sensor_ecu_config(const MachinePart& part){
SensorEcuConfig config;config.strategy=part.sensor_ecu.strategy;config.ignition_map=part.ecu.ignition_map;config.rev_limit_enabled=part.ecu.rev_limit_enabled;config.rev_limit_rpm=part.ecu.rev_limit_rpm;config.rev_resume_rpm=part.ecu.rev_resume_rpm;
const auto& c=part.sensor_ecu;config.fuel_rpm.assign(c.fuel_rpm.begin(),c.fuel_rpm.end());config.fuel_tps.assign(c.fuel_tps.begin(),c.fuel_tps.end());config.fuel_per_cycle.assign(c.fuel_per_cycle.begin(),c.fuel_per_cycle.end());
config.displacement=c.displacement;config.air_gas_constant=c.air_gas_constant;config.target_afr=c.target_afr;config.ve_rpm.assign(c.ve_rpm.begin(),c.ve_rpm.end());config.ve_pressure.assign(c.ve_pressure.begin(),c.ve_pressure.end());config.volumetric_efficiency.assign(c.volumetric_efficiency.begin(),c.volumetric_efficiency.end());return config;
}
const char* machine_connection_name(MachineConnectionKind k){static const char* values[]{"Orifice","Inertance","Characteristic","Legacy Rusanov","ECU control wire","Mechanical attachment"};return values[int(k)];}
const char* machine_connection_key(MachineConnectionKind kind){switch(kind){case MachineConnectionKind::Orifice:return "orifice";case MachineConnectionKind::Inertance:return "inertance";case MachineConnectionKind::Characteristic:return "characteristic";case MachineConnectionKind::Legacy:return "legacy_rusanov";case MachineConnectionKind::Control:return "ecu_assignment";case MachineConnectionKind::Mechanical:return "mechanical";}throw std::invalid_argument("Unknown connection method");}
MachinePart* MachineDocument::find(std::uint32_t id){for(auto& p:parts)if(p.id==id)return &p;return nullptr;}
const MachinePart* MachineDocument::find(std::uint32_t id)const{for(auto& p:parts)if(p.id==id)return &p;return nullptr;}
MachinePart& MachineDocument::add(MachinePartKind kind,Vec3 pos){
MachinePart p;p.kind=kind;p.id=next_id++;p.name=std::string(machine_part_name(kind))+" "+std::to_string(p.id);p.position=pos;
if(kind==MachinePartKind::Throttle||kind==MachinePartKind::CheckValve||kind==MachinePartKind::Carburetor)p.size={.04f,.05f,.05f};
if(kind==MachinePartKind::Junction){p.size={.06f,.06f,.06f};p.junction_method=1;}
if(kind==MachinePartKind::Ecu){p.size={.10f,.025f,.075f};p.ecu.rev_limit_cut_time=.02f;}
if(kind==MachinePartKind::Reservoir||kind==MachinePartKind::Cap)p.size={.04f,.04f,.04f};
if(kind==MachinePartKind::Shaft){p.size={.25f,.035f,.035f};if(!primary_shaft)primary_shaft=p.id;}
if(kind==MachinePartKind::Flywheel)p.size={.025f,.16f,.16f};
if(kind==MachinePartKind::DriveMotor)p.size={.12f,.09f,.09f};
if(kind==MachinePartKind::Brake)p.size={.07f,.10f,.10f};
parts.push_back(std::move(p));return parts.back();
}
void MachineDocument::remove(std::uint32_t id){
// Keep sensor entities and their intended host labels. Zero is an explicit
// unresolved binding, so later part insertion cannot silently repair it.
for(auto& sensor:sensors)for(auto* tap:{&sensor.target,&sensor.reference})if(tap->part==id){if(const auto host=find(id))tap->label=host->name;tap->part=0;}
for(auto& node:logic.nodes)if((node.kind==LogicNodeKind::Command||node.kind==LogicNodeKind::Ecu)&&node.part==id){if(const auto host=find(id))node.binding_label=host->name;node.part=0;}
for(auto& part:parts)if(part.injector&&part.injector->ecu==id){if(const auto host=find(id))part.injector->ecu_label=host->name;part.injector->ecu=0;}
if(primary_shaft==id)primary_shaft=0;
std::erase_if(links,[&](const auto& l){return l.a.part==id||l.b.part==id;});std::erase_if(probes,[&](const auto& p){return p.part==id;});std::erase_if(parts,[&](auto& p){return p.id==id;});
}
const char* machine_quantity_name(MachineQuantity q){static const char* names[]{"Pressure","Temperature","Mass flow","Bulk AFR","Lambda","Charge AFR (pre-ignition)","Shaft RPM","Shaft torque","Shaft power"};return names[int(q)];}
const char* machine_reading_state_name(MachineReadingState state){switch(state){case MachineReadingState::Valid:return "Valid";case MachineReadingState::NoFuel:return "No fuel detected";case MachineReadingState::LeanLimit:return "Very lean / trace fuel (>1000 AFR)";case MachineReadingState::FuelCut:return "Injection fuel cut";case MachineReadingState::AwaitingCharge:return "Awaiting pre-ignition charge";}return "Unavailable";}
const char* machine_quantity_unit(MachineQuantity q){static const char* units[]{"kPa abs","K","g/s","AFR","lambda","AFR","RPM","Nm","kW"};return units[int(q)];}
float machine_quantity_scale(MachineQuantity q){return q==MachineQuantity::Pressure||q==MachineQuantity::Power?.001f:q==MachineQuantity::MassFlow?1000.f:1.f;}
bool machine_quantity_supported(const MachinePart& part,MachineQuantity q){
const auto kind=part.kind;
if(q>=MachineQuantity::Rpm)return machine_rotational_part(kind)||kind==MachinePartKind::Cylinder||kind==MachinePartKind::PistonPump;
if(machine_rotational_part(kind))return false;
if(q==MachineQuantity::ChargeAfr)return kind==MachinePartKind::Cylinder;
if(kind==MachinePartKind::Ecu||kind==MachinePartKind::Cap)return false;
if(kind==MachinePartKind::Junction)return part.junction_method==0;
if(q==MachineQuantity::Pressure||q==MachineQuantity::Temperature)return true;
if(q==MachineQuantity::MassFlow)return kind==MachinePartKind::Pipe||kind==MachinePartKind::Cylinder||kind==MachinePartKind::PistonPump||kind==MachinePartKind::Chamber||kind==MachinePartKind::Throttle||kind==MachinePartKind::CheckValve||kind==MachinePartKind::Carburetor;
return kind==MachinePartKind::Pipe||kind==MachinePartKind::Cylinder||kind==MachinePartKind::Carburetor||kind==MachinePartKind::Chamber;
}
void MachineDocument::disconnect(MachinePort p){std::erase_if(links,[&](auto& l){return l.a==p||l.b==p;});}
Vec3 machine_world_point(const MachinePart& p,Vec3 local){return add(p.position,rotate(local,p.rotation));}
Vec3 machine_local_point(const MachinePart& p,Vec3 world){return inverse_rotate(subtract(world,p.position),p.rotation);}
float machine_pipe_length(const MachinePart& p){float l=0;for(std::size_t i=1;i<p.points.size();++i)l+=length(subtract(p.points[i],p.points[i-1]));return l;}
std::vector<float> machine_pipe_diameters(const MachinePart& p){
if(!p.point_diameters.empty()){require(p.point_diameters.size()==p.points.size(),"Pipe needs one diameter per point");return p.point_diameters;}
std::vector<float> result;result.reserve(p.points.size());const float total=machine_pipe_length(p);float at=0;
for(std::size_t i=0;i<p.points.size();++i){if(i)at+=length(subtract(p.points[i],p.points[i-1]));result.push_back(std::lerp(p.diameter,p.outlet_diameter,total>0?at/total:0.f));}return result;
}
std::vector<PipeSection> machine_pipe_sections(const MachinePart& p){
const auto diameters=machine_pipe_diameters(p);std::vector<PipeSection> result;
for(std::size_t i=1;i<p.points.size();++i)result.push_back({length(subtract(p.points[i],p.points[i-1])),diameters[i-1],diameters[i]});
return result;
}
float machine_pipe_diameter(const MachinePart& p,float distance){
const auto legacy=p.point_diameters.empty()?machine_pipe_diameters(p):std::vector<float>{};const auto& diameters=p.point_diameters.empty()?legacy:p.point_diameters;require(!diameters.empty(),"Pipe has no points");distance=std::max(0.f,distance);
for(std::size_t i=1;i<p.points.size();++i){const float span=length(subtract(p.points[i],p.points[i-1]));if(distance<=span)return std::lerp(diameters[i-1],diameters[i],span>0?distance/span:0.f);distance-=span;}return diameters.back();
}
void machine_insert_pipe_point(MachinePart& p,std::size_t segment,Vec3 point){
require(p.kind==MachinePartKind::Pipe&&segment+1<p.points.size()&&p.points.size()<128,"Invalid pipe point insertion");
auto diameters=machine_pipe_diameters(p);const auto edge=subtract(p.points[segment+1],p.points[segment]);const float squared=dot(edge,edge);require(squared>0,"Invalid pipe segment");
const float t=std::clamp(dot(subtract(point,p.points[segment]),edge)/squared,0.f,1.f);
diameters.insert(diameters.begin()+segment+1,std::lerp(diameters[segment],diameters[segment+1],t));
p.points.insert(p.points.begin()+segment+1,point);p.point_diameters=std::move(diameters);
}
void machine_remove_pipe_point(MachinePart& p,std::size_t point){
require(p.kind==MachinePartKind::Pipe&&point>0&&point+1<p.points.size(),"Only interior pipe points can be removed");
auto diameters=machine_pipe_diameters(p);p.points.erase(p.points.begin()+point);diameters.erase(diameters.begin()+point);p.point_diameters=std::move(diameters);
}
std::vector<Vec3> machine_pipe_path(const MachinePart& p){std::vector<Vec3> v;for(auto point:p.points)v.push_back(machine_world_point(p,point));return v;}
Vec3 machine_part_size(const MachinePart& p){
if(cylinder_kind(p.kind))return {p.cylinder.bore,p.cylinder.stroke*p.cylinder.compression/(p.cylinder.compression-1),p.cylinder.bore};
return p.size;
}
float machine_part_volume(const MachinePart& p){
if(machine_rotational_part(p.kind))return 0;
if(p.kind==MachinePartKind::Pipe){double volume=0;for(const auto& s:machine_pipe_sections(p))volume+=double(pi)/12*s.length*(double(s.inlet_diameter)*s.inlet_diameter+double(s.inlet_diameter)*s.outlet_diameter+double(s.outlet_diameter)*s.outlet_diameter);return float(volume);}
if(cylinder_kind(p.kind))return pi*.25f*p.cylinder.bore*p.cylinder.bore*machine_part_size(p).y;
if(p.kind==MachinePartKind::Carburetor||p.kind==MachinePartKind::Throttle)return pi*.25f*p.size.x*p.size.y*p.size.z;
if((p.kind==MachinePartKind::Junction&&p.junction_method)||p.kind==MachinePartKind::Cap||p.kind==MachinePartKind::Reservoir||p.kind==MachinePartKind::Ecu)return 0;
require(p.kind==MachinePartKind::Chamber||p.kind==MachinePartKind::Junction||p.kind==MachinePartKind::CheckValve,"Gas storage is not declared for this part kind");
return p.size.x*p.size.y*p.size.z;
}
unsigned machine_port_count(const MachinePart& p){
if(p.kind==MachinePartKind::Junction&&p.junction_method)return 1;
switch(p.kind){case MachinePartKind::Pipe:case MachinePartKind::CheckValve:return 2;
case MachinePartKind::PistonPump:return 3;
case MachinePartKind::Cylinder:return 4;
case MachinePartKind::Throttle:case MachinePartKind::Carburetor:return 3;
case MachinePartKind::Chamber:case MachinePartKind::Junction:return p.port_coordinates.empty()?6:unsigned(p.port_coordinates.size());
case MachinePartKind::Reservoir:case MachinePartKind::Cap:case MachinePartKind::Ecu:case MachinePartKind::Shaft:case MachinePartKind::Flywheel:case MachinePartKind::DriveMotor:case MachinePartKind::Brake:return 1;}
throw std::invalid_argument("Port definitions are not implemented for this part kind");
}
bool machine_control_port(const MachinePart& p,unsigned port){return machine_port_descriptor(p,port).domain==PortDomain::EcuAssignment;}
bool machine_port_available(const MachineDocument& d,MachinePort port){const auto p=d.find(port.part);return p&&port.port<machine_port_count(*p)&&(machine_port_descriptor(*p,port.port).cardinality==PortCardinality::Multiple||!occupied(d,port));}
std::string machine_port_name(const MachinePart& p,unsigned port){return machine_port_descriptor(p,port).name;}
Vec3 machine_local_port(const MachinePart& p,unsigned port){
if(port>=machine_port_count(p))throw std::invalid_argument("Invalid machine port");
if(machine_port_descriptor(p,port).domain==PortDomain::Rotational){if(cylinder_kind(p.kind))return {0,-machine_part_size(p).y*.5f-.025f,0};return {p.kind==MachinePartKind::Shaft?p.size.x*.5f:-p.size.x*.5f,0,0};}
if(p.kind==MachinePartKind::Pipe)return port?p.points.back():p.points.front();
if(machine_control_port(p,port))return p.kind==MachinePartKind::Ecu?Vec3{p.size.x*.5f,0,0}:Vec3{0,machine_part_size(p).y*.5f+.025f,0};
if(p.kind==MachinePartKind::Reservoir||p.kind==MachinePartKind::Cap||(p.kind==MachinePartKind::Junction&&p.junction_method))return {};
const auto s=machine_part_size(p);
if(port<p.port_coordinates.size()){const auto v=p.port_coordinates[port];return {v.x*s.x,v.y*s.y,v.z*s.z};}
if(cylinder_kind(p.kind))return {(port?1:-1)*p.cylinder.bore*.23f,s.y*.5f,0};
switch(port){case 0:return {-s.x*.5f,0,0};case 1:return {s.x*.5f,0,0};case 2:return {0,-s.y*.5f,0};case 3:return {0,s.y*.5f,0};case 4:return {0,0,-s.z*.5f};default:return {0,0,s.z*.5f};}
}
Vec3 machine_port_position(const MachinePart& p,unsigned port){return machine_world_point(p,machine_local_port(p,port));}
void MachineDocument::connect(MachinePort a,MachinePort b,MachineConnectionKind kind){
auto pa=find(a.part),pb=find(b.part);require(pa&&pb&&a.part!=b.part,"Choose ports on two different parts");
require(a.port<machine_port_count(*pa)&&b.port<machine_port_count(*pb),"Invalid port");
const auto compatible=port_compatibility(machine_port_descriptor(*pa,a.port),machine_port_descriptor(*pb,b.port));require(compatible.allowed,compatible.message);
machine_connection_key(kind);
if(machine_port_descriptor(*pa,a.port).domain==PortDomain::Rotational){
if(pa->kind!=MachinePartKind::Shaft){std::swap(a,b);std::swap(pa,pb);}
require(pa->kind==MachinePartKind::Shaft&&pb->kind!=MachinePartKind::Shaft,"Attach a component to one shaft; use a coupling between shafts");
require(machine_port_available(*this,b),"This component already has a shaft");links.push_back({a,b,MachineConnectionKind::Mechanical});return;
}
if(machine_control_port(*pa,a.port)||machine_control_port(*pb,b.port)){
if(pa->kind!=MachinePartKind::Ecu){std::swap(a,b);std::swap(pa,pb);}
require(pa->kind==MachinePartKind::Ecu&&pb->kind!=MachinePartKind::Ecu&&machine_control_port(*pb,b.port),"Connect an ECU output to a cylinder, throttle, or carburetor ECU input");
require(machine_port_available(*this,b),"This part already has an ECU assigned");links.push_back({a,b,MachineConnectionKind::Control});return;
}
require(int(kind)>=0&&int(kind)<4,"Gas ports require a gas connection method");
if(pa->kind!=MachinePartKind::Pipe){std::swap(a,b);std::swap(pa,pb);}
require(pa->kind==MachinePartKind::Pipe,"Connect air parts using a pipe");
require(!occupied(*this,a),"Start with a free pipe endpoint");
if(!machine_port_available(*this,b)){
require(pb->kind==MachinePartKind::Pipe,"Use a junction to branch this occupied component port");
const auto old=std::find_if(links.begin(),links.end(),[&](auto& l){return l.a==b||l.b==b;});require(old!=links.end(),"Invalid occupied port");
const auto other=old->a==b?old->b:old->a;require(find(other.part)->kind==MachinePartKind::Pipe,"Branch at a junction or at a pipe-to-pipe connection");
const auto location=machine_port_position(*pb,b.port);auto proposed=pa->points;proposed[a.port?proposed.size()-1:0]=machine_local_point(*pa,location);
for(std::size_t i=1;i<proposed.size();++i)require(length(subtract(proposed[i],proposed[i-1]))>=.001f,"Connection would collapse a pipe segment");
pa->points=std::move(proposed);const auto old_kind=old->kind;const float loss=old->loss;links.erase(old);
auto& node=add(MachinePartKind::Junction,location);node.junction_method=old_kind==MachineConnectionKind::Legacy?2:1;node.turning_loss=loss;const auto node_id=node.id;
links.push_back({b,{node_id,0}});links.push_back({other,{node_id,0}});links.push_back({a,{node_id,0}});return;
}
require(pa->kind==MachinePartKind::Pipe||pb->kind==MachinePartKind::Pipe,"Connect parts using a pipe");
if(pa->kind!=MachinePartKind::Pipe){std::swap(a,b);std::swap(pa,pb);}
if(pa->kind!=MachinePartKind::Pipe||pb->kind!=MachinePartKind::Pipe)
require(kind==MachineConnectionKind::Orifice||kind==MachineConnectionKind::Inertance,"Use an orifice or inertance for a component port");
else if(kind==MachineConnectionKind::Inertance)throw std::invalid_argument("Pipe-to-pipe connections use an ideal junction");
auto proposed=pa->points;proposed[a.port?proposed.size()-1:0]=machine_local_point(*pa,machine_port_position(*pb,b.port));
for(std::size_t i=1;i<proposed.size();++i)require(length(subtract(proposed[i],proposed[i-1]))>=.001f,"Connection collapses a pipe segment; move the pipe first");
links.push_back({a,b,kind});pa->points=std::move(proposed);
}
void MachineDocument::sync_connections(std::uint32_t moved){
for(auto& l:links){if(machine_link_domain(*this,l)!=PortDomain::Gas||(moved&&l.a.part!=moved&&l.b.part!=moved))continue;auto a=find(l.a.part),b=find(l.b.part);if(!a||!b)continue;
if(a->kind!=MachinePartKind::Pipe){std::swap(a,b);std::swap(l.a,l.b);}
// Non-pipe parts anchor the endpoint. For two pipes, the moved pipe drives its neighbor.
if(b->kind==MachinePartKind::Pipe&&a->id==moved){b->points[l.b.port?b->points.size()-1:0]=machine_local_point(*b,machine_port_position(*a,l.a.port));}
else a->points[l.a.port?a->points.size()-1:0]=machine_local_point(*a,machine_port_position(*b,l.b.port));
}
}
void MachineDocument::transform_selection(const std::vector<std::uint32_t>& ids,Vec3 pivot,Vec3 translation,unsigned axis,float degrees){
const Vec3 r=axis==0?Vec3{degrees,0,0}:axis==1?Vec3{0,degrees,0}:Vec3{0,0,degrees};
for(auto id:ids)if(auto p=find(id)){p->position=machine_math::add(machine_math::add(pivot,rotate(subtract(p->position,pivot),r)),translation);if(degrees!=0)p->rotation=rotate_euler_world(p->rotation,axis,degrees);}
const auto chosen=[&](unsigned id){return std::find(ids.begin(),ids.end(),id)!=ids.end();};
for(auto& l:links){if(machine_link_domain(*this,l)!=PortDomain::Gas||(!chosen(l.a.part)&&!chosen(l.b.part)))continue;auto a=find(l.a.part),b=find(l.b.part);if(a->kind!=MachinePartKind::Pipe){std::swap(a,b);std::swap(l.a,l.b);}
if(b->kind==MachinePartKind::Pipe&&chosen(a->id)&&!chosen(b->id))b->points[l.b.port?b->points.size()-1:0]=machine_local_point(*b,machine_port_position(*a,l.a.port));
else a->points[l.a.port?a->points.size()-1:0]=machine_local_point(*a,machine_port_position(*b,l.b.port));
}
}
void validate_machine(const MachineDocument& d){
require(d.probes.size()<=machine_probe_limit,"A machine supports at most 8 measurement channels");std::set<std::uint32_t> probe_ids;
for(const auto& probe:d.probes){require(probe.id&&probe_ids.insert(probe.id).second,"Invalid or duplicate probe ID");require(!probe.name.empty()&&probe.name.size()<=120,"Probe name must have 1..120 characters");require(int(probe.quantity)>=0&&int(probe.quantity)<=8&&range(probe.position,0,1)&&range(probe.reference_afr,1,50),"Invalid probe quantity or position");
require(!probe.generated_output||probe.quantity==MachineQuantity::Torque||probe.quantity==MachineQuantity::Power,"Generated-output mode applies only to torque and power");
if(probe.generated_output&&probe.part){const auto target=d.find(probe.part);require(target&&target->kind==MachinePartKind::Shaft,"Generated-output gauge needs a shaft target");}
if(probe.quantity>=MachineQuantity::Rpm){if(probe.part){const auto part=d.find(probe.part);require(part&&machine_quantity_supported(*part,probe.quantity),"Rotational instrument needs a compatible target");}else require(machine_primary_shaft(d)||std::none_of(d.parts.begin(),d.parts.end(),[](const auto& p){return p.kind==MachinePartKind::Shaft;}),"Choose a target for a rotational instrument");}else{const auto part=d.find(probe.part);require(part&&machine_quantity_supported(*part,probe.quantity),"Probe refers to a missing or incompatible part");}}
require(d.parts.size()<=512&&d.links.size()<=2048,"Machine exceeds 512 parts / 2048 connections");
require(!d.name.empty()&&d.name.size()<=120,"Machine name must have 1..120 characters");
require(range(d.cells_per_meter,1,500)&&range(d.drive_rpm,0,50000)&&range(d.shaft_inertia,.001f,100)&&range(d.load_torque,0,10000),"Invalid simulation settings");
require(range(d.friction_constant,0,1000)&&range(d.friction_viscous,0,100)&&range(d.friction_quadratic,0,10)&&range(d.starter_torque,0,1000),"Invalid shaft friction or starter torque");
require(int(d.solver)>=0&&int(d.solver)<4&&int(d.scene)>=0&&int(d.scene)<5,"Invalid solver or scene");
if(d.primary_shaft){const auto part=d.find(d.primary_shaft);require(part&&part->kind==MachinePartKind::Shaft,"Primary readout refers to a missing shaft");}
std::set<std::uint32_t> ids;std::set<std::pair<unsigned,unsigned>> used;unsigned cells=0;
for(auto& p:d.parts){
const auto prefix=p.name+": ";require(p.id&&p.id<1000000000&&ids.insert(p.id).second,"Duplicate or invalid part ID");
require(int(p.kind)>=0&&int(p.kind)<machine_part_kind_count,"Invalid part type");
require(p.name.size()<=120&&finite(p.position)&&finite(p.rotation)&&finite(p.size),prefix+"invalid transform");
require(std::max({std::abs(p.position.x),std::abs(p.position.y),std::abs(p.position.z)})<=100,prefix+"position must be within 100 m");
require(range(p.size.x,.002f,10)&&range(p.size.y,.002f,10)&&range(p.size.z,.002f,10),prefix+"dimensions must be 2 mm..10 m");
if(p.kind!=MachinePartKind::Pipe||p.point_diameters.empty())require(range(p.diameter,.001f,2)&&range(p.outlet_diameter,.001f,2),prefix+"diameters must be 1..2000 mm");
require(range(p.pressure,1000,10000000)&&range(p.temperature,100,3000),prefix+"invalid initial pressure or temperature");
require(range(p.opening,0,1)&&range(p.discharge,.01f,1)&&range(p.cracking_pressure,0,1000000)&&range(p.phase_degrees,0,720)&&range(p.turning_loss,0,10),prefix+"invalid valve settings");
require(p.junction_method>=0&&p.junction_method<=2,prefix+"invalid junction method");
if(p.kind==MachinePartKind::Carburetor||p.kind==MachinePartKind::Throttle||p.kind==MachinePartKind::CheckValve)require(p.diameter<=std::min(p.size.y,p.size.z)+1e-6f,prefix+"throat diameter exceeds body dimensions; enlarge the body first");
const bool custom_ports=(p.kind==MachinePartKind::Chamber||(p.kind==MachinePartKind::Junction&&!p.junction_method))&&!p.port_coordinates.empty();
require(p.port_keys.empty()||(custom_ports&&p.port_keys.size()==p.port_coordinates.size()),prefix+"custom port keys must match port coordinates");
std::set<std::string> port_keys;for(unsigned index=0;index<machine_port_count(p);++index){const auto descriptor=machine_port_descriptor(p,index);validate_port_descriptor(descriptor);require(port_keys.insert(descriptor.key).second,prefix+"duplicate port key");}
require(p.port_coordinates.size()<=128,prefix+"too many ports");for(auto v:p.port_coordinates)require(finite(v)&&std::max({std::abs(v.x),std::abs(v.y),std::abs(v.z)})<=10,prefix+"invalid port coordinates");
if(machine_rotational_part(p.kind)){const auto& shaft=p.shaft;const auto& drive=p.drive;const auto& brake=p.brake;
require(range(shaft.inertia,.000001f,10000)&&range(shaft.initial_speed,-10000,10000)&&range(shaft.initial_angle,-1000000,1000000),prefix+"invalid rotational inertia or initial state");
require(range(shaft.friction_constant,0,100000)&&range(shaft.friction_viscous,0,10000)&&range(shaft.friction_quadratic,0,1000)&&range(shaft.starter_torque,0,10000),prefix+"invalid shaft losses or starter calibration");
require(range(drive.target_speed,-10000,10000)&&range(drive.torque,-1000000,1000000),prefix+"invalid motor command");
require(range(brake.constant,0,1000000)&&range(brake.viscous,0,10000)&&range(brake.quadratic,0,1000),prefix+"invalid brake coefficients");
}
require(range(p.ignition_tdc_degrees,0,359.99999f),prefix+"invalid ignition TDC calibration");
sensor_ecu_strategy_key(p.sensor_ecu.strategy);require(!p.sensor_ecu.enabled||p.kind==MachinePartKind::Ecu,prefix+"sensor ECU configuration requires an ECU part");
if(p.injector){const auto& c=*p.injector;require(p.kind==MachinePartKind::Chamber,prefix+"injector attachment requires a chamber");require(c.ecu<1000000000&&c.ecu_label.size()<=120&&c.cylinders>=1&&c.cylinders<=64&&range(c.maximum_flow,.000000001f,1)&&range(c.initial_mass,0,1000)&&range(c.temperature,100,2000),prefix+"invalid injector supply or capacity");}
if(p.kind==MachinePartKind::Ecu){const auto& e=p.ecu;
if(p.sensor_ecu.enabled){SensorEcu controller(machine_sensor_ecu_config(p));}
require(range(e.idle_rpm,0,30000)&&range(e.idle_base_opening,0,1)&&range(e.idle_max_opening,e.idle_base_opening,1)&&range(e.idle_kp,0,1)&&range(e.idle_ki,0,1),prefix+"invalid idle controller");
require(range(e.rev_limit_rpm,100,50000)&&range(e.rev_resume_rpm,0,e.rev_limit_rpm-1)&&range(e.rev_limit_cut_time,0,10),prefix+"invalid rev limiter settings");
require(range(e.stoichiometric_afr,5,50)&&range(e.power_afr,5,50)&&range(e.throttle_time_constant,.00001f,10)&&range(e.throttle_rate,.001f,100000)&&range(e.throttle_closing_time_constant,0,10)&&range(e.throttle_closing_rate,0,100000),prefix+"invalid fuel or throttle actuator settings");
require(range(e.overrun_resume_rpm,0,e.overrun_cut_rpm)&&range(e.overrun_cut_rpm,0,50000)&&range(e.idle_spark_gain,0,1)&&range(e.idle_minimum_advance,-90,90),prefix+"invalid overrun / idle ignition settings");
require(!e.ignition_map.empty()&&e.ignition_map.size()<=128,prefix+"ignition map needs 1..128 points");float previous=-1;for(auto point:e.ignition_map){require(range(point.rpm,0,100000)&&point.rpm>previous&&range(point.degrees_btdc,-60,90),prefix+"invalid ignition map");previous=point.rpm;}
}
if(p.kind==MachinePartKind::Carburetor){const auto& c=p.carburetor;
require(range(c.pilot_jet_diameter,.000001f,.02f)&&range(c.main_jet_diameter,.000001f,.02f)&&range(c.needle_jet_diameter,.000001f,.02f)&&range(c.fuel_density,100,2000),prefix+"invalid fuel density or jets");
require(range(c.bowl_capacity,.000001f,10)&&range(c.bowl_refill_rate,0,10)&&range(c.film_fraction,0,1)&&range(c.film_time_constant,.00001f,10)&&range(c.bowl_head_pressure,0,100000),prefix+"invalid float bowl or fuel film");
require(range(c.pilot_discharge_coefficient,0,1)&&range(c.main_discharge_coefficient,0,1),prefix+"invalid jet discharge coefficient");
auto copy=c;for(auto [key,value]:carb_values(copy))require(std::isfinite(*value)&&*value>=0&&*value<=100000,prefix+"invalid carburetor parameter "+key);
require(c.overrun_exit_rpm<=c.overrun_rpm,prefix+"overrun resume must not exceed cut RPM");
}
if(p.kind==MachinePartKind::Pipe){
require(p.points.size()>=2&&p.points.size()<=128,prefix+"pipe needs 2..128 path points");
require(p.point_diameters.empty()||p.point_diameters.size()==p.points.size(),prefix+"pipe needs one diameter per point");
for(float diameter:p.point_diameters)require(range(diameter,.001f,2),prefix+"point diameters must be 1..2000 mm");
for(std::size_t i=0;i<p.points.size();++i){require(finite(p.points[i]),prefix+"invalid centerline");if(i)require(range(length(subtract(p.points[i],p.points[i-1])),.001f,20),prefix+"segments must be 1 mm..20 m");}
const auto n=profile_section_cells(machine_pipe_length(p),1/d.cells_per_meter);require(n<=4096,prefix+"pipe mesh exceeds 4096 cells");cells+=unsigned(n);
}
if(cylinder_kind(p.kind)){
auto& c=p.cylinder;require(range(c.bore,.005f,1)&&range(c.stroke,.002f,2)&&range(c.rod,c.stroke*.51f,10)&&range(c.compression,1.1f,100),prefix+"invalid bore, stroke, rod length, or compression ratio");
require(range(c.intake_valve_diameter,.001f,c.bore*.8f)&&range(c.exhaust_valve_diameter,.001f,c.bore*.8f)&&c.intake_valve_count>=1&&c.intake_valve_count<=8&&c.exhaust_valve_count>=1&&c.exhaust_valve_count<=8,prefix+"invalid valve diameter/count");
require(range(p.maximum_lift,.0001f,.05f)&&range(p.intake_open,0,719)&&range(p.intake_close,p.intake_open+1,720)&&range(p.exhaust_open,0,719)&&range(p.exhaust_close,p.exhaust_open+1,720),prefix+"invalid cam timing / lift");
require(range(p.valve.mass_kg,.001f,1)&&range(p.valve.spring_rate_n_per_m,100,1000000)&&range(p.valve.damping_n_s_per_m,0,1000)&&range(p.valve.seat_force_n,0,10000),prefix+"invalid valve dynamics");
require(range(c.afr,5,50)&&range(c.spark_advance,-20,60)&&range(c.heat_transfer,0,10000)&&range(c.wall_area,.00001f,10),prefix+"invalid cylinder calibration");
require(range(c.burn_duration,1,720)&&range(c.burn_start,-60,120)&&range(c.wiebe_a,.01f,100)&&range(c.wiebe_m,0,10)&&range(c.fuel_lhv,1000,1e9f)&&range(c.energy_variation,0,1)&&range(c.misfire_probability,0,1)&&range(c.ambient_temperature,100,3000),prefix+"invalid combustion calibration");
for(auto dynamics:{p.valve,p.exhaust_valve})require(range(dynamics.mass_kg,.001f,1)&&range(dynamics.spring_rate_n_per_m,100,1000000)&&range(dynamics.damping_n_s_per_m,0,1000)&&range(dynamics.seat_force_n,0,10000)&&range(dynamics.maximum_lift_m,.0001f,.1f)&&range(dynamics.seat_restitution,0,1)&&range(dynamics.cam_restitution,0,1)&&range(dynamics.stop_restitution,0,1),prefix+"invalid valve dynamics");
for(auto profile:{&p.intake_profile,&p.exhaust_profile}){require(profile->points.size()<=2048,prefix+"cam has too many points");float previous=-1;for(auto point:profile->points){require(range(point.angle_degrees,0,720)&&point.angle_degrees>previous&&range(point.lift_metres,0,.05f),prefix+"invalid imported cam");previous=point.angle_degrees;}}
}
}
require(cells<=65536,"Machine exceeds 65536 pipe cells");
validate_machine_sensors(d);
validate_machine_logic(d);
require(d.next_id>0&&d.next_id<1000000000&&(ids.empty()||d.next_id>*ids.rbegin()),"Invalid next part ID");
for(auto& l:d.links){
auto a=d.find(l.a.part),b=d.find(l.b.part);require(a&&b&&a!=b,"Connection refers to a missing part or itself");
require(l.a.port<machine_port_count(*a)&&l.b.port<machine_port_count(*b),"Connection uses an invalid port");
const auto da=machine_port_descriptor(*a,l.a.port),db=machine_port_descriptor(*b,l.b.port);const auto compatible=port_compatibility(da,db);require(compatible.allowed,compatible.message);
const auto claim=[&](const MachinePortDescriptor& descriptor,MachinePort port){if(descriptor.cardinality==PortCardinality::Multiple)return;require(used.emplace(port.part,port.port).second,"A port has multiple connections");};claim(da,l.a);claim(db,l.b);
if(l.kind==MachineConnectionKind::Mechanical){require(da.domain==PortDomain::Rotational&&a->kind==MachinePartKind::Shaft&&b->kind!=MachinePartKind::Shaft,"Invalid shaft attachment");continue;}
if(l.kind==MachineConnectionKind::Control){require(da.domain==PortDomain::EcuAssignment&&da.direction==PortDirection::Output&&db.direction==PortDirection::Input,"Invalid ECU control wire");require(!a->sensor_ecu.enabled||b->kind==MachinePartKind::Cylinder,"Sensor-driven ECU assignments target cylinders; use explicit throttle commands");continue;}
require(da.domain==PortDomain::Gas&&db.domain==PortDomain::Gas,"Gas methods require gas ports");
require(a->kind==MachinePartKind::Pipe||b->kind==MachinePartKind::Pipe,"A connection must include a pipe");
require(length(subtract(machine_port_position(*a,l.a.port),machine_port_position(*b,l.b.port)))<.0001f,"Connected ports must occupy the same position");
require(int(l.kind)>=0&&int(l.kind)<4&&range(l.discharge,.01f,1)&&range(l.inertance_length,.0001f,10)&&range(l.loss,0,100),"Invalid connection properties");
const bool both=a->kind==MachinePartKind::Pipe&&b->kind==MachinePartKind::Pipe;
require(both?l.kind!=MachineConnectionKind::Inertance:int(l.kind)<=1,"Connection method is incompatible with these ports");
const auto other=a->kind==MachinePartKind::Pipe?b:a;
if(!both&&(other->kind==MachinePartKind::Reservoir||other->kind==MachinePartKind::Cap||(other->kind==MachinePartKind::Junction&&other->junction_method)))
require(l.kind==MachineConnectionKind::Orifice,"Boundary and ideal-node ports use their component's boundary method");
if(both)require(l.loss<=10,"Junction turning loss must be in 0..10");
}
for(const auto& shaft:d.parts)if(shaft.kind==MachinePartKind::Shaft){unsigned prescribed=0;for(const auto& part:d.parts)if(part.kind==MachinePartKind::DriveMotor&&part.drive.speed_control&&machine_shaft_for(d,part.id)==shaft.id){const bool signal_enable=std::any_of(d.logic.nodes.begin(),d.logic.nodes.end(),[&](const auto& node){return node.kind==LogicNodeKind::Command&&node.command==LogicCommand::MotorEnable&&node.part==part.id;});if(part.drive.enabled||signal_enable)++prescribed;}require(prescribed<=1,"A shaft supports only one potentially active ideal speed drive");}
}
MachineConnectionPreview machine_connection_preview(const MachineDocument& d,MachinePort a,MachinePort b){
MachineConnectionPreview result;
try{
auto proposed=d;proposed.connect(a,b);validate_machine(proposed);result.allowed=true;
const auto pa=d.find(a.part),pb=d.find(b.part);
if(machine_port_descriptor(*pa,a.port).domain==PortDomain::Rotational)result.message="Mechanical attachment: component shares the named shaft";
else if(machine_control_port(*pa,a.port)||machine_control_port(*pb,b.port))result.message="ECU control wire: injection, ignition or throttle control";
else{
const auto pipe=pa->kind==MachinePartKind::Pipe?pa:pb;result.pipe=*proposed.find(pipe->id);
result.message=proposed.parts.size()>d.parts.size()?"Connect and create a shared air junction":"Connect air ports; preview shows the new pipe route";
}
}catch(const std::exception& e){result.message=e.what();}
return result;
}
bool machine_has_fuel_source(const MachineDocument& d,std::uint32_t cylinder){
const auto target=d.find(cylinder);if(!target||target->kind!=MachinePartKind::Cylinder)return false;
for(const auto& l:d.links)if(l.kind==MachineConnectionKind::Control&&l.b.part==cylinder){const auto ecu=d.find(l.a.part);if(ecu&&!ecu->sensor_ecu.enabled&&ecu->injection_enabled)return true;}
std::vector<unsigned> visited{cylinder};
for(std::size_t i=0;i<visited.size();++i)for(const auto& link:d.links)if(machine_link_domain(d,link)==PortDomain::Gas){
auto here=link.a,other=link.b;if(here.part!=visited[i])std::swap(here,other);
if(here.part!=visited[i]||(here.part==cylinder&&here.port!=0))continue;
const auto next=d.find(other.part);if(!next)continue;
if(next->kind==MachinePartKind::Carburetor)return true;
if(next->injector){const auto ecu=d.find(next->injector->ecu);if(ecu&&ecu->kind==MachinePartKind::Ecu&&ecu->sensor_ecu.enabled&&ecu->sensor_ecu.strategy!=SensorEcuConfig::Strategy::Ignition)return true;}
if(next->kind==MachinePartKind::Cylinder)continue;
if(std::find(visited.begin(),visited.end(),other.part)==visited.end())visited.push_back(other.part);
}
return false;
}
std::vector<MachineCheck> machine_checks(const MachineDocument& d){
std::vector<MachineCheck> result;
for(const auto& sensor:d.sensors){const auto status=machine_sensor_binding(d,sensor);if(status!=MachineSensorStatus::Valid)result.push_back({sensor.target.part,true,std::string(machine_sensor_status_name(status))+": "+sensor.name,"Open Sense to bind the sensor to a compatible physical part. Unresolved outputs remain invalid.",sensor.id});}
for(const auto& node:d.logic.nodes){const auto problem=logic_binding_problem(d,node);if(!problem.empty())result.push_back({node.part,true,node.name+": "+problem,"Open Logic and repair the explicit sensor/actuator reference.",0,node.id});
for(const auto& port:logic_node_ports(node))if(port.required&&!std::any_of(d.logic.links.begin(),d.logic.links.end(),[&](const auto& link){return link.to.node==node.id&&link.to.port==port.key;}))result.push_back({node.part,true,node.name+" / "+port.name+": unconnected","Connect a compatible signal in Logic before Test.",0,node.id});
}
// Fuel-source reachability is a wiring/plumbing check, not a guarantee that
// fuel can pass every valve or that the engine will sustain combustion.
for(const auto& p:d.parts){
if(((machine_rotational_part(p.kind)&&p.kind!=MachinePartKind::Shaft)||cylinder_kind(p.kind))&&!machine_shaft_for(d,p.id))result.push_back({p.id,true,"No shaft attached","Connect this part's gold mechanical port to a shaft."});
if(p.kind==MachinePartKind::Cylinder&&!machine_has_fuel_source(d,p.id))result.push_back({p.id,true,"No fuel source", "Wire an injection ECU or pipe a carb to the intake."});
if(p.kind==MachinePartKind::Ecu&&!std::any_of(d.links.begin(),d.links.end(),[&](const auto& l){return l.kind==MachineConnectionKind::Control&&l.a.part==p.id;}))result.push_back({p.id,true,"ECU has no assigned parts","Connect its blue output to blue part inputs."});
if(p.kind==MachinePartKind::Ecu&&p.sensor_ecu.enabled&&!std::any_of(d.logic.nodes.begin(),d.logic.nodes.end(),[&](const auto& node){return node.kind==LogicNodeKind::Ecu&&node.part==p.id;}))result.push_back({p.id,true,"Sensor ECU has no signal connector","Use Show ECU connector in Logic, then wire its required measurements."});
if(p.injector){const auto target=d.find(p.injector->ecu);if(!target||target->kind!=MachinePartKind::Ecu||!target->sensor_ecu.enabled||target->sensor_ecu.strategy==SensorEcuConfig::Strategy::Ignition)result.push_back({p.id,true,"Injector ECU binding is unresolved","Choose a sensor-driven EFI ECU in the chamber inspector."});}
if(p.kind==MachinePartKind::Ecu){std::set<std::uint32_t> shafts;for(const auto& link:d.links)if(link.kind==MachineConnectionKind::Control&&link.a.part==p.id){const auto target=d.find(link.b.part);if(target&&target->kind==MachinePartKind::Cylinder){const auto shaft=machine_shaft_for(d,target->id);if(shaft)shafts.insert(shaft);}}if(shafts.size()>1)result.push_back({p.id,true,"ECU spans multiple shafts","Use a separate ECU for each independent engine shaft."});}
if((p.kind==MachinePartKind::Throttle||p.kind==MachinePartKind::Carburetor)&&!p.pedal_control&&p.opening<.001f&&!std::any_of(d.links.begin(),d.links.end(),[&](const auto& l){return l.kind==MachineConnectionKind::Control&&l.b.part==p.id;}))result.push_back({p.id,true,"Fixed throttle is closed","Increase opening or enable pedal control."});
}
for(const auto& p:d.parts)for(unsigned i=0;i<machine_port_count(p);++i)if(machine_port_descriptor(p,i).domain==PortDomain::Gas&&!occupied(d,{p.id,i})&&(p.kind==MachinePartKind::Pipe||cylinder_kind(p.kind)))
result.push_back({p.id,false,machine_port_name(p,i)+" is sealed","Connect a pipe, or leave sealed intentionally."});
for(const auto& shaft:d.parts)if(shaft.kind==MachinePartKind::Shaft&&std::abs(shaft.shaft.initial_speed)<.01f){
const bool driven=std::any_of(d.parts.begin(),d.parts.end(),[&](const auto& p){return p.kind==MachinePartKind::DriveMotor&&p.drive.enabled&&machine_shaft_for(d,p.id)==shaft.id;});
const bool engine=std::any_of(d.parts.begin(),d.parts.end(),[&](const auto& p){return p.kind==MachinePartKind::Cylinder&&machine_shaft_for(d,p.id)==shaft.id;});
if(engine&&!driven)result.push_back({shaft.id,false,"Free shaft starts stopped",shaft.shaft.starter_torque>0?"Choose this primary shaft and hold V in Test to crank.":"Connect a drive to crank this engine."});
}
return result;
}
std::vector<std::string> machine_diagnostics(const MachineDocument& d){
std::vector<std::string> messages;
for(auto& p:d.parts)for(unsigned i=0;i<machine_port_count(p);++i)if(machine_port_descriptor(p,i).domain==PortDomain::Gas&&!occupied(d,{p.id,i})&&(p.kind==MachinePartKind::Pipe||cylinder_kind(p.kind)))
messages.push_back(p.name+" / "+machine_port_name(p,i)+": sealed (unconnected)");
return messages;
}
std::filesystem::path machine_directory(){if(const char* path=std::getenv("FLUIDSIM_MACHINE_DIR");path&&*path)return path;return std::filesystem::current_path()/"machines";}
std::vector<std::filesystem::path> machine_files(){
std::vector<std::filesystem::path> result;auto directory=machine_directory();
if(std::filesystem::is_directory(directory))for(auto& e:std::filesystem::directory_iterator(directory))if(e.is_regular_file()&&e.path().filename().string().ends_with(".machine.toml"))result.push_back(e.path());
std::sort(result.begin(),result.end());return result;
}
std::filesystem::path machine_assembly_directory(){return machine_directory()/"assemblies";}
std::vector<std::filesystem::path> machine_assembly_files(){
std::vector<std::filesystem::path> result;const auto directory=machine_assembly_directory();
if(std::filesystem::is_directory(directory))for(const auto& entry:std::filesystem::directory_iterator(directory))if(entry.is_regular_file()&&entry.path().filename().string().ends_with(".assembly.toml"))result.push_back(entry.path());
std::sort(result.begin(),result.end());return result;
}
Vec3 machine_selection_center(const MachineDocument& document,const std::vector<std::uint32_t>& ids){
Vec3 lo{1e6f,1e6f,1e6f},hi{-1e6f,-1e6f,-1e6f};
const auto include=[&](Vec3 point){lo={std::min(lo.x,point.x),std::min(lo.y,point.y),std::min(lo.z,point.z)};hi={std::max(hi.x,point.x),std::max(hi.y,point.y),std::max(hi.z,point.z)};};
for(const auto id:ids){const auto* part=document.find(id);require(part,"Selection contains a part that no longer exists");
if(part->kind==MachinePartKind::Pipe){const auto points=machine_pipe_path(*part);const auto diameters=machine_pipe_diameters(*part);for(std::size_t i=0;i<points.size();++i){const float r=diameters[i]*.5f;include(add(points[i],{r,r,r}));include(subtract(points[i],{r,r,r}));}}
else{const bool symbol=part->kind==MachinePartKind::Reservoir||part->kind==MachinePartKind::Cap||(part->kind==MachinePartKind::Junction&&part->junction_method);const auto size=symbol?Vec3{.04f,.04f,.04f}:machine_part_size(*part);for(int corner=0;corner<8;++corner)include(machine_world_point(*part,{(corner&1?1:-1)*size.x*.5f,(corner&2?1:-1)*size.y*.5f,(corner&4?1:-1)*size.z*.5f}));}
}
return ids.empty()?Vec3{}:multiply(add(lo,hi),.5f);
}
MachineDocument machine_extract_assembly(const MachineDocument& source,const std::vector<std::uint32_t>& ids,std::string name){
std::set<std::uint32_t> chosen(ids.begin(),ids.end());require(!chosen.empty(),"Select at least one part to save as an assembly");
MachineDocument assembly;assembly.name=name.empty()?"Reusable assembly":std::move(name);assembly.cells_per_meter=source.cells_per_meter;
const auto center=machine_selection_center(source,ids);
for(const auto& part:source.parts)if(chosen.contains(part.id)){assembly.parts.push_back(part);assembly.next_id=std::max(assembly.next_id,part.id+1);}
for(auto& part:assembly.parts){part.position=subtract(part.position,center);if(part.injector&&!chosen.contains(part.injector->ecu)){if(const auto ecu=source.find(part.injector->ecu))part.injector->ecu_label=ecu->name;part.injector->ecu=0;}}
for(const auto& link:source.links)if(chosen.contains(link.a.part)&&chosen.contains(link.b.part))assembly.links.push_back(link);
for(const auto& probe:source.probes)if(probe.part&&chosen.contains(probe.part)){
auto copy=probe;copy.id=std::uint32_t(assembly.probes.size()+1);assembly.probes.push_back(std::move(copy));
}
for(auto sensor:source.sensors)if(chosen.contains(sensor.target.part)||chosen.contains(sensor.reference.part)){
for(auto* tap:{&sensor.target,&sensor.reference})if(!chosen.contains(tap->part)){if(const auto host=source.find(tap->part))tap->label=host->name;tap->part=0;}
assembly.next_sensor_id=std::max(assembly.next_sensor_id,sensor.id+1);assembly.sensors.push_back(std::move(sensor));
}
if(chosen.contains(source.primary_shaft))assembly.primary_shaft=source.primary_shaft;
extract_machine_logic(source,chosen,assembly);
validate_machine(assembly);return assembly;
}
std::vector<std::uint32_t> machine_insert_assembly(MachineDocument& destination,const MachineDocument& assembly,Vec3 center){
validate_machine(assembly);require(!assembly.parts.empty(),"Assembly contains no parts");require(finite(center),"Assembly placement must be finite");
require(destination.parts.size()+assembly.parts.size()<=512,"Assembly would exceed the 512-part machine limit");
require(destination.links.size()+assembly.links.size()<=2048,"Assembly would exceed the 2048-connection limit");
require(destination.probes.size()+assembly.probes.size()<=machine_probe_limit,"Remove a probe before inserting this assembly (8-channel limit)");
auto proposed=destination;std::unordered_map<std::uint32_t,std::uint32_t> mapped;std::vector<std::uint32_t> inserted;inserted.reserve(assembly.parts.size());
for(const auto& part:assembly.parts){auto copy=part;copy.id=proposed.next_id++;copy.position=add(copy.position,center);mapped.emplace(part.id,copy.id);inserted.push_back(copy.id);proposed.parts.push_back(std::move(copy));}
for(const auto id:inserted)if(auto& injector=proposed.find(id)->injector;injector)injector->ecu=mapped.contains(injector->ecu)?mapped.at(injector->ecu):0;
for(auto link:assembly.links){require(mapped.contains(link.a.part)&&mapped.contains(link.b.part),"Assembly has a connection to a missing part");link.a.part=mapped.at(link.a.part);link.b.part=mapped.at(link.b.part);proposed.links.push_back(link);}
std::uint32_t next_probe=1;for(const auto& probe:proposed.probes)next_probe=std::max(next_probe,probe.id+1);
for(auto probe:assembly.probes){require(mapped.contains(probe.part),"Assembly probe refers to a missing part");probe.id=next_probe++;probe.part=mapped.at(probe.part);proposed.probes.push_back(std::move(probe));}
std::unordered_map<std::uint32_t,std::uint32_t> sensor_ids;
for(auto sensor:assembly.sensors){const auto original=sensor.id;sensor.id=proposed.next_sensor_id++;sensor_ids.emplace(original,sensor.id);for(auto* tap:{&sensor.target,&sensor.reference})tap->part=mapped.contains(tap->part)?mapped.at(tap->part):0;proposed.sensors.push_back(std::move(sensor));}
insert_machine_logic(proposed,assembly,mapped,sensor_ids);
if(!proposed.primary_shaft&&mapped.contains(assembly.primary_shaft))proposed.primary_shaft=mapped.at(assembly.primary_shaft);
validate_machine(proposed);destination=std::move(proposed);return inserted;
}
MachineDocument load_machine(const std::filesystem::path& file){
try{
const auto table=toml::parse_file(file.string());const auto format=table["format"].value_or(0);require(format>=1&&format<=10,"Unsupported machine file version");
MachineDocument d;d.name=table["name"].value_or(std::string("Untitled machine"));
d.cells_per_meter=read_number(table,"cells_per_meter",40);d.drive_rpm=read_number(table,"drive_rpm",format==1?1500.f:0.f);d.shaft_inertia=read_number(table,"shaft_inertia",.15f);d.load_torque=read_number(table,"load_torque",0);d.driven=table["driven"].value_or(format==1);
d.friction_constant=read_number(table,"friction_constant",d.friction_constant);d.friction_viscous=read_number(table,"friction_viscous",d.friction_viscous);d.friction_quadratic=read_number(table,"friction_quadratic",0);d.starter_torque=read_number(table,"starter_torque",15);
if(format>=6){const auto id=table["primary_shaft"].value_or(int64_t(0));require(id>=0&&id<1000000000,"Invalid primary shaft ID");d.primary_shaft=std::uint32_t(id);}
d.solver=parse_pipe_solver(table["solver"].value_or(std::string("rusanov")));const auto scene=table["scene"].value_or(2);require(scene>=0&&scene<5,"Invalid scene");d.scene=OutdoorScene(scene);
auto parts=table["parts"].as_array();require(parts&&parts->size()<=512,"Missing parts array or too many parts");
for(auto& item:*parts){
auto t=item.as_table();require(t!=nullptr,"Invalid part table");MachinePart p;p.id=read_id((*t)["id"]);p.kind=read_kind((*t)["kind"].value_or(std::string{}));p.name=(*t)["name"].value_or(std::string(machine_part_name(p.kind)));
p.position=read_xyz((*t)["position_m"],p.position);p.rotation=read_xyz((*t)["rotation_deg"]);p.size=read_xyz((*t)["size_m"],p.size);
if(auto points=(*t)["points_m"].as_array()){require(points->size()<=128,"Too many pipe points");p.points.clear();for(auto& point:*points){auto a=point.as_array();require(a&&a->size()==3,"Invalid pipe point");Vec3 v;float* values[]{&v.x,&v.y,&v.z};for(int i=0;i<3;++i){auto n=(*a)[i].value<double>();require(bool(n),"Invalid pipe coordinate");*values[i]=float(*n);}p.points.push_back(v);}}
const auto get=[&](const char* key,float& value){value=read_number(*t,key,value);};
get("diameter_m",p.diameter);get("outlet_diameter_m",p.outlet_diameter);get("pressure_pa",p.pressure);get("temperature_k",p.temperature);get("opening",p.opening);get("discharge",p.discharge);get("cracking_pressure_pa",p.cracking_pressure);get("phase_deg",p.phase_degrees);get("maximum_lift_m",p.maximum_lift);get("intake_open_deg",p.intake_open);get("intake_close_deg",p.intake_close);get("exhaust_open_deg",p.exhaust_open);get("exhaust_close_deg",p.exhaust_close);get("turning_loss",p.turning_loss);
if(p.kind==MachinePartKind::Pipe){const auto values=(*t)["point_diameters_m"].as_array();require(format<3||values,"Pipe needs point_diameters_m in version 3");
if((*t).contains("point_diameters_m")){require(values&&values->size()==p.points.size(),"Pipe needs one diameter per point");for(const auto& value:*values){const auto number=value.value<double>();require(bool(number),"Invalid pipe point diameter");p.point_diameters.push_back(float(*number));}}
}
p.pedal_control=(*t)["pedal_control"].value_or(true);p.junction_method=(*t)["junction_method"].value_or(0);
auto& c=p.cylinder;get("bore_m",c.bore);get("stroke_m",c.stroke);get("rod_m",c.rod);get("compression",c.compression);get("intake_valve_m",c.intake_valve_diameter);get("exhaust_valve_m",c.exhaust_valve_diameter);get("afr",c.afr);get("spark_advance_deg",c.spark_advance);get("heat_transfer",c.heat_transfer);get("wall_area_m2",c.wall_area);
c.intake_valve_count=(*t)["intake_valve_count"].value_or(1);c.exhaust_valve_count=(*t)["exhaust_valve_count"].value_or(1);
get("valve_mass_kg",p.valve.mass_kg);get("valve_spring_n_m",p.valve.spring_rate_n_per_m);get("valve_damping",p.valve.damping_n_s_per_m);get("valve_preload_n",p.valve.seat_force_n);
get("burn_duration_deg",c.burn_duration);get("burn_start_deg",c.burn_start);get("wiebe_a",c.wiebe_a);get("wiebe_m",c.wiebe_m);get("fuel_lhv",c.fuel_lhv);get("energy_variation",c.energy_variation);get("misfire_probability",c.misfire_probability);get("ambient_temperature",c.ambient_temperature);
if(auto config=(*t)["intake_dynamics"].as_table())read_numbers(*config,valve_values(p.valve));
p.exhaust_valve=p.valve;
if(auto config=(*t)["exhaust_dynamics"].as_table())read_numbers(*config,valve_values(p.exhaust_valve));
if(auto points=(*t)["intake_profile"].as_array())p.intake_profile=read_cam(*points);
if(auto points=(*t)["exhaust_profile"].as_array())p.exhaust_profile=read_cam(*points);
if(auto coordinates=(*t)["port_coordinates"].as_array()){require(coordinates->size()<=128,"Too many ports");for(auto& item:*coordinates){auto a=item.as_array();require(a&&a->size()==3,"Port needs three coordinates");Vec3 v;float* values[]{&v.x,&v.y,&v.z};for(int i=0;i<3;++i){auto n=(*a)[i].value<double>();require(bool(n),"Invalid port coordinate");*values[i]=float(*n);}p.port_coordinates.push_back(v);}}
if((*t).contains("port_keys")){const auto values=(*t)["port_keys"].as_array();require(values&&values->size()<=128,"Invalid custom port keys");for(const auto& value:*values){const auto key=value.value<std::string>();require(bool(key),"Port key must be a string");p.port_keys.push_back(*key);}}
const bool custom_ports=(p.kind==MachinePartKind::Chamber||(p.kind==MachinePartKind::Junction&&!p.junction_method))&&!p.port_coordinates.empty();
if(custom_ports&&p.port_keys.empty()){require(format<5,"Version 5 custom ports require stable keys");for(unsigned i=0;i<machine_port_count(p);++i)p.port_keys.push_back("gas_"+std::to_string(i));}
p.injection_enabled=(*t)["injection_enabled"].value_or(true);
if(auto config=(*t)["ecu"].as_table()){auto& e=p.ecu;read_numbers(*config,ecu_values(e));e.idle_control=(*config)["fixed_idle"].value_or(false)?EcuConfig::IdleControl::FixedOpening:EcuConfig::IdleControl::Feedback;
e.rev_limit_enabled=(*config)["rev_limit_enabled"].value_or(true);e.rev_limit_fuel_cut_enabled=(*config)["rev_limit_fuel_cut_enabled"].value_or(true);e.rev_limit_ignition_cut_enabled=(*config)["rev_limit_ignition_cut_enabled"].value_or(true);e.overrun_fuel_cut_enabled=(*config)["overrun_fuel_cut_enabled"].value_or(true);e.overrun_ignition_cut_enabled=(*config)["overrun_ignition_cut_enabled"].value_or(false);
if(auto map=(*config)["ignition_map"].as_array()){require(map->size()<=128,"Too many ignition points");e.ignition_map.clear();for(auto& item:*map){auto pair=item.as_array();require(pair&&pair->size()==2,"Ignition point needs RPM and advance");auto rpm=(*pair)[0].value<double>(),advance=(*pair)[1].value<double>();require(rpm&&advance,"Invalid ignition point");e.ignition_map.push_back({float(*rpm),float(*advance)});}}
}
get("ignition_tdc_deg",p.ignition_tdc_degrees);
require(!t->contains("injector")||(*t)["injector"].is_table(),"Injector must be a table");
if(auto config=(*t)["injector"].as_table()){
require(!config->contains("finite_supply")||bool((*config)["finite_supply"].value<bool>()),"Injector finite_supply must be Boolean");
MachineInjectorConfig c;const auto ecu=(*config)["ecu"].value_or(int64_t(0)),count=(*config)["cylinders"].value_or(int64_t(1));require(ecu>=0&&ecu<1000000000&&count>=1&&count<=64,"Invalid injector ECU or cylinder count");c.ecu=std::uint32_t(ecu);c.cylinders=unsigned(count);c.ecu_label=(*config)["ecu_label"].value_or(std::string{});c.finite_supply=(*config)["finite_supply"].value_or(true);read_numbers(*config,{{"maximum_kg_s",&c.maximum_flow},{"initial_kg",&c.initial_mass},{"temperature_k",&c.temperature}});p.injector=c;
}
require(!t->contains("sensor_ecu")||(*t)["sensor_ecu"].is_table(),"Sensor ECU must be a table");
if(auto config=(*t)["sensor_ecu"].as_table()){
require(!config->contains("enabled")||bool((*config)["enabled"].value<bool>()),"Sensor ECU enabled must be Boolean");require(!config->contains("strategy")||bool((*config)["strategy"].value<std::string>()),"Sensor ECU strategy must be text");
auto& c=p.sensor_ecu;c.enabled=(*config)["enabled"].value_or(false);c.strategy=sensor_ecu_strategy_from_key((*config)["strategy"].value_or(std::string("ignition")));
const auto array=[&](const char* key,std::vector<float>& target){if(!config->contains(key))return;const auto source=(*config)[key].as_array();require(source&&source->size()<=65536,"Invalid ECU fuel table array");target.clear();for(const auto& item:*source){const auto value=item.value<double>();require(bool(value),"Fuel table entries must be numeric");target.push_back(float(*value));}};
array("fuel_rpm",c.fuel_rpm);array("fuel_tps",c.fuel_tps);array("fuel_kg_cycle",c.fuel_per_cycle);
array("ve_rpm",c.ve_rpm);array("ve_absolute_pa",c.ve_pressure);array("volumetric_efficiency",c.volumetric_efficiency);read_numbers(*config,{{"displacement_m3",&c.displacement},{"air_r_j_kg_k",&c.air_gas_constant},{"target_afr",&c.target_afr}});
}
if(auto config=(*t)["carburetor"].as_table())read_numbers(*config,carb_values(p.carburetor));
if(auto config=(*t)["shaft"].as_table()){auto& c=p.shaft;read_numbers(*config,{{"inertia_kg_m2",&c.inertia},{"initial_speed_rad_s",&c.initial_speed},{"initial_angle_rad",&c.initial_angle},{"friction_nm",&c.friction_constant},{"viscous_nm_s",&c.friction_viscous},{"quadratic_nm_s2",&c.friction_quadratic},{"starter_nm",&c.starter_torque}});}
if(auto config=(*t)["drive"].as_table()){auto& c=p.drive;read_numbers(*config,{{"target_speed_rad_s",&c.target_speed},{"torque_nm",&c.torque}});c.speed_control=(*config)["speed_control"].value_or(true);c.enabled=(*config)["enabled"].value_or(true);}
if(auto config=(*t)["brake"].as_table()){auto& c=p.brake;read_numbers(*config,{{"constant_nm",&c.constant},{"viscous_nm_s",&c.viscous},{"quadratic_nm_s2",&c.quadratic}});c.enabled=(*config)["enabled"].value_or(true);}
d.next_id=std::max(d.next_id,p.id+1);d.parts.push_back(std::move(p));
}
if(auto links=table["connections"].as_array()){
require(links->size()<=2048,"Too many connections");
for(const auto& item:*links){
const auto t=item.as_table();require(t,"Invalid connection table");MachineLink link;link.a.part=read_id((*t)["a_part"]);link.b.part=read_id((*t)["b_part"]);
if(format>=5){
const auto a=d.find(link.a.part),b=d.find(link.b.part);require(a&&b,"Connection refers to a missing part");
const auto ak=(*t)["a_port"].value<std::string>(),bk=(*t)["b_port"].value<std::string>(),method=(*t)["method"].value<std::string>();require(ak&&bk&&method,"Version 5 connections require named ports and method");
link.a.port=machine_port_index(*a,*ak);link.b.port=machine_port_index(*b,*bk);bool found=false;
for(int i=0;i<machine_connection_kind_count;++i)if(*method==machine_connection_key(MachineConnectionKind(i))){link.kind=MachineConnectionKind(i);found=true;break;}
require(found,"Unknown connection method: "+*method);
}else{
const auto ap=(*t)["a_port"].value_or(-1),bp=(*t)["b_port"].value_or(-1),method=(*t)["method"].value_or(0);require(ap>=0&&ap<128&&bp>=0&&bp<128&&method>=0&&method<=4,"Invalid connection port / method");
const auto migrate_port=[&](unsigned id,unsigned index){const auto part=d.find(id);require(part,"Connection refers to a missing part");if(part->kind==MachinePartKind::Junction&&part->junction_method){const auto legacy_count=part->port_coordinates.empty()?6:part->port_coordinates.size();require(index<legacy_count,"Invalid legacy junction port");return 0u;}require(index<machine_port_count(*part),"Invalid legacy port index");return index;};
link.a.port=migrate_port(link.a.part,unsigned(ap));link.b.port=migrate_port(link.b.part,unsigned(bp));link.kind=MachineConnectionKind(method);
}
link.discharge=read_number(*t,"discharge",1);link.inertance_length=read_number(*t,"inertance_length_m",.01f);link.loss=read_number(*t,"loss",0);d.links.push_back(link);
}
}
if(auto probes=table["probes"].as_array()){require(probes->size()<=machine_probe_limit,"Too many probes");for(const auto& item:*probes){const auto p=item.as_table();require(p,"Invalid probe table");MachineProbe probe;probe.id=read_id((*p)["id"]);const auto part=(*p)["part"].value_or(int64_t(-1));require(part>=0&&part<1000000000,"Invalid probe part");probe.part=std::uint32_t(part);const auto quantity=(*p)["quantity"].value_or(-1);require(quantity>=0&&quantity<=8,"Invalid probe quantity");probe.quantity=MachineQuantity(quantity);probe.name=(*p)["name"].value_or(std::string(machine_quantity_name(probe.quantity)));probe.position=read_number(*p,"position",.5f);probe.reference_afr=read_number(*p,"reference_afr",14.7f);probe.generated_output=(*p)["generated_output"].value_or(format<=5&&(probe.quantity==MachineQuantity::Torque||probe.quantity==MachineQuantity::Power));d.probes.push_back(std::move(probe));}}
if(table.contains("sensors")){
const auto sensors=table["sensors"].as_array();require(sensors&&sensors->size()<=1024,"Invalid sensor array");
for(const auto& item:*sensors){const auto s=item.as_table();require(s,"Invalid sensor table");MachineSensor sensor;sensor.id=read_id((*s)["id"]);
const auto key=(*s)["kind"].value_or(std::string{});bool found=false;for(int i=0;i<machine_sensor_kind_count;++i)if(key==machine_sensor_key(MachineSensorKind(i))){sensor.kind=MachineSensorKind(i);found=true;break;}require(found,"Unknown sensor kind: "+key);
sensor.name=(*s)["name"].value_or(std::string(machine_sensor_name(sensor.kind)));sensor.angle_offset=read_number(*s,"angle_offset_rad",0);
const auto tap=[&](const char* key,MachineSensorTap& target){const auto t=(*s)[key].as_table();require(t,std::string("Sensor needs ")+key+" binding");const auto id=(*t)["part"].value<int64_t>();require(id&&*id>=0&&*id<1000000000,"Invalid sensor host ID");target.part=std::uint32_t(*id);target.position=read_number(*t,"position",.5f);target.label=(*t)["label"].value_or(std::string{});if(!d.find(target.part))target.part=0;};
tap("target",sensor.target);tap("reference",sensor.reference);d.next_sensor_id=std::max(d.next_sensor_id,sensor.id+1);d.sensors.push_back(std::move(sensor));
}
}
for(auto [key,value]:{std::pair{"next_id",&d.next_id},std::pair{"next_sensor_id",&d.next_sensor_id}})if(table.contains(key)){const auto id=table[key].value<int64_t>();require(id&&*id>=*value&&*id<1000000000,std::string("Invalid ")+key);*value=std::uint32_t(*id);}
d.logic=read_logic_graph(table);for(auto& n:d.logic.nodes){if(n.kind==LogicNodeKind::Sensor&&!d.find_sensor(n.sensor))n.sensor=0;if((n.kind==LogicNodeKind::Command||n.kind==LogicNodeKind::Ecu)&&!d.find(n.part))n.part=0;}for(auto& p:d.parts)if(p.injector&&!d.find(p.injector->ecu))p.injector->ecu=0;
validate_machine(d);if(format<=5){upgrade_machine_mechanics(d);validate_machine(d);}for(auto& p:d.parts)if(p.kind==MachinePartKind::Pipe&&p.point_diameters.empty())p.point_diameters=machine_pipe_diameters(p);return d;
}catch(const std::exception& error){throw std::runtime_error(file.filename().string()+": "+error.what());}
}
void save_machine(const MachineDocument& d,const std::filesystem::path& file){
validate_machine(d);toml::table t{{"format",10},{"name",d.name},{"cells_per_meter",double(d.cells_per_meter)},{"solver",pipe_solver_name(d.solver)},{"scene",int(d.scene)},{"primary_shaft",int64_t(d.primary_shaft)},{"next_id",int64_t(d.next_id)},{"next_sensor_id",int64_t(d.next_sensor_id)}};
t.insert("logic",write_logic_graph(d.logic));
toml::array parts,links;
for(auto& p:d.parts){
toml::table pt{{"id",int64_t(p.id)},{"kind",machine_part_key(p.kind)},{"name",p.name},{"position_m",xyz(p.position)},{"rotation_deg",xyz(p.rotation)},{"size_m",xyz(p.size)},{"pedal_control",p.pedal_control},{"junction_method",p.junction_method}};
const auto put=[&](const char* key,float v){pt.insert(key,double(v));};
if(p.kind==MachinePartKind::Pipe){toml::array diameters;for(float diameter:machine_pipe_diameters(p))diameters.push_back(double(diameter));pt.insert("point_diameters_m",std::move(diameters));}
else{put("diameter_m",p.diameter);put("outlet_diameter_m",p.outlet_diameter);}
put("pressure_pa",p.pressure);put("temperature_k",p.temperature);put("opening",p.opening);put("discharge",p.discharge);put("cracking_pressure_pa",p.cracking_pressure);put("phase_deg",p.phase_degrees);put("maximum_lift_m",p.maximum_lift);put("intake_open_deg",p.intake_open);put("intake_close_deg",p.intake_close);put("exhaust_open_deg",p.exhaust_open);put("exhaust_close_deg",p.exhaust_close);put("turning_loss",p.turning_loss);
auto& c=p.cylinder;put("bore_m",c.bore);put("stroke_m",c.stroke);put("rod_m",c.rod);put("compression",c.compression);put("intake_valve_m",c.intake_valve_diameter);put("exhaust_valve_m",c.exhaust_valve_diameter);put("afr",c.afr);put("spark_advance_deg",c.spark_advance);put("heat_transfer",c.heat_transfer);put("wall_area_m2",c.wall_area);pt.insert("intake_valve_count",c.intake_valve_count);pt.insert("exhaust_valve_count",c.exhaust_valve_count);
put("valve_mass_kg",p.valve.mass_kg);put("valve_spring_n_m",p.valve.spring_rate_n_per_m);put("valve_damping",p.valve.damping_n_s_per_m);put("valve_preload_n",p.valve.seat_force_n);
put("burn_duration_deg",c.burn_duration);put("burn_start_deg",c.burn_start);put("wiebe_a",c.wiebe_a);put("wiebe_m",c.wiebe_m);put("fuel_lhv",c.fuel_lhv);put("energy_variation",c.energy_variation);put("misfire_probability",c.misfire_probability);put("ambient_temperature",c.ambient_temperature);
auto intake=p.valve,exhaust=p.exhaust_valve;pt.insert("intake_dynamics",write_numbers(valve_values(intake)));pt.insert("exhaust_dynamics",write_numbers(valve_values(exhaust)));pt.insert("intake_profile",write_cam(p.intake_profile));pt.insert("exhaust_profile",write_cam(p.exhaust_profile));
toml::array coordinates;for(auto v:p.port_coordinates)coordinates.push_back(xyz(v));pt.insert("port_coordinates",std::move(coordinates));pt.insert("injection_enabled",p.injection_enabled);
if((p.kind==MachinePartKind::Chamber||(p.kind==MachinePartKind::Junction&&!p.junction_method))&&!p.port_coordinates.empty()){toml::array stable_keys;for(unsigned i=0;i<machine_port_count(p);++i)stable_keys.push_back(machine_port_descriptor(p,i).key);pt.insert("port_keys",std::move(stable_keys));}
if(p.kind==MachinePartKind::Cylinder)put("ignition_tdc_deg",p.ignition_tdc_degrees);
if(p.injector){const auto& c=*p.injector;pt.insert("injector",toml::table{{"ecu",int64_t(c.ecu)},{"ecu_label",c.ecu_label},{"cylinders",int64_t(c.cylinders)},{"finite_supply",c.finite_supply},{"maximum_kg_s",double(c.maximum_flow)},{"initial_kg",double(c.initial_mass)},{"temperature_k",double(c.temperature)}});}
if(p.kind==MachinePartKind::Ecu){auto e=p.ecu;auto config=write_numbers(ecu_values(e));config.insert("fixed_idle",e.idle_control==EcuConfig::IdleControl::FixedOpening);config.insert("rev_limit_enabled",e.rev_limit_enabled);config.insert("rev_limit_fuel_cut_enabled",e.rev_limit_fuel_cut_enabled);config.insert("rev_limit_ignition_cut_enabled",e.rev_limit_ignition_cut_enabled);config.insert("overrun_fuel_cut_enabled",e.overrun_fuel_cut_enabled);config.insert("overrun_ignition_cut_enabled",e.overrun_ignition_cut_enabled);toml::array map;for(auto point:e.ignition_map)map.push_back(toml::array{double(point.rpm),double(point.degrees_btdc)});config.insert("ignition_map",std::move(map));pt.insert("ecu",std::move(config));
const auto& c=p.sensor_ecu;toml::table sensor{{"enabled",c.enabled},{"strategy",sensor_ecu_strategy_key(c.strategy)},{"displacement_m3",double(c.displacement)},{"air_r_j_kg_k",double(c.air_gas_constant)},{"target_afr",double(c.target_afr)}};const auto array=[&](const char* key,const std::vector<float>& values){toml::array data;for(const auto value:values)data.push_back(double(value));sensor.insert(key,std::move(data));};array("fuel_rpm",c.fuel_rpm);array("fuel_tps",c.fuel_tps);array("fuel_kg_cycle",c.fuel_per_cycle);array("ve_rpm",c.ve_rpm);array("ve_absolute_pa",c.ve_pressure);array("volumetric_efficiency",c.volumetric_efficiency);pt.insert("sensor_ecu",std::move(sensor));
}
if(p.kind==MachinePartKind::Carburetor){auto config=p.carburetor;pt.insert("carburetor",write_numbers(carb_values(config)));}
if(p.kind==MachinePartKind::Shaft||p.kind==MachinePartKind::Flywheel){const auto& c=p.shaft;pt.insert("shaft",toml::table{{"inertia_kg_m2",double(c.inertia)},{"initial_speed_rad_s",double(c.initial_speed)},{"initial_angle_rad",double(c.initial_angle)},{"friction_nm",double(c.friction_constant)},{"viscous_nm_s",double(c.friction_viscous)},{"quadratic_nm_s2",double(c.friction_quadratic)},{"starter_nm",double(c.starter_torque)}});}
if(p.kind==MachinePartKind::DriveMotor){const auto& c=p.drive;pt.insert("drive",toml::table{{"target_speed_rad_s",double(c.target_speed)},{"torque_nm",double(c.torque)},{"speed_control",c.speed_control},{"enabled",c.enabled}});}
if(p.kind==MachinePartKind::Brake){const auto& c=p.brake;pt.insert("brake",toml::table{{"constant_nm",double(c.constant)},{"viscous_nm_s",double(c.viscous)},{"quadratic_nm_s2",double(c.quadratic)},{"enabled",c.enabled}});}
toml::array points;for(auto v:p.points)points.push_back(xyz(v));pt.insert("points_m",std::move(points));parts.push_back(std::move(pt));
}
for(auto& l:d.links)links.push_back(toml::table{{"a_part",int64_t(l.a.part)},{"a_port",machine_port_descriptor(*d.find(l.a.part),l.a.port).key},{"b_part",int64_t(l.b.part)},{"b_port",machine_port_descriptor(*d.find(l.b.part),l.b.port).key},{"method",machine_connection_key(l.kind)},{"discharge",double(l.discharge)},{"inertance_length_m",double(l.inertance_length)},{"loss",double(l.loss)}});
toml::array probes;for(const auto& p:d.probes)probes.push_back(toml::table{{"id",int64_t(p.id)},{"part",int64_t(p.part)},{"name",p.name},{"quantity",int(p.quantity)},{"position",double(p.position)},{"reference_afr",double(p.reference_afr)},{"generated_output",p.generated_output}});t.insert("probes",std::move(probes));
toml::array sensors;for(const auto& s:d.sensors){const auto tap=[](const MachineSensorTap& tap){return toml::table{{"part",int64_t(tap.part)},{"position",double(tap.position)},{"label",tap.label}};};sensors.push_back(toml::table{{"id",int64_t(s.id)},{"kind",machine_sensor_key(s.kind)},{"name",s.name},{"angle_offset_rad",double(s.angle_offset)},{"target",tap(s.target)},{"reference",tap(s.reference)}});}t.insert("sensors",std::move(sensors));
t.insert("parts",std::move(parts));t.insert("connections",std::move(links));if(!file.parent_path().empty())std::filesystem::create_directories(file.parent_path());
auto temporary=file;temporary+=".tmp";
{std::ofstream out(temporary);out.exceptions(std::ios::failbit|std::ios::badbit);out<<t<<'\n';}
load_machine(temporary);
// Keep a recoverable prior version on platforms whose rename cannot replace a file.
auto backup=file;backup+=".bak";std::error_code ec;std::filesystem::remove(backup,ec);
const bool existed=std::filesystem::exists(file);if(existed)std::filesystem::rename(file,backup);
try{std::filesystem::rename(temporary,file);}catch(...){if(existed)std::filesystem::rename(backup,file);throw;}
std::filesystem::remove(backup,ec);
}
namespace {
std::uint32_t pipe_between(MachineDocument& d,MachinePort a,MachinePort b,float diameter=.032f){
const auto start=machine_port_position(*d.find(a.part),a.port),end=machine_port_position(*d.find(b.part),b.port);
auto& p=d.add(MachinePartKind::Pipe);p.position=multiply(add(start,end),.5f);p.points={subtract(start,p.position),subtract(end,p.position)};p.point_diameters={diameter,diameter};auto id=p.id;
d.connect({id,0},a);d.connect({id,1},b);return id;
}
}
MachineDocument machine_pump_example(){
MachineDocument d;d.name="Vacuum pump bench";d.drive_rpm=900;d.driven=true;
const auto tank=d.add(MachinePartKind::Chamber,{-.4f,.28f,0}).id;d.find(tank)->size={.2f,.18f,.18f};
const auto pump=d.add(MachinePartKind::PistonPump,{0,.2f,0}).id;
const auto air=d.add(MachinePartKind::Reservoir,{.4f,.32f,0}).id;
pipe_between(d,{tank,1},{pump,0});pipe_between(d,{pump,1},{air,0});upgrade_machine_mechanics(d);return d;
}
MachineDocument machine_engine_example(){
MachineDocument d;d.name="Single cylinder engine bench";d.drive_rpm=0;d.driven=false;
const auto inlet=d.add(MachinePartKind::Reservoir,{-.65f,.3f,0}).id;
const auto throttle=d.add(MachinePartKind::Throttle,{-.4f,.3f,0}).id;
const auto chamber=d.add(MachinePartKind::Chamber,{-.2f,.3f,0}).id;d.find(chamber)->size={.1f,.08f,.08f};
const auto cylinder=d.add(MachinePartKind::Cylinder,{0,.2f,0}).id;
const auto collector=d.add(MachinePartKind::Chamber,{.2f,.3f,0}).id;d.find(collector)->size={.08f,.08f,.08f};
const auto exhaust=d.add(MachinePartKind::Reservoir,{.65f,.3f,0}).id;
const auto ecu=d.add(MachinePartKind::Ecu,{0,.5f,0}).id;d.connect({ecu,0},{cylinder,2});d.connect({ecu,0},{throttle,2});
pipe_between(d,{inlet,0},{throttle,0});pipe_between(d,{throttle,1},{chamber,0});pipe_between(d,{chamber,1},{cylinder,0});pipe_between(d,{cylinder,1},{collector,0});pipe_between(d,{collector,1},{exhaust,0});upgrade_machine_mechanics(d);return d;
}
}

1114
src/machine_builder.cpp Normal file

File diff suppressed because it is too large Load Diff

71
src/machine_import.cpp Normal file
View File

@@ -0,0 +1,71 @@
#include "FluidSim/Machine.hpp"
#include "FluidSim/Scenario.hpp"
#include "FluidSim/ScenarioCatalog.hpp"
#include <algorithm>
#include <stdexcept>
namespace fluidsim::port {
using namespace machine_math;
MachineDocument machine_from_engine(const ScenarioExample& definition){
if(definition.kind==ScenarioKind::TwoStroke)throw std::invalid_argument("Two-stroke preset import needs a crankcase/scavenge assembly; choose a four-stroke layout");
FourStrokeScenario source(44100,definition);const auto& e=source.engine_config;const auto& g=e.geometry;const int n=e.cylinders;
if(e.has_silenced_exhaust())throw std::invalid_argument("Choose an unsilenced layout; profiled silencer assemblies are not yet imported");
MachineDocument d;d.name=definition.title+" / editable";if(d.name.size()>120)d.name.resize(120);d.driven=false;d.drive_rpm=e.initial_rpm;d.shaft_inertia=e.crankshaft.inertia;
d.friction_constant=e.crankshaft.friction_constant;d.friction_viscous=e.crankshaft.friction_viscous;d.friction_quadratic=e.crankshaft.friction_quadratic;d.starter_torque=e.starter_torque;d.cells_per_meter=e.cells_per_meter>0?e.cells_per_meter:40;
std::vector<unsigned> pipes,cylinders;
for(std::size_t i=0;i<source.pipes.size();++i){const auto& native=source.pipes[i];const float diameter=2*std::sqrt(native.cell_areas.front()/pi);
for(float a:native.cell_areas)if(std::abs(a-native.cell_areas.front())>1e-6f*native.cell_areas.front())throw std::invalid_argument("This layout contains a profiled duct; choose single-450, twin, flat6, or v8-h");
auto& p=d.add(MachinePartKind::Pipe);p.name="Pipe "+std::to_string(i)+" / "+definition.id;p.position=GeometryPath{g.pipes[i].points}.position(g.pipes[i].length()*.5f);
p.points.clear();for(auto v:g.pipes[i].points)p.points.push_back(subtract(v,p.position));p.point_diameters.assign(p.points.size(),diameter);const auto state=native.state(0);p.pressure=state.pressure;p.temperature=state.pressure/(state.rho*gas_constant);pipes.push_back(p.id);
}
for(int i=0;i<n;++i){auto& p=d.add(MachinePartKind::Cylinder);p.name="Cylinder "+std::to_string(i+1);p.cylinder=e.cylinder;p.valve=e.intake_valve;p.exhaust_valve=e.exhaust_valve;p.intake_profile=e.intake_cam;p.exhaust_profile=e.exhaust_cam;p.phase_degrees=e.phase_degrees[i];
const auto& placement=g.cylinders[i];p.rotation={placement.tilt_degrees,0,0};const float height=machine_part_size(p).y,clearance=e.cylinder.stroke/(e.cylinder.compression-1),head=e.cylinder.rod+e.cylinder.stroke*.5f+clearance;
p.position=cylinder_point(placement,{0,head-height*.5f,0});p.maximum_lift=0;for(auto point:e.intake_cam.points)p.maximum_lift=std::max(p.maximum_lift,point.lift_metres);
const auto timing=[](const FourStrokeCylinder::CamProfile& profile){float open=0,close=720;for(std::size_t j=0;j<profile.points.size();++j)if(profile.points[j].lift_metres>0){open=j?profile.points[j-1].angle_degrees:0;break;}for(std::size_t j=profile.points.size();j-->0;)if(profile.points[j].lift_metres>0){close=j+1<profile.points.size()?profile.points[j+1].angle_degrees:720;break;}return std::pair{open,close};};
const auto intake=timing(e.intake_cam),exhaust=timing(e.exhaust_cam);p.intake_open=intake.first;p.intake_close=intake.second;p.exhaust_open=exhaust.first;p.exhaust_close=exhaust.second;
const auto normalize=[&](Vec3 world){auto local=machine_local_point(p,world);auto size=machine_part_size(p);return Vec3{local.x/size.x,local.y/size.y,local.z/size.z};};
p.port_coordinates={normalize(g.pipes[1+i].points.back()),normalize(g.pipes[1+n+i].points.front())};cylinders.push_back(p.id);
}
const auto box=[&](const std::string& id){const auto geometry=g.volume(id);if(!geometry)throw std::invalid_argument("Missing preset geometry volume: "+id);auto& p=d.add(MachinePartKind::Chamber,geometry->center);p.name=id;p.size=geometry->size;p.temperature=id=="intake_plenum"?300.f:700.f;return p.id;};
const auto attach=[&](int index,bool left,unsigned owner,unsigned port){d.connect({pipes.at(index),left?0u:1u},{owner,port});};
const auto attach_box=[&](int index,bool left,unsigned owner){auto p=d.find(owner);const auto endpoint=machine_port_position(*d.find(pipes.at(index)),left?0:1);const auto local=machine_local_point(*p,endpoint);const auto s=machine_part_size(*p);unsigned port=unsigned(p->port_coordinates.size());p->port_coordinates.push_back({local.x/s.x,local.y/s.y,local.z/s.z});attach(index,left,owner,port);};
const auto plenum=box("intake_plenum");
for(int i=0;i<n;++i){attach_box(1+i,true,plenum);attach(1+i,false,cylinders[i],0);attach(1+n+i,true,cylinders[i],1);}
// Insert the visible metering body into the final straight inlet segment. The new
// body and stub replace that segment's gas volume and preserve the original route.
const auto inlet_id=pipes.front();auto inlet=d.find(inlet_id);const auto path=machine_pipe_path(*inlet);const auto end=path.back();const float last=length(subtract(end,path[path.size()-2]));const auto direction=unit(subtract(end,path[path.size()-2]));
// Position the newly introduced metering body far enough upstream that its
// outlet does not create an artificial 5 mm duct. This only partitions the
// existing inlet route; its endpoints, total length and gas volume stay fixed.
const float body_length=std::min(.04f,last*.5f),stub_length=std::min(std::max(.005f,1/d.cells_per_meter),(last-body_length)*.5f);const float inlet_diameter=inlet->point_diameters.back();const auto start=subtract(end,multiply(direction,body_length+stub_length));inlet->points.back()=machine_local_point(*inlet,start);
auto& metering=d.add(e.carbureted?MachinePartKind::Carburetor:MachinePartKind::Throttle,multiply(add(start,subtract(end,multiply(direction,stub_length))),.5f));const auto metering_id=metering.id;
metering.name=e.carbureted?"Carburetor":"Electronic throttle";metering.rotation={0,-std::asin(std::clamp(direction.z,-1.f,1.f))*180/pi,std::atan2(direction.y,direction.x)*180/pi};metering.size={body_length,inlet_diameter,inlet_diameter};
metering.diameter=e.throttle_diameter;metering.carburetor=e.fuel;metering.discharge=.7f;
d.connect({inlet_id,1},{metering_id,0});auto& stub=d.add(MachinePartKind::Pipe);const auto stub_id=stub.id;stub.name="Inlet metering stub";stub.position={};stub.points={machine_port_position(*d.find(metering_id),1),end};stub.point_diameters={inlet_diameter,inlet_diameter};d.connect({stub_id,0},{metering_id,1});
const auto local=machine_local_point(*d.find(plenum),end);const auto size=d.find(plenum)->size;const unsigned port=unsigned(d.find(plenum)->port_coordinates.size());d.find(plenum)->port_coordinates.push_back({local.x/size.x,local.y/size.y,local.z/size.z});d.connect({stub_id,1},{plenum,port});
auto& ecu=d.add(MachinePartKind::Ecu,add(d.find(plenum)->position,{0,.25f,0}));const auto ecu_id=ecu.id;ecu.ecu=e.ecu;ecu.injection_enabled=!e.carbureted;ecu.name=e.carbureted?"Ignition / idle ECU":"Injection / ignition ECU";
for(auto id:cylinders)d.connect({ecu_id,0},{id,2});
d.connect({ecu_id,0},{metering_id,2});
const auto mouth=[&](int index,bool left){const auto position=machine_port_position(*d.find(pipes.at(index)),left?0:1);auto& p=d.add(MachinePartKind::Reservoir,position);p.name=left?"Intake atmosphere":"Exhaust atmosphere";const auto id=p.id;attach(index,left,id,0);};mouth(0,true);
int junction_index=0;
const auto junction=[&](const std::vector<std::pair<int,bool>>& ends){
if(definition.junctions.model=="volume"){const auto id=box("junction_"+std::to_string(junction_index));for(auto [index,left]:ends)attach_box(index,left,id);}
else{const auto pos=machine_port_position(*d.find(pipes.at(ends.front().first)),ends.front().second?0:1);auto& p=d.add(MachinePartKind::Junction,pos);const auto id=p.id;p.name="Exhaust junction "+std::to_string(junction_index+1);p.junction_method=definition.junctions.model=="legacy"?2:1;p.turning_loss=definition.junctions.turning_loss;for(auto [index,left]:ends)attach(index,left,id,0);}
++junction_index;
};
if(e.american_headers()){
junction({{9,false},{10,false},{17,true}});junction({{11,false},{12,false},{18,true}});junction({{13,false},{15,false},{19,true}});junction({{14,false},{16,false},{20,true}});junction({{17,false},{18,false},{21,true}});junction({{19,false},{20,false},{22,true}});
if(definition.junctions.h_pipe){junction({{21,false},{23,true},{25,true}});junction({{22,false},{24,true},{25,false}});}else{d.connect({pipes[21],1},{pipes[23],0});d.connect({pipes[22],1},{pipes[24],0});}
mouth(23,false);mouth(24,false);
}else if(e.staged_headers()){
const int exit=1+2*n,groups=e.header_groups(),secondary=exit+(e.dual_outlets()?2:1);
for(int group=0;group<groups;++group){std::vector<std::pair<int,bool>> ends;for(int i=0;i<n;++i)if(groups==1||(i<(n+1)/2?0:1)==group)ends.push_back({1+n+i,false});ends.push_back({secondary+group,true});junction(ends);}
if(e.dual_outlets()){for(int bank=0;bank<2;++bank){junction({{secondary+bank,false},{exit+bank,true}});mouth(exit+bank,false);}}
else{std::vector<std::pair<int,bool>> ends{{exit,true}};for(int group=0;group<groups;++group)ends.push_back({secondary+group,false});junction(ends);mouth(exit,false);}
}else{
const int exit=1+2*n;if(definition.junctions.model=="volume"){const auto collector=box("exhaust_collector");for(int i=0;i<n;++i)attach_box(1+n+i,false,collector);attach_box(exit,true,collector);}
else{std::vector<std::pair<int,bool>> ends{{exit,true}};for(int i=0;i<n;++i)ends.push_back({1+n+i,false});junction(ends);}mouth(exit,false);
}
upgrade_machine_mechanics(d);validate_machine(d);return d;
}
}

133
src/machine_logic.cpp Normal file
View File

@@ -0,0 +1,133 @@
#include "FluidSim/Machine.hpp"
#include <algorithm>
#include <cmath>
#include <map>
#include <set>
#include <sstream>
#include <stdexcept>
#include <unordered_map>
namespace fluidsim::port {
namespace {
constexpr const char* keys[]{"boolean_constant","switch","button","number_constant","slider","number_input","and","or","not","xor","nand","nor","greater","less","equal","window","hysteresis","add","subtract","multiply","divide","minimum","maximum","absolute","clamp","gain_offset","map_range","select","boolean_to_number","sensor","command","lamp","readout","boolean_delay","number_delay","ecu"};
constexpr const char* names[]{"Boolean constant","Toggle switch","Momentary button","Number constant","Number slider","Numeric input","AND","OR","NOT","XOR","NAND","NOR","Greater than","Less than","Equal within tolerance","Range / window","Hysteresis threshold","Add","Subtract","Multiply by scalar","Divide by scalar","Minimum","Maximum","Absolute value","Clamp","Gain / offset","Map range","Number selector","Boolean to number","Sensor reference","Actuator command","Boolean lamp","Number readout","Boolean delay","Number delay","ECU connector"};
static_assert(std::size(keys)==logic_node_kind_count&&std::size(names)==logic_node_kind_count);
void require(bool value,const std::string& message){if(!value)throw std::invalid_argument(message);}
MachinePortDescriptor boolean(std::string key,std::string name,PortDirection direction,bool required=true){MachinePortDescriptor p;p.key=std::move(key);p.name=std::move(name);p.domain=PortDomain::Boolean;p.direction=direction;p.cardinality=direction==PortDirection::Output?PortCardinality::Multiple:PortCardinality::Single;p.required=required&&direction==PortDirection::Input;return p;}
MachinePortDescriptor number(const LogicNode& n,std::string key,std::string name,PortDirection direction,bool output_type=false){auto p=boolean(std::move(key),std::move(name),direction);p.domain=PortDomain::Number;p.quantity=output_type?n.output_quantity:n.quantity;p.pressure_reference=output_type?n.output_pressure_reference:n.pressure_reference;return p;}
}
const char* logic_node_key(LogicNodeKind kind){require(int(kind)>=0&&int(kind)<logic_node_kind_count,"Unknown logic node kind");return keys[int(kind)];}
const char* logic_node_name(LogicNodeKind kind){require(int(kind)>=0&&int(kind)<logic_node_kind_count,"Unknown logic node kind");return names[int(kind)];}
const char* logic_command_key(LogicCommand command){switch(command){case LogicCommand::MotorEnable:return "motor_enable";case LogicCommand::MotorSpeed:return "motor_speed";case LogicCommand::MotorTorque:return "motor_torque";case LogicCommand::BrakeEnable:return "brake_enable";case LogicCommand::ThrottleOpening:return "throttle_opening";case LogicCommand::Starter:return "starter";}throw std::invalid_argument("Unknown logic command");}
const char* logic_command_name(LogicCommand command){switch(command){case LogicCommand::MotorEnable:return "Motor enable";case LogicCommand::MotorSpeed:return "Motor speed target";case LogicCommand::MotorTorque:return "Motor torque target";case LogicCommand::BrakeEnable:return "Brake enable";case LogicCommand::ThrottleOpening:return "Valve opening";case LogicCommand::Starter:return "Starter enable";}throw std::invalid_argument("Unknown logic command");}
bool logic_memory_node(LogicNodeKind kind){return kind==LogicNodeKind::BooleanDelay||kind==LogicNodeKind::NumberDelay;}
std::vector<MachinePortDescriptor> logic_node_ports(const LogicNode& n,const MachineDocument* context){
logic_node_key(n.kind);std::vector<MachinePortDescriptor> ports;
const auto input=[&](const char* key,const char* name){ports.push_back(number(n,key,name,PortDirection::Input));};
const auto output=[&](bool mapped=false){ports.push_back(number(n,"out","Output",PortDirection::Output,mapped));};
const auto bit_input=[&](const char* key,const char* name){ports.push_back(boolean(key,name,PortDirection::Input));};
const auto bit_output=[&]{ports.push_back(boolean("out","Output",PortDirection::Output));};
switch(n.kind){
case LogicNodeKind::BooleanConstant:case LogicNodeKind::Switch:case LogicNodeKind::Button:bit_output();break;
case LogicNodeKind::NumberConstant:case LogicNodeKind::Slider:case LogicNodeKind::NumberInput:output();if(n.kind!=LogicNodeKind::NumberConstant){ports.back().minimum=n.minimum;ports.back().maximum=n.maximum;}break;
case LogicNodeKind::And:case LogicNodeKind::Or:case LogicNodeKind::Xor:case LogicNodeKind::Nand:case LogicNodeKind::Nor:
require(n.input_count>=2&&n.input_count<=16,"Boolean gates need 2..16 inputs");for(unsigned i=0;i<n.input_count;++i)ports.push_back(boolean("in"+std::to_string(i+1),"Input "+std::to_string(i+1),PortDirection::Input));bit_output();break;
case LogicNodeKind::Not:case LogicNodeKind::BooleanDelay:bit_input("in","Input");bit_output();break;
case LogicNodeKind::Greater:case LogicNodeKind::Less:case LogicNodeKind::Equal:input("a","A");input("b","B");bit_output();break;
case LogicNodeKind::Window:case LogicNodeKind::Hysteresis:input("in","Measurement");bit_output();break;
case LogicNodeKind::Add:case LogicNodeKind::Subtract:case LogicNodeKind::Minimum:case LogicNodeKind::Maximum:
input("a","A");input("b","B");
if(n.quantity==SignalQuantity::Pressure&&n.kind==LogicNodeKind::Add)ports.back().pressure_reference=PressureReference::Differential;
output();if(n.quantity==SignalQuantity::Pressure&&n.kind==LogicNodeKind::Subtract)ports.back().pressure_reference=PressureReference::Differential;
break;
case LogicNodeKind::Multiply:case LogicNodeKind::Divide:input("a","Quantity");input("b",n.kind==LogicNodeKind::Divide?"Scalar divisor":"Scalar multiplier");ports.back().quantity=SignalQuantity::Scalar;ports.back().pressure_reference=PressureReference::None;output();break;
case LogicNodeKind::Absolute:case LogicNodeKind::Clamp:case LogicNodeKind::GainOffset:case LogicNodeKind::NumberDelay:input("in","Input");output();break;
case LogicNodeKind::MapRange:input("in","Input");output(true);break;
case LogicNodeKind::Select:bit_input("select","Select B");input("a","A / false");input("b","B / true");output();break;
case LogicNodeKind::BooleanToNumber:bit_input("in","Input");output();break;
case LogicNodeKind::Sensor:{MachineSensor sensor;sensor.kind=n.sensor_kind;for(unsigned i=0;i<machine_sensor_output_count(n.sensor_kind);++i)ports.push_back(machine_sensor_output(sensor,i));break;}
case LogicNodeKind::Ecu:{
auto speed=number(n,"speed","Crank speed",PortDirection::Input);speed.quantity=SignalQuantity::AngularSpeed;speed.pressure_reference=PressureReference::None;speed.minimum=0;ports.push_back(speed);
auto angle=number(n,"angle","Crank position",PortDirection::Input);angle.quantity=SignalQuantity::Angle;angle.pressure_reference=PressureReference::None;angle.minimum=0;angle.maximum=2*double(pi);ports.push_back(angle);
if(n.ecu_strategy==SensorEcuConfig::Strategy::AlphaN){auto tps=number(n,"tps","Throttle position",PortDirection::Input);tps.quantity=SignalQuantity::Fraction;tps.pressure_reference=PressureReference::None;tps.minimum=0;tps.maximum=1;ports.push_back(tps);}
if(n.ecu_strategy==SensorEcuConfig::Strategy::SpeedDensity){auto map=number(n,"map","MAP absolute",PortDirection::Input);map.quantity=SignalQuantity::Pressure;map.pressure_reference=PressureReference::Absolute;map.minimum=1e-9;ports.push_back(map);auto iat=number(n,"iat","Intake air temp",PortDirection::Input);iat.quantity=SignalQuantity::Temperature;iat.pressure_reference=PressureReference::None;iat.minimum=1e-9;ports.push_back(iat);}
break;
}
case LogicNodeKind::Command:{
const bool numeric=n.command==LogicCommand::MotorSpeed||n.command==LogicCommand::MotorTorque||n.command==LogicCommand::ThrottleOpening;
auto p=boolean("in",logic_command_name(n.command),PortDirection::Input,false);
if(numeric){p.domain=PortDomain::Number;p.quantity=n.command==LogicCommand::MotorSpeed?SignalQuantity::AngularSpeed:n.command==LogicCommand::MotorTorque?SignalQuantity::Torque:SignalQuantity::Fraction;p.default_value=0.;p.minimum=n.command==LogicCommand::ThrottleOpening?0.:-10000.;p.maximum=n.command==LogicCommand::ThrottleOpening?1.:10000.;}
else p.default_value=false;
if(context&&n.command==LogicCommand::MotorSpeed){const auto shaft=machine_shaft_for(*context,n.part);if(shaft&&std::any_of(context->parts.begin(),context->parts.end(),[&](const auto& part){return part.kind==MachinePartKind::Cylinder&&machine_shaft_for(*context,part.id)==shaft;}))p.minimum=0.;}
ports.push_back(std::move(p));break;
}
case LogicNodeKind::Lamp:bit_input("in","State");break;
case LogicNodeKind::Readout:input("in","Reading");break;
}
for(const auto& p:ports)validate_port_descriptor(p);
return ports;
}
LogicNode* LogicGraph::find(std::uint32_t id){for(auto& n:nodes)if(n.id==id)return &n;return nullptr;}
const LogicNode* LogicGraph::find(std::uint32_t id)const{for(const auto& n:nodes)if(n.id==id)return &n;return nullptr;}
LogicNode& LogicGraph::add(LogicNodeKind kind,LogicPosition position){LogicNode n;n.id=next_id++;n.kind=kind;n.position=position;n.name=std::string(logic_node_name(kind))+" "+std::to_string(n.id);nodes.push_back(std::move(n));return nodes.back();}
void LogicGraph::remove(std::uint32_t id){std::erase_if(links,[&](const auto& link){return link.from.node==id||link.to.node==id;});std::erase_if(nodes,[&](const auto& n){return n.id==id;});}
void LogicGraph::disconnect(const LogicEndpoint& endpoint){std::erase_if(links,[&](const auto& link){return link.from==endpoint||link.to==endpoint;});}
MachinePortDescriptor logic_port(const LogicGraph& graph,const LogicEndpoint& endpoint){const auto node=graph.find(endpoint.node);require(node,"Logic connection refers to a missing node");for(const auto& p:logic_node_ports(*node))if(p.key==endpoint.port)return p;throw std::invalid_argument(node->name+": unknown logic port '"+endpoint.port+"'");}
void LogicGraph::connect(LogicEndpoint a,LogicEndpoint b){const auto pa=logic_port(*this,a),pb=logic_port(*this,b);const auto compatible=port_compatibility(pa,pb);require(compatible.allowed,compatible.message);if(pa.direction==PortDirection::Input)std::swap(a,b);auto proposed=*this;proposed.links.push_back({std::move(a),std::move(b)});validate_logic_graph(proposed);*this=std::move(proposed);}
PortCompatibility logic_connection_preview(const LogicGraph& graph,LogicEndpoint a,LogicEndpoint b){try{auto proposed=graph;proposed.connect(std::move(a),std::move(b));return {true,"Compatible typed signal; output may fan out"};}catch(const std::exception& e){return {false,e.what()};}}
std::vector<std::uint32_t> logic_evaluation_order(const LogicGraph& graph,std::vector<std::uint32_t>* cycle_path){
if(cycle_path)cycle_path->clear();
std::unordered_map<std::uint32_t,std::vector<std::uint32_t>> inputs;for(const auto& l:graph.links){const auto from=graph.find(l.from.node),to=graph.find(l.to.node);require(from&&to,"Logic wire refers to a missing node");if(!logic_memory_node(from->kind)&&!logic_memory_node(to->kind))inputs[to->id].push_back(from->id);}
std::unordered_map<std::uint32_t,int> marks;std::vector<std::uint32_t> order,path;
std::function<void(std::uint32_t)> visit=[&](std::uint32_t id){if(marks[id]==2)return;if(marks[id]==1){std::ostringstream message;message<<"Combinational cycle: ";const auto start=std::find(path.begin(),path.end(),id);if(cycle_path){cycle_path->assign(start,path.end());cycle_path->push_back(id);}for(auto p=start;p!=path.end();++p)message<<graph.find(*p)->name<<" -> ";message<<graph.find(id)->name;throw std::invalid_argument(message.str());}marks[id]=1;path.push_back(id);for(const auto source:inputs[id])visit(source);path.pop_back();marks[id]=2;order.push_back(id);};
for(const auto& node:graph.nodes)if(!logic_memory_node(node.kind))visit(node.id);
return order;
}
void validate_logic_graph(const LogicGraph& graph){
require(graph.nodes.size()<=2048&&graph.links.size()<=8192,"Logic exceeds 2048 nodes / 8192 wires");std::set<std::uint32_t> ids;
for(const auto& n:graph.nodes){require(n.id&&n.id<1000000000&&ids.insert(n.id).second,"Invalid or duplicate logic node ID");logic_node_key(n.kind);logic_command_key(n.command);machine_sensor_key(n.sensor_kind);
require(!n.name.empty()&&n.name.size()<=120&&n.binding_label.size()<=120,"Logic names must have 1..120 characters");require(std::isfinite(n.position.x)&&std::isfinite(n.position.y)&&std::abs(n.position.x)<=1e6&&std::abs(n.position.y)<=1e6,"Invalid logic canvas position");
require(n.part<1000000000&&n.sensor<1000000000,"Invalid logic physical binding");
sensor_ecu_strategy_key(n.ecu_strategy);
for(const auto value:{n.value,n.minimum,n.maximum,n.low,n.high,n.tolerance,n.gain,n.offset,n.false_value,n.true_value,n.input_low,n.input_high,n.output_low,n.output_high})require(std::isfinite(value),n.name+": numeric parameters must be finite");
require(n.minimum<=n.maximum&&n.low<=n.high&&n.tolerance>=0,n.name+": reversed bounds or negative tolerance");
if(n.kind==LogicNodeKind::MapRange)require(n.input_low!=n.input_high&&std::isfinite(n.input_high-n.input_low),n.name+": mapping input range must be nonzero and finite");
if(n.kind==LogicNodeKind::Slider||n.kind==LogicNodeKind::NumberInput)require(n.value>=n.minimum&&n.value<=n.maximum,n.name+": initial value is outside the control range");
std::set<std::string> ports;for(const auto& p:logic_node_ports(n))require(ports.insert(p.key).second,"Duplicate logic port key");
}
require(graph.next_id>0&&graph.next_id<1000000000&&(ids.empty()||graph.next_id>*ids.rbegin()),"Invalid next logic node ID");
std::set<std::pair<std::uint32_t,std::string>> used;
for(const auto& l:graph.links){const auto a=logic_port(graph,l.from),b=logic_port(graph,l.to);const auto compatible=port_compatibility(a,b);require(compatible.allowed,compatible.message);require(a.direction==PortDirection::Output&&b.direction==PortDirection::Input,"Logic wires must be output -> input");require(used.emplace(l.to.node,l.to.port).second,"A logic input has multiple drivers");}
logic_evaluation_order(graph);
}
std::vector<std::string> logic_required_inputs(const LogicGraph& graph){std::vector<std::string> result;for(const auto& node:graph.nodes)for(const auto& port:logic_node_ports(node))if(port.required&&!std::any_of(graph.links.begin(),graph.links.end(),[&](const auto& link){return link.to.node==node.id&&link.to.port==port.key;}))result.push_back(node.name+" / "+port.name+": required signal is unconnected");return result;}
bool logic_topology_equal(const LogicGraph& a,const LogicGraph& b){
if(a.nodes.size()!=b.nodes.size()||a.links!=b.links)return false;
for(std::size_t i=0;i<a.nodes.size();++i){const auto& x=a.nodes[i];const auto& y=b.nodes[i];if(x.id!=y.id||x.kind!=y.kind||x.quantity!=y.quantity||x.output_quantity!=y.output_quantity||x.pressure_reference!=y.pressure_reference||x.output_pressure_reference!=y.output_pressure_reference||x.input_count!=y.input_count||x.sensor!=y.sensor||x.sensor_kind!=y.sensor_kind||x.part!=y.part||x.command!=y.command||x.ecu_strategy!=y.ecu_strategy||x.minimum!=y.minimum||x.maximum!=y.maximum||x.initial!=y.initial)return false;
if(logic_memory_node(x.kind)&&(x.value!=y.value||x.boolean!=y.boolean))return false;
}return true;
}
std::string logic_binding_problem(const MachineDocument& doc,const LogicNode& node){
if(node.kind==LogicNodeKind::Ecu){const auto part=doc.find(node.part);if(!part||part->kind!=MachinePartKind::Ecu||!part->sensor_ecu.enabled)return "Choose a sensor-driven ECU target";if(part->sensor_ecu.strategy!=node.ecu_strategy)return "ECU strategy differs from the connector's saved schema";return {};}
if(node.kind==LogicNodeKind::Sensor){const auto sensor=doc.find_sensor(node.sensor);if(!sensor)return "Unresolved sensor reference";if(sensor->kind!=node.sensor_kind)return "Sensor kind differs from the node's saved port schema";return {};}
if(node.kind!=LogicNodeKind::Command)return {};
const auto part=doc.find(node.part);if(!part)return "Unresolved command target";
switch(node.command){
case LogicCommand::MotorEnable:if(part->kind!=MachinePartKind::DriveMotor)return "Motor enable needs a drive motor";break;
case LogicCommand::MotorSpeed:if(part->kind!=MachinePartKind::DriveMotor||!part->drive.speed_control)return "Speed target needs a motor in ideal speed mode";break;
case LogicCommand::MotorTorque:if(part->kind!=MachinePartKind::DriveMotor||part->drive.speed_control)return "Torque target needs a motor in torque mode";break;
case LogicCommand::BrakeEnable:if(part->kind!=MachinePartKind::Brake)return "Brake enable needs a brake / load";break;
case LogicCommand::ThrottleOpening:
if(part->kind!=MachinePartKind::Throttle&&part->kind!=MachinePartKind::Carburetor)return "Opening command needs a throttle / carburetor";
if(std::any_of(doc.links.begin(),doc.links.end(),[&](const auto& link){return link.kind==MachineConnectionKind::Control&&link.b.part==part->id;}))return "Opening already belongs to an assigned ECU";
break;
case LogicCommand::Starter:if(part->kind!=MachinePartKind::Shaft||part->shaft.starter_torque<=0)return "Starter command needs a shaft with declared starter torque";break;
}return {};
}
void validate_machine_logic(const MachineDocument& doc){
validate_logic_graph(doc.logic);std::set<std::pair<std::uint32_t,int>> owners;
std::set<std::uint32_t> ecu_owners;for(const auto& node:doc.logic.nodes)if(node.kind==LogicNodeKind::Ecu&&node.part)require(ecu_owners.insert(node.part).second,"An ECU has multiple signal connectors");
for(const auto& n:doc.logic.nodes)if(n.kind==LogicNodeKind::Command&&n.part&&doc.find(n.part)){const int field=n.command==LogicCommand::MotorTorque?int(LogicCommand::MotorSpeed):int(n.command);require(owners.emplace(n.part,field).second,"Multiple signal command owners target the same actuator field");}
}
const LogicNode* logic_command_owner(const MachineDocument& doc,std::uint32_t part,LogicCommand command){for(const auto& node:doc.logic.nodes)if(node.kind==LogicNodeKind::Command&&node.part==part&&node.command==command)return &node;return nullptr;}
}

32
src/machine_mechanics.cpp Normal file
View File

@@ -0,0 +1,32 @@
#include "FluidSim/Machine.hpp"
#include <algorithm>
#include <stdexcept>
namespace fluidsim::port {
bool machine_rotational_part(MachinePartKind kind){return kind==MachinePartKind::Shaft||kind==MachinePartKind::Flywheel||kind==MachinePartKind::DriveMotor||kind==MachinePartKind::Brake;}
std::uint32_t machine_shaft_for(const MachineDocument& document,std::uint32_t id){
const auto part=document.find(id);if(!part)return 0;if(part->kind==MachinePartKind::Shaft)return id;
for(const auto& link:document.links)if(link.kind==MachineConnectionKind::Mechanical){const auto other=link.a.part==id?link.b.part:link.b.part==id?link.a.part:0;if(other){const auto shaft=document.find(other);if(shaft&&shaft->kind==MachinePartKind::Shaft)return other;}}
return 0;
}
std::uint32_t machine_primary_shaft(const MachineDocument& document){
if(document.primary_shaft){const auto part=document.find(document.primary_shaft);return part&&part->kind==MachinePartKind::Shaft?part->id:0;}
std::uint32_t found=0;for(const auto& part:document.parts)if(part.kind==MachinePartKind::Shaft){if(found)return 0;found=part.id;}return found;
}
bool machine_shaft_driven(const MachineDocument& document,std::uint32_t shaft){for(const auto& part:document.parts)if(part.kind==MachinePartKind::DriveMotor&&part.drive.enabled&&part.drive.speed_control&&machine_shaft_for(document,part.id)==shaft)return true;return false;}
void upgrade_machine_mechanics(MachineDocument& document){
if(std::any_of(document.parts.begin(),document.parts.end(),[](const auto& p){return p.kind==MachinePartKind::Shaft;}))return;
std::vector<std::uint32_t> attached;Vec3 center{};
for(const auto& part:document.parts)if(part.kind==MachinePartKind::Cylinder||part.kind==MachinePartKind::PistonPump){attached.push_back(part.id);center=machine_math::add(center,part.position);}
// Old gas-only documents can still have explicit RPM instruments or a drive.
const bool rotational_probe=std::any_of(document.probes.begin(),document.probes.end(),[](const auto& p){return p.quantity>=MachineQuantity::Rpm;});
if(attached.empty()&&!document.driven&&document.drive_rpm==0&&!rotational_probe)return;
if(!attached.empty())center=machine_math::multiply(center,1.f/attached.size());
center.y-=.15f;
auto& shaft=document.add(MachinePartKind::Shaft,center);const auto id=shaft.id;shaft.name="Main crankshaft";shaft.shaft={document.shaft_inertia,document.drive_rpm*2*pi/60,0,document.friction_constant,document.friction_viscous,document.friction_quadratic,document.starter_torque};document.primary_shaft=id;
for(auto part:attached)document.connect({id,0},{part,machine_port_index(*document.find(part),"shaft")});
if(document.driven){auto& drive=document.add(MachinePartKind::DriveMotor,machine_math::add(center,{-.28f,0,0}));const auto motor=drive.id;drive.name="Bench speed drive";drive.drive.target_speed=document.drive_rpm*2*pi/60;document.connect({id,0},{motor,0});}
if(document.load_torque>0){auto& brake=document.add(MachinePartKind::Brake,machine_math::add(center,{.28f,0,0}));const auto load=brake.id;brake.name="Bench load";brake.brake.constant=document.load_torque;document.connect({id,0},{load,0});}
for(auto& probe:document.probes)if(probe.quantity>=MachineQuantity::Rpm&&probe.part==0)probe.part=id;
}
}

71
src/machine_ports.cpp Normal file
View File

@@ -0,0 +1,71 @@
#include "FluidSim/Machine.hpp"
#include <cmath>
#include <stdexcept>
namespace fluidsim::port {
namespace {
void require(bool value,const std::string& message){if(!value)throw std::invalid_argument(message);}
bool key_valid(const std::string& key){if(key.empty()||key.size()>64)return false;for(const auto c:key)if(!((c>='a'&&c<='z')||(c>='A'&&c<='Z')||(c>='0'&&c<='9')||c=='_'||c=='-'||c=='.'))return false;return true;}
}
SignalValue SignalValue::boolean(bool value,double time){return {value,std::isfinite(time)?SignalValidity::Valid:SignalValidity::Fault,time,{}};}
SignalValue SignalValue::number(double value,double time){return {value,std::isfinite(value)&&std::isfinite(time)?SignalValidity::Valid:SignalValidity::Fault,time,{}};}
const char* port_domain_key(PortDomain domain){switch(domain){case PortDomain::Gas:return "gas";case PortDomain::Rotational:return "rotational";case PortDomain::Translational:return "translational";case PortDomain::Boolean:return "boolean";case PortDomain::Number:return "number";case PortDomain::EcuAssignment:return "ecu_assignment";}throw std::invalid_argument("Unknown port domain");}
const char* signal_quantity_key(SignalQuantity q){switch(q){case SignalQuantity::None:return "none";case SignalQuantity::Scalar:return "scalar";case SignalQuantity::Fraction:return "fraction";case SignalQuantity::Pressure:return "pressure";case SignalQuantity::Temperature:return "temperature";case SignalQuantity::MassFlow:return "mass_flow";case SignalQuantity::FreshAirMassFlow:return "fresh_air_mass_flow";case SignalQuantity::AngularSpeed:return "angular_speed";case SignalQuantity::Angle:return "angle";case SignalQuantity::CyclePhase:return "cycle_phase";case SignalQuantity::Torque:return "torque";case SignalQuantity::Power:return "power";case SignalQuantity::Position:return "position";case SignalQuantity::Velocity:return "velocity";case SignalQuantity::Force:return "force";case SignalQuantity::Time:return "time";case SignalQuantity::Inertia:return "inertia";}throw std::invalid_argument("Unknown signal quantity");}
const char* signal_quantity_unit(SignalQuantity q){switch(q){case SignalQuantity::None:case SignalQuantity::Scalar:case SignalQuantity::Fraction:return "1";case SignalQuantity::Pressure:return "Pa";case SignalQuantity::Temperature:return "K";case SignalQuantity::MassFlow:case SignalQuantity::FreshAirMassFlow:return "kg/s";case SignalQuantity::AngularSpeed:return "rad/s";case SignalQuantity::Angle:case SignalQuantity::CyclePhase:return "rad";case SignalQuantity::Torque:return "N m";case SignalQuantity::Power:return "W";case SignalQuantity::Position:return "m";case SignalQuantity::Velocity:return "m/s";case SignalQuantity::Force:return "N";case SignalQuantity::Time:return "s";case SignalQuantity::Inertia:return "kg m2";}throw std::invalid_argument("Unknown signal quantity");}
bool signal_domain(PortDomain domain){return domain==PortDomain::Boolean||domain==PortDomain::Number;}
void validate_port_descriptor(const MachinePortDescriptor& port){
require(key_valid(port.key),"Invalid stable port key");require(!port.name.empty()&&port.name.size()<=120,"Invalid port display name");
port_domain_key(port.domain);signal_quantity_key(port.quantity);
require(port.direction==PortDirection::Input||port.direction==PortDirection::Output||port.direction==PortDirection::Bidirectional,"Invalid port direction");
require(port.cardinality==PortCardinality::Single||port.cardinality==PortCardinality::Multiple,"Invalid port cardinality");
const bool directed=signal_domain(port.domain)||port.domain==PortDomain::EcuAssignment;
require(directed?port.direction!=PortDirection::Bidirectional:port.direction==PortDirection::Bidirectional,"Port direction is incompatible with its domain");
if(directed&&port.direction==PortDirection::Input)require(port.cardinality==PortCardinality::Single,"A signal input must have a single driver");
require((port.domain==PortDomain::Number)==(port.quantity!=SignalQuantity::None),"Numeric ports need an explicit quantity; other ports cannot declare one");
if(port.quantity==SignalQuantity::Pressure)require(port.pressure_reference==PressureReference::Absolute||port.pressure_reference==PressureReference::Gauge||port.pressure_reference==PressureReference::Differential,"Pressure signal needs an explicit reference");
else require(port.pressure_reference==PressureReference::None,"Only pressure signals declare a pressure reference");
require(!port.required||port.direction==PortDirection::Input,"Only inputs declare required wiring");
require(!port.required||std::holds_alternative<std::monostate>(port.default_value),"A required input cannot also declare a default");
require(!port.minimum||std::isfinite(*port.minimum),"Signal minimum must be finite");require(!port.maximum||std::isfinite(*port.maximum),"Signal maximum must be finite");
require(!port.minimum||!port.maximum||*port.minimum<=*port.maximum,"Signal range is reversed");
require(port.domain==PortDomain::Number||(!port.minimum&&!port.maximum),"Only numbers declare numeric ranges");
if(!std::holds_alternative<std::monostate>(port.default_value)){
require(port.direction==PortDirection::Input&&signal_domain(port.domain),"Only signal inputs declare default values");
const auto value=std::holds_alternative<bool>(port.default_value)?SignalValue::boolean(std::get<bool>(port.default_value)):SignalValue::number(std::get<double>(port.default_value));
require(signal_value_valid_for(value,port),"Input default has the wrong type or is outside its range");
}
}
PortCompatibility port_compatibility(const MachinePortDescriptor& a,const MachinePortDescriptor& b){
try{validate_port_descriptor(a);validate_port_descriptor(b);}catch(const std::exception& error){return {false,error.what()};}
if(a.domain!=b.domain)return {false,std::string("Cannot connect ")+port_domain_key(a.domain)+" to "+port_domain_key(b.domain)};
if(a.direction!=PortDirection::Bidirectional&&!(a.direction==PortDirection::Output&&b.direction==PortDirection::Input)&&!(b.direction==PortDirection::Output&&a.direction==PortDirection::Input))return {false,"Connect an output to an input"};
if(a.domain==PortDomain::Number&&(a.quantity!=b.quantity||a.pressure_reference!=b.pressure_reference))return {false,"Number quantities or pressure references do not match; use an explicit conversion"};
return {true,"Compatible ports"};
}
bool signal_value_valid_for(const SignalValue& value,const MachinePortDescriptor& port){
if(value.validity!=SignalValidity::Valid||!std::isfinite(value.timestamp))return false;
if(port.domain==PortDomain::Boolean)return std::holds_alternative<bool>(value.value);
if(port.domain!=PortDomain::Number||!std::holds_alternative<double>(value.value))return false;
const auto number=std::get<double>(value.value);return std::isfinite(number)&&(!port.minimum||number>=*port.minimum)&&(!port.maximum||number<=*port.maximum);
}
MachinePortDescriptor machine_port_descriptor(const MachinePart& part,unsigned index){
require(index<machine_port_count(part),"Invalid machine port index");MachinePortDescriptor port;
const auto kind=part.kind;
if(machine_rotational_part(kind)||(kind==MachinePartKind::Cylinder&&index==3)||(kind==MachinePartKind::PistonPump&&index==2)){
port.key="shaft";port.name=kind==MachinePartKind::Shaft?"Shaft attachments":"Shaft";port.domain=PortDomain::Rotational;port.cardinality=kind==MachinePartKind::Shaft?PortCardinality::Multiple:PortCardinality::Single;return port;
}
const bool ecu=kind==MachinePartKind::Ecu,assignment=ecu||((kind==MachinePartKind::Cylinder||kind==MachinePartKind::Throttle||kind==MachinePartKind::Carburetor)&&index==2);
if(assignment){port.key=ecu?"assignments":"ecu_assignment";port.name=ecu?"ECU output":"ECU input";port.domain=PortDomain::EcuAssignment;port.direction=ecu?PortDirection::Output:PortDirection::Input;port.cardinality=ecu?PortCardinality::Multiple:PortCardinality::Single;return port;}
if(kind==MachinePartKind::Pipe){port.key=index?"end":"start";port.name=index?"End":"Start";}
else if(kind==MachinePartKind::Cylinder){port.key=index?"exhaust":"intake";port.name=index?"Exhaust":"Intake";}
else if(kind==MachinePartKind::Throttle||kind==MachinePartKind::Carburetor||kind==MachinePartKind::CheckValve||kind==MachinePartKind::PistonPump){port.key=index?"outlet":"inlet";port.name=index?"Outlet (+X)":"Inlet (-X)";}
else if(kind==MachinePartKind::Reservoir||kind==MachinePartKind::Cap){port.key="gas";port.name=kind==MachinePartKind::Cap?"Sealed end":"Atmosphere";}
else if(kind==MachinePartKind::Junction&&part.junction_method){port.key="junction";port.name="Shared junction";port.cardinality=PortCardinality::Multiple;}
else if(!part.port_keys.empty()){require(index<part.port_keys.size(),"Missing custom port key");port.key=part.port_keys[index];port.name="Gas port "+std::to_string(index+1);}
else if(!part.port_coordinates.empty()){port.key="gas_"+std::to_string(index);port.name="Gas port "+std::to_string(index+1);}
else{const char* keys[]{"face_x_negative","face_x_positive","face_y_negative","face_y_positive","face_z_negative","face_z_positive"};const char* names[]{"-X","+X","-Y","+Y","-Z","+Z"};require(index<6,"Invalid volume face");port.key=keys[index];port.name=names[index];}
return port;
}
unsigned machine_port_index(const MachinePart& part,const std::string& key){for(unsigned i=0;i<machine_port_count(part);++i)if(machine_port_descriptor(part,i).key==key)return i;throw std::invalid_argument(part.name+": unknown port key '"+key+"'");}
PortDomain machine_link_domain(const MachineDocument& document,const MachineLink& link){const auto a=document.find(link.a.part),b=document.find(link.b.part);require(a&&b,"Connection refers to a missing part");const auto da=machine_port_descriptor(*a,link.a.port),db=machine_port_descriptor(*b,link.b.port);require(da.domain==db.domain,"Connection crosses incompatible port domains");return da.domain;}
}

185
src/machine_renderer_3d.cpp Normal file
View File

@@ -0,0 +1,185 @@
#include "FluidSim/Desktop/MachineRenderer3D.hpp"
#include "FluidSim/Desktop/PressurePalette.hpp"
#include "FluidSim/Desktop/PipePath.hpp"
#include "FluidSim/Desktop/PortAppearance.hpp"
#include <SFML/OpenGL.hpp>
#include <algorithm>
#include <array>
#include <cstddef>
#include <unordered_map>
namespace fluidsim::port::desktop {
using namespace machine_math;
namespace {
void vertex(Vec3 p){glVertex3f(p.x,p.y,p.z);}
void tint(Vec3 p,float alpha=1){glColor4f(p.x,p.y,p.z,alpha);}
void line(Vec3 a,Vec3 b){vertex(a);vertex(b);}
struct MeshVertex {Vec3 position,normal;};
struct Mesh {
std::vector<MeshVertex> vertices;
std::vector<unsigned> indices;
GLenum mode{GL_TRIANGLES};
void draw(const std::vector<Vec3>* colors=nullptr)const{
glDisableClientState(GL_TEXTURE_COORD_ARRAY);glEnableClientState(GL_VERTEX_ARRAY);glEnableClientState(GL_NORMAL_ARRAY);
glVertexPointer(3,GL_FLOAT,sizeof(MeshVertex),vertices.data());glNormalPointer(GL_FLOAT,sizeof(MeshVertex),reinterpret_cast<const char*>(vertices.data())+offsetof(MeshVertex,normal));
if(colors){glEnableClientState(GL_COLOR_ARRAY);glColorPointer(3,GL_FLOAT,sizeof(Vec3),colors->data());}else glDisableClientState(GL_COLOR_ARRAY);
glDrawElements(mode,GLsizei(indices.size()),GL_UNSIGNED_INT,indices.data());
glDisableClientState(GL_COLOR_ARRAY);glDisableClientState(GL_NORMAL_ARRAY);glDisableClientState(GL_VERTEX_ARRAY);
}
};
void box(Vec3 s){
static const Mesh mesh=[] {Mesh m;m.mode=GL_QUADS;
const std::array<Vec3,8> v{{{-.5f,-.5f,-.5f},{.5f,-.5f,-.5f},{.5f,.5f,-.5f},{-.5f,.5f,-.5f},{-.5f,-.5f,.5f},{.5f,-.5f,.5f},{.5f,.5f,.5f},{-.5f,.5f,.5f}}};
const int faces[6][4]{{0,3,2,1},{4,5,6,7},{0,4,7,3},{1,2,6,5},{3,7,6,2},{0,1,5,4}};
const Vec3 normals[]{{0,0,-1},{0,0,1},{-1,0,0},{1,0,0},{0,1,0},{0,-1,0}};
for(int f=0;f<6;++f)for(int i:faces[f]){m.indices.push_back(unsigned(m.vertices.size()));m.vertices.push_back({v[i],normals[f]});}
return m;}();
glPushMatrix();glScalef(s.x,s.y,s.z);mesh.draw();glPopMatrix();
}
void cylinder(float radius,float bottom,float top){
static const Mesh mesh=[] {Mesh m;constexpr unsigned n=28;
for(unsigned i=0;i<=n;++i){float a=2*pi*i/n,x=std::cos(a),z=std::sin(a);m.vertices.push_back({{x,0,z},{x,0,z}});m.vertices.push_back({{x,1,z},{x,0,z}});if(i<n){unsigned k=2*i;m.indices.insert(m.indices.end(),{k,k+1,k+2,k+2,k+1,k+3});}}
for(unsigned side=0;side<2;++side){unsigned start=unsigned(m.vertices.size());const Vec3 nrm{0,side?1.f:-1.f,0};m.vertices.push_back({{0,float(side),0},nrm});for(unsigned i=0;i<=n;++i){float a=(side?-1:1)*2*pi*i/n;m.vertices.push_back({{std::cos(a),float(side),std::sin(a)},nrm});if(i<n)m.indices.insert(m.indices.end(),{start,start+i+1,start+i+2});}}
return m;}();
glPushMatrix();glTranslatef(0,bottom,0);glScalef(radius,top-bottom,radius);mesh.draw();glPopMatrix();
}
struct PipeVisual {
Mesh mesh;std::array<Mesh,2> caps;
std::vector<Vec3> points,colors;
std::vector<float> fractions;
std::vector<float> diameters;bool valid{};std::uint64_t frame{};
bool matches(const MachinePart& p)const{return valid&&points==p.points&&(p.point_diameters.empty()?diameters==machine_pipe_diameters(p):diameters==p.point_diameters);}
void build(const MachinePart& p){
mesh={};caps={};fractions.clear();points=p.points;diameters=machine_pipe_diameters(p);
float total=machine_pipe_length(p),distance=0;std::vector<PipeAnchor> anchors;
for(std::size_t i=0;i<p.points.size();++i){if(i)distance+=length(subtract(p.points[i],p.points[i-1]));anchors.push_back({distance,p.points[i]});}
PipePath path(anchors);std::vector<float> samples;
for(std::size_t i=1;i<anchors.size();++i){const auto a=anchors[i-1].distance,b=anchors[i].distance;const int steps=std::max(1,int(std::ceil((b-a)*50)));for(int k=0;k<steps;++k)samples.push_back(std::lerp(a,b,float(k)/steps));}samples.push_back(total);
Vec3 last_normal{};bool first=true;
for(float d:samples){const auto center=path.position(d),axis=path.tangent(d);const auto projected=subtract(last_normal,multiply(axis,dot(last_normal,axis)));
const Vec3 n=first||length(projected)<1e-6f?unit(cross(axis,std::abs(axis.y)<.9f?Vec3{0,1,0}:Vec3{1,0,0})):unit(projected);last_normal=n;auto b=unit(cross(axis,n));const float radius=machine_pipe_diameter(p,d)*.5f;
const unsigned start=unsigned(mesh.vertices.size());fractions.push_back(d/total);
for(unsigned j=0;j<24;++j){float a=2*pi*j/24;const auto nrm=add(multiply(n,std::cos(a)),multiply(b,std::sin(a)));mesh.vertices.push_back({add(center,multiply(path.miter_offset(d,nrm),radius)),nrm});
if(!first){unsigned k=(j+1)%24,previous=start-24;mesh.indices.insert(mesh.indices.end(),{previous+j,start+j,previous+k,previous+k,start+j,start+k});}}
first=false;
}
for(unsigned end=0;end<2;++end){auto& cap=caps[end];
const auto center=p.points[end?p.points.size()-1:0],axis=multiply(path.tangent(end?total:0),end?1.f:-1.f);
auto n=unit(cross(axis,std::abs(axis.y)<.9f?Vec3{0,1,0}:Vec3{1,0,0})),b=unit(cross(axis,n));const float radius=(end?diameters.back():diameters.front())*.5f;
cap.vertices.push_back({center,axis});for(unsigned i=0;i<=24;++i){float a=2*pi*i/24;cap.vertices.push_back({add(center,add(multiply(n,radius*std::cos(a)),multiply(b,radius*std::sin(a)))),axis});if(i<24)cap.indices.insert(cap.indices.end(),{0,i+1,i+2});}
}
colors.resize(mesh.vertices.size());valid=true;
}
void draw(const MachinePartState* state,Vec3 base,std::array<bool,2> sealed){
if(state&&!state->pipe_pressures.empty()){for(std::size_t i=0;i<fractions.size();++i){const auto index=std::min(state->pipe_pressures.size()-1,std::size_t(fractions[i]*state->pipe_pressures.size()));std::fill_n(colors.begin()+i*24,24,pressure_tint(state->pipe_pressures[index]));}mesh.draw(&colors);}
else{tint(base);mesh.draw();}
tint(base);for(unsigned end=0;end<2;++end)if(sealed[end])caps[end].draw();
}
};
Vec3 part_color(MachinePartKind k){
if(machine_rotational_part(k))return k==MachinePartKind::DriveMotor?Vec3{.26f,.46f,.70f}:k==MachinePartKind::Brake?Vec3{.65f,.33f,.28f}:Vec3{.72f,.64f,.42f};
switch(k){case MachinePartKind::Pipe:return {.52f,.65f,.70f};case MachinePartKind::Chamber:return {.4f,.55f,.64f};case MachinePartKind::Throttle:return {.8f,.58f,.25f};case MachinePartKind::CheckValve:return {.55f,.7f,.4f};case MachinePartKind::Cylinder:return {.62f,.66f,.72f};case MachinePartKind::PistonPump:return {.55f,.45f,.72f};case MachinePartKind::Reservoir:return {.4f,.78f,.67f};case MachinePartKind::Carburetor:return {.82f,.63f,.3f};case MachinePartKind::Ecu:return {.25f,.64f,.72f};default:return {.6f,.65f,.7f};}
}
void draw_part(const MachinePart& p,const MachinePartState* s,bool selected,bool ghost=false,std::array<bool,2> sealed={false,false},PipeVisual* visual=nullptr){
glPushMatrix();glTranslatef(p.position.x,p.position.y,p.position.z);glRotatef(p.rotation.z,0,0,1);glRotatef(p.rotation.y,0,1,0);glRotatef(p.rotation.x,1,0,0);
const Vec3 base=selected?Vec3{1,.74f,.3f}:part_color(p.kind);tint(ghost?Vec3{.35f,.9f,.8f}:s&&p.kind!=MachinePartKind::Ecu&&!machine_rotational_part(p.kind)?pressure_tint(s->pressure):base);
if(ghost){glPolygonMode(GL_FRONT_AND_BACK,GL_LINE);glDisable(GL_LIGHTING);}
if(machine_rotational_part(p.kind)){
const float radius=std::min(p.size.y,p.size.z)*.5f;
glPushMatrix();glRotatef(-90,0,0,1);cylinder(radius,-p.size.x*.5f,p.size.x*.5f);tint({.75f,.76f,.78f});cylinder(radius*.25f,-p.size.x*.58f,p.size.x*.58f);glPopMatrix();
const float angle=s?float(std::fmod(s->shaft_angle,2*pi)):0;
glDisable(GL_LIGHTING);tint({.95f,.82f,.48f});glBegin(GL_LINES);
for(int i=0;i<(p.kind==MachinePartKind::Flywheel?6:1);++i){const float a=angle+i*2*pi/6;line({p.size.x*.51f,0,0},{p.size.x*.51f,radius*.85f*std::cos(a),radius*.85f*std::sin(a)});}glEnd();glEnable(GL_LIGHTING);
if(p.kind==MachinePartKind::Brake){tint({.25f,.27f,.31f});glPushMatrix();glTranslatef(0,radius*.72f,0);box({p.size.x*.9f,p.size.y*.25f,p.size.z*.3f});glPopMatrix();}
}
else if(p.kind==MachinePartKind::Pipe){if(visual)visual->draw(s,base,sealed);}
else if(p.kind==MachinePartKind::Cylinder||p.kind==MachinePartKind::PistonPump){
const auto size=machine_part_size(p);const float top=size.y*.5f,bottom=-top,r=p.cylinder.bore*.5f;
const float piston=top-p.cylinder.stroke/(p.cylinder.compression-1)-p.cylinder.stroke*(s?std::clamp(s->piston_fraction,0.f,1.f):.6f);
cylinder(r,piston,top);tint({.78f,.8f,.85f});cylinder(r,piston-.002f,piston);
glPolygonMode(GL_FRONT_AND_BACK,GL_LINE);tint(base);cylinder(r,bottom,top);glPolygonMode(GL_FRONT_AND_BACK,ghost?GL_LINE:GL_FILL);
for(unsigned port=0;port<2;++port){const auto pos=machine_local_port(p,port);glPushMatrix();glTranslatef(pos.x,0,pos.z);tint(port?Vec3{.9f,.5f,.32f}:Vec3{.4f,.7f,.9f});const float lift=s?(port?s->exhaust_lift:s->intake_lift):0;cylinder((port?p.cylinder.exhaust_valve_diameter:p.cylinder.intake_valve_diameter)*.5f,top-lift-.001f,top-lift);glPopMatrix();}
}else if(p.kind==MachinePartKind::Carburetor){
// The visible throat is the same elliptical cylinder used for gas storage.
glPushMatrix();glRotatef(-90,0,0,1);glScalef(1,1,p.size.z/p.size.y);cylinder(p.size.y*.5f,-p.size.x*.5f,p.size.x*.5f);glPopMatrix();
const float bowl=std::cbrt(p.carburetor.bowl_capacity/p.carburetor.fuel_density);glPushMatrix();glTranslatef(0,-p.size.y*.5f-bowl*.5f,0);tint({.68f,.51f,.2f});box({bowl,bowl,bowl});glPopMatrix();
glDisable(GL_LIGHTING);tint({.95f,.72f,.25f});glBegin(GL_LINES);line({0,-p.size.y*.5f,0},{0,0,0});glEnd();glEnable(GL_LIGHTING);
}else if(p.kind==MachinePartKind::Ecu){
box(p.size);tint({.12f,.22f,.26f});glPushMatrix();glTranslatef(0,p.size.y*.55f,0);box({p.size.x*.5f,p.size.y*.15f,p.size.z*.55f});glPopMatrix();
tint({.8f,.8f,.7f});for(int i=0;i<6;++i)for(float side:{-1.f,1.f}){glPushMatrix();glTranslatef((i-2.5f)*p.size.x*.13f,0,side*p.size.z*.55f);box({p.size.x*.07f,p.size.y*.3f,p.size.z*.12f});glPopMatrix();}
}else if(p.kind==MachinePartKind::Reservoir||p.kind==MachinePartKind::Cap||(p.kind==MachinePartKind::Junction&&p.junction_method)){
glDisable(GL_LIGHTING);glBegin(GL_LINES);
if(p.kind==MachinePartKind::Reservoir){for(int plane=0;plane<3;++plane)for(int i=0;i<32;++i){const float a=2*pi*i/32,b=2*pi*(i+1)/32,r=.018f;const auto point=[&](float t){return plane==0?Vec3{0,r*std::cos(t),r*std::sin(t)}:plane==1?Vec3{r*std::cos(t),0,r*std::sin(t)}:Vec3{r*std::cos(t),r*std::sin(t),0};};line(point(a),point(b));}}
else if(p.kind==MachinePartKind::Cap){line({-.015f,-.015f,0},{.015f,.015f,0});line({-.015f,.015f,0},{.015f,-.015f,0});}
else{line({-.02f,0,0},{.02f,0,0});line({0,-.02f,0},{0,.02f,0});line({0,0,-.02f},{0,0,.02f});}glEnd();glEnable(GL_LIGHTING);
}else{
const bool valve=p.kind==MachinePartKind::Throttle||p.kind==MachinePartKind::CheckValve;
if(valve)glPolygonMode(GL_FRONT_AND_BACK,GL_LINE);
if(p.kind==MachinePartKind::Throttle){glPushMatrix();glRotatef(-90,0,0,1);glScalef(1,1,p.size.z/p.size.y);cylinder(p.size.y*.5f,-p.size.x*.5f,p.size.x*.5f);glPopMatrix();}else box(p.size);
glPolygonMode(GL_FRONT_AND_BACK,ghost?GL_LINE:GL_FILL);
if(valve){glPushMatrix();if(p.kind==MachinePartKind::Throttle)glRotatef((s?s->opening:p.opening)*90,0,0,1);else glTranslatef(s?s->opening*.005f:0,0,0);
tint({.85f,.67f,.3f});glBegin(GL_TRIANGLE_FAN);glNormal3f(1,0,0);glVertex3f(0,0,0);for(int i=0;i<=32;++i){float a=2*pi*i/32;glVertex3f(0,p.diameter*.5f*std::cos(a),p.diameter*.5f*std::sin(a));}glEnd();glPopMatrix();}
if(p.kind==MachinePartKind::Throttle||p.kind==MachinePartKind::CheckValve){glDisable(GL_LIGHTING);tint({.9f,.75f,.35f});glBegin(GL_LINES);line({-p.size.x*.5f,p.size.y*.51f,0},{p.size.x*.5f,p.size.y*.51f,0});line({p.size.x*.5f,p.size.y*.51f,0},{p.size.x*.2f,p.size.y*.51f,.012f});line({p.size.x*.5f,p.size.y*.51f,0},{p.size.x*.2f,p.size.y*.51f,-.012f});glEnd();glEnable(GL_LIGHTING);}
}
if(p.injector){tint({.8f,.62f,.26f});cylinder(.006f,p.size.y*.45f,p.size.y*.5f+.025f);glPushMatrix();glTranslatef(0,p.size.y*.5f+.025f,0);tint({.2f,.25f,.3f});box({.022f,.016f,.016f});glPopMatrix();}
if(selected&&p.kind!=MachinePartKind::Pipe){glDisable(GL_LIGHTING);glPolygonMode(GL_FRONT_AND_BACK,GL_LINE);tint({1,.78f,.3f});box(machine_part_size(p));glPolygonMode(GL_FRONT_AND_BACK,GL_FILL);glEnable(GL_LIGHTING);}
glPolygonMode(GL_FRONT_AND_BACK,GL_FILL);glEnable(GL_LIGHTING);glPopMatrix();
}
}
struct MachineRenderer3D::Impl {
std::unordered_map<unsigned,PipeVisual> pipes;
PipeVisual ghost,icon_pipe;
std::array<MachinePart,machine_part_kind_count> icon_parts;
std::unique_ptr<sf::RenderTexture> icons;
std::uint64_t frame{},mesh_builds{},atlas_builds{};
Impl(){MachineDocument d;for(int i=0;i<machine_part_kind_count;++i)icon_parts[i]=d.add(MachinePartKind(i),{});icon_pipe.build(icon_parts[int(MachinePartKind::Pipe)]);}
};
MachineRenderer3D::MachineRenderer3D():impl_(std::make_unique<Impl>()){}
MachineRenderer3D::~MachineRenderer3D()=default;
MachineRenderStats MachineRenderer3D::stats()const{return {impl_->mesh_builds,impl_->atlas_builds,impl_->pipes.size()};}
void MachineRenderer3D::draw(sf::RenderWindow& window,const MachineDocument& document,const MachineSnapshot* state,sf::FloatRect viewport,const OrbitCamera& camera,std::uint32_t selected,const MachinePart* ghost,const std::vector<std::uint32_t>& selection){
const auto frame=++impl_->frame;
window.pushGLStates();const auto size=window.getSize();const int x=int(viewport.position.x),y=int(size.y-viewport.position.y-viewport.size.y),w=int(viewport.size.x),h=int(viewport.size.y);
glViewport(x,y,w,h);glEnable(GL_SCISSOR_TEST);glScissor(x,y,w,h);glClearColor(.035f,.047f,.061f,1);glClear(GL_DEPTH_BUFFER_BIT|GL_COLOR_BUFFER_BIT);glEnable(GL_DEPTH_TEST);glDepthFunc(GL_LEQUAL);glDisable(GL_TEXTURE_2D);glDisable(GL_BLEND);glDisable(GL_CULL_FACE);
glEnable(GL_NORMALIZE);glEnable(GL_LIGHTING);glEnable(GL_LIGHT0);glEnable(GL_COLOR_MATERIAL);glColorMaterial(GL_FRONT_AND_BACK,GL_AMBIENT_AND_DIFFUSE);
glMatrixMode(GL_PROJECTION);glLoadIdentity();const double near_clip=.03,half=near_clip*.41421356;glFrustum(-half*w/h,half*w/h,-half,half,near_clip,std::max(250.f,camera.distance*4));
glMatrixMode(GL_MODELVIEW);glLoadIdentity();const GLfloat light[]{-.3f,1,1,0},ambient[]{.35f,.35f,.35f,1};glLightfv(GL_LIGHT0,GL_POSITION,light);glLightModelfv(GL_LIGHT_MODEL_AMBIENT,ambient);
glTranslatef(0,0,-camera.distance);glRotatef(camera.pitch,1,0,0);glRotatef(camera.yaw,0,1,0);glTranslatef(-camera.target.x,-camera.target.y,-camera.target.z);
glDisable(GL_LIGHTING);glDepthMask(GL_FALSE);glBegin(GL_LINES);const float extent=std::max(2.f,std::ceil(camera.distance/2));const float step=extent>5?.5f:.1f;
for(float i=-extent;i<=extent+.001f;i+=step){tint({.1f,.14f,.18f});line({i,0,-extent},{i,0,extent});line({-extent,0,i},{extent,0,i});}tint({.35f,.12f,.12f});line({-extent,0,0},{extent,0,0});tint({.12f,.2f,.4f});line({0,0,-extent},{0,0,extent});glEnd();glDepthMask(GL_TRUE);glEnable(GL_LIGHTING);
glDisable(GL_LIGHTING);glBegin(GL_LINES);for(const auto& link:document.links){const auto domain=machine_link_domain(document,link);if(domain==PortDomain::Gas)continue;const auto color=port_color(domain);glColor3ub(color.r,color.g,color.b);line(machine_port_position(*document.find(link.a.part),link.a.port),machine_port_position(*document.find(link.b.part),link.b.port));}glEnd();glEnable(GL_LIGHTING);
for(auto& p:document.parts){const MachinePartState* s=nullptr;if(state)for(auto& v:state->parts)if(v.id==p.id){s=&v;break;}
std::array<bool,2> sealed{true,true};if(p.kind==MachinePartKind::Pipe)for(auto& link:document.links){if(link.a.part==p.id)sealed[link.a.port]=document.find(link.b.part)->kind==MachinePartKind::Cap;else if(link.b.part==p.id)sealed[link.b.port]=document.find(link.a.part)->kind==MachinePartKind::Cap;}
PipeVisual* visual=nullptr;if(p.kind==MachinePartKind::Pipe){auto& cache=impl_->pipes[p.id];cache.frame=frame;if(!cache.matches(p)){cache.build(p);++impl_->mesh_builds;}visual=&cache;}
draw_part(p,s,p.id==selected||std::find(selection.begin(),selection.end(),p.id)!=selection.end(),false,sealed,visual);}
std::erase_if(impl_->pipes,[&](const auto& entry){return entry.second.frame!=frame;});
if(ghost){PipeVisual* visual=nullptr;if(ghost->kind==MachinePartKind::Pipe){if(!impl_->ghost.matches(*ghost)){impl_->ghost.build(*ghost);++impl_->mesh_builds;}visual=&impl_->ghost;}draw_part(*ghost,nullptr,false,true,{false,false},visual);}
glDisable(GL_SCISSOR_TEST);window.popGLStates();
// popGLStates restores the pre-call GL state, while SFML's state cache was
// changed by pushGLStates. Reconcile them even if this was the first draw.
window.resetGLStates();
}
namespace {
void render_icon(sf::RenderTarget& target,const MachinePart& part,PipeVisual* pipe,sf::FloatRect viewport){
const auto kind=part.kind;auto size=machine_part_size(part);float extent=std::max({size.x,size.y,size.z})*.8f;
if(kind==MachinePartKind::Pipe)extent=.19f;
if(kind==MachinePartKind::Reservoir||kind==MachinePartKind::Cap||kind==MachinePartKind::Junction)extent=.028f;
if(kind==MachinePartKind::Carburetor)extent=.065f;
target.pushGLStates();const auto window_size=target.getSize();const int x=int(viewport.position.x),y=int(window_size.y-viewport.position.y-viewport.size.y),w=int(viewport.size.x),h=int(viewport.size.y);
glViewport(x,y,w,h);glEnable(GL_SCISSOR_TEST);glScissor(x,y,w,h);glClear(GL_DEPTH_BUFFER_BIT);glEnable(GL_DEPTH_TEST);glDisable(GL_TEXTURE_2D);glDisable(GL_BLEND);glDisable(GL_CULL_FACE);glEnable(GL_NORMALIZE);glEnable(GL_LIGHTING);glEnable(GL_LIGHT0);glEnable(GL_COLOR_MATERIAL);glColorMaterial(GL_FRONT_AND_BACK,GL_AMBIENT_AND_DIFFUSE);
glMatrixMode(GL_PROJECTION);glLoadIdentity();glOrtho(-extent*w/h,extent*w/h,-extent,extent,-2,2);glMatrixMode(GL_MODELVIEW);glLoadIdentity();const GLfloat light[]{-.3f,1,1,0},ambient[]{.4f,.4f,.4f,1};glLightfv(GL_LIGHT0,GL_POSITION,light);glLightModelfv(GL_LIGHT_MODEL_AMBIENT,ambient);glRotatef(22,1,0,0);glRotatef(-32,0,1,0);
draw_part(part,nullptr,false,false,{true,true},pipe);glDisable(GL_SCISSOR_TEST);target.popGLStates();
}
}
void MachineRenderer3D::draw_icon(sf::RenderWindow& window,MachinePartKind kind,sf::FloatRect viewport){
constexpr unsigned width=64,height=48;
if(!impl_->icons){
sf::ContextSettings settings;settings.depthBits=24;settings.majorVersion=2;settings.minorVersion=1;
impl_->icons=std::make_unique<sf::RenderTexture>(sf::Vector2u{width*machine_part_kind_count,height},settings);
impl_->icons->clear(sf::Color::Transparent);
for(int i=0;i<machine_part_kind_count;++i)render_icon(*impl_->icons,impl_->icon_parts[i],i==int(MachinePartKind::Pipe)?&impl_->icon_pipe:nullptr,{{float(i*width),0},{float(width),float(height)}});
impl_->icons->display();++impl_->atlas_builds;window.resetGLStates();
}
sf::Sprite sprite(impl_->icons->getTexture(),{{int(kind)*int(width),0},{int(width),int(height)}});
sprite.setPosition(viewport.position);sprite.setScale({viewport.size.x/width,viewport.size.y/height});window.draw(sprite);
}
}

77
src/machine_sensors.cpp Normal file
View File

@@ -0,0 +1,77 @@
#include "FluidSim/Machine.hpp"
#include <algorithm>
#include <cmath>
#include <set>
#include <stdexcept>
namespace fluidsim::port {
namespace {
constexpr const char* keys[]{"pressure","differential_pressure","gas_temperature","mass_flow","crank","throttle_position"};
constexpr const char* names[]{"Pressure / MAP","Differential pressure","Gas temperature / IAT","Total mass flow","Crank speed / position","Throttle position"};
static_assert(std::size(keys)==machine_sensor_kind_count&&std::size(names)==machine_sensor_kind_count);
void require(bool condition,const std::string& message){if(!condition)throw std::invalid_argument(message);}
}
const char* machine_sensor_key(MachineSensorKind kind){require(int(kind)>=0&&int(kind)<machine_sensor_kind_count,"Unknown sensor kind");return keys[int(kind)];}
const char* machine_sensor_name(MachineSensorKind kind){require(int(kind)>=0&&int(kind)<machine_sensor_kind_count,"Unknown sensor kind");return names[int(kind)];}
const char* machine_sensor_status_name(MachineSensorStatus status){switch(status){case MachineSensorStatus::Valid:return "Valid / ideal pickup";case MachineSensorStatus::MissingHost:return "Unresolved host";case MachineSensorStatus::IncompatibleHost:return "Incompatible host";case MachineSensorStatus::Fault:return "Measurement fault";}return "Unknown sensor status";}
unsigned machine_sensor_output_count(MachineSensorKind kind){machine_sensor_key(kind);return kind==MachineSensorKind::Crank?3:1;}
MachinePortDescriptor machine_sensor_output(const MachineSensor& sensor,unsigned index){
require(index<machine_sensor_output_count(sensor.kind),"Unknown sensor output");
MachinePortDescriptor p;p.key="value";p.name=machine_sensor_name(sensor.kind);p.domain=PortDomain::Number;p.direction=PortDirection::Output;p.cardinality=PortCardinality::Multiple;
switch(sensor.kind){
case MachineSensorKind::Pressure:p.quantity=SignalQuantity::Pressure;p.pressure_reference=PressureReference::Absolute;p.minimum=0;break;
case MachineSensorKind::DifferentialPressure:p.quantity=SignalQuantity::Pressure;p.pressure_reference=PressureReference::Differential;break;
case MachineSensorKind::GasTemperature:p.quantity=SignalQuantity::Temperature;p.minimum=0;break;
case MachineSensorKind::MassFlow:p.quantity=SignalQuantity::MassFlow;break;
case MachineSensorKind::ThrottlePosition:p.quantity=SignalQuantity::Fraction;p.minimum=0;p.maximum=1;break;
case MachineSensorKind::Crank:
if(index==0){p.key="speed";p.name="Angular speed";p.quantity=SignalQuantity::AngularSpeed;}
else if(index==1){p.key="angle";p.name="Crank angle";p.quantity=SignalQuantity::Angle;p.minimum=0;p.maximum=2*double(pi);}
else {p.key="synchronized";p.name="Ideal pickup synchronized";p.domain=PortDomain::Boolean;}
break;
}
validate_port_descriptor(p);return p;
}
bool machine_sensor_supported(const MachinePart& part,MachineSensorKind kind,bool reference){
if(reference)return kind==MachineSensorKind::DifferentialPressure&&machine_quantity_supported(part,MachineQuantity::Pressure);
switch(kind){
case MachineSensorKind::Pressure:case MachineSensorKind::DifferentialPressure:return machine_quantity_supported(part,MachineQuantity::Pressure);
case MachineSensorKind::GasTemperature:return machine_quantity_supported(part,MachineQuantity::Temperature);
// A volume has no unique through-flow direction. Use a named pipe tap.
case MachineSensorKind::MassFlow:return part.kind==MachinePartKind::Pipe;
case MachineSensorKind::Crank:return part.kind==MachinePartKind::Shaft;
case MachineSensorKind::ThrottlePosition:return part.kind==MachinePartKind::Throttle||part.kind==MachinePartKind::Carburetor;
}return false;
}
MachineSensorStatus machine_sensor_binding(const MachineDocument& doc,const MachineSensor& sensor){
const auto target=doc.find(sensor.target.part);if(!target)return MachineSensorStatus::MissingHost;
if(!machine_sensor_supported(*target,sensor.kind))return MachineSensorStatus::IncompatibleHost;
if(sensor.kind==MachineSensorKind::DifferentialPressure){const auto reference=doc.find(sensor.reference.part);if(!reference)return MachineSensorStatus::MissingHost;if(!machine_sensor_supported(*reference,sensor.kind,true))return MachineSensorStatus::IncompatibleHost;}
return MachineSensorStatus::Valid;
}
std::optional<Vec3> machine_sensor_position(const MachineDocument& doc,const MachineSensorTap& tap){
const auto host=doc.find(tap.part);if(!host)return {};
if(host->kind!=MachinePartKind::Pipe)return machine_world_point(*host,{0,machine_part_size(*host).y*.5f,0});
return GeometryPath{machine_pipe_path(*host)}.position(machine_pipe_length(*host)*tap.position);
}
MachineSensor* MachineDocument::find_sensor(std::uint32_t id){for(auto& s:sensors)if(s.id==id)return &s;return nullptr;}
const MachineSensor* MachineDocument::find_sensor(std::uint32_t id)const{for(const auto& s:sensors)if(s.id==id)return &s;return nullptr;}
void MachineDocument::remove_sensor(std::uint32_t id){for(auto& node:logic.nodes)if(node.kind==LogicNodeKind::Sensor&&node.sensor==id){if(const auto sensor=find_sensor(id))node.binding_label=sensor->name;node.sensor=0;}std::erase_if(sensors,[&](const auto& s){return s.id==id;});}
MachineSensor& MachineDocument::add_sensor(MachineSensorKind kind,std::uint32_t host,float position){
const auto part=find(host);require(part&&machine_sensor_supported(*part,kind),"This part does not support that sensor");
require(std::isfinite(position)&&position>=0&&position<=1,"Sensor pipe position must be in 0..1");
MachineSensor sensor;sensor.id=next_sensor_id++;sensor.kind=kind;sensor.target={host,position,part->name};sensor.name=std::string(machine_sensor_name(kind))+" / "+part->name;if(sensor.name.size()>120)sensor.name.resize(120);
sensors.push_back(std::move(sensor));return sensors.back();
}
void validate_machine_sensors(const MachineDocument& doc){
require(doc.sensors.size()<=1024,"Machine exceeds 1024 sensor attachments");std::set<std::uint32_t> ids;
for(const auto& s:doc.sensors){
require(s.id>0&&s.id<1000000000&&ids.insert(s.id).second,"Invalid or duplicate sensor ID");machine_sensor_key(s.kind);
require(!s.name.empty()&&s.name.size()<=120,"Sensor name must have 1..120 characters");
for(const auto* tap:{&s.target,&s.reference})require(tap->part<1000000000&&tap->label.size()<=120&&std::isfinite(tap->position)&&tap->position>=0&&tap->position<=1,"Invalid sensor host or pipe position");
require(std::isfinite(s.angle_offset)&&std::abs(s.angle_offset)<=1000000,"Invalid crank pickup offset");
for(unsigned i=0;i<machine_sensor_output_count(s.kind);++i)validate_port_descriptor(machine_sensor_output(s,i));
}
require(doc.next_sensor_id>0&&doc.next_sensor_id<1000000000&&(ids.empty()||doc.next_sensor_id>*ids.rbegin()),"Invalid next sensor ID");
}
}

382
src/machine_simulation.cpp Normal file
View File

@@ -0,0 +1,382 @@
#include "FluidSim/Machine.hpp"
#include "FluidSim/Fluid.hpp"
#include "FluidSim/PipeProfile.hpp"
#include "FluidSim/Solver.hpp"
#include "FluidSim/Ecu.hpp"
#include <algorithm>
#include <deque>
#include <functional>
#include <unordered_map>
#include <tuple>
namespace fluidsim::port {
using namespace machine_math;
namespace {
struct Actuator final:Component {
std::function<void(float)> advance;
std::vector<Port*> ports()override{return {};}
void prepare_step(float dt)override{advance(dt);}
void update_state(float)override{}
};
FourStrokeCylinder::CamProfile cam(float open,float close,float lift){
FourStrokeCylinder::CamProfile c;c.points.push_back({0,0});
if(open>0)c.points.push_back({open,0});
for(int i=1;i<32;++i){const float f=float(i)/32,s=std::sin(pi*f);c.points.push_back({std::lerp(open,close,f),lift*s*s});}
c.points.push_back({close,0});if(close<720)c.points.push_back({720,0});return c;
}
float bore_area(const MachinePart& p){return pi*.25f*p.cylinder.bore*p.cylinder.bore;}
float pump_volume(const MachinePart& p,float angle){
const float radius=p.cylinder.stroke*.5f,rod=p.cylinder.rod,s=std::sin(angle);
const float travel=radius*(1-std::cos(angle))+rod-std::sqrt(rod*rod-radius*radius*s*s);
return bore_area(p)*(p.cylinder.stroke/(p.cylinder.compression-1)+travel);
}
}
struct MachineSimulation::Impl {
struct Gate;
struct ShaftRuntime {std::uint32_t id{};std::unique_ptr<Crankshaft> timing;double previous_speed{};};
struct Part {
const MachinePart* definition{};
int pipe{-1};
std::unique_ptr<Volume0D> volume;
std::unique_ptr<FourStrokeCylinder> cylinder;
Ecu ecu;
std::unique_ptr<SensorEcu> sensor_ecu;
std::unique_ptr<FuelInjector> injector;
Part* injector_ecu{};
CrankIgnitionClock ignition_clock;
Part* controller{};
ShaftRuntime* shaft{};double gas_torque{};
std::unique_ptr<Carburetor> carburetor;
bool fuel_source{};
std::optional<bool> signal_enable,signal_starter;
std::optional<double> signal_target;
std::optional<float> signal_opening;
Gate* inlet{};
FourStrokeCylinder::CamProfile intake_cam,exhaust_cam;
};
struct Gate {Part* part;unsigned port;int pipe;bool left;float area{},maximum{};Port* component_port{};float throat_area{};};
struct Mouth {std::size_t index;std::unique_ptr<OutdoorAudio> audio;};
struct SensorTap {Part* host{};int cell{};};
struct Sensor {const MachineSensor* definition{};SensorTap target,reference;MachineSensorState state;std::array<MachinePortDescriptor,3> outputs;};
MachineDocument document;
std::vector<Pipe1D> pipes;
BoundarySystem boundaries{pipes};
Solver solver{pipes,boundaries};
Actuator actuator;
MechanicalSystem mechanics;
std::vector<std::unique_ptr<ShaftRuntime>> shafts;
std::unordered_map<std::uint32_t,ShaftRuntime*> shaft_by_id;
std::vector<std::unique_ptr<Part>> parts;
std::unordered_map<std::uint32_t,Part*> by_id;
std::deque<Gate> gates;
std::vector<Part*> cylinders,controllers,pumps,carburetors;
std::vector<Part*> motors,brakes;
std::vector<Gate*> cylinder_gates,throttle_gates,check_gates;
std::vector<Mouth> mouths;
std::vector<Sensor> sensors;
std::unordered_map<std::uint32_t,std::size_t> sensor_by_id;
std::unique_ptr<MachineLogicRuntime> logic;
float dt{},pedal{.2f};bool starter{};
double time{};
explicit Impl(const MachineDocument& d,int rate):document(d),dt(1.f/std::max(1,rate)){
validate_machine(document);if(document.parts.empty()&&document.logic.nodes.empty())throw std::invalid_argument("Place some parts or logic nodes before testing");
for(const auto& p:document.parts)if(p.kind==MachinePartKind::Shaft){
bool engine=false;for(const auto& c:document.parts)if(c.kind==MachinePartKind::Cylinder&&machine_shaft_for(document,c.id)==p.id)engine=true;
const auto& q=p.shaft;mechanics.add_shaft(p.id,{q.inertia,q.initial_speed,q.initial_angle,q.friction_constant,q.friction_viscous,q.friction_quadratic,engine});
auto runtime=std::make_unique<ShaftRuntime>();runtime->id=p.id;runtime->timing=std::make_unique<Crankshaft>();runtime->timing->angle=runtime->timing->previous_angle=std::fmod(q.initial_angle,4*pi);runtime->timing->omega=q.initial_speed;runtime->previous_speed=q.initial_speed;shaft_by_id[p.id]=runtime.get();shafts.push_back(std::move(runtime));
}
for(const auto& p:document.parts){const auto shaft=machine_shaft_for(document,p.id);
if((machine_rotational_part(p.kind)&&p.kind!=MachinePartKind::Shaft)||p.kind==MachinePartKind::Cylinder||p.kind==MachinePartKind::PistonPump){if(!shaft)throw std::invalid_argument(p.name+": connect its mechanical port to a shaft");}
if(p.kind==MachinePartKind::Flywheel)mechanics.add_flywheel(p.id,shaft,p.shaft.inertia);
else if(p.kind==MachinePartKind::DriveMotor)mechanics.add_drive(p.id,shaft,{p.drive.speed_control?DriveMode::Speed:DriveMode::Torque,p.drive.speed_control?p.drive.target_speed:p.drive.torque,p.drive.enabled});
else if(p.kind==MachinePartKind::Brake)mechanics.add_brake(p.id,shaft,{p.brake.constant,p.brake.viscous,p.brake.quadratic,p.brake.enabled});
}
actuator.advance=[this](float){update_check_valves();};solver.minimum_substeps=1;
parts.reserve(d.parts.size());
for(auto& p:document.parts){
auto runtime=std::make_unique<Part>();auto& r=*runtime;r.definition=&p;const auto shaft_id=machine_shaft_for(document,p.id);if(shaft_id)r.shaft=shaft_by_id.at(shaft_id);
if(p.kind==MachinePartKind::Pipe){
// Integrate the per-point profile over uniform arclength cells.
// Edited route knots do not force tiny fluid substeps.
r.pipe=int(pipes.size());const auto profile=machine_pipe_sections(p);
pipes.push_back(make_uniform_profiled_pipe(profile,1/d.cells_per_meter));auto& pipe=pipes.back();pipe.solver=d.solver;
for(auto& cell:pipe.cells){cell.rho=p.pressure/(gas_constant*p.temperature);cell.energy=p.pressure/pipe_gm1;}
}else if(p.kind==MachinePartKind::Cylinder){
r.cylinder=std::make_unique<FourStrokeCylinder>();auto& c=*r.cylinder;static_cast<CylinderConfig&>(c)=p.cylinder;
c.phase=p.phase_degrees*pi/180;r.intake_cam=p.intake_profile.points.empty()?cam(p.intake_open,p.intake_close,p.maximum_lift):p.intake_profile;r.exhaust_cam=p.exhaust_profile.points.empty()?cam(p.exhaust_open,p.exhaust_close,p.maximum_lift):p.exhaust_profile;
c.set_valve_profiles(r.intake_cam,p.valve,r.exhaust_cam,p.exhaust_valve);
r.ecu.ignition_map={{0,p.cylinder.spark_advance},{20000,p.cylinder.spark_advance}};r.ecu.stoichiometric_afr=r.ecu.power_afr=p.cylinder.afr;
r.ecu.rev_limit_enabled=false;r.ecu.overrun_fuel_cut_enabled=false;r.ecu.overrun_ignition_cut_enabled=false;c.ecu=&r.ecu;
c.transported_fuel=true; // No implicit injector. Only an assigned injection ECU can enable dosing.
c.initialize(*r.shaft->timing);c.volume=c.compute_volume(r.shaft->timing->angle+c.phase);c.air_mass=p.pressure*c.volume/(gas_constant*p.temperature);c.energy=p.pressure*c.volume/(gamma-1);c.update_state(0);solver.add_component(c);
}else if(p.kind==MachinePartKind::Ecu){static_cast<EcuConfig&>(r.ecu)=p.ecu;if(p.sensor_ecu.enabled)r.sensor_ecu=std::make_unique<SensorEcu>(machine_sensor_ecu_config(p));}
else if(!machine_rotational_part(p.kind)&&p.kind!=MachinePartKind::Reservoir&&p.kind!=MachinePartKind::Cap&&!(p.kind==MachinePartKind::Junction&&p.junction_method)){
const float v=p.kind==MachinePartKind::PistonPump?pump_volume(p,r.shaft->timing->angle+p.phase_degrees*pi/180):machine_part_volume(p);
r.volume=std::make_unique<Volume0D>(v,p.pressure,p.temperature);solver.add_component(*r.volume);
if(p.kind==MachinePartKind::Carburetor)r.carburetor=std::make_unique<Carburetor>(p.diameter,p.carburetor);
}
if(p.injector){const auto& c=*p.injector;FuelInjectorConfig config;config.supply=c.finite_supply?FuelInjectorConfig::Supply::FiniteRail:FuelInjectorConfig::Supply::IdealRail;config.maximum_mass_flow=c.maximum_flow;config.initial_fuel_mass=c.initial_mass;config.fuel_temperature=c.temperature;r.injector=std::make_unique<FuelInjector>(config);}
if(r.cylinder)cylinders.push_back(&r);
if(r.carburetor)carburetors.push_back(&r);
if(p.kind==MachinePartKind::Ecu)controllers.push_back(&r);
if(p.kind==MachinePartKind::PistonPump)pumps.push_back(&r);
if(p.kind==MachinePartKind::DriveMotor)motors.push_back(&r);
if(p.kind==MachinePartKind::Brake)brakes.push_back(&r);
by_id[p.id]=&r;parts.push_back(std::move(runtime));
}
if(pipes.empty()&&shafts.empty()&&document.logic.nodes.empty())throw std::invalid_argument("Add a pipe, mechanical shaft or logic graph to test");
for(auto& link:document.links)if(machine_link_domain(document,link)==PortDomain::EcuAssignment){auto target=by_id.at(link.b.part),control=by_id.at(link.a.part);target->controller=control;
if(target->cylinder){if(control->shaft&&control->shaft!=target->shaft)throw std::invalid_argument(control->definition->name+": controlled cylinders must share a shaft");control->shaft=target->shaft;target->cylinder->ecu=&control->ecu;target->cylinder->transported_fuel=bool(control->sensor_ecu)||!control->definition->injection_enabled;target->fuel_source=!control->sensor_ecu&&control->definition->injection_enabled;if(control->sensor_ecu)target->cylinder->ignition_command=FourStrokeCylinder::IgnitionCommand{};}
}
// Legacy carb metering can use the unique shaft reached through its
// intake network; a sensor-driven ECU will replace this ideal reference.
const auto gas_reference=[&](Part* source){std::vector<std::uint32_t> visited{source->definition->id};ShaftRuntime* found=nullptr;for(std::size_t i=0;i<visited.size();++i)for(const auto& link:document.links)if(machine_link_domain(document,link)==PortDomain::Gas){const auto other=link.a.part==visited[i]?link.b:link.b.part==visited[i]?link.a:MachinePort{};if(!other.part||std::find(visited.begin(),visited.end(),other.part)!=visited.end())continue;auto target=by_id.at(other.part);if(target->cylinder){if(other.port==0){if(found&&found!=target->shaft)return static_cast<ShaftRuntime*>(nullptr);found=target->shaft;}continue;}visited.push_back(other.part);}return found;};
for(auto controller:controllers)if(!controller->shaft){for(const auto& link:document.links)if(link.kind==MachineConnectionKind::Control&&link.a.part==controller->definition->id){auto reference=gas_reference(by_id.at(link.b.part));if(reference){if(controller->shaft&&controller->shaft!=reference)throw std::invalid_argument("ECU targets refer to multiple shafts");controller->shaft=reference;}}}
for(auto& part:parts)if(part->injector){const auto found=by_id.find(part->definition->injector->ecu);if(found==by_id.end()||!found->second->sensor_ecu||found->second->definition->sensor_ecu.strategy==SensorEcuConfig::Strategy::Ignition)throw std::invalid_argument(part->definition->name+": bind its injector to a sensor-driven EFI ECU");part->injector_ecu=found->second;}
for(auto controller:controllers)if(controller->sensor_ecu){unsigned targets=0,doses=0;for(auto target:cylinders)if(target->controller==controller)++targets;for(const auto& part:parts)if(part->injector_ecu==controller)doses+=part->definition->injector->cylinders;if(!targets)throw std::invalid_argument(controller->definition->name+": assign controlled cylinders");if(controller->definition->sensor_ecu.strategy!=SensorEcuConfig::Strategy::Ignition&&doses!=targets)throw std::invalid_argument(controller->definition->name+": injector cylinder doses must match assigned cylinders");}
for(auto carb:carburetors)carb->shaft=carb->controller?carb->controller->shaft:gas_reference(carb);
// This reachability flag is diagnostic only; carburetor fuel still has to travel through the gas solver.
for(auto r:cylinders)r->fuel_source=machine_has_fuel_source(document,r->definition->id);
std::vector<std::array<bool,2>> claimed(pipes.size(),{false,false});
std::unordered_map<std::uint32_t,std::vector<BoundarySystem::JunctionFace>> junctions;
const auto face=[&](MachinePort e){auto& r=*by_id.at(e.part);const auto path=machine_pipe_path(*r.definition);const auto axis=e.port?subtract(path[path.size()-2],path.back()):subtract(path[1],path.front());const auto dir=unit(axis);return BoundarySystem::JunctionFace{r.pipe,e.port==0,{dir.x,dir.y,dir.z}};};
for(auto& link:document.links){
if(machine_link_domain(document,link)!=PortDomain::Gas)continue;
MachinePort a=link.a,b=link.b;if(by_id.at(a.part)->pipe<0)std::swap(a,b);
auto& pipe_part=*by_id.at(a.part);auto& other=*by_id.at(b.part);const auto& p=*other.definition;
const auto& pipe=pipes[pipe_part.pipe];const bool left=a.port==0;
const float area=left?pipe.cell_areas.front():pipe.cell_areas.back();claimed[pipe_part.pipe][a.port]=true;
if(other.pipe>=0){
claimed[other.pipe][b.port]=true;
auto ends={face(a),face(b)};
boundaries.add_junction(ends,link.kind==MachineConnectionKind::Legacy?BoundarySystem::JunctionMethod::LegacyRusanov:BoundarySystem::JunctionMethod::Characteristic,link.loss);
}else if(p.kind==MachinePartKind::Cap)boundaries.add_closed_end(pipe_part.pipe,left);
else if(p.kind==MachinePartKind::Reservoir){
boundaries.add_radiating_open_end(pipe_part.pipe,left,p.pressure,area,p.temperature);
OutdoorAudioConfig config;config.intake_position=machine_port_position(p,b.port);config.ground_y=-.02f;config.listener={1,.7f,1.5f};
auto audio=std::make_unique<OutdoorAudio>(rate,config);audio->set_scene(d.scene);audio->set_source_enabled(1,false);audio->set_source_enabled(2,false);audio->set_source_enabled(3,false);
mouths.push_back({mouths.size(),std::move(audio)});
}else if(p.kind==MachinePartKind::Junction&&p.junction_method)junctions[p.id].push_back(face(a));
else{
Port* component_port=other.cylinder?(b.port?&other.cylinder->exhaust:&other.cylinder->intake):&other.volume->create_port();
gates.push_back({&other,b.port,pipe_part.pipe,left,area,area,component_port});auto& gate=gates.back();
if(other.cylinder)cylinder_gates.push_back(&gate);
else if((p.kind==MachinePartKind::Throttle||p.kind==MachinePartKind::Carburetor)&&b.port==0){gate.throat_area=std::min(area,pi*.25f*p.diameter*p.diameter);throttle_gates.push_back(&gate);if(other.carburetor)other.inlet=&gate;}
else if(p.kind==MachinePartKind::CheckValve||p.kind==MachinePartKind::PistonPump){const float diameter=p.kind==MachinePartKind::PistonPump?(b.port?p.cylinder.exhaust_valve_diameter:p.cylinder.intake_valve_diameter):p.diameter;
const int count=p.kind==MachinePartKind::PistonPump?(b.port?p.cylinder.exhaust_valve_count:p.cylinder.intake_valve_count):1;gate.throat_area=std::min(area,pi*.25f*diameter*diameter*count);check_gates.push_back(&gate);}
const float cd=link.discharge*((p.kind==MachinePartKind::Throttle||p.kind==MachinePartKind::Carburetor||p.kind==MachinePartKind::CheckValve||other.cylinder||p.kind==MachinePartKind::PistonPump)?p.discharge:1.f);
if(link.kind==MachineConnectionKind::Inertance)boundaries.add_orifice_with_inertance(*component_port,pipe_part.pipe,left,gate.area,cd,link.inertance_length,link.loss);
else boundaries.add_orifice(*component_port,pipe_part.pipe,left,gate.area,cd);
}
}
for(auto& [id,faces]:junctions){auto& p=*by_id.at(id)->definition;
if(faces.size()==1)boundaries.add_closed_end(faces.front().pipe,faces.front().left);
else boundaries.add_junction(faces,p.junction_method==2?BoundarySystem::JunctionMethod::LegacyRusanov:BoundarySystem::JunctionMethod::Characteristic,p.turning_loss);
}
for(std::size_t i=0;i<pipes.size();++i)for(unsigned end=0;end<2;++end)if(!claimed[i][end])boundaries.add_closed_end(int(i),end==0);
boundaries.validate_topology();
if(!check_gates.empty())solver.add_component(actuator);
const auto sensor_tap=[&](const MachineSensorTap& tap){
SensorTap result;if(const auto found=by_id.find(tap.part);found!=by_id.end())result.host=found->second;
if(result.host&&result.host->pipe>=0){const auto& pipe=pipes[result.host->pipe];double length=0;for(float dx:pipe.cell_lengths)length+=dx;const auto position=length*tap.position;double end=0;
result.cell=int(pipe.cells.size())-1;for(std::size_t i=0;i<pipe.cells.size();++i){end+=pipe.cell_lengths[i];if(position<end){result.cell=int(i);break;}}
}return result;
};
sensors.reserve(document.sensors.size());for(const auto& s:document.sensors){Sensor sensor;sensor.definition=&s;sensor.target=sensor_tap(s.target);sensor.reference=sensor_tap(s.reference);sensor.state.id=s.id;sensor.state.count=machine_sensor_output_count(s.kind);sensor.state.status=machine_sensor_binding(document,s);for(unsigned i=0;i<sensor.state.count;++i)sensor.outputs[i]=machine_sensor_output(s,i);sensors.push_back(std::move(sensor));}
sample_sensors();
for(std::size_t i=0;i<sensors.size();++i)sensor_by_id.emplace(sensors[i].state.id,i);
logic=std::make_unique<MachineLogicRuntime>(document,[this](std::uint32_t id){const auto found=sensor_by_id.find(id);return found==sensor_by_id.end()?MachineSensorState{}:sensors[found->second].state;});
for(const auto controller:controllers)if(controller->sensor_ecu&&!std::any_of(logic->ecu_inputs().begin(),logic->ecu_inputs().end(),[&](const auto& input){return input.part==controller->definition->id;}))throw std::invalid_argument(controller->definition->name+": add its ECU connector in Logic");
latch_logic(false);sample_sensors();
}
float throttle_opening(const Part& part)const{const auto& p=*part.definition;return part.signal_opening?*part.signal_opening:part.controller?part.controller->ecu.throttle_opening():p.pedal_control?pedal:p.opening;}
void latch_logic(bool evaluate=true){
if(evaluate)logic->evaluate(time,dt);
for(const auto& command:logic->commands()){auto& target=*by_id.at(command.part);const bool valid=command.value.validity==SignalValidity::Valid;
switch(command.command){
case LogicCommand::MotorEnable:case LogicCommand::BrakeEnable:target.signal_enable=valid&&std::get<bool>(command.value.value);break;
case LogicCommand::MotorSpeed:case LogicCommand::MotorTorque:target.signal_target=valid?std::get<double>(command.value.value):0.;break;
case LogicCommand::ThrottleOpening:target.signal_opening=valid?float(std::get<double>(command.value.value)):0.f;break;
case LogicCommand::Starter:target.signal_starter=valid&&std::get<bool>(command.value.value);break;
}
}
for(auto motor:motors)if(motor->signal_enable||motor->signal_target){const auto& p=*motor->definition;mechanics.set_drive(p.id,{p.drive.speed_control?DriveMode::Speed:DriveMode::Torque,motor->signal_target.value_or(p.drive.speed_control?p.drive.target_speed:p.drive.torque),motor->signal_enable.value_or(p.drive.enabled)});}
for(auto brake:brakes)if(brake->signal_enable){const auto& p=*brake->definition;mechanics.set_brake(p.id,{p.brake.constant,p.brake.viscous,p.brake.quadratic,*brake->signal_enable});}
if(evaluate)for(const auto& input:logic->ecu_inputs()){
auto& controller=*by_id.at(input.part);auto speed=input.speed;const auto measured=ecu_crank_sample(speed,input.angle,time);if(!measured||!controller.shaft||measured->shaft!=controller.shaft->id)speed.validity=SignalValidity::Fault;const auto& state=controller.sensor_ecu->sample(SensorEcuMeasurements{speed,input.angle,input.tps,input.map,input.iat},time);
for(auto target:cylinders)if(target->controller==&controller){
const auto crank=ecu_crank_sample(input.speed,input.angle,time);
const bool bound=crank&&crank->shaft==target->shaft->id;
CrankIgnitionClock::Result event;
if(bound&&!state.sensor_fault)event=target->ignition_clock.sample(input.speed,input.angle,time,state.spark_advance,target->definition->ignition_tdc_degrees);else target->ignition_clock.reset();
const bool cut=!bound||state.sensor_fault||state.rev_limited||!event.valid;
target->cylinder->ignition_command=FourStrokeCylinder::IgnitionCommand{event.valid&&event.sparks>0,cut};
}
}
}
void deliver_injectors(){for(auto& target:parts)if(target->injector){const auto& state=target->injector_ecu->sensor_ecu->state();auto command=SignalValue::number(state.fuel_mass_flow*target->definition->injector->cylinders,time);if(state.sensor_fault)command.validity=SignalValidity::Fault;target->injector->deliver(*target->volume,command,time,dt);}}
double gas_sensor_value(const SensorTap& tap,MachineSensorKind kind)const{
const auto& r=*tap.host;
if(r.pipe>=0){const auto& pipe=pipes[r.pipe];const auto cell=pipe.state(tap.cell);if(kind==MachineSensorKind::MassFlow)return double(cell.rho)*cell.velocity*pipe.cell_areas[tap.cell];if(kind==MachineSensorKind::GasTemperature)return cell.pressure/(cell.rho*gas_constant);return cell.pressure;}
if(r.cylinder)return kind==MachineSensorKind::GasTemperature?r.cylinder->temperature():r.cylinder->pressure;
if(r.volume)return kind==MachineSensorKind::GasTemperature?r.volume->temperature():r.volume->pressure();
return kind==MachineSensorKind::GasTemperature?r.definition->temperature:r.definition->pressure;
}
void sample_sensors(){
for(auto& sensor:sensors){const auto& definition=*sensor.definition;auto& state=sensor.state;
// Bindings cannot change during Test; a numeric fault may recover.
const bool bound=state.status==MachineSensorStatus::Valid||state.status==MachineSensorStatus::Fault;
state.timing_shaft=0;state.unwrapped_angle=0;
for(unsigned i=0;i<state.count;++i){state.outputs[i]=definition.kind==MachineSensorKind::Crank&&i==2?SignalValue::boolean(false,time):SignalValue::number(0,time);state.outputs[i].validity=SignalValidity::Missing;}
if(!bound)continue;
state.status=MachineSensorStatus::Valid;
const auto& host=*sensor.target.host;
if(definition.kind==MachineSensorKind::Crank){const auto& shaft=mechanics.shaft(host.definition->id);state.timing_shaft=shaft.id;state.unwrapped_angle=shaft.angle+definition.angle_offset;double angle=std::fmod(state.unwrapped_angle,2*double(pi));if(angle<0)angle+=2*double(pi);
state.outputs[0]=SignalValue::number(shaft.speed,time);state.outputs[1]=SignalValue::number(angle,time);state.outputs[2]=SignalValue::boolean(true,time);
const SignalTiming timing{definition.id,shaft.id,time,state.unwrapped_angle,shaft.speed};
for(auto& output:state.outputs)output.timing=timing;
}else if(definition.kind==MachineSensorKind::ThrottlePosition)state.outputs[0]=SignalValue::number(throttle_opening(host),time);
else{auto value=gas_sensor_value(sensor.target,definition.kind);if(definition.kind==MachineSensorKind::DifferentialPressure)value-=gas_sensor_value(sensor.reference,MachineSensorKind::Pressure);state.outputs[0]=SignalValue::number(value,time);}
for(unsigned i=0;i<state.count;++i)if(!signal_value_valid_for(state.outputs[i],sensor.outputs[i]))state.status=MachineSensorStatus::Fault;
if(state.status==MachineSensorStatus::Fault){state.timing_shaft=0;for(unsigned i=0;i<state.count;++i)state.outputs[i].validity=SignalValidity::Fault;}
}
}
// Shaft, ECU, cams and combustion advance at the audio sample rate, as in
// FourStrokeScenario. Gas transport and pressure-operated checks retain CFL substeps.
void advance_controls(float sub_dt){
const auto primary=machine_primary_shaft(document);
for(const auto& shaft:shafts){const auto& part=*by_id.at(shaft->id);const bool enabled=part.signal_starter.value_or(starter&&shaft->id==primary);if(enabled){const auto& state=mechanics.shaft(shaft->id);if(state.speed>=0&&state.speed<200)mechanics.add_torque(shaft->id,part.definition->shaft.starter_torque);}}
mechanics.step(sub_dt);
for(auto& shaft:shafts){auto& crank=*shaft->timing;shaft->previous_speed=crank.omega;const auto& state=mechanics.shaft(shaft->id);crank.previous_angle=crank.angle;crank.angle=std::fmod(state.angle,4*pi);crank.omega=float(state.speed);crank.inertia=float(state.inertia);const float output=float(state.external_torque+state.friction_torque);crank.record_output(output,output*crank.omega);}
const auto rpm_of=[](const Part* p){return p->shaft?float(p->shaft->timing->omega*60/(2*pi)):0.f;};
for(auto r:controllers)if(!r->sensor_ecu)r->ecu.step(sub_dt,pedal,rpm_of(r));
for(auto r:cylinders){if(!r->controller)r->ecu.step(sub_dt,pedal,rpm_of(r));r->cylinder->pre_step(*r->shaft->timing,sub_dt,pedal);r->gas_torque=r->shaft->timing->take_torque();mechanics.add_torque(r->shaft->id,r->gas_torque);}
for(auto r:carburetors){const auto& p=*r->definition;float flow=0,upstream=p.pressure,density=ambient_density;if(auto g=r->inlet){const auto& pipe=pipes[g->pipe];const auto state=pipe.state(g->left?0:int(pipe.cells.size()-1));upstream=state.pressure;density=state.rho;flow=std::max(0.f,g->component_port->mass_flow);}
const float opening=throttle_opening(*r);
r->carburetor->step(sub_dt,pedal,opening,rpm_of(r),flow,density,upstream,r->volume->pressure());r->volume->add_fuel_mass(r->carburetor->fuel_flow()*sub_dt);
}
for(auto r:pumps){const auto& p=*r->definition;
const float theta=r->shaft->timing->angle+p.phase_degrees*pi/180,radius=p.cylinder.stroke*.5f,sine=std::sin(theta),cosine=std::cos(theta),rod=p.cylinder.rod;
const float travel_derivative=radius*sine+radius*radius*sine*cosine/std::sqrt(std::max(1e-12f,rod*rod-radius*radius*sine*sine));
r->gas_torque=(r->volume->pressure()-101325)*bore_area(p)*travel_derivative;mechanics.add_torque(r->shaft->id,r->gas_torque);
r->volume->volume_change_rate=(pump_volume(p,theta)-r->volume->volume())/sub_dt;
}
for(auto g:cylinder_gates)g->area=std::min(g->maximum,g->part->cylinder->valve_area(0,g->port==0));
for(auto g:throttle_gates){const float opening=throttle_opening(*g->part);g->area=g->throat_area*(1-std::cos(opening*pi*.5f));}
}
void update_check_valves(){
for(auto g:check_gates){const auto& p=*g->part->definition;const auto& pipe=pipes[g->pipe];const auto state=pipe.state(g->left?0:int(pipe.cells.size()-1));
const float internal=g->part->volume->pressure(),delta=g->port==0?state.pressure-internal:internal-state.pressure;
g->area=delta>p.cracking_pressure?g->throat_area:0;
}
}
AudioFrame process(){latch_logic();advance_controls(dt);deliver_injectors();solver.step(dt);time+=dt;sample_sensors();AudioFrame result;
for(auto& mouth:mouths){const auto s=mouth.audio->process(ambient_density*boundaries.open_end_volume_flow(mouth.index),0);result.left+=s.left;result.right+=s.right;}
return result;
}
double rotational_reading(std::uint32_t part,MachineQuantity quantity)const{
if(!part)part=machine_primary_shaft(document);
if(!part)return std::numeric_limits<double>::quiet_NaN();
const auto& r=*by_id.at(part);if(!r.shaft)return std::numeric_limits<double>::quiet_NaN();const auto& shaft=mechanics.shaft(r.shaft->id);if(quantity==MachineQuantity::Rpm)return shaft.speed*60/(2*pi);
double torque=shaft.net_torque,power=torque*(shaft.speed+r.shaft->previous_speed)*.5;
if(r.definition->kind==MachinePartKind::DriveMotor){const auto& drive=mechanics.drive(part);torque=drive.torque;power=drive.power;}
else if(r.definition->kind==MachinePartKind::Brake){const auto& brake=mechanics.brake(part);torque=brake.torque;power=brake.power;}
else if(r.definition->kind==MachinePartKind::Flywheel){torque*=r.definition->shaft.inertia/shaft.inertia;power*=r.definition->shaft.inertia/shaft.inertia;}
else if(r.cylinder||r.definition->kind==MachinePartKind::PistonPump){torque=r.gas_torque;power=torque*(shaft.angle-shaft.previous_angle)/dt;}
return quantity==MachineQuantity::Torque?torque:power;
}
MachineMeasurement measure()const{
MachineMeasurement result;result.time=time;result.count=document.probes.size();result.values.fill(std::numeric_limits<float>::quiet_NaN());
for(std::size_t i=0;i<document.probes.size();++i){const auto& probe=document.probes[i];auto& value=result.values[i];auto& status=result.states[i];
if(probe.quantity>=MachineQuantity::Rpm){value=float(rotational_reading(probe.part,probe.quantity));if(probe.generated_output){const auto shaft=machine_shaft_for(document,probe.part?probe.part:machine_primary_shaft(document));if(shaft){const auto& timing=*shaft_by_id.at(shaft)->timing;value=probe.quantity==MachineQuantity::Torque?timing.average_torque:timing.average_power;}}continue;}
const auto& r=*by_id.at(probe.part);float pressure=r.definition->pressure,temperature=r.definition->temperature,flow=0,air=0,fuel=0;
if(r.pipe>=0){const auto& p=pipes[r.pipe];const int at=std::min(int(p.cells.size())-1,int(probe.position*p.cells.size()));const auto s=p.state(at);pressure=s.pressure;temperature=s.pressure/(s.rho*gas_constant);flow=s.rho*s.velocity*p.cell_areas[at];fuel=s.fuel_equivalent;air=1-fuel;}
else if(r.cylinder){pressure=r.cylinder->pressure;temperature=r.cylinder->temperature();flow=r.cylinder->intake.mass_flow+r.cylinder->exhaust.mass_flow;fuel=r.cylinder->fuel_equivalent_mass;air=r.cylinder->total_mass()-fuel;}
else if(r.volume){pressure=r.volume->pressure();temperature=r.volume->temperature();fuel=r.volume->fuel_equivalent_fraction();air=1-fuel;for(const auto& g:gates)if(g.part==&r)flow+=g.component_port->mass_flow;}
if(probe.quantity==MachineQuantity::Pressure)value=pressure;else if(probe.quantity==MachineQuantity::Temperature)value=temperature;else if(probe.quantity==MachineQuantity::MassFlow)value=flow;
else if(probe.quantity==MachineQuantity::AirFuelRatio||probe.quantity==MachineQuantity::Lambda){
if(fuel<=1e-12f)status=MachineReadingState::NoFuel;
else{const float afr=air/fuel;if(!std::isfinite(afr)||afr>machine_afr_reading_limit)status=MachineReadingState::LeanLimit;else value=probe.quantity==MachineQuantity::Lambda?afr/probe.reference_afr:afr;}
}else if(probe.quantity==MachineQuantity::ChargeAfr&&r.cylinder){const auto& cylinder=*r.cylinder;
if(!cylinder.transported_fuel&&cylinder.ecu&&cylinder.ecu->fuel_cut())status=MachineReadingState::FuelCut;
else if(!cylinder.charge_sampled)status=MachineReadingState::AwaitingCharge;
else if(!std::isfinite(cylinder.charge_afr))status=MachineReadingState::NoFuel;
else if(cylinder.charge_afr>machine_afr_reading_limit)status=MachineReadingState::LeanLimit;
else value=cylinder.charge_afr;
}
}return result;
}
void apply_tuning(const MachineDocument& proposed){
validate_machine(proposed);
if(proposed.driven!=document.driven||proposed.drive_rpm!=document.drive_rpm||proposed.shaft_inertia!=document.shaft_inertia||proposed.load_torque!=document.load_torque||proposed.friction_constant!=document.friction_constant||proposed.friction_viscous!=document.friction_viscous||proposed.friction_quadratic!=document.friction_quadratic||proposed.starter_torque!=document.starter_torque)throw std::invalid_argument("Shared-shaft settings are legacy: edit the explicit shaft, drive or brake part");
const auto frozen=[](bool match){if(!match)throw std::invalid_argument("This property requires returning to Build");};
frozen(proposed.parts.size()==document.parts.size()&&proposed.links.size()==document.links.size()&&proposed.cells_per_meter==document.cells_per_meter&&proposed.solver==document.solver&&proposed.scene==document.scene&&proposed.sensors==document.sensors&&logic_topology_equal(document.logic,proposed.logic));
for(std::size_t i=0;i<document.links.size();++i){const auto& a=document.links[i];const auto& b=proposed.links[i];frozen(a.a==b.a&&a.b==b.b&&a.kind==b.kind&&a.discharge==b.discharge&&a.inertance_length==b.inertance_length&&a.loss==b.loss);}
const auto cam_equal=[](const auto& a,const auto& b){return a.points.size()==b.points.size()&&std::equal(a.points.begin(),a.points.end(),b.points.begin(),[](const auto& x,const auto& y){return x.angle_degrees==y.angle_degrees&&x.lift_metres==y.lift_metres;});};
const auto valve=[](const auto& v){return std::tuple{v.mass_kg,v.spring_rate_n_per_m,v.damping_n_s_per_m,v.seat_force_n,v.maximum_lift_m,v.seat_restitution,v.cam_restitution,v.stop_restitution};};
const auto cylinder_geometry=[](const auto& c){return std::tuple{c.bore,c.stroke,c.rod,c.compression,c.intake_valve_diameter,c.exhaust_valve_diameter,c.intake_valve_count,c.exhaust_valve_count};};
for(std::size_t i=0;i<document.parts.size();++i){const auto& a=document.parts[i];const auto& b=proposed.parts[i];
frozen(a.id==b.id&&a.kind==b.kind&&a.position==b.position&&a.rotation==b.rotation&&a.size==b.size&&a.points==b.points&&machine_pipe_diameters(a)==machine_pipe_diameters(b)&&a.port_coordinates==b.port_coordinates&&a.port_keys==b.port_keys);
if(machine_rotational_part(a.kind))frozen(a.shaft.inertia==b.shaft.inertia&&a.shaft.initial_speed==b.shaft.initial_speed&&a.shaft.initial_angle==b.shaft.initial_angle);
frozen(a.diameter==b.diameter&&a.outlet_diameter==b.outlet_diameter&&a.pressure==b.pressure&&a.temperature==b.temperature&&a.discharge==b.discharge&&a.phase_degrees==b.phase_degrees&&a.junction_method==b.junction_method&&a.turning_loss==b.turning_loss);
frozen(a.sensor_ecu==b.sensor_ecu&&a.ignition_tdc_degrees==b.ignition_tdc_degrees);
frozen(a.injection_enabled==b.injection_enabled);
frozen(a.injector==b.injector);
frozen(cylinder_geometry(a.cylinder)==cylinder_geometry(b.cylinder)&&a.maximum_lift==b.maximum_lift&&a.intake_open==b.intake_open&&a.intake_close==b.intake_close&&a.exhaust_open==b.exhaust_open&&a.exhaust_close==b.exhaust_close&&valve(a.valve)==valve(b.valve)&&valve(a.exhaust_valve)==valve(b.exhaust_valve)&&cam_equal(a.intake_profile,b.intake_profile)&&cam_equal(a.exhaust_profile,b.exhaust_profile));
}
logic->apply_tuning(proposed);document.logic=proposed.logic;
document.primary_shaft=proposed.primary_shaft;document.probes=proposed.probes;
for(std::size_t i=0;i<document.parts.size();++i){auto& p=document.parts[i];const auto& q=proposed.parts[i];p.carburetor=q.carburetor;p.ecu=q.ecu;p.cylinder=q.cylinder;p.opening=q.opening;p.pedal_control=q.pedal_control;p.cracking_pressure=q.cracking_pressure;p.injection_enabled=q.injection_enabled;p.shaft=q.shaft;p.drive=q.drive;p.brake=q.brake;
if(p.kind==MachinePartKind::Shaft)mechanics.set_friction(p.id,p.shaft.friction_constant,p.shaft.friction_viscous,p.shaft.friction_quadratic);
if(p.kind==MachinePartKind::DriveMotor)mechanics.set_drive(p.id,{p.drive.speed_control?DriveMode::Speed:DriveMode::Torque,p.drive.speed_control?p.drive.target_speed:p.drive.torque,p.drive.enabled});
if(p.kind==MachinePartKind::Brake)mechanics.set_brake(p.id,{p.brake.constant,p.brake.viscous,p.brake.quadratic,p.brake.enabled});
auto& r=*by_id.at(p.id);if(r.carburetor)static_cast<CarburetorConfig&>(*r.carburetor)=p.carburetor;
if(p.kind==MachinePartKind::Ecu){static_cast<EcuConfig&>(r.ecu)=p.ecu;if(r.sensor_ecu)r.sensor_ecu->apply_calibration(machine_sensor_ecu_config(p));}
if(r.cylinder){static_cast<CylinderConfig&>(*r.cylinder)=p.cylinder;if(!r.controller){r.ecu.ignition_map={{0,p.cylinder.spark_advance},{20000,p.cylinder.spark_advance}};r.ecu.stoichiometric_afr=r.ecu.power_afr=p.cylinder.afr;}}
}
for(auto r:cylinders){r->cylinder->transported_fuel=!(r->controller&&r->controller->definition->injection_enabled);r->fuel_source=machine_has_fuel_source(document,r->definition->id);}
sample_sensors();
}
MachineSnapshot snapshot()const{
MachineSnapshot s;s.time=time;s.primary_shaft=machine_primary_shaft(document);s.rpm=float(rotational_reading(s.primary_shaft,MachineQuantity::Rpm));s.torque=float(rotational_reading(s.primary_shaft,MachineQuantity::Torque));s.power=float(rotational_reading(s.primary_shaft,MachineQuantity::Power));s.substeps=solver.substep_count();for(const auto& shaft:shafts)s.shafts.push_back(mechanics.shaft(shaft->id));
for(const auto& sensor:sensors)s.sensors.push_back(sensor.state);
s.logic=logic->snapshot();
for(auto& r:parts){const auto& p=*r->definition;MachinePartState v;v.id=p.id;v.pressure=p.pressure;v.temperature=p.temperature;v.volume=machine_part_volume(p);
v.signal_controlled=r->signal_enable.has_value()||r->signal_target.has_value()||r->signal_opening.has_value()||r->signal_starter.has_value();
if(p.kind==MachinePartKind::DriveMotor){v.actuator_enabled=r->signal_enable.value_or(p.drive.enabled);v.command_target=r->signal_target.value_or(p.drive.speed_control?p.drive.target_speed:p.drive.torque);}
else if(p.kind==MachinePartKind::Brake)v.actuator_enabled=r->signal_enable.value_or(p.brake.enabled);
if(r->shaft){const auto& shaft=mechanics.shaft(r->shaft->id);v.shaft=shaft.id;v.shaft_angle=shaft.angle;v.shaft_speed=shaft.speed;v.rotational_inertia=p.kind==MachinePartKind::Flywheel?p.shaft.inertia:shaft.inertia;v.kinetic_energy=.5*v.rotational_inertia*shaft.speed*shaft.speed;v.torque=rotational_reading(p.id,MachineQuantity::Torque);v.power=rotational_reading(p.id,MachineQuantity::Power);if(p.kind==MachinePartKind::DriveMotor)v.mechanical_work=mechanics.drive(p.id).work;else if(p.kind==MachinePartKind::Brake)v.mechanical_loss=mechanics.brake(p.id).loss;else if(p.kind==MachinePartKind::Shaft){v.mechanical_work=shaft.external_work+shaft.drive_work;v.mechanical_loss=shaft.friction_loss+shaft.brake_loss;}}
if(p.kind==MachinePartKind::Throttle||p.kind==MachinePartKind::Carburetor)v.opening=throttle_opening(*r);
if(p.kind==MachinePartKind::Ecu){v.fuel_cut=r->ecu.fuel_cut();v.ignition_cut=r->ecu.ignition_cut();v.idle_control=r->ecu.idle_active();v.afr=r->ecu.target_afr();v.opening=r->ecu.throttle_opening();}
if(r->sensor_ecu){const auto& state=r->sensor_ecu->state();v.ignition_cut=state.sensor_fault||state.rev_limited;v.fuel_cut=v.ignition_cut&&p.sensor_ecu.strategy!=SensorEcuConfig::Strategy::Ignition;v.sensor_fault=state.sensor_fault;v.rev_limited=state.rev_limited;v.spark_advance=float(state.spark_advance);v.idle_control=false;v.afr=0;v.opening=0;v.fuel_flow=float(state.fuel_mass_flow);}
if(r->carburetor){v.fuel_flow=r->carburetor->fuel_flow();v.afr=r->carburetor->indicated_afr();}
if(r->injector){const auto& state=r->injector->state();v.fuel_flow=float(state.mass_flow);v.fuel_cut=state.command_fault||state.empty;v.injector_fault=state.command_fault;v.injector_empty=state.empty;v.injector_remaining=state.remaining_mass;v.injector_delivered=state.delivered_mass;v.fuel_mass=r->volume->fuel_mass();}
if(r->pipe>=0){const auto& pipe=pipes[r->pipe];s.cells+=pipe.cells.size();s.positivity_repairs+=pipe.positivity_repairs;v.pressure=0;v.volume=0;const auto shortest=pipe.minimum_cell_length();if(s.minimum_cell_length==0||shortest<s.minimum_cell_length){s.minimum_cell_length=shortest;s.minimum_cell_part=p.id;}
for(std::size_t i=0;i<pipe.cells.size();++i){auto state=pipe.state(int(i));v.pipe_pressures.push_back(state.pressure);v.pressure+=state.pressure;v.flow+=state.rho*state.velocity*pipe.cell_areas[i];v.volume+=pipe.cell_areas[i]*pipe.cell_lengths[i];}
v.pressure/=pipe.cells.size();v.flow/=pipe.cells.size();
}else if(r->volume){v.pressure=r->volume->pressure();v.temperature=r->volume->temperature();v.volume=r->volume->volume();
if(p.kind==MachinePartKind::PistonPump)v.piston_fraction=(v.volume/bore_area(p)-p.cylinder.stroke/(p.cylinder.compression-1))/p.cylinder.stroke;
}else if(r->cylinder){const auto& c=*r->cylinder;v.pressure=c.pressure;v.temperature=c.temperature();v.volume=c.volume;v.piston_fraction=(c.volume-c.clearance_volume())/c.swept_volume();v.intake_lift=c.intake_lift();v.exhaust_lift=c.exhaust_lift();v.fuel_mass=c.fuel_mass;v.combustion=c.combustion_active;v.burn_fraction=c.burn_fraction;v.fuel_source=r->fuel_source;v.valve_float=c.valve_floating();v.fuel_cut=c.ecu->fuel_cut();v.ignition_cut=c.ecu->ignition_cut();}
if(r->cylinder&&r->cylinder->ignition_command)v.ignition_cut=r->cylinder->ignition_command->cut;
if(r->cylinder&&r->controller&&r->controller->sensor_ecu)v.fuel_cut=v.ignition_cut&&r->controller->definition->sensor_ecu.strategy!=SensorEcuConfig::Strategy::Ignition;
for(auto& g:gates)if(g.part==r.get()&&g.component_port){v.flow+=g.component_port->mass_flow;if(p.kind==MachinePartKind::CheckValve)v.opening=std::max(v.opening,g.area>0?1.f:0.f);}
s.parts.push_back(std::move(v));
}return s;
}
};
MachineSimulation::MachineSimulation(const MachineDocument& d,int rate):impl_(std::make_unique<Impl>(d,rate)){}
MachineSimulation::~MachineSimulation()=default;
AudioFrame MachineSimulation::process(){return impl_->process();}
void MachineSimulation::set_controls(float pedal,bool starter){impl_->pedal=std::clamp(pedal,0.f,1.f);impl_->starter=starter;}
void MachineSimulation::set_listener(AudioListener listener){for(auto& mouth:impl_->mouths)mouth.audio->set_listener(listener);}
MachineSnapshot MachineSimulation::snapshot()const{return impl_->snapshot();}
MachineMeasurement MachineSimulation::measure()const{return impl_->measure();}
MachineSensorState MachineSimulation::sense(std::uint32_t id)const{for(const auto& sensor:impl_->sensors)if(sensor.state.id==id)return sensor.state;throw std::out_of_range("Unknown machine sensor");}
void MachineSimulation::set_logic_control(std::uint32_t node,bool value){impl_->logic->set_manual(node,value);}
void MachineSimulation::set_logic_control(std::uint32_t node,double value){impl_->logic->set_manual(node,value);}
void MachineSimulation::release_logic_buttons(){impl_->logic->release_buttons();}
void MachineSimulation::apply_tuning(const MachineDocument& d){impl_->apply_tuning(d);}
}

101
src/mechanics.cpp Normal file
View File

@@ -0,0 +1,101 @@
#include "FluidSim/Mechanics.hpp"
#include <algorithm>
#include <cmath>
#include <set>
#include <stdexcept>
#include <vector>
namespace fluidsim::port {
namespace {
void require(bool condition,const char* message){if(!condition)throw std::invalid_argument(message);}
void drag_parameters(double constant,double viscous,double quadratic){require(std::isfinite(constant)&&constant>=0&&std::isfinite(viscous)&&viscous>=0&&std::isfinite(quadratic)&&quadratic>=0,"Mechanical drag coefficients must be finite and nonnegative");}
void drive_parameters(const DriveParameters& p){require(p.mode==DriveMode::Torque||p.mode==DriveMode::Speed,"Unknown drive mode");require(std::isfinite(p.command),"Drive command must be finite");}
double sign(double value){return value>0?1.:value<0?-1.:0.;}
}
struct MechanicalSystem::Impl {
struct Shaft {ShaftParameters parameters;ShaftState state;double pending_torque{};std::vector<std::size_t> drives,brakes;};
struct Drive {DriveParameters parameters;DriveState state;};
struct Brake {BrakeParameters parameters;BrakeState state;double step_start_loss{};};
std::vector<Shaft> shafts;std::vector<Drive> drives;std::vector<Brake> brakes;std::set<std::uint32_t> ids;bool started{};
Shaft& shaft(std::uint32_t id){for(auto& s:shafts)if(s.state.id==id)return s;throw std::invalid_argument("Unknown mechanical shaft");}
const Shaft& shaft(std::uint32_t id)const{for(const auto& s:shafts)if(s.state.id==id)return s;throw std::invalid_argument("Unknown mechanical shaft");}
Drive& drive(std::uint32_t id){for(auto& d:drives)if(d.state.id==id)return d;throw std::invalid_argument("Unknown mechanical drive");}
Brake& brake(std::uint32_t id){for(auto& b:brakes)if(b.state.id==id)return b;throw std::invalid_argument("Unknown mechanical brake");}
void claim(std::uint32_t id){require(!started,"Mechanical topology changes require a fresh simulation");require(id&&ids.insert(id).second,"Duplicate or invalid mechanical part ID");}
void validate_drive(const Shaft& s,const DriveParameters& parameters,std::uint32_t replacing=0)const{
drive_parameters(parameters);if(!parameters.enabled||parameters.mode!=DriveMode::Speed)return;
require(!s.parameters.forward_only||parameters.command>=0,"A forward-only shaft cannot use a negative speed target");
for(auto i:s.drives){const auto& other=drives[i];if(other.state.id!=replacing)require(!(other.parameters.enabled&&other.parameters.mode==DriveMode::Speed),"A shaft supports only one active ideal speed drive; reaction sharing would be undefined");}
}
void losses(Shaft& s,double speed,double duration){
const double magnitude=std::abs(speed),direction=sign(speed),distance=magnitude*duration;const auto& p=s.parameters;
const double friction=p.friction_constant+p.friction_viscous*magnitude+p.friction_quadratic*magnitude*magnitude;
s.state.friction_loss+=friction*distance;s.state.friction_torque-=direction*friction*duration;
for(auto index:s.brakes){auto& b=brakes[index];if(!b.parameters.enabled)continue;const auto& c=b.parameters;const double torque=c.constant+c.viscous*magnitude+c.quadratic*magnitude*magnitude;
b.state.torque-=direction*torque*duration;b.state.loss+=torque*distance;s.state.brake_loss+=torque*distance;s.state.brake_torque-=direction*torque*duration;
}
}
void static_reaction(Shaft& s,double torque,double constant,double duration){
// At rest only the needed fraction of Coulomb capacity reacts; no work.
const double fraction=constant>0?std::min(1.,std::abs(torque)/constant):0,direction=sign(torque);
s.state.friction_torque-=direction*s.parameters.friction_constant*fraction*duration;
for(auto index:s.brakes){auto& b=brakes[index];if(!b.parameters.enabled)continue;const double reaction=-direction*b.parameters.constant*fraction*duration;b.state.torque+=reaction;s.state.brake_torque+=reaction;}
if(s.parameters.forward_only&&torque<0) s.state.constraint_torque+=(-torque+direction*constant*fraction)*duration;
}
void advance(Shaft& s,double dt){
auto& state=s.state;const double before=state.speed,energy=state.kinetic_energy;state.previous_angle=state.angle;
state.friction_torque=state.brake_torque=state.constraint_torque=0;double torque=s.pending_torque,constant=s.parameters.friction_constant,viscous=s.parameters.friction_viscous,quadratic=s.parameters.friction_quadratic;Drive* speed_drive=nullptr;
for(auto index:s.drives){auto& d=drives[index];d.state.torque=d.state.power=0;if(!d.parameters.enabled)continue;if(d.parameters.mode==DriveMode::Speed)speed_drive=&d;else torque+=d.parameters.command;}
for(auto index:s.brakes){auto& b=brakes[index];b.state.torque=b.state.power=0;b.step_start_loss=b.state.loss;if(b.parameters.enabled){constant+=b.parameters.constant;viscous+=b.parameters.viscous;quadratic+=b.parameters.quadratic;}}
const double old_losses=state.friction_loss+state.brake_loss;
double displacement=0;
if(speed_drive){
state.speed=speed_drive->parameters.command;displacement=state.speed*dt;losses(s,state.speed,dt);
}else{
double remaining=dt;
// A step can decelerate to rest, then accelerate in the other direction.
// Each segment uses midpoint work, so its kinetic-energy change equals
// source work minus drag work even when a stop splits the interval.
for(int segment=0;segment<2&&remaining>0;++segment){
const double w0=std::abs(state.speed),direction=w0>0?sign(state.speed):sign(torque);
if(w0==0&&(std::abs(torque)<=constant||(s.parameters.forward_only&&torque<=0))){static_reaction(s,torque,constant,remaining);break;}
const double source=torque*direction;
const double a=quadratic*.25,b=state.inertia/remaining+viscous*.5+quadratic*w0*.5;
const double c=quadratic*w0*w0*.25-state.inertia*w0/remaining-source+constant+viscous*w0*.5;
double duration=remaining,w1=0;
if(c>0&&w0>0){duration=state.inertia*w0/(constant+viscous*w0*.5+quadratic*w0*w0*.25-source);duration=std::clamp(duration,0.,remaining);}
else if(c<=0){w1=a>0?-2*c/(b+std::sqrt(b*b-4*a*c)):-c/b;}
const double midpoint=direction*(w0+w1)*.5;displacement+=midpoint*duration;losses(s,midpoint,duration);state.speed=direction*w1;remaining-=duration;
if(w1!=0)break;
}
}
state.angle+=displacement;state.kinetic_energy=.5*state.inertia*state.speed*state.speed;
state.external_work+=s.pending_torque*displacement;
for(auto index:s.drives){auto& d=drives[index];if(!d.parameters.enabled||d.parameters.mode!=DriveMode::Torque)continue;const double work=d.parameters.command*displacement;d.state.torque=d.parameters.command;d.state.work+=work;d.state.power=work/dt;state.drive_work+=work;}
state.friction_torque/=dt;state.brake_torque/=dt;state.constraint_torque/=dt;
state.net_torque=state.inertia*(state.speed-before)/dt;state.source_torque=torque;state.external_torque=s.pending_torque;
if(speed_drive){const double work=state.kinetic_energy-energy-torque*displacement+(state.friction_loss+state.brake_loss-old_losses);
speed_drive->state.torque=state.net_torque-torque-state.friction_torque-state.brake_torque;speed_drive->state.work+=work;speed_drive->state.power=work/dt;state.drive_work+=work;state.source_torque+=speed_drive->state.torque;}
for(auto index:s.brakes){auto& b=brakes[index];b.state.torque/=dt;b.state.power=(b.state.loss-b.step_start_loss)/dt;}
s.pending_torque=0;
require(std::isfinite(state.angle)&&std::isfinite(state.speed)&&std::isfinite(state.kinetic_energy)&&std::isfinite(state.drive_work)&&std::isfinite(state.external_work)&&std::isfinite(state.friction_loss)&&std::isfinite(state.brake_loss),"Nonfinite mechanical state");
}
};
MechanicalSystem::MechanicalSystem():impl_(std::make_unique<Impl>()){}
MechanicalSystem::~MechanicalSystem()=default;
void MechanicalSystem::add_shaft(std::uint32_t id,const ShaftParameters& p){
require(std::isfinite(p.inertia)&&p.inertia>0&&std::isfinite(p.initial_speed)&&std::isfinite(p.initial_angle),"Invalid shaft inertia or initial state");require(!p.forward_only||p.initial_speed>=0,"A forward-only shaft cannot start in reverse");drag_parameters(p.friction_constant,p.friction_viscous,p.friction_quadratic);impl_->claim(id);
Impl::Shaft s;s.parameters=p;s.state.id=id;s.state.inertia=p.inertia;s.state.angle=s.state.previous_angle=p.initial_angle;s.state.speed=p.initial_speed;s.state.initial_energy=s.state.kinetic_energy=.5*p.inertia*p.initial_speed*p.initial_speed;impl_->shafts.push_back(std::move(s));
}
void MechanicalSystem::add_flywheel(std::uint32_t id,std::uint32_t shaft,double inertia){require(std::isfinite(inertia)&&inertia>0,"Flywheel inertia must be positive and finite");auto& s=impl_->shaft(shaft);impl_->claim(id);s.state.inertia+=inertia;s.state.initial_energy=s.state.kinetic_energy=.5*s.state.inertia*s.state.speed*s.state.speed;}
void MechanicalSystem::add_drive(std::uint32_t id,std::uint32_t shaft,const DriveParameters& p){auto& s=impl_->shaft(shaft);impl_->validate_drive(s,p);impl_->claim(id);s.drives.push_back(impl_->drives.size());Impl::Drive d;d.parameters=p;d.state.id=id;d.state.shaft=shaft;impl_->drives.push_back(d);}
void MechanicalSystem::add_brake(std::uint32_t id,std::uint32_t shaft,const BrakeParameters& p){drag_parameters(p.constant,p.viscous,p.quadratic);auto& s=impl_->shaft(shaft);impl_->claim(id);s.brakes.push_back(impl_->brakes.size());Impl::Brake b;b.parameters=p;b.state.id=id;b.state.shaft=shaft;impl_->brakes.push_back(b);}
void MechanicalSystem::set_drive(std::uint32_t id,const DriveParameters& p){auto& d=impl_->drive(id);impl_->validate_drive(impl_->shaft(d.state.shaft),p,id);d.parameters=p;}
void MechanicalSystem::set_brake(std::uint32_t id,const BrakeParameters& p){drag_parameters(p.constant,p.viscous,p.quadratic);impl_->brake(id).parameters=p;}
void MechanicalSystem::set_friction(std::uint32_t id,double constant,double viscous,double quadratic){drag_parameters(constant,viscous,quadratic);auto& p=impl_->shaft(id).parameters;p.friction_constant=constant;p.friction_viscous=viscous;p.friction_quadratic=quadratic;}
void MechanicalSystem::add_torque(std::uint32_t shaft,double torque){require(std::isfinite(torque),"Shaft torque must be finite");auto& s=impl_->shaft(shaft);require(std::isfinite(s.pending_torque+torque),"Accumulated shaft torque overflow");s.pending_torque+=torque;}
void MechanicalSystem::step(double dt){require(std::isfinite(dt)&&dt>0,"Mechanical timestep must be positive and finite");impl_->started=true;for(auto& shaft:impl_->shafts)impl_->advance(shaft,dt);}
const ShaftState& MechanicalSystem::shaft(std::uint32_t id)const{return impl_->shaft(id).state;}
const DriveState& MechanicalSystem::drive(std::uint32_t id)const{return impl_->drive(id).state;}
const BrakeState& MechanicalSystem::brake(std::uint32_t id)const{return impl_->brake(id).state;}
}

View File

@@ -1,83 +1,187 @@
#include "FluidSim/Desktop/ScenarioMenu.hpp" #include "FluidSim/Desktop/ScenarioMenu.hpp"
#include "FluidSim/Desktop/MachineRenderer3D.hpp"
#include "FluidSim/Desktop/WindowPresentation.hpp"
#include "FluidSim/Machine.hpp"
#include <algorithm> #include <algorithm>
namespace fluidsim::port::desktop { #include <cctype>
std::optional<std::size_t> choose_scenario(sf::RenderWindow& window,const sf::Font* font,const std::vector<ScenarioExample>& examples) { #include <cmath>
if(examples.empty())return std::nullopt; #include <functional>
int selected=0,first=0; #include <sstream>
std::vector<std::string> categories;
for(const auto& e:examples) if(std::find(categories.begin(),categories.end(),e.category)==categories.end())categories.push_back(e.category);
std::string category;
struct Item { std::string title,description;std::size_t value; };
auto label=[&](const std::string& str,float x,float y,unsigned size,sf::Color color=sf::Color::White) { namespace fluidsim::port::desktop {
if(!font)return; namespace {
sf::Text text(*font,str,size);text.setPosition({x,y});text.setFillColor(color);window.draw(text); const sf::Color ink{232,238,246},muted{152,168,190},accent{116,170,235},edge{53,67,86},surface{23,31,43};
}; std::string lower(std::string text){for(auto& c:text)c=char(std::tolower(static_cast<unsigned char>(c)));return text;}
while(window.isOpen()) { std::string fit(const sf::Font* font,std::string text,float width,unsigned size){
const auto size=window.getSize();const float w=float(size.x),h=float(size.y); if(!font)return {};
std::vector<Item> items; sf::Text measured(*font,text,size);if(measured.getLocalBounds().size.x<=width)return text;
if(category.empty()) for(std::size_t i=0;i<categories.size();++i) { while(!text.empty()){text.pop_back();measured.setString(text+"...");if(measured.getLocalBounds().size.x<=width)return text+"...";}return {};
const auto count=std::count_if(examples.begin(),examples.end(),[&](const auto& e){return e.category==categories[i];}); }
items.push_back({categories[i],std::to_string(count)+" variants / click to browse",i}); void text(sf::RenderWindow& w,const sf::Font* font,const std::string& value,float x,float y,float width,unsigned size=14,sf::Color color=ink){
} else for(std::size_t i=0;i<examples.size();++i) if(examples[i].category==category)items.push_back({examples[i].title,examples[i].description,i}); if(!font)return;
const int count=int(items.size()), rows=std::max(1,int((h-210)/86)); sf::Text label(*font,fit(font,value,width,size),size);label.setPosition({x,y});label.setFillColor(color);w.draw(label);
first=std::clamp(first,0,std::max(0,count-rows)); }
const sf::FloatRect start{{24,h-85},{w-48,46}}; void wrap(sf::RenderWindow& w,const sf::Font* font,const std::string& value,float x,float y,float width,unsigned size,int lines,sf::Color color=muted){
bool navigating=false; if(!font)return;
auto activate=[&]()->std::optional<std::size_t> { std::istringstream words(value);std::string word,line;int row=0;sf::Text measured(*font,"",size);
if(category.empty()) {category=categories[items[selected].value];selected=0;first=0;navigating=true;return std::nullopt;} while(words>>word){const auto candidate=line.empty()?word:line+" "+word;measured.setString(candidate);
return items[selected].value; if(!line.empty()&&measured.getLocalBounds().size.x>width){text(w,font,line,x,y+row*(size+7),width,size,color);if(++row>=lines)return;line=word;}else line=candidate;
}; }if(row<lines)text(w,font,line,x,y+row*(size+7),width,size,color);
auto back=[&] {category.clear();selected=0;first=0;navigating=true;}; }
auto reveal=[&] {if(selected<first)first=selected;if(selected>=first+rows)first=selected-rows+1;}; void box(sf::RenderWindow& w,sf::FloatRect r,sf::Color fill=surface,sf::Color outline=edge){sf::RectangleShape shape(r.size);shape.setPosition(r.position);shape.setFillColor(fill);shape.setOutlineThickness(1);shape.setOutlineColor(outline);w.draw(shape);}
while(auto event=window.pollEvent()) { struct MenuLayout {
if(event->is<sf::Event::Closed>())return std::nullopt; float width,height,list_x,list_width;int rows;
if(auto key=event->getIf<sf::Event::KeyPressed>()) { sf::FloatRect back,search,detail;
switch(key->code) { static MenuLayout calculate(sf::Vector2u size,bool scenarios){
case sf::Keyboard::Key::Escape:if(category.empty())return std::nullopt;back();break; MenuLayout l;l.width=float(size.x);l.height=float(size.y);l.list_x=scenarios?252.f:32.f;
case sf::Keyboard::Key::Backspace:case sf::Keyboard::Key::Left:if(!category.empty())back();break; const float detail_width=std::clamp(l.width*.36f,380.f,600.f);
case sf::Keyboard::Key::Enter:if(auto choice=activate())return choice;break; l.detail={{l.width-detail_width-32,154},{detail_width,l.height-210}};
case sf::Keyboard::Key::Up:selected=std::max(0,selected-1);reveal();break; l.list_width=l.detail.position.x-l.list_x-24;l.rows=std::max(1,int((l.height-216)/78));
case sf::Keyboard::Key::Down:case sf::Keyboard::Key::Tab:selected=std::min(count-1,selected+1);reveal();break; l.back={{32,28},{112,40}};l.search={{l.list_x,104},{l.list_width,32}};return l;
case sf::Keyboard::Key::PageUp:selected=std::max(0,selected-rows);reveal();break;
case sf::Keyboard::Key::PageDown:selected=std::min(count-1,selected+rows);reveal();break;
case sf::Keyboard::Key::Home:selected=0;reveal();break;
case sf::Keyboard::Key::End:selected=count-1;reveal();break;
default:break;
}
}
if(auto wheel=event->getIf<sf::Event::MouseWheelScrolled>()) {
first=std::clamp(first+(wheel->delta<0?1:-1),0,std::max(0,count-rows));
selected=std::clamp(selected,first,std::min(count-1,first+rows-1));
}
if(auto click=event->getIf<sf::Event::MouseButtonReleased>();click && click->button==sf::Mouse::Button::Left) {
sf::Vector2f point{float(click->position.x),float(click->position.y)};
if(!category.empty() && sf::FloatRect({24,54},{150,36}).contains(point))back();
if(start.contains(point)) {if(auto choice=activate())return choice;}
for(int row=0;row<rows && first+row<count;++row)
if(!navigating && sf::FloatRect({24,105.f+row*86},{w-58,76}).contains(point)) {selected=first+row;if(auto choice=activate())return choice;break;}
}
if(navigating)break;
}
if(navigating)continue;
window.setView(sf::View(sf::FloatRect({0,0},{w,h})));window.clear({8,8,8});
label("FLUIDSIM / EXAMPLES",24,20,24);label(category.empty()?"Choose an engine category":"< Categories "+category,24,61,15,{170,170,170});
for(int row=0;row<rows && first+row<count;++row) {
const int index=first+row;const float y=105.f+row*86;
sf::RectangleShape card({w-58,76});card.setPosition({24,y});
card.setFillColor(index==selected?sf::Color(48,48,48):sf::Color(23,23,23));
card.setOutlineThickness(1);card.setOutlineColor(index==selected?sf::Color::White:sf::Color(60,60,60));window.draw(card);
label(items[index].title,38,y+10,17);label(items[index].description,38,y+44,12,{170,170,170});
}
if(count>rows) {
const float track=rows*86.f, thumb=track*std::min(1.f,float(rows)/count);
sf::RectangleShape bar({4,thumb});bar.setPosition({w-21,105+(track-thumb)*first/std::max(1,count-rows)});bar.setFillColor({170,170,170});window.draw(bar);
}
sf::RectangleShape button(start.size);button.setPosition(start.position);button.setFillColor({55,55,55});window.draw(button);
label(category.empty()?"Browse variants [Enter]":"Start selected [Enter]",38,h-76,16);
label("Wheel / Up / Down / Page keys | "+std::to_string(selected+1)+" / "+std::to_string(count),24,h-28,12,{170,170,170});
window.display();
} }
return std::nullopt; };
}
struct ScenarioMenu::Impl {
struct Item{std::string title,description;std::optional<std::size_t> example;std::filesystem::path machine;};
struct Action{sf::FloatRect rect;std::string title,subtitle;std::function<void()> run;bool selected{},enabled{true},primary{};int kind{};};
std::vector<ScenarioExample> examples;std::vector<std::filesystem::path> machines;std::vector<std::string> categories{"All engines"};
std::vector<Item> items,saved_items;MenuScreen page{MenuScreen::Home};int selected{},first{},category{},category_first{},home_selection{};
bool quit{},search_focus{};std::string query;sf::Vector2f mouse{-1,-1};std::optional<ScenarioMenuChoice> choice;
MachineRenderer3D renderer;std::optional<MachineDocument> preview;OrbitCamera camera;std::string preview_key,preview_note;
Impl(std::vector<ScenarioExample> e,std::vector<std::filesystem::path> m):examples(std::move(e)),machines(std::move(m)){
for(const auto& item:examples)if(std::find(categories.begin(),categories.end(),item.category)==categories.end())categories.push_back(item.category);
for(const auto& file:machines){std::string title=file.stem().stem().string();try{title=load_machine(file).name;}catch(const std::exception&){}saved_items.push_back({title,file.filename().string()+" / edit or play",{},file});}
}
void refresh(){
items.clear();const auto search=lower(query);
const auto add=[&](Item item){if(search.empty()||lower(item.title+" "+item.description).find(search)!=std::string::npos)items.push_back(std::move(item));};
if(page==MenuScreen::Workshop){add({"New machine","Start an empty workshop and build your own machine",{}, {}});for(const auto& item:saved_items)add(item);}
if(page==MenuScreen::Scenarios)for(std::size_t i=0;i<examples.size();++i)if(category==0||examples[i].category==categories[category])add({examples[i].title,examples[i].description,i,{}});
selected=std::clamp(selected,0,std::max(0,int(items.size())-1));
}
void navigate(MenuScreen screen){page=screen;selected=first=category=category_first=0;query.clear();search_focus=false;preview_key.clear();refresh();}
void reveal(int rows){if(selected<first)first=selected;if(selected>=first+rows)first=selected-rows+1;}
void activate(bool play=false){
if(page==MenuScreen::Home){if(home_selection==0)navigate(MenuScreen::Workshop);else if(home_selection==1)navigate(MenuScreen::Scenarios);else quit=true;return;}
if(items.empty())return;
const auto& item=items[selected];choice=ScenarioMenuChoice{item.example,item.machine,play&&!item.machine.empty()};
}
std::vector<Action> actions(sf::Vector2u size){
const auto l=MenuLayout::calculate(size,page==MenuScreen::Scenarios);std::vector<Action> result;
const auto add=[&](sf::FloatRect r,std::string title,std::function<void()> run,bool active=false,bool enabled=true,bool primary=false,std::string subtitle={},int kind=0){result.push_back({r,std::move(title),std::move(subtitle),std::move(run),active,enabled,primary,kind});};
if(page==MenuScreen::Home){
const float top=std::max(254.f,l.height*.36f);
add({{64,top},{416,92}},"Machine Workshop",[this]{navigate(MenuScreen::Workshop);},home_selection==0,true,false,"Build, connect and tune your own machines",2);
add({{64,top+108},{416,92}},"Engine Scenarios",[this]{navigate(MenuScreen::Scenarios);},home_selection==1,true,false,"Explore the engine collection",2);
add({{64,top+224},{196,44}},"Quit",[this]{quit=true;},home_selection==2);
return result;
}
add(l.back,"< Back",[this]{navigate(MenuScreen::Home);});
first=std::clamp(first,0,std::max(0,int(items.size())-l.rows));
for(int row=0;row<l.rows&&first+row<int(items.size());++row){const int i=first+row;add({{l.list_x,154+row*78.f},{l.list_width,68}},items[i].title,[this,i]{selected=i;preview_key.clear();},i==selected,true,false,items[i].description,1);}
if(page==MenuScreen::Scenarios){const int rows=std::max(1,int((l.height-230)/44));category_first=std::clamp(category_first,0,std::max(0,int(categories.size())-rows));
for(int row=0;row<rows&&category_first+row<int(categories.size());++row){const int i=category_first+row;add({{32,154+row*44.f},{196,34}},categories[i],[this,i]{category=i;selected=first=0;refresh();preview_key.clear();},category==i);}
}
const bool any=!items.empty();const bool saved=any&&!items[selected].machine.empty();const auto d=l.detail;
add({{d.position.x+20,d.position.y+d.size.y-52},{d.size.x-40,34}},page==MenuScreen::Scenarios?"Start engine [Enter]":saved?"Edit machine [Enter]":"Create machine [Enter]",[this]{activate();},false,any,true);
if(page==MenuScreen::Workshop&&saved)add({{d.position.x+20,d.position.y+d.size.y-96},{d.size.x-40,34}},"Play machine [Space]",[this]{activate(true);});
return result;
}
void update_preview(){
const Item* item=page!=MenuScreen::Home&&!items.empty()?&items[selected]:nullptr;
std::string key=page==MenuScreen::Home?"home":item?(item->example?"engine:"+std::to_string(*item->example):item->machine.empty()?"new":"machine:"+item->machine.string()):"none";
if(key==preview_key)return;
preview_key=key;preview.reset();preview_note.clear();
try{
if(item&&item->example){const auto& engine=examples[*item->example];if(engine.kind==ScenarioKind::TwoStroke){preview_note="TWO-STROKE / ENGINE SCENARIO";return;}preview=machine_from_engine(engine);}
else if(item&&!item->machine.empty())preview=load_machine(item->machine);
else if(item)preview=MachineDocument{};
else if(page==MenuScreen::Home){auto found=std::find_if(examples.begin(),examples.end(),[](const auto& e){return e.id=="flat6";});if(found!=examples.end())preview=machine_from_engine(*found);else preview=machine_engine_example();}
if(!preview)return;
if(preview->parts.empty()){camera.target={0,.22f,0};camera.distance=1.8f;camera.pitch=24;camera.yaw=-32;return;}
Vec3 lo{1e6f,1e6f,1e6f},hi{-1e6f,-1e6f,-1e6f};
const auto include=[&](Vec3 p){lo={std::min(lo.x,p.x),std::min(lo.y,p.y),std::min(lo.z,p.z)};hi={std::max(hi.x,p.x),std::max(hi.y,p.y),std::max(hi.z,p.z)};};
for(const auto& part:preview->parts){if(part.kind==MachinePartKind::Pipe)for(auto p:machine_pipe_path(part))include(p);else{const auto half=machine_math::multiply(machine_part_size(part),.5f);include(machine_math::subtract(part.position,half));include(machine_math::add(part.position,half));}}
camera.target=machine_math::multiply(machine_math::add(lo,hi),.5f);camera.distance=std::clamp(machine_math::length(machine_math::subtract(hi,lo))*1.3f+.2f,.8f,100.f);camera.pitch=24;camera.yaw=-32;
}catch(const std::exception&){preview.reset();preview_note="No 3D preview available";}
}
};
ScenarioMenu::ScenarioMenu(std::vector<ScenarioExample> e,std::vector<std::filesystem::path> m):impl_(std::make_unique<Impl>(std::move(e),std::move(m))){}
ScenarioMenu::~ScenarioMenu()=default;
bool ScenarioMenu::finished()const{return impl_->quit;}
MenuScreen ScenarioMenu::screen()const{return impl_->page;}
std::optional<ScenarioMenuChoice> ScenarioMenu::take_choice(){auto result=impl_->choice;impl_->choice.reset();return result;}
bool ScenarioMenu::handle_event(const sf::Event& event,sf::Vector2u size){
auto& m=*impl_;const auto layout=MenuLayout::calculate(size,m.page==MenuScreen::Scenarios);
if(event.is<sf::Event::Closed>()){m.quit=true;return true;}
if(auto move=event.getIf<sf::Event::MouseMoved>()){m.mouse=sf::Vector2f(move->position);return true;}
if(auto press=event.getIf<sf::Event::MouseButtonPressed>();press&&press->button==sf::Mouse::Button::Left){m.search_focus=m.page!=MenuScreen::Home&&layout.search.contains(sf::Vector2f(press->position));return true;}
if(auto typed=event.getIf<sf::Event::TextEntered>();typed&&m.search_focus){if(typed->unicode>=32&&typed->unicode<127&&m.query.size()<80){m.query+=char(typed->unicode);m.selected=m.first=0;m.refresh();}return true;}
if(auto key=event.getIf<sf::Event::KeyPressed>()){
using K=sf::Keyboard::Key;const auto k=key->code;
if(m.search_focus){if(k==K::Escape||k==K::Enter)m.search_focus=false;else if(k==K::Backspace&&!m.query.empty()){m.query.pop_back();m.selected=m.first=0;m.refresh();}return true;}
if(key->control&&k==K::F&&m.page!=MenuScreen::Home){m.search_focus=true;return true;}
if(k==K::Escape||k==K::Backspace){if(m.page==MenuScreen::Home){if(k==K::Escape)m.quit=true;}else m.navigate(MenuScreen::Home);return true;}
if(k==K::B){m.choice=ScenarioMenuChoice{};return true;}
if(k==K::Enter||k==K::Space){m.activate(k==K::Space);return true;}
int& selected=m.page==MenuScreen::Home?m.home_selection:m.selected;const int count=m.page==MenuScreen::Home?3:int(m.items.size());
if(k==K::Up)selected=std::max(0,selected-1);else if(k==K::Down||k==K::Tab)selected=std::min(std::max(0,count-1),selected+1);else if(k==K::Home)selected=0;else if(k==K::End)selected=std::max(0,count-1);else if(k==K::PageDown)selected=std::min(std::max(0,count-1),selected+layout.rows);else if(k==K::PageUp)selected=std::max(0,selected-layout.rows);else return false;
m.reveal(layout.rows);return true;
}
if(auto wheel=event.getIf<sf::Event::MouseWheelScrolled>();wheel&&m.page!=MenuScreen::Home){
if(m.page==MenuScreen::Scenarios&&wheel->position.x<232){const int rows=std::max(1,int((layout.height-230)/44));m.category_first=std::clamp(m.category_first+(wheel->delta<0?1:-1),0,std::max(0,int(m.categories.size())-rows));}
else{m.first=std::clamp(m.first+(wheel->delta<0?1:-1),0,std::max(0,int(m.items.size())-layout.rows));if(!m.items.empty())m.selected=std::clamp(m.selected,m.first,std::min(int(m.items.size())-1,m.first+layout.rows-1));}return true;
}
if(auto click=event.getIf<sf::Event::MouseButtonReleased>();click&&click->button==sf::Mouse::Button::Left){m.mouse=sf::Vector2f(click->position);auto actions=m.actions(size);for(auto& action:actions)if(action.rect.contains(m.mouse)){if(action.enabled)action.run();return true;}return true;}
return false;
}
void ScenarioMenu::draw(sf::RenderWindow& w,const sf::Font* font){
auto& m=*impl_;const auto size=w.getSize();const auto l=MenuLayout::calculate(size,m.page==MenuScreen::Scenarios);const auto full=sf::View(sf::FloatRect({0,0},sf::Vector2f(size)));
w.setView(full);w.clear({11,16,24});
m.update_preview();const bool home=m.page==MenuScreen::Home;
const sf::FloatRect scene=home?sf::FloatRect{{528,96},{l.width-560,l.height-200}}:sf::FloatRect{l.detail.position,{l.detail.size.x,l.detail.size.y*.43f}};
if(!home)box(w,l.detail);
if(m.preview)m.renderer.draw(w,*m.preview,nullptr,scene,m.camera,0);else{
box(w,scene,{15,22,32});for(int i=0;i<6;++i){sf::CircleShape ring(30.f+i*13);ring.setFillColor(sf::Color::Transparent);ring.setOutlineColor({38,55,76});ring.setOutlineThickness(1);ring.setPosition({scene.position.x+scene.size.x*.5f-ring.getRadius(),scene.position.y+scene.size.y*.5f-ring.getRadius()});w.draw(ring);}
text(w,font,m.preview_note,scene.position.x+20,scene.position.y+scene.size.y*.5f-8,scene.size.x-40,12,accent);
}
w.setView(full);
if(home){
text(w,font,"FLUIDSIM",64,96,440,54);text(w,font,"THE MACHINE SANDBOX",68,170,412,14,accent);
wrap(w,font,"Explore engine sound. Build machines. Put your ideas in motion.",68,204,390,14,2);
text(w,font,"BUILD. CONNECT. SIMULATE.",552,l.height-92,l.width-600,18,ink);
}else{
text(w,font,m.page==MenuScreen::Workshop?"MACHINE WORKSHOP":"ENGINE SCENARIOS",174,31,l.width-220,26);
text(w,font,m.page==MenuScreen::Workshop?"Create a machine, or continue one of your saved designs.":"Choose a category, select an engine, then start your session.",32,80,l.width-64,13,muted);
box(w,l.search,{14,21,31},m.search_focus?accent:edge);text(w,font,m.query.empty()&&!m.search_focus?"Search... [Ctrl+F]":m.query+(m.search_focus?"|":""),l.search.position.x+10,111,l.search.size.x-20,13,muted);
if(m.page==MenuScreen::Scenarios){text(w,font,"COLLECTION",32,112,196,12,accent);if(int(m.categories.size())>std::max(1,int((l.height-230)/44)))text(w,font,"Scroll for more categories",32,l.height-64,196,11,muted);}
const auto d=l.detail;const float info_y=scene.position.y+scene.size.y+18;
if(!m.items.empty()){
const auto& item=m.items[m.selected];wrap(w,font,item.title,d.position.x+20,info_y,d.size.x-40,18,2,ink);
const auto subtitle=item.example?m.examples[*item.example].category:item.machine.empty()?"NEW DESIGN":"SAVED MACHINE";
text(w,font,subtitle,d.position.x+20,info_y+58,d.size.x-40,11,accent);
wrap(w,font,item.description,d.position.x+20,info_y+86,d.size.x-40,13,3);
}else{
text(w,font,"No matches",l.list_x,174,l.list_width,18);wrap(w,font,"Clear your search or choose another category.",l.list_x,210,l.list_width,13,2);
}
if(int(m.items.size())>l.rows){const float height=l.rows*78.f;box(w,{{l.detail.position.x-14,154},{3,height}},{32,43,58});box(w,{{l.detail.position.x-14,154+height*m.first/m.items.size()},{3,height*l.rows/m.items.size()}},accent);}
}
std::string hover_text;
for(const auto& action:m.actions(size)){
const bool hover=action.rect.contains(m.mouse);box(w,action.rect,action.primary?sf::Color(42,77,115):action.selected||hover?sf::Color(32,48,68):surface,action.selected||hover||action.primary?accent:edge);
const float x=action.rect.position.x+16,y=action.rect.position.y,width=action.rect.size.x-32;
text(w,font,action.title,x,y+(action.kind==2?14:action.kind==1?10:9),width,action.kind==2?21:action.kind==1?15:14,action.enabled?ink:sf::Color(91,107,128));
if(!action.subtitle.empty())text(w,font,action.subtitle,x,y+(action.kind==2?54:42),width,12,muted);
if(hover&&(fit(font,action.title,width,action.kind==2?21:action.kind==1?15:14)!=action.title||(!action.subtitle.empty()&&fit(font,action.subtitle,width,12)!=action.subtitle)))hover_text=action.title+(action.subtitle.empty()?"":" / "+action.subtitle);
}
text(w,font,home?"A sandbox for engines, compressed air and mechanical systems.":"Up / Down: select Enter: open Escape: main menu B: new machine",32,l.height-30,l.width-64,12,muted);
if(!hover_text.empty()){const float width=std::min(520.f,l.width-48),x=std::clamp(m.mouse.x+12,24.f,l.width-width-24),y=std::clamp(m.mouse.y+32,24.f,l.height-112);box(w,{{x,y},{width,70}},{13,20,30},accent);wrap(w,font,hover_text,x+12,y+9,width-24,12,3,ink);}
}
std::optional<ScenarioMenuChoice> choose_scenario(sf::RenderWindow& window,const sf::Font* font,const std::vector<ScenarioExample>& examples){
maximize_window(window);ScenarioMenu menu(examples,machine_files());
while(window.isOpen()&&!menu.finished()){
while(auto event=window.pollEvent()){menu.handle_event(*event,window.getSize());if(auto choice=menu.take_choice())return choice;if(menu.finished())return {};}
menu.draw(window,font);window.display();
}return {};
} }
} }

View File

@@ -49,6 +49,7 @@ bool SimulationRingBuffer::wait_for_space(std::size_t target_fill, std::stop_tok
void SimulationRingBuffer::reset_consumer() { void SimulationRingBuffer::reset_consumer() {
consumer_initialized_ = false; consumer_initialized_ = false;
} }
void SimulationRingBuffer::discard(){read_head_.store(write_head_.load(std::memory_order_acquire),std::memory_order_release);consumer_initialized_=false;}
std::size_t SimulationRingBuffer::read_interpolated(std::span<float> dst_left, std::size_t SimulationRingBuffer::read_interpolated(std::span<float> dst_left,
std::span<float> dst_right, std::span<float> dst_right,

View File

@@ -26,6 +26,7 @@ void Solver::step(float dt,bool profile_sample) {
if (!(sub_dt > 0) || ++count > 1000000) if (!(sub_dt > 0) || ++count > 1000000)
throw std::runtime_error("solver timestep failed to progress"); throw std::runtime_error("solver timestep failed to progress");
++substep_count_; ++substep_count_;
for (Component *component : components_) component->prepare_step(sub_dt);
{ ProfileScope measured(profiler_,profile_sample,ProfileStage::Boundary);boundaries_.resolve(sub_dt); } { ProfileScope measured(profiler_,profile_sample,ProfileStage::Boundary);boundaries_.resolve(sub_dt); }
{ ProfileScope measured(profiler_,profile_sample,ProfileStage::Pipe); { ProfileScope measured(profiler_,profile_sample,ProfileStage::Pipe);
for (auto &pipe : pipes_) pipe.advance(sub_dt); } for (auto &pipe : pipes_) pipe.advance(sub_dt); }

View File

@@ -0,0 +1,30 @@
#include "FluidSim/Machine.hpp"
#include <vector>
namespace fluidsim::port {
MachineDocument machine_speed_density_example(){
auto doc=machine_sensor_engine_example(true);doc.name="Sensor speed-density EFI bench";
std::uint32_t ecu=0,host=0;
for(auto& part:doc.parts){
if(part.kind==MachinePartKind::Ecu){ecu=part.id;part.name="Speed-density ECU";part.sensor_ecu.strategy=SensorEcuConfig::Strategy::SpeedDensity;}
if(part.injector)host=part.id;
}
// This is a declared calibration set when constructing the example, not
// runtime access to measured/trapped cylinder charge.
for(const auto& part:doc.parts)if(part.kind==MachinePartKind::Cylinder)doc.find(ecu)->sensor_ecu.displacement=pi*.25f*part.cylinder.bore*part.cylinder.bore*part.cylinder.stroke;
doc.find(host)->injector->ecu_label=doc.find(ecu)->name;
std::vector<std::uint32_t> removed;
for(const auto& node:doc.logic.nodes)if(node.kind==LogicNodeKind::Sensor&&node.sensor_kind==MachineSensorKind::ThrottlePosition)removed.push_back(node.id);
for(const auto id:removed)doc.logic.remove(id);
removed.clear();for(const auto& sensor:doc.sensors)if(sensor.kind==MachineSensorKind::ThrottlePosition)removed.push_back(sensor.id);
for(const auto id:removed)doc.remove_sensor(id);
std::uint32_t connector=0;
for(auto& node:doc.logic.nodes)if(node.kind==LogicNodeKind::Ecu){connector=node.id;node.ecu_strategy=SensorEcuConfig::Strategy::SpeedDensity;node.binding_label=doc.find(ecu)->name;node.position={340,80};}
const auto map=doc.add_sensor(MachineSensorKind::Pressure,host).id,iat=doc.add_sensor(MachineSensorKind::GasTemperature,host).id;
doc.find_sensor(map)->name="Intake MAP / absolute";doc.find_sensor(iat)->name="Intake gas temperature / IAT";
const auto pressure=doc.logic.add(LogicNodeKind::Sensor,{0,240}).id;doc.logic.find(pressure)->sensor=map;doc.logic.find(pressure)->sensor_kind=MachineSensorKind::Pressure;doc.logic.find(pressure)->name="Measured manifold pressure";
const auto temperature=doc.logic.add(LogicNodeKind::Sensor,{0,460}).id;doc.logic.find(temperature)->sensor=iat;doc.logic.find(temperature)->sensor_kind=MachineSensorKind::GasTemperature;doc.logic.find(temperature)->name="Measured intake temperature";
doc.logic.connect({pressure,"value"},{connector,"map"});doc.logic.connect({temperature,"value"},{connector,"iat"});
validate_machine(doc);return doc;
}
}

View File

@@ -0,0 +1,28 @@
#include "FluidSim/Desktop/WindowPresentation.hpp"
#include <SFML/Window/VideoMode.hpp>
#ifdef _WIN32
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <windows.h>
#endif
namespace fluidsim::port::desktop {
void maximize_window(sf::RenderWindow& window){
if(!window.isOpen())return;
#ifdef _WIN32
ShowWindow(window.getNativeHandle(),SW_MAXIMIZE);
#else
// SFML has no portable maximize operation. Keep other desktop builds usable.
window.setPosition({0,0});window.setSize(sf::VideoMode::getDesktopMode().size);
#endif
}
bool window_is_maximized(const sf::RenderWindow& window){
if(!window.isOpen())return false;
#ifdef _WIN32
return IsZoomed(window.getNativeHandle())!=FALSE;
#else
return window.getSize()==sf::VideoMode::getDesktopMode().size;
#endif
}
}

View File

@@ -0,0 +1,13 @@
#pragma once
#include "FluidSim/Machine.hpp"
#include <stdexcept>
namespace machine_test {
inline void target_speed(fluidsim::port::MachineDocument& d,float rpm){using namespace fluidsim::port;for(auto& part:d.parts)if(part.kind==MachinePartKind::DriveMotor&&machine_shaft_for(d,part.id)==machine_primary_shaft(d)){part.drive.target_speed=rpm*2*pi/60;return;}throw std::runtime_error("Test bench has no speed drive");}
inline void free(fluidsim::port::MachineDocument& d,float rpm){using namespace fluidsim::port;const auto shaft=machine_primary_shaft(d);d.find(shaft)->shaft.initial_speed=rpm*2*pi/60;for(auto& p:d.parts)if(p.kind==MachinePartKind::DriveMotor&&machine_shaft_for(d,p.id)==shaft)p.drive.enabled=false;}
inline void drive(fluidsim::port::MachineDocument& d,float rpm){
using namespace fluidsim::port;const auto shaft=machine_primary_shaft(d);if(!shaft)throw std::runtime_error("Test bench needs a primary shaft");
d.find(shaft)->shaft.initial_speed=rpm*2*pi/60;
for(auto& part:d.parts)if(part.kind==MachinePartKind::DriveMotor&&machine_shaft_for(d,part.id)==shaft){part.drive.enabled=true;part.drive.speed_control=true;part.drive.target_speed=rpm*2*pi/60;return;}
auto& motor=d.add(MachinePartKind::DriveMotor,machine_math::add(d.find(shaft)->position,{-.3f,0,0}));const auto id=motor.id;motor.name="Test speed drive";motor.drive.target_speed=rpm*2*pi/60;d.connect({shaft,0},{id,0});
}
}

View File

@@ -7,7 +7,7 @@ using namespace fluidsim::port;
namespace { namespace {
void check(bool condition,const char* message) { if(!condition) throw std::runtime_error(message); } 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{}; }; 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) { Outcome flow(bool reverse,bool rotate,float loss,bool mixed=false,bool swap_donors=false,bool legacy=false) {
std::vector<Pipe1D> pipes; std::vector<Pipe1D> pipes;
for(int j=0;j<3;++j) { for(int j=0;j<3;++j) {
pipes.emplace_back(.001f*(mixed?j+1:1),.2f,8); pipes.emplace_back(.001f*(mixed?j+1:1),.2f,8);
@@ -16,19 +16,21 @@ Outcome flow(bool reverse,bool rotate,float loss,bool mixed=false,bool swap_dono
const double p=mixed?(j==0?120000:101325):101325; 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); 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}; cell={float(rho),float(rho*u),float(p/pipe_gm1+.5*rho*u*u),j==0?.7f:1.f,j==0?.1f:0.f};
cell.fuel_equivalent=j==0?.15f:0.f;
} }
} }
BoundarySystem boundaries(pipes); BoundarySystem boundaries(pipes);
std::array<std::array<float,3>,3> axes{{{-1,0,0},{1,0,0},{0,1,0}}}; 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]}; 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_junction({{0,reverse,axes[0]},{1,!reverse,axes[1]},{2,!reverse,axes[2]}},legacy?BoundarySystem::JunctionMethod::LegacyRusanov:BoundarySystem::JunctionMethod::Characteristic,loss);
boundaries.add_closed_end(0,!reverse);boundaries.add_closed_end(1,reverse);boundaries.add_closed_end(2,reverse); boundaries.add_closed_end(0,!reverse);boundaries.add_closed_end(1,reverse);boundaries.add_closed_end(2,reverse);
const auto totals=[&] { const auto totals=[&] {
std::array<double,4> t{}; std::array<double,5> t{};
for(auto& p:pipes) for(int i=0;i<int(p.cells.size());++i) { for(auto& p:pipes) for(int i=0;i<int(p.cells.size());++i) {
const double m=p.cell_mass(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[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; t[2]+=m*p.cells[i].air;t[3]+=m*p.cells[i].fuel;
t[4]+=m*p.cells[i].fuel_equivalent;
} }
return t; return t;
}; };
@@ -36,7 +38,7 @@ Outcome flow(bool reverse,bool rotate,float loss,bool mixed=false,bool swap_dono
// End before waves reflected by the sealed far ends reach the fitting. // End before waves reflected by the sealed far ends reach the fitting.
for(int i=0;i<30;++i) solver.step(1e-5f); for(int i=0;i<30;++i) solver.step(1e-5f);
const auto final=totals(); 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"); for(int j=0;j<5;++j) check(std::abs(final[j]-initial[j])<std::max(1e-10,std::abs(initial[j])*1e-5),"junction lost mass, energy, or species (including burned-fuel origin)");
Outcome result; Outcome result;
for(int j=0;j<3;++j) { for(int j=0;j<3;++j) {
const int endpoint=(j==0?reverse:!reverse)?0:7; const int endpoint=(j==0?reverse:!reverse)?0:7;
@@ -62,6 +64,7 @@ int main() {
} }
flow(false,false,.5f,true);flow(true,true,.5f,true); flow(false,false,.5f,true);flow(true,true,.5f,true);
flow(false,false,.5f,true,true);flow(true,true,.5f,true,true); flow(false,false,.5f,true,true);flow(true,true,.5f,true,true);
flow(false,false,0,false,false,true);flow(true,true,0,false,false,true);
// Supersonic endpoint data must explicitly select the conservative fallback. // 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); 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; } for(auto& cell:pipes[0].cells) { const float u=500;cell.momentum=cell.rho*u;cell.energy+=.5f*cell.rho*u*u; }

View File

@@ -0,0 +1,86 @@
#include "FluidSim/Desktop/LogicEditor.hpp"
#include "FluidSim/Desktop/PortAppearance.hpp"
#include <cmath>
#include <filesystem>
#include <iostream>
#include <stdexcept>
using namespace fluidsim::port;
using namespace fluidsim::port::desktop;
namespace {
void check(bool condition,const char* why){if(!condition)throw std::runtime_error(why);}
bool close(double a,double b,double tolerance=1e-5){return std::abs(a-b)<tolerance;}
struct Fixture {
MachineDocument doc;LogicEditor editor;std::unique_ptr<MachineSimulation> simulation;MachineSnapshot live;
sf::Vector2u size{1920,1080};WorkshopLayout ui=WorkshopLayout::calculate(size);sf::FloatRect canvas=ui.viewport;
std::vector<MachineDocument> history;std::string status;std::uint32_t physical{},focused{};bool shift{};
LogicEditorHooks hooks(){return {doc,simulation?&live:nullptr,shift,
[this](const std::function<void()>& action){const auto before=doc;try{action();validate_machine(doc);if(simulation)simulation->apply_tuning(doc);history.push_back(before);return true;}catch(const std::exception& e){doc=before;status=e.what();return false;}},
[this](std::uint32_t id,bool value){simulation->set_logic_control(id,value);},[this](std::uint32_t id,double value){simulation->set_logic_control(id,value);},[this]{if(simulation)simulation->release_logic_buttons();},
[this](std::uint32_t id){physical=id;},[this](std::uint32_t id){focused=id;},[this](const std::string& text){status=text;},[this]{load(machine_logic_example());}};}
void load(MachineDocument d){simulation.reset();doc=std::move(d);editor.reset_document();editor.fit(doc.logic,canvas);history.clear();}
void send(sf::Event e){auto h=hooks();editor.event(e,h,ui,canvas,true);}
void key(sf::Keyboard::Key code,bool control=false){send(sf::Event::KeyPressed{.code=code,.control=control});}
void text(const std::string& value){for(const auto c:value)send(sf::Event::TextEntered{std::uint32_t(c)});}
void press(sf::Vector2f point,sf::Mouse::Button button=sf::Mouse::Button::Left){send(sf::Event::MouseButtonPressed{button,{int(std::round(point.x)),int(std::round(point.y))}});}
void release(sf::Vector2f point,sf::Mouse::Button button=sf::Mouse::Button::Left){send(sf::Event::MouseButtonReleased{button,{int(std::round(point.x)),int(std::round(point.y))}});}
void click(sf::Vector2f point,sf::Mouse::Button button=sf::Mouse::Button::Left){press(point,button);release(point,button);}
void drag(sf::Vector2f from,sf::Vector2f to){press(from);send(sf::Event::MouseMoved{{int(std::round(to.x)),int(std::round(to.y))}});release(to);}
sf::Vector2f pin(std::uint32_t id,const char* key){return editor.pin_position(*doc.logic.find(id),key,canvas);}
sf::Vector2f control(std::uint32_t id,float fraction=.5f){const auto r=editor.node_rect(*doc.logic.find(id),canvas);const float zoom=r.size.x/240;return {r.position.x+(12+216*fraction)*zoom,r.position.y+r.size.y-26*zoom};}
void start(){simulation=std::make_unique<MachineSimulation>(doc);step();}
void step(int count=1){for(int i=0;i<count;++i)simulation->process();live=simulation->snapshot();}
SignalValue value(std::uint32_t id,const char* key){const auto ports=logic_node_ports(*doc.logic.find(id));for(const auto& n:live.logic)if(n.id==id)for(std::size_t i=0;i<ports.size();++i)if(ports[i].key==key)return n.values[i];throw std::runtime_error("Missing live pin");}
};
}
int main(int argc,char** argv)try{
Fixture f;
{
f.load(machine_speed_density_example());std::uint32_t connector=0;for(const auto& n:f.doc.logic.nodes)if(n.kind==LogicNodeKind::Ecu)connector=n.id;
const auto pins=logic_node_ports(*f.doc.logic.find(connector));check(pins.size()==4&&pins[2].key=="map"&&pins[2].pressure_reference==PressureReference::Absolute&&pins[3].key=="iat","Speed-density canvas connector has the wrong sensor schema");
f.editor.reveal(connector,f.doc,f.canvas);f.click(f.editor.node_rect(*f.doc.logic.find(connector),f.canvas).position+sf::Vector2f{30,12});f.start();check(f.value(connector,"map").validity==SignalValidity::Valid&&f.value(connector,"iat").validity==SignalValidity::Valid,"Live speed-density canvas lost MAP/IAT values");
}
{
f.load(machine_sensor_engine_example(true));std::uint32_t connector=0,target=0;for(const auto& n:f.doc.logic.nodes)if(n.kind==LogicNodeKind::Ecu){connector=n.id;target=n.part;}
const auto ports=logic_node_ports(*f.doc.logic.find(connector));check(ports.size()==3&&ports[0].quantity==SignalQuantity::AngularSpeed&&ports[1].quantity==SignalQuantity::Angle&&ports[2].quantity==SignalQuantity::Fraction,"Alpha-N connector lost required typed sensor inputs");for(const auto& pin:ports)check(pin.direction==PortDirection::Input&&pin.required,"ECU inputs are not required ring ports");
f.editor.reveal(connector,f.doc,f.canvas);f.click(f.editor.node_rect(*f.doc.logic.find(connector),f.canvas).position+sf::Vector2f{30,12});check(f.physical==target,"ECU graph selection did not select physical controller");
f.key(sf::Keyboard::Key::C,true);f.key(sf::Keyboard::Key::V,true);const auto pasted=f.editor.selected_node();check(pasted!=connector&&f.doc.logic.find(pasted)->kind==LogicNodeKind::Ecu&&f.doc.logic.find(pasted)->part==0,"Copied ECU connector stole existing ownership");
}
{
f.load({});f.key(sf::Keyboard::Key::F,true);f.text("Hysteresis");f.key(sf::Keyboard::Key::Enter);f.click({80,244});const auto point=f.canvas.position+sf::Vector2f{120,140};f.click(point);check(f.doc.logic.nodes.size()==1&&f.doc.logic.nodes[0].kind==LogicNodeKind::Hysteresis,"Searchable node library did not create the chosen kind");check(f.history.size()==1,"Node creation did not enter undo history");
}
MachineDocument wired;const auto a=wired.logic.add(LogicNodeKind::Switch,{0,0}).id,b=wired.logic.add(LogicNodeKind::Switch,{0,250}).id,gate=wired.logic.add(LogicNodeKind::And,{420,80}).id,number=wired.logic.add(LogicNodeKind::NumberConstant,{420,360}).id;f.load(wired);
f.click(f.pin(a,"out"));f.click(f.pin(gate,"in1"));check(f.doc.logic.links.size()==1,"Click-to-connect failed");f.drag(f.pin(b,"out"),f.pin(gate,"in2"));check(f.doc.logic.links.size()==2,"Drag-to-connect failed");
const auto before=f.doc.logic.links;f.click(f.pin(number,"out"));f.click(f.pin(gate,"in1"));check(f.doc.logic.links==before&&!f.status.empty(),"Incompatible preview/edit changed existing wiring");f.key(sf::Keyboard::Key::Escape);
f.drag(f.pin(gate,"in2"),f.pin(a,"out"));check(f.doc.logic.links.size()==2&&f.doc.logic.links.back().from.node==a,"Rewiring a connected input retained its old driver or rejected fan-out");
const auto rewired=f.doc.logic.links;f.drag(f.pin(gate,"in2"),f.pin(number,"out"));check(f.doc.logic.links==rewired,"Rejected rewire disconnected the original signal");f.key(sf::Keyboard::Key::Escape);f.click(f.pin(gate,"in1"),sf::Mouse::Button::Right);check(f.doc.logic.links.size()==1,"Right-click input did not disconnect its driver");
{
const auto old=f.doc.logic.find(a)->position;const auto r=f.editor.node_rect(*f.doc.logic.find(a),f.canvas);const auto from=r.position+sf::Vector2f{30,12},to=from+sf::Vector2f{80,25};const auto p=f.editor.unproject(from,f.canvas),q=f.editor.unproject(to,f.canvas);const auto count=f.history.size();f.drag(from,to);check(close(f.doc.logic.find(a)->position.x-old.x,q.x-p.x,2)&&close(f.doc.logic.find(a)->position.y-old.y,q.y-p.y,2),"Header drag did not move the selected graph node");check(f.history.size()==count+1,"A node drag created more than one undo entry");
const auto anchor=f.canvas.position+f.canvas.size*.4f;const auto original=f.editor.unproject(anchor,f.canvas);f.send(sf::Event::MouseWheelScrolled{sf::Mouse::Wheel::Vertical,2,{int(anchor.x),int(anchor.y)}});const auto after=f.editor.unproject(anchor,f.canvas);check(close(original.x,after.x,1)&&close(original.y,after.y,1),"Wheel zoom did not preserve the point beneath the pointer");
}
{
MachineDocument doc;const auto button=doc.logic.add(LogicNodeKind::Button,{0,0}).id,lamp=doc.logic.add(LogicNodeKind::Lamp,{420,0}).id;doc.logic.connect({button,"out"},{lamp,"in"});f.load(doc);f.start();f.press(f.control(button));f.step();check(std::get<bool>(f.value(lamp,"in").value),"Live momentary control did not reach the graph");f.send(sf::Event::FocusLost{});f.step();check(!std::get<bool>(f.value(lamp,"in").value),"Focus loss left a momentary button true");f.press(f.control(button));auto h=f.hooks();f.editor.deactivate(h);f.step();check(!std::get<bool>(f.value(lamp,"in").value),"Closing the graph left a held button active");
const auto nodes=f.doc.logic.nodes.size();f.key(sf::Keyboard::Key::Delete);check(f.doc.logic.nodes.size()==nodes,"Test allowed node deletion");
}
{
MachineDocument doc;const auto toggle=doc.logic.add(LogicNodeKind::Switch,{0,0}).id,lamp=doc.logic.add(LogicNodeKind::Lamp,{420,0}).id;doc.logic.connect({toggle,"out"},{lamp,"in"});f.load(doc);f.start();const auto time=f.live.time;f.click(f.control(toggle));f.click(f.control(toggle));check(f.live.time==time,"Editing controls advanced a paused simulation");f.step();check(!std::get<bool>(f.value(lamp,"in").value)&&!f.doc.logic.find(toggle)->boolean,"Two queued toggles collapsed or changed configured initial state");
}
{
MachineDocument doc;const auto slider=doc.logic.add(LogicNodeKind::Slider,{0,0}).id,readout=doc.logic.add(LogicNodeKind::Readout,{420,0}).id;doc.logic.connect({slider,"out"},{readout,"in"});f.load(doc);f.start();f.click(f.control(slider,.75f));f.step();check(close(std::get<double>(f.value(readout,"in").value),.75,.01)&&f.doc.logic.find(slider)->value==0,"Live slider failed or changed its initial value");
}
{
MachineDocument doc;const auto constant=doc.logic.add(LogicNodeKind::NumberConstant,{0,0}).id,readout=doc.logic.add(LogicNodeKind::Readout,{420,0}).id;doc.logic.connect({constant,"out"},{readout,"in"});f.load(doc);f.start();f.editor.select_node(constant,f.doc);const auto time=f.live.time;f.click({f.ui.inspector_x+40,342});f.text("-3.25");f.key(sf::Keyboard::Key::Enter);check(f.doc.logic.find(constant)->value==-3.25&&f.live.time==time,"Live constant property edit failed or restarted physics");f.step();check(std::get<double>(f.value(readout,"in").value)==-3.25,"Live constant edit did not reach the next sample");f.click({f.ui.inspector_x+40,342});f.text("1e999");f.key(sf::Keyboard::Key::Enter);check(f.editor.editing_text()&&f.doc.logic.find(constant)->value==-3.25,"Invalid numeric input was lost or applied");f.key(sf::Keyboard::Key::Escape);
}
{
MachineDocument doc;const auto host=doc.add(MachinePartKind::Chamber).id,sensor=doc.add_sensor(MachineSensorKind::Pressure,host).id,reference=doc.logic.add(LogicNodeKind::Sensor,{0,0}).id;f.load(doc);f.editor.select_node(reference,f.doc);f.click({f.ui.inspector_x+60,208});check(f.doc.logic.find(reference)->sensor==sensor&&f.doc.sensors.size()==1&&f.doc.parts.size()==1,"Sensor reference created a duplicate physical sensor or failed to bind");f.click({f.ui.inspector_x+60,f.ui.height-92});check(f.focused==host,"Focus in 3D did not resolve the shared physical host");
f.key(sf::Keyboard::Key::C,true);f.key(sf::Keyboard::Key::V,true);check(f.doc.logic.nodes.size()==2&&f.doc.logic.nodes.back().sensor==sensor,"Copied sensor node did not reference the same sensor entity");
}
{
MachineDocument destination;const auto part=destination.add(MachinePartKind::Chamber).id;destination.add_sensor(MachineSensorKind::GasTemperature,part);f.load(destination);f.key(sf::Keyboard::Key::V,true);check(f.doc.logic.nodes.size()==1&&f.doc.logic.nodes[0].sensor==0&&!f.doc.logic.nodes[0].binding_label.empty(),"Cross-document graph paste silently matched a coincidental sensor ID or lost its intended binding");
}
if(argc==3&&std::string(argv[1])=="--ui-preview"){
const std::filesystem::path output=argv[2];std::filesystem::create_directories(output);sf::ContextSettings settings;settings.depthBits=24;settings.majorVersion=2;settings.minorVersion=1;sf::RenderWindow window(sf::VideoMode({1920,1080}),"Logic editor review",sf::Style::Default,sf::State::Windowed,settings);window.setVisible(false);sf::Font font("C:/Windows/Fonts/consola.ttf");
{MachineDocument shapes;const auto source=shapes.logic.add(LogicNodeKind::Switch,{0,0}).id,input=shapes.logic.add(LogicNodeKind::Lamp,{420,0}).id,number=shapes.logic.add(LogicNodeKind::NumberConstant,{0,250}).id,readout=shapes.logic.add(LogicNodeKind::Readout,{420,250}).id;shapes.logic.connect({source,"out"},{input,"in"});shapes.logic.connect({number,"out"},{readout,"in"});f.load(shapes);window.clear();auto h=f.hooks();f.editor.draw(window,&font,h,f.ui,f.canvas,true);sf::Texture pixels(f.size);pixels.update(window);const auto image=pixels.copyToImage();const auto sample=[&](std::uint32_t id,const char* key){const auto at=f.pin(id,key);return image.getPixel({unsigned(std::round(at.x)),unsigned(std::round(at.y))});};const auto red=sample(source,"out"),green=sample(number,"out"),ring=sample(input,"in");check(red.r>150&&red.g<150&&green.g>150&&green.r<150,"Rendered signal output circles do not use the declared Boolean/Number colors");check(ring.r<50&&ring.g<50&&ring.b<50,"Rendered input pin has a filled center instead of a ring");}
for(const auto resolution:{sf::Vector2u{1920,1080},sf::Vector2u{1280,720}}){f.size=resolution;f.ui=WorkshopLayout::calculate(resolution);f.canvas=f.ui.viewport;window.setSize(resolution);f.load(machine_logic_example());auto capture=[&](const std::string& name){window.clear();auto h=f.hooks();f.editor.draw(window,&font,h,f.ui,f.canvas,true);sf::Texture pixels(resolution);pixels.update(window);check(pixels.copyToImage().saveToFile(output/(std::to_string(resolution.x)+"-"+name+".png")),"Logic screenshot save failed");};capture("graph-build");f.start();for(int frame=0;frame<10;++frame){f.step(1200);auto h=f.hooks();f.editor.draw(window,&font,h,f.ui,f.canvas,true);}capture("graph-live");for(const auto& n:f.doc.logic.nodes)if(n.kind==LogicNodeKind::Hysteresis){f.editor.reveal(n.id,f.doc,f.canvas);capture("threshold-inspector");break;}}
}
std::cout<<"Logic canvas placement, typed wiring/rewiring, undo, controls, focus release, binding and live inspector passed\n";return 0;
}catch(const std::exception& e){std::cerr<<e.what()<<'\n';return 1;}

84
tests/logic_tests.cpp Normal file
View File

@@ -0,0 +1,84 @@
#include "FluidSim/Machine.hpp"
#include <algorithm>
#include <chrono>
#include <cmath>
#include <fstream>
#include <iostream>
#include <limits>
#include <stdexcept>
using namespace fluidsim::port;
namespace {
void check(bool condition,const char* why){if(!condition)throw std::runtime_error(why);}
bool close(double a,double b){return std::abs(a-b)<=1e-9*std::max({1.,std::abs(a),std::abs(b)});}
template<class F>void rejects(F&& f,const char* why){bool rejected=false;try{f();}catch(const std::exception&){rejected=true;}check(rejected,why);}
double number(const MachineLogicRuntime& r,std::uint32_t n,const char* port="out"){const auto value=r.value({n,port});check(value.validity==SignalValidity::Valid,"Numeric output unexpectedly invalid");return std::get<double>(value.value);}
bool bit(const MachineLogicRuntime& r,std::uint32_t n,const char* port="out"){const auto value=r.value({n,port});check(value.validity==SignalValidity::Valid,"Boolean output unexpectedly invalid");return std::get<bool>(value.value);}
std::uint32_t add(LogicGraph& g,LogicNodeKind kind){return g.add(kind).id;}
}
int main()try{
for(int i=0;i<logic_node_kind_count;++i){LogicNode node;node.kind=LogicNodeKind(i);check(!logic_node_ports(node).empty(),"Node kind has no declared typed ports");}
for(const auto kind:{LogicNodeKind::And,LogicNodeKind::Or,LogicNodeKind::Xor,LogicNodeKind::Nand,LogicNodeKind::Nor})for(int mask=0;mask<4;++mask){
MachineDocument doc;auto& g=doc.logic;const auto gate=add(g,kind),a=add(g,LogicNodeKind::BooleanConstant),b=add(g,LogicNodeKind::BooleanConstant);g.find(a)->boolean=(mask&1)!=0;g.find(b)->boolean=(mask&2)!=0;g.connect({gate,"in1"},{a,"out"});g.connect({b,"out"},{gate,"in2"});MachineLogicRuntime runtime(doc);runtime.evaluate(0,.001);
bool expected=false;if(kind==LogicNodeKind::And)expected=mask==3;else if(kind==LogicNodeKind::Or)expected=mask!=0;else if(kind==LogicNodeKind::Xor)expected=mask==1||mask==2;else if(kind==LogicNodeKind::Nand)expected=mask!=3;else expected=mask==0;
check(bit(runtime,gate)==expected,"Boolean gate truth table is wrong");check(runtime.value({gate,"out"}).timestamp==0,"Logic output has the wrong sample timestamp");
}
{
MachineDocument doc;auto& g=doc.logic;const auto gate=add(g,LogicNodeKind::And),source=add(g,LogicNodeKind::Switch),negated=add(g,LogicNodeKind::Not);g.find(gate)->input_count=3;
g.connect({source,"out"},{gate,"in1"});g.connect({source,"out"},{gate,"in2"});g.connect({source,"out"},{gate,"in3"});g.connect({gate,"out"},{negated,"in"});MachineLogicRuntime runtime(doc);runtime.evaluate(0,.01);check(bit(runtime,negated),"NOT / fan-out failed");runtime.set_manual(source,true);runtime.evaluate(.01,.01);check(bit(runtime,gate)&&!bit(runtime,negated),"Three-input AND did not use all inputs");
const auto before=g.links.size();check(!logic_connection_preview(g,{source,"out"},{gate,"in1"}).allowed&&g.links.size()==before,"Connection preview mutated the graph or accepted two drivers");rejects([&]{g.connect({source,"out"},{gate,"out"});},"Output-to-output connection was accepted");rejects([&]{g.connect({source,"missing"},{negated,"in"});},"Unknown stable pin was accepted");
auto broken=g;broken.disconnect({gate,"in3"});validate_logic_graph(broken);auto missing=doc;missing.logic=broken;rejects([&]{MachineLogicRuntime invalid(missing);},"Required unwired input did not block compilation");
rejects([&]{runtime.set_manual(source,1.);},"Boolean control accepted an implicit number cast");
}
{
MachineDocument doc;auto& g=doc.logic;const auto a=add(g,LogicNodeKind::Not),b=add(g,LogicNodeKind::Not);g.find(a)->name="First NOT";g.find(b)->name="Second NOT";g.connect({a,"out"},{b,"in"});std::string error;try{g.connect({b,"out"},{a,"in"});}catch(const std::exception& e){error=e.what();}check(g.links.size()==1&&error.find("First NOT")!=std::string::npos&&error.find("Second NOT")!=std::string::npos,"Cycle rejection omitted its path or changed the draft");
const auto delay=add(g,LogicNodeKind::BooleanDelay);g.connect({b,"out"},{delay,"in"});g.connect({delay,"out"},{a,"in"});MachineLogicRuntime feedback(doc);feedback.evaluate(0,.001);check(!bit(feedback,delay)&&bit(feedback,a)&&!bit(feedback,b),"Memory output was not initialized before combinational evaluation");feedback.evaluate(.001,.001);check(!bit(feedback,delay),"Delay did not commit stored state simultaneously");
auto cyclic=g;cyclic.disconnect({a,"in"});cyclic.links.push_back({{b,"out"},{a,"in"}});std::vector<std::uint32_t> path;rejects([&]{logic_evaluation_order(cyclic,&path);},"Cycle path query accepted a combinational cycle");check(path.size()==3&&path.front()==path.back()&&std::find(path.begin(),path.end(),a)!=path.end()&&std::find(path.begin(),path.end(),b)!=path.end(),"Cycle feedback did not identify the actual nodes");logic_evaluation_order(g,&path);check(path.empty(),"A valid graph retained a stale cycle highlight");
}
{
MachineDocument doc;auto& g=doc.logic;const auto delay=add(g,LogicNodeKind::NumberDelay),sum=add(g,LogicNodeKind::Add),one=add(g,LogicNodeKind::NumberConstant);g.find(one)->value=1;g.connect({delay,"out"},{sum,"a"});g.connect({one,"out"},{sum,"b"});g.connect({sum,"out"},{delay,"in"});MachineLogicRuntime feedback(doc);
for(int i=0;i<8;++i){feedback.evaluate(i*.01,.01);check(number(feedback,delay)==i&&number(feedback,sum)==i+1,"Delayed numeric recurrence depends on document order or leaks next state");}
}
{
MachineDocument doc;auto& g=doc.logic;const auto button=add(g,LogicNodeKind::Button),lamp=add(g,LogicNodeKind::Lamp),slider=add(g,LogicNodeKind::Slider);g.connect({button,"out"},{lamp,"in"});MachineLogicRuntime controls(doc);controls.set_manual(button,true);controls.set_manual(button,false);controls.set_manual(button,true);controls.set_manual(button,false);
for(int i=0;i<4;++i){controls.evaluate(i*.001,.001);check(bit(controls,lamp,"in")==!(i%2),"Short button events collapsed or remained pressed");}
controls.set_manual(button,true);controls.release_buttons();controls.evaluate(.004,.001);check(!bit(controls,button),"Focus-loss release left a queued button press");controls.set_manual(slider,.75);controls.evaluate(.005,.001);check(number(controls,slider)==.75,"Numeric manual control event was lost");rejects([&]{controls.set_manual(slider,1.5);},"Out-of-range slider control was accepted");rejects([&]{controls.set_manual(slider,true);},"Numeric control accepted Boolean coercion");
for(int i=0;i<10000;++i)controls.set_manual(slider,double(i%1000)/1000);controls.evaluate(.006,.001);check(close(number(controls,slider),.999),"Paused slider motion overflowed its event queue or lost the latest value");
MachineLogicRuntime fresh(doc);fresh.evaluate(0,.001);check(!bit(fresh,button)&&number(fresh,slider)==0,"Fresh Test inherited transient controls");
}
{
MachineDocument doc;auto& g=doc.logic;const auto a=add(g,LogicNodeKind::NumberConstant),b=add(g,LogicNodeKind::NumberConstant);g.find(a)->value=7;g.find(b)->value=3;
std::vector<std::pair<std::uint32_t,double>> expected;
for(const auto [kind,value]:{std::pair{LogicNodeKind::Add,10.},std::pair{LogicNodeKind::Subtract,4.},std::pair{LogicNodeKind::Multiply,21.},std::pair{LogicNodeKind::Divide,7./3},std::pair{LogicNodeKind::Minimum,3.},std::pair{LogicNodeKind::Maximum,7.}}){const auto n=add(g,kind);g.connect({a,"out"},{n,"a"});g.connect({b,"out"},{n,"b"});expected.emplace_back(n,value);}
const auto absolute=add(g,LogicNodeKind::Absolute),clamp=add(g,LogicNodeKind::Clamp),gain=add(g,LogicNodeKind::GainOffset),map=add(g,LogicNodeKind::MapRange);g.find(clamp)->low=2;g.find(clamp)->high=5;g.find(gain)->gain=2;g.find(gain)->offset=1;g.find(map)->input_low=0;g.find(map)->input_high=10;g.find(map)->output_low=0;g.find(map)->output_high=100;
for(auto id:{absolute,clamp,gain,map})g.connect({a,"out"},{id,"in"});expected.insert(expected.end(),{{absolute,7},{clamp,5},{gain,15},{map,70}});
const auto greater=add(g,LogicNodeKind::Greater),less=add(g,LogicNodeKind::Less),equal=add(g,LogicNodeKind::Equal),window=add(g,LogicNodeKind::Window);for(auto id:{greater,less,equal}){g.connect({a,"out"},{id,"a"});g.connect({b,"out"},{id,"b"});}g.find(window)->low=2;g.find(window)->high=8;g.connect({a,"out"},{window,"in"});
MachineLogicRuntime runtime(doc);runtime.evaluate(0,.001);for(const auto& [n,value]:expected)check(close(number(runtime,n),value),"Numeric arithmetic or mapping is incorrect");check(bit(runtime,greater)&&!bit(runtime,less)&&!bit(runtime,equal)&&bit(runtime,window),"Numeric comparisons are incorrect");
g.find(b)->value=0;runtime.apply_tuning(doc);runtime.evaluate(.001,.001);const auto divider=expected[3].first;check(runtime.value({divider,"out"}).validity==SignalValidity::Fault,"Division by zero silently produced an ordinary number");bool diagnosed=false;for(const auto& n:runtime.snapshot())if(n.id==divider)diagnosed=n.problem=="Division by zero";check(diagnosed,"Invalid math omitted its runtime diagnostic");g.find(b)->value=7+1e-7;runtime.apply_tuning(doc);runtime.evaluate(.002,.001);check(bit(runtime,equal),"Equal tolerance was ignored");
g.find(a)->value=std::numeric_limits<double>::max();g.find(gain)->gain=2;runtime.apply_tuning(doc);runtime.evaluate(.003,.001);check(runtime.value({gain,"out"}).validity==SignalValidity::Fault,"Arithmetic overflow was valid");
}
{
MachineDocument doc;auto& g=doc.logic;const auto n=add(g,LogicNodeKind::NumberConstant),threshold=add(g,LogicNodeKind::Hysteresis);g.find(threshold)->low=3;g.find(threshold)->high=7;g.connect({n,"out"},{threshold,"in"});MachineLogicRuntime runtime(doc);int tick=0;
for(const auto [input,expected]:{std::pair{5.,false},std::pair{8.,true},std::pair{5.,true},std::pair{2.,false}}){g.find(n)->value=input;runtime.apply_tuning(doc);runtime.evaluate(tick++*.001,.001);check(bit(runtime,threshold)==expected,"Hysteresis did not preserve its state in the deadband");}
}
{
MachineDocument doc;auto& g=doc.logic;const auto good=add(g,LogicNodeKind::NumberConstant),zero=add(g,LogicNodeKind::NumberConstant),bad=add(g,LogicNodeKind::Divide),select=add(g,LogicNodeKind::Select),condition=add(g,LogicNodeKind::BooleanConstant);g.find(good)->value=42;
g.connect({good,"out"},{bad,"a"});g.connect({zero,"out"},{bad,"b"});g.connect({condition,"out"},{select,"select"});g.connect({good,"out"},{select,"a"});g.connect({bad,"out"},{select,"b"});MachineLogicRuntime runtime(doc);runtime.evaluate(0,.001);check(number(runtime,select)==42,"Invalid unselected branch poisoned a valid selected value");for(const auto& state:runtime.snapshot())if(state.id==select)check(state.problem.empty(),"Selector reported an inactive-branch fault as an output fault");
}
{
MachineDocument doc;auto& g=doc.logic;const auto p=add(g,LogicNodeKind::NumberConstant),q=add(g,LogicNodeKind::NumberConstant),difference=add(g,LogicNodeKind::Subtract),target=add(g,LogicNodeKind::Readout);for(auto id:{p,q,difference}){g.find(id)->quantity=SignalQuantity::Pressure;g.find(id)->pressure_reference=PressureReference::Absolute;}g.find(p)->value=150000;g.find(q)->value=101325;g.find(target)->quantity=SignalQuantity::Pressure;g.find(target)->pressure_reference=PressureReference::Differential;
g.connect({p,"out"},{difference,"a"});g.connect({q,"out"},{difference,"b"});g.connect({difference,"out"},{target,"in"});check(!logic_connection_preview(g,{p,"out"},{target,"in"}).allowed,"Absolute and differential pressure implicitly mixed");MachineLogicRuntime runtime(doc);runtime.evaluate(0,.01);check(number(runtime,target,"in")==48675,"Pressure subtraction did not produce a differential quantity");
const auto speed=add(g,LogicNodeKind::Readout);g.find(speed)->quantity=SignalQuantity::AngularSpeed;check(!logic_connection_preview(g,{p,"out"},{speed,"in"}).allowed,"Pressure connected to angular speed");
}
{
MachineDocument doc;const auto host=doc.add(MachinePartKind::Chamber).id,sensor=doc.add_sensor(MachineSensorKind::Pressure,host).id;auto& g=doc.logic;const auto source=add(g,LogicNodeKind::Sensor),threshold=add(g,LogicNodeKind::Hysteresis),choose=add(g,LogicNodeKind::Select),convert=add(g,LogicNodeKind::BooleanToNumber),a=add(g,LogicNodeKind::NumberConstant),b=add(g,LogicNodeKind::NumberConstant);
g.find(source)->sensor=sensor;g.find(threshold)->quantity=SignalQuantity::Pressure;g.find(threshold)->pressure_reference=PressureReference::Absolute;g.find(threshold)->low=100000;g.find(threshold)->high=130000;g.find(a)->value=1;g.find(b)->value=9;g.find(convert)->false_value=-1;g.find(convert)->true_value=2;
g.connect({source,"value"},{threshold,"in"});g.connect({threshold,"out"},{choose,"select"});g.connect({a,"out"},{choose,"a"});g.connect({b,"out"},{choose,"b"});g.connect({threshold,"out"},{convert,"in"});
double time=0;SignalValue reading=SignalValue::number(140000);MachineLogicRuntime runtime(doc,[&](std::uint32_t id){MachineSensorState state;state.id=id;state.count=1;state.outputs[0]=reading;return state;});runtime.evaluate(time,.01);check(number(runtime,choose)==9&&number(runtime,convert)==2,"Typed number selection/conversion failed");
time=.01;reading=SignalValue::number(0,time);reading.validity=SignalValidity::Missing;runtime.evaluate(time,.01);check(runtime.value({threshold,"out"}).validity==SignalValidity::Missing&&runtime.value({choose,"out"}).validity==SignalValidity::Missing,"Missing sensor was converted to ordinary false or zero");
time=.02;reading=SignalValue::boolean(true,time);runtime.evaluate(time,.01);check(runtime.value({source,"value"}).validity==SignalValidity::Fault,"Sensor supplied the wrong runtime variant without a fault");
const auto file=std::filesystem::temp_directory_path()/("fluidsim-logic-"+std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())+".machine.toml");save_machine(doc,file);const auto loaded=load_machine(file);check(loaded.logic==doc.logic,"Logic types, keys, parameters or layout did not round-trip");
{std::ofstream out(file);out<<"format=8\n[[logic.nodes]]\nid=1\nkind='unknown_future_gate'\n";}rejects([&]{load_machine(file);},"Unknown saved logic kind was silently accepted");
{std::ofstream out(file);out<<"format=8\n[[logic.nodes]]\nid=1\nkind='switch'\nboolean=1\n";}rejects([&]{load_machine(file);},"Saved Boolean control silently accepted a number");std::filesystem::remove(file);
}
std::cout<<"Typed graph ordering, gates, arithmetic, validity, manual events, pressure references and explicit delayed feedback passed\n";return 0;
}catch(const std::exception& e){std::cerr<<e.what()<<'\n';return 1;}

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;}

View File

@@ -0,0 +1,70 @@
#include "FluidSim/Machine.hpp"
#include <algorithm>
#include <chrono>
#include <cmath>
#include <fstream>
#include <iostream>
#include <stdexcept>
using namespace fluidsim::port;
namespace {
void check(bool value,const char* why){if(!value)throw std::runtime_error(why);}
bool close(double a,double b,double tolerance=1e-5){return std::abs(a-b)<=tolerance*std::max({1.,std::abs(a),std::abs(b)});}
template<class F>void rejects(F&& f,const char* why){bool caught=false;try{f();}catch(const std::exception&){caught=true;}check(caught,why);}
void run(MachineSimulation& sim,int count){for(int i=0;i<count;++i){const auto a=sim.process();check(std::isfinite(a.left)&&std::isfinite(a.right),"Logic-controlled machine generated invalid audio");}}
SignalValue value(const MachineSnapshot& snapshot,const MachineDocument& doc,std::uint32_t id,const char* key){const auto ports=logic_node_ports(*doc.logic.find(id));for(const auto& n:snapshot.logic)if(n.id==id)for(std::size_t p=0;p<ports.size();++p)if(ports[p].key==key)return n.values[p];throw std::runtime_error("Missing live logic port");}
const MachinePartState& part(const MachineSnapshot& snapshot,std::uint32_t id){for(const auto& p:snapshot.parts)if(p.id==id)return p;throw std::runtime_error("Missing physical part");}
std::uint32_t node(const MachineDocument& d,LogicNodeKind kind){for(const auto& n:d.logic.nodes)if(n.kind==kind)return n.id;throw std::runtime_error("Missing logic node kind");}
}
int main(int argc,char** argv)try{
MachineDocument controlled;const auto shaft=controlled.add(MachinePartKind::Shaft).id,motor=controlled.add(MachinePartKind::DriveMotor).id;controlled.find(shaft)->shaft.inertia=1;controlled.find(motor)->drive={0,10,false,true};controlled.connect({shaft,0},{motor,0});
const auto enable=controlled.logic.add(LogicNodeKind::Command).id;controlled.logic.find(enable)->part=motor;
{MachineSimulation inactive(controlled,1000);check(!part(inactive.snapshot(),motor).actuator_enabled,"Signal-owned enable started in its manual state before the first sample");run(inactive,10);check(part(inactive.snapshot(),shaft).shaft_speed==0,"Unwired optional command used the manual enabled state instead of inactive default");}
const auto toggle=controlled.logic.add(LogicNodeKind::Switch).id;controlled.logic.connect({toggle,"out"},{enable,"in"});
const auto target=controlled.logic.add(LogicNodeKind::Command).id;controlled.logic.find(target)->part=motor;controlled.logic.find(target)->command=LogicCommand::MotorTorque;
const auto amount=controlled.logic.add(LogicNodeKind::NumberConstant).id;controlled.logic.find(amount)->quantity=SignalQuantity::Torque;controlled.logic.find(amount)->value=3;controlled.logic.connect({amount,"out"},{target,"in"});
MachineSimulation sim(controlled,1000);sim.set_logic_control(toggle,true);run(sim,10);check(close(part(sim.snapshot(),shaft).shaft_speed,.03),"Signal enable / torque target did not own the declared drive fields");
sim.set_logic_control(toggle,false);run(sim,10);check(close(part(sim.snapshot(),shaft).shaft_speed,.03)&&part(sim.snapshot(),motor).torque==0,"Disabling a signal-owned motor continued to add drive work");
auto tuned=controlled;tuned.logic.find(amount)->value=6;const auto time=sim.snapshot().time;sim.apply_tuning(tuned);check(sim.snapshot().time==time,"Constant tuning reset simulation time");sim.set_logic_control(toggle,true);run(sim,10);check(close(part(sim.snapshot(),shaft).shaft_speed,.09),"Live constant tuning did not update latched drive commands");
auto retyped=tuned;retyped.logic.find(amount)->quantity=SignalQuantity::Power;rejects([&]{sim.apply_tuning(retyped);},"A live quantity edit changed compiled pin meaning");
auto duplicate=controlled;const auto second=duplicate.logic.add(LogicNodeKind::Command).id;duplicate.logic.find(second)->part=motor;rejects([&]{validate_machine(duplicate);},"Two signal owners claimed the same motor field");
{
auto bad=controlled;bad.logic.disconnect({target,"in"});const auto divider=bad.logic.add(LogicNodeKind::Divide).id;bad.logic.find(divider)->quantity=SignalQuantity::Torque;const auto zero=bad.logic.add(LogicNodeKind::NumberConstant).id;bad.logic.connect({amount,"out"},{divider,"a"});bad.logic.connect({zero,"out"},{divider,"b"});bad.logic.connect({divider,"out"},{target,"in"});bad.logic.find(toggle)->boolean=true;MachineSimulation fault(bad,1000);run(fault,20);check(part(fault.snapshot(),shaft).shaft_speed==0&&value(fault.snapshot(),bad,target,"in").validity==SignalValidity::Fault,"Invalid numeric command drove the machine or hid its fault");
}
{
auto engine=machine_engine_example();std::uint32_t throttle=0;for(const auto& p:engine.parts)if(p.kind==MachinePartKind::Throttle)throttle=p.id;const auto owner=engine.logic.add(LogicNodeKind::Command).id;engine.logic.find(owner)->command=LogicCommand::ThrottleOpening;engine.logic.find(owner)->part=throttle;rejects([&]{MachineSimulation conflicting(engine);},"Signal and legacy ECU simultaneously owned throttle opening");
}
{
auto engine=machine_engine_example();const auto crank=machine_primary_shaft(engine),drive=engine.add(MachinePartKind::DriveMotor).id;engine.connect({crank,0},{drive,0});const auto command=engine.logic.add(LogicNodeKind::Command).id,speed=engine.logic.add(LogicNodeKind::NumberConstant).id;engine.logic.find(command)->part=drive;engine.logic.find(command)->command=LogicCommand::MotorSpeed;engine.logic.find(speed)->quantity=SignalQuantity::AngularSpeed;engine.logic.find(speed)->value=-10;engine.logic.connect({speed,"out"},{command,"in"});check(logic_node_ports(*engine.logic.find(command),&engine)[0].minimum==0,"Engine command metadata does not expose its forward-only range");MachineSimulation forward(engine);run(forward,16);check(value(forward.snapshot(),engine,command,"in").validity==SignalValidity::Fault&&part(forward.snapshot(),drive).command_target==0,"Negative speed on a forward-only engine stopped Test instead of applying its inactive fault fallback");engine.logic.find(speed)->value=20;forward.apply_tuning(engine);run(forward,16);check(close(part(forward.snapshot(),crank).shaft_speed,20),"A recovered speed command remained faulted");
}
{
MachineDocument valve;const auto duct=valve.add(MachinePartKind::Pipe).id,body=valve.add(MachinePartKind::Throttle).id;valve.connect({duct,1},{body,0});const auto tps=valve.add_sensor(MachineSensorKind::ThrottlePosition,body).id;
const auto opening=valve.logic.add(LogicNodeKind::Command).id;valve.logic.find(opening)->command=LogicCommand::ThrottleOpening;valve.logic.find(opening)->part=body;const auto slider=valve.logic.add(LogicNodeKind::Slider).id;valve.logic.find(slider)->quantity=SignalQuantity::Fraction;valve.logic.connect({slider,"out"},{opening,"in"});MachineSimulation commanded(valve);commanded.set_controls(.95f);commanded.set_logic_control(slider,.4);run(commanded,1);check(close(std::get<double>(commanded.sense(tps).outputs[0].value),.4),"TPS or valve ignored signal ownership and followed the pedal");
}
{
MachineDocument pure;const auto button=pure.logic.add(LogicNodeKind::Button).id,lamp=pure.logic.add(LogicNodeKind::Lamp).id;pure.logic.connect({button,"out"},{lamp,"in"});MachineSimulation logic_only(pure,1000);logic_only.set_logic_control(button,true);logic_only.set_logic_control(button,false);run(logic_only,1);check(std::get<bool>(value(logic_only.snapshot(),pure,lamp,"in").value),"A short momentary press vanished before a physics boundary");run(logic_only,1);check(!std::get<bool>(value(logic_only.snapshot(),pure,lamp,"in").value),"A short momentary press remained true");check(logic_only.snapshot().cells==0&&logic_only.snapshot().parts.empty(),"Logic-only test invented physical gas parts");
}
{
MachineDocument start;const auto crank=start.add(MachinePartKind::Shaft).id;start.find(crank)->shaft.inertia=1;start.find(crank)->shaft.starter_torque=5;const auto command=start.logic.add(LogicNodeKind::Command).id,button=start.logic.add(LogicNodeKind::BooleanConstant).id;start.logic.find(command)->part=crank;start.logic.find(command)->command=LogicCommand::Starter;start.logic.find(button)->boolean=true;start.logic.connect({button,"out"},{command,"in"});MachineSimulation starter(start,1000);run(starter,100);check(close(part(starter.snapshot(),crank).shaft_speed,.5),"Starter command did not supply its declared shaft torque");start.logic.find(button)->boolean=false;starter.apply_tuning(start);starter.set_controls(0,true);run(starter,100);check(close(part(starter.snapshot(),crank).shaft_speed,.5),"Keyboard starter competed with signal ownership");
}
{
MachineDocument braking;const auto crank=braking.add(MachinePartKind::Shaft).id,drive=braking.add(MachinePartKind::DriveMotor).id,brake=braking.add(MachinePartKind::Brake).id;braking.find(crank)->shaft.inertia=1;braking.find(drive)->drive={0,4,false,true};braking.find(brake)->brake.constant=5;braking.connect({crank,0},{drive,0});braking.connect({crank,0},{brake,0});const auto command=braking.logic.add(LogicNodeKind::Command).id,toggle=braking.logic.add(LogicNodeKind::Switch).id;braking.logic.find(command)->part=brake;braking.logic.find(command)->command=LogicCommand::BrakeEnable;braking.logic.find(toggle)->boolean=true;braking.logic.connect({toggle,"out"},{command,"in"});MachineSimulation load(braking,1000);run(load,100);check(part(load.snapshot(),crank).shaft_speed==0,"Signal-enabled brake drove through a static hold");load.set_logic_control(toggle,false);run(load,100);check(close(part(load.snapshot(),crank).shaft_speed,.4),"Signal disable did not release the brake");
}
auto bench=machine_logic_example();const auto master=node(bench,LogicNodeKind::Switch),hysteresis=node(bench,LogicNodeKind::Hysteresis),pressure=node(bench,LogicNodeKind::Sensor);std::uint32_t bench_motor=0,tank=bench.sensors[0].target.part;for(const auto& p:bench.parts)if(p.kind==MachinePartKind::DriveMotor)bench_motor=p.id;
MachineSimulation compressor(bench);bool stopped=false;double first_stop=0;for(int i=0;i<44100;++i){compressor.process();if(i%32==0){const auto snapshot=compressor.snapshot();const auto demand=value(snapshot,bench,hysteresis,"out");if(demand.validity==SignalValidity::Valid&&!std::get<bool>(demand.value)){check(part(snapshot,tank).pressure>=149000,"Compressor threshold switched off before its upper pressure");check(std::abs(part(snapshot,bench_motor).torque)<1e-8,"Compressor drive still supplied torque after demand switched off");stopped=true;first_stop=snapshot.time;break;}}}
check(stopped&&first_stop>0,"Pressure feedback did not stop the compressor at its upper threshold");
{
auto counted=bench;counted.cells_per_meter=500;const auto memory=counted.logic.add(LogicNodeKind::NumberDelay).id,sum=counted.logic.add(LogicNodeKind::Add).id,one=counted.logic.add(LogicNodeKind::NumberConstant).id;counted.logic.find(one)->value=1;counted.logic.connect({memory,"out"},{sum,"a"});counted.logic.connect({one,"out"},{sum,"b"});counted.logic.connect({sum,"out"},{memory,"in"});MachineSimulation ticks(counted);run(ticks,10);const auto snapshot=ticks.snapshot();check(snapshot.substeps>10&&std::get<double>(value(snapshot,counted,memory,"out").value)==9,"Logic advanced at fluid CFL frequency instead of the configured sample boundary");
}
MachineSimulation manual(bench);manual.set_logic_control(master,false);run(manual,64);check(part(manual.snapshot(),bench_motor).shaft_speed==0,"Master switch failed to inhibit automatic compressor demand");
auto missing=bench;missing.remove_sensor(bench.logic.find(pressure)->sensor);check(missing.logic.find(pressure)->sensor==0,"Deleted sensor reference can silently match a later sensor ID");MachineSimulation safe(missing);run(safe,64);check(part(safe.snapshot(),bench_motor).shaft_speed==0&&value(safe.snapshot(),missing,pressure,"value").validity==SignalValidity::Missing,"Missing pressure sensor enabled compressor drive");
auto removed_host=bench;removed_host.remove(tank);MachineSimulation detached_sensor(removed_host);run(detached_sensor,64);check(!part(detached_sensor.snapshot(),bench_motor).actuator_enabled,"Unresolved physical sensor host enabled the controlled motor");
auto wrong=bench;wrong.sensors[0].kind=MachineSensorKind::GasTemperature;MachineSimulation wrong_units(wrong);run(wrong_units,64);check(part(wrong_units.snapshot(),bench_motor).shaft_speed==0,"Mismatched sensor schema fed temperature into pressure control");
{
std::vector<std::uint32_t> all;for(const auto& p:bench.parts)all.push_back(p.id);const auto assembly=machine_extract_assembly(bench,all,"Automatic compressor module");check(assembly.logic.nodes.size()==bench.logic.nodes.size(),"Assembly dropped a connected control harness");auto combined=bench;machine_insert_assembly(combined,assembly,{2,.25f,0});check(combined.logic.nodes.size()==2*bench.logic.nodes.size()&&combined.sensors.size()==2*bench.sensors.size(),"Copied logic or sensors were not independently remapped");
MachineSimulation independent(combined);independent.set_logic_control(master,false);run(independent,64);check(part(independent.snapshot(),bench_motor).shaft_speed==0,"Original master switch was lost during assembly insertion");bool copy_running=false;for(const auto& p:independent.snapshot().parts)if(p.id!=bench_motor&&combined.find(p.id)->kind==MachinePartKind::DriveMotor)copy_running=p.shaft_speed>80;check(copy_running,"Copied compressor still used the original master switch or sensor/command IDs");
const auto incomplete=machine_extract_assembly(bench,{bench_motor},"External sensor harness");check(!incomplete.logic.nodes.empty()&&!incomplete.sensors.empty()&&incomplete.sensors[0].target.part==0,"Partial assembly silently retained external physical sensor IDs");
}
const auto file=std::filesystem::temp_directory_path()/("fluidsim-machine-logic-"+std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())+".machine.toml");save_machine(bench,file);const auto restored=load_machine(file);check(restored.logic==bench.logic,"Saved compressor harness changed its topology or calibration");MachineSimulation roundtrip(restored);run(roundtrip,128);check(part(roundtrip.snapshot(),bench_motor).shaft_speed>80,"Saved graph did not operate its physical drive");std::filesystem::remove(file);
if(argc==3&&std::string(argv[1])=="--write-example")save_machine(bench,argv[2]);
std::cout<<"Per-sample logic, explicit actuator ownership/fallbacks, pressure-controlled compression, independent copied harnesses and persistence passed\n";return 0;
}catch(const std::exception& e){std::cerr<<e.what()<<'\n';return 1;}

View File

@@ -0,0 +1,84 @@
#include "FluidSim/Machine.hpp"
#include "MachineTestControls.hpp"
#include <algorithm>
#include <chrono>
#include <cmath>
#include <fstream>
#include <iostream>
#include <stdexcept>
using namespace fluidsim::port;
namespace {
void check(bool value,const char* why){if(!value)throw std::runtime_error(why);}
bool close(double a,double b,double tolerance=1e-5){return std::abs(a-b)<=tolerance*std::max({1.,std::abs(a),std::abs(b)});}
template<class F>void rejects(F&& f,const char* why){bool caught=false;try{f();}catch(const std::exception&){caught=true;}check(caught,why);}
void run(MachineSimulation& s,int samples){for(int i=0;i<samples;++i){const auto audio=s.process();check(std::isfinite(audio.left)&&std::isfinite(audio.right),"Mechanical machine generated invalid audio");}}
const ShaftState& shaft(const MachineSnapshot& state,std::uint32_t id){for(const auto& s:state.shafts)if(s.id==id)return s;throw std::runtime_error("Missing shaft snapshot");}
void balance(const ShaftState& s){check(close(s.initial_energy+s.external_work+s.drive_work,s.kinetic_energy+s.friction_loss+s.brake_loss,1e-8),"Compiled machine lost rotational work");}
std::uint32_t cylinder(const MachineDocument& d){return std::find_if(d.parts.begin(),d.parts.end(),[](const auto& p){return p.kind==MachinePartKind::Cylinder;})->id;}
}
int main()try{
MachineDocument bench;bench.name="Independent mechanical bench";
const auto a=bench.add(MachinePartKind::Shaft,{}).id,b=bench.add(MachinePartKind::Shaft,{0,0,.5f}).id;
bench.find(a)->shaft.inertia=2;bench.find(b)->shaft.inertia=4;
const auto wheel=bench.add(MachinePartKind::Flywheel,{.3f,0,0}).id;bench.find(wheel)->shaft.inertia=1;bench.connect({wheel,0},{a,0});
const auto motor=bench.add(MachinePartKind::DriveMotor,{-.3f,0,0}).id;bench.find(motor)->drive={0,6,false,true};bench.connect({a,0},{motor,0});
const auto reverse=bench.add(MachinePartKind::DriveMotor,{-.3f,0,.5f}).id;bench.find(reverse)->drive={0,-4,false,true};bench.connect({b,0},{reverse,0});
const auto brake=bench.add(MachinePartKind::Brake,{.3f,0,.2f}).id;bench.find(brake)->brake.constant=1;bench.connect({a,0},{brake,0});
bench.probes={{1,a,"First RPM",MachineQuantity::Rpm},{2,b,"Second RPM",MachineQuantity::Rpm},{3,motor,"Motor torque",MachineQuantity::Torque},{4,brake,"Absorbed power",MachineQuantity::Power}};
validate_machine(bench);check(machine_part_volume(*bench.find(a))==0&&machine_part_volume(*bench.find(motor))==0,"Mechanical parts added gas storage");
check(!machine_connection_preview(bench,{a,0},{b,0}).allowed,"Direct shaft-to-shaft rigid constraint was accepted without a coupling");
check(!machine_connection_preview(bench,{b,0},{wheel,0}).allowed,"Flywheel was attached to two independent shafts");
for(int rate:{1000,4000}){MachineSimulation sim(bench,rate);run(sim,rate);const auto state=sim.snapshot();check(state.cells==0&&state.shafts.size()==2,"Pure mechanical machine required a hidden gas network");
check(close(shaft(state,a).speed,5./3)&&close(shaft(state,b).speed,-1),"Compiled shafts share state or use the wrong effective inertia");
check(close(shaft(state,a).inertia,3),"Compiled flywheel inertia was counted incorrectly");const auto values=sim.measure();check(close(values.values[0],5./3*60/(2*pi))&&close(values.values[1],-60/(2*pi))&&values.values[2]==6&&values.values[3]>=0,"Rotational probes lost their named targets or sign conventions");balance(shaft(state,a));balance(shaft(state,b));
auto tuned=bench;tuned.find(motor)->drive.torque=9;const auto time=state.time;sim.apply_tuning(tuned);check(sim.snapshot().time==time,"Live motor tuning restarted the simulation");run(sim,rate);check(shaft(sim.snapshot(),a).speed>4,"Live motor tuning did not affect the running shaft");
tuned.find(wheel)->shaft.inertia=2;rejects([&]{sim.apply_tuning(tuned);},"Flywheel inertia was rebuilt during Test");
}
auto detached=bench;detached.disconnect({wheel,0});rejects([&]{MachineSimulation invalid(detached);},"Unattached flywheel ran silently");
const auto detached_checks=machine_checks(detached);check(std::any_of(detached_checks.begin(),detached_checks.end(),[&](const auto& c){return c.part==wheel&&c.title=="No shaft attached";}),"Unattached flywheel lacks a navigable check");
auto first=machine_engine_example();machine_test::drive(first,1200);const auto first_cylinder=cylinder(first),first_shaft=machine_shaft_for(first,first_cylinder);
auto second=machine_engine_example();machine_test::drive(second,2400);const auto second_cylinder=cylinder(second);
std::vector<std::uint32_t> selected;for(const auto& p:second.parts)selected.push_back(p.id);const auto assembly=machine_extract_assembly(second,selected,"Second engine");
const auto ids=machine_insert_assembly(first,assembly,{1.5f,.3f,0});std::uint32_t copied_cylinder=0,copied_motor=0;
for(std::size_t i=0;i<assembly.parts.size();++i){if(assembly.parts[i].id==second_cylinder)copied_cylinder=ids[i];if(assembly.parts[i].kind==MachinePartKind::DriveMotor)copied_motor=ids[i];}
const auto second_shaft=machine_shaft_for(first,copied_cylinder);check(first_shaft&&second_shaft&&first_shaft!=second_shaft,"Assembly insertion aliased an existing shaft");
first.probes={{1,first_cylinder,"First engine RPM",MachineQuantity::Rpm},{2,copied_cylinder,"Second engine RPM",MachineQuantity::Rpm}};
MachineSimulation engines(first);run(engines,11025);const auto state=engines.snapshot();check(close(shaft(state,first_shaft).speed,1200*2*pi/60)&&close(shaft(state,second_shaft).speed,2400*2*pi/60),"Independent engines did not retain their speeds");
check(close(engines.measure().values[0],1200)&&close(engines.measure().values[1],2400),"Cylinder probes used a global RPM");
auto live=first;live.find(copied_motor)->drive.target_speed=3600*2*pi/60;engines.apply_tuning(live);check(engines.snapshot().time==state.time,"Second engine tuning reset the first engine");run(engines,128);check(close(engines.measure().values[0],1200)&&close(engines.measure().values[1],3600),"Tuning one motor changed another engine");balance(shaft(engines.snapshot(),first_shaft));balance(shaft(engines.snapshot(),second_shaft));
auto mixed=first;std::uint32_t controller=0;for(const auto& link:mixed.links)if(link.kind==MachineConnectionKind::Control&&link.b.part==first_cylinder)controller=link.a.part;mixed.disconnect({copied_cylinder,2});mixed.connect({controller,0},{copied_cylinder,2});rejects([&]{MachineSimulation invalid(mixed);},"An ECU silently controlled cylinders on different shafts");
auto unbound=first;unbound.disconnect({first_cylinder,machine_port_index(*unbound.find(first_cylinder),"shaft")});rejects([&]{MachineSimulation invalid(unbound);},"Cylinder without a shaft ran on an implicit crank");
auto reverse_engine=first;reverse_engine.find(first_shaft)->shaft.initial_speed=-10;rejects([&]{MachineSimulation invalid(reverse_engine);},"Unsupported reverse engine timing was enabled");
auto moved=first;std::vector<std::vector<Vec3>> paths;for(const auto& p:first.parts)if(p.kind==MachinePartKind::Pipe)paths.push_back(p.points);moved.transform_selection({first_shaft},{},{.2f,.1f,0},0);validate_machine(moved);std::size_t index=0;for(const auto& p:moved.parts)if(p.kind==MachinePartKind::Pipe)check(p.points==paths[index++],"Moving a shaft silently changed gas geometry");
const auto checks=machine_checks(unbound);check(std::any_of(checks.begin(),checks.end(),[&](const auto& c){return c.part==first_cylinder&&c.title=="No shaft attached";}),"Unattached cylinder lacks a navigable check");
{
std::vector<std::uint32_t> all;for(const auto& p:bench.parts)all.push_back(p.id);const auto module=machine_extract_assembly(bench,all,"Mechanical module");auto destination=bench;const auto copied=machine_insert_assembly(destination,module,{2,0,0});
check(destination.probes.size()==8&&destination.probes[4].part==copied[0]&&destination.probes[4].part!=a,"Copied rotational instrument did not follow its copied shaft");
const auto source_motor=std::find_if(module.parts.begin(),module.parts.end(),[&](const auto& p){return p.id==motor;})-module.parts.begin();destination.find(copied[source_motor])->drive.torque=12;
MachineSimulation copied_sim(destination,1000);run(copied_sim,1000);check(copied_sim.measure().values[4]>copied_sim.measure().values[0]*2,"Copied motor or instrument still referenced the original shaft");
const auto loose=machine_extract_assembly(bench,{wheel},"Unattached flywheel");const auto inserted=machine_insert_assembly(destination,loose,{3,0,0});check(machine_shaft_for(destination,inserted.front())==0,"External assembly attachment accidentally matched a destination ID");rejects([&]{MachineSimulation invalid(destination);},"Unresolved external shaft attachment was silently repaired");
}
const auto file=std::filesystem::temp_directory_path()/("fluidsim-mechanical-"+std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())+".machine.toml");save_machine(bench,file);const auto restored=load_machine(file);std::filesystem::remove(file);
check(restored.parts.size()==bench.parts.size()&&restored.links.size()==bench.links.size()&&restored.primary_shaft==bench.primary_shaft,"Explicit mechanical topology did not round-trip");MachineSimulation restored_sim(restored,1000);run(restored_sim,1000);check(close(shaft(restored_sim.snapshot(),a).speed,5./3),"Saved motor/flywheel configuration changed behavior");
{std::ofstream legacy(file);legacy<<R"(format = 4
driven = true
drive_rpm = 600.0
shaft_inertia = 1.0
friction_constant = 2.0
friction_viscous = 0.0
parts = []
[[probes]]
id = 1
part = 0
name = 'Legacy output torque'
quantity = 7
[[probes]]
id = 2
part = 0
name = 'Legacy output power'
quantity = 8
)";}
auto legacy=load_machine(file);check(legacy.probes[0].generated_output&&legacy.probes[0].part==legacy.primary_shaft,"Legacy torque gauge lost its measurement definition");MachineSimulation legacy_sim(legacy,1000);run(legacy_sim,10);check(close(legacy_sim.measure().values[0],-2)&&close(legacy_sim.measure().values[1],-2*600*2*pi/60),"Legacy gauges included speed-drive reaction torque");save_machine(legacy,file);check(load_machine(file).probes.front().generated_output,"Legacy gauge mode was not saved");std::filesystem::remove(file);
std::cout<<"Mechanical parts, gas torque coupling, independent engines and targeted instruments passed\n";return 0;
}catch(const std::exception& error){std::cerr<<error.what()<<'\n';return 1;}

View File

@@ -0,0 +1,130 @@
#include "FluidSim/Machine.hpp"
#include "FluidSim/ScenarioCatalog.hpp"
#include <algorithm>
#include <chrono>
#include <fstream>
#include <iostream>
#include <limits>
#include <set>
#include <stdexcept>
using namespace fluidsim::port;
namespace {
void check(bool value,const char* why){if(!value)throw std::runtime_error(why);}
template<class F>void rejects(F&& operation,const char* why){bool rejected=false;try{operation();}catch(const std::exception&){rejected=true;}check(rejected,why);}
struct TempDirectory {
std::filesystem::path path=std::filesystem::temp_directory_path()/("fluidsim-port-tests-"+std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
TempDirectory(){std::filesystem::create_directories(path);}
~TempDirectory(){std::error_code error;std::filesystem::remove_all(path,error);}
};
void write(const std::filesystem::path& file,const std::string& value){std::ofstream stream(file);stream.exceptions(std::ios::failbit|std::ios::badbit);stream<<value;}
std::string read(const std::filesystem::path& file){std::ifstream stream(file);return {(std::istreambuf_iterator<char>(stream)),{}};}
MachinePortDescriptor signal(PortDomain domain,PortDirection direction,SignalQuantity quantity=SignalQuantity::None){MachinePortDescriptor port;port.key="value";port.name="Value";port.domain=domain;port.direction=direction;port.quantity=quantity;port.cardinality=direction==PortDirection::Output?PortCardinality::Multiple:PortCardinality::Single;return port;}
}
int main()try{
MachinePart undeclared;undeclared.kind=MachinePartKind(machine_part_kind_count);
rejects([&]{machine_port_count(undeclared);},"Undeclared part silently acquired gas ports");
rejects([&]{machine_part_volume(undeclared);},"Undeclared part silently acquired gas storage");
for(int kind=0;kind<machine_part_kind_count;++kind){MachineDocument d;const auto& part=d.add(MachinePartKind(kind));std::set<std::string> keys;
for(unsigned i=0;i<machine_port_count(part);++i){const auto descriptor=machine_port_descriptor(part,i);validate_port_descriptor(descriptor);check(keys.insert(descriptor.key).second,"Duplicate built-in stable port key");check(machine_port_index(part,descriptor.key)==i,"Stable port lookup changed an endpoint");}
rejects([&]{machine_port_descriptor(part,machine_port_count(part));},"Out-of-range port acquired a descriptor");rejects([&]{machine_port_index(part,"unknown");},"Unknown port key was accepted");
}
auto boolean_out=signal(PortDomain::Boolean,PortDirection::Output),boolean_in=signal(PortDomain::Boolean,PortDirection::Input);
auto number_out=signal(PortDomain::Number,PortDirection::Output,SignalQuantity::AngularSpeed),number_in=signal(PortDomain::Number,PortDirection::Input,SignalQuantity::AngularSpeed);
check(port_compatibility(boolean_out,boolean_in).allowed&&port_compatibility(boolean_in,boolean_out).allowed,"Boolean input/output pair was rejected");
check(port_compatibility(number_out,number_in).allowed,"Compatible numeric pair was rejected");
check(!port_compatibility(boolean_out,number_in).allowed,"Implicit Boolean-to-number conversion was accepted");
check(!port_compatibility(boolean_out,boolean_out).allowed&&!port_compatibility(boolean_in,boolean_in).allowed,"Signal direction mismatch was accepted");
auto wrong_quantity=number_in;wrong_quantity.quantity=SignalQuantity::Pressure;wrong_quantity.pressure_reference=PressureReference::Absolute;check(!port_compatibility(number_out,wrong_quantity).allowed,"RPM connected to pressure");
auto pressure_out=wrong_quantity;pressure_out.direction=PortDirection::Output;pressure_out.cardinality=PortCardinality::Multiple;auto gauge_in=wrong_quantity;gauge_in.pressure_reference=PressureReference::Gauge;
check(!port_compatibility(pressure_out,gauge_in).allowed,"Absolute and gauge pressure were implicitly converted");
auto faulty=number_in;faulty.quantity=SignalQuantity::None;rejects([&]{validate_port_descriptor(faulty);},"Untyped number was accepted");
faulty=wrong_quantity;faulty.pressure_reference=PressureReference::None;rejects([&]{validate_port_descriptor(faulty);},"Pressure without a reference was accepted");
faulty=boolean_in;faulty.cardinality=PortCardinality::Multiple;rejects([&]{validate_port_descriptor(faulty);},"Multiple input drivers were permitted");
faulty=boolean_in;faulty.default_value=0.0;rejects([&]{validate_port_descriptor(faulty);},"Numeric Boolean default was accepted");
faulty=boolean_in;faulty.required=true;faulty.default_value=false;rejects([&]{validate_port_descriptor(faulty);},"Required input silently acquired a default");
number_in.minimum=0;number_in.maximum=100;number_in.default_value=20.0;validate_port_descriptor(number_in);
check(signal_value_valid_for(SignalValue::number(30),number_in)&&!signal_value_valid_for(SignalValue::number(-1),number_in),"Numeric range check failed");
check(!signal_value_valid_for(SignalValue::boolean(true),number_in)&&!signal_value_valid_for(SignalValue::number(1),boolean_in),"Signal value type was coerced");
const auto nan=std::numeric_limits<double>::quiet_NaN();check(SignalValue::number(nan).validity==SignalValidity::Fault&&!signal_value_valid_for(SignalValue::number(1,nan),number_in),"Nonfinite signal was treated as valid");
for(auto domain:{PortDomain::Gas,PortDomain::Rotational,PortDomain::Translational}){MachinePortDescriptor a;a.key="physical";a.name="Physical";a.domain=domain;validate_port_descriptor(a);check(port_compatibility(a,a).allowed,"Physical bidirectional domain was rejected");check(!port_compatibility(a,boolean_in).allowed,"Signal connected to a physical port");}
check(std::string(signal_quantity_unit(SignalQuantity::AngularSpeed))=="rad/s","Internal speed units are not SI");
TempDirectory temp;const auto file=temp.path/"ports.machine.toml";
const std::string legacy=R"(format = 4
name = 'Legacy control'
[[parts]]
id = 1
kind = 'ecu'
[[parts]]
id = 2
kind = 'cylinder'
[[parts]]
id = 3
kind = 'throttle'
[[connections]]
a_part = 1
a_port = 0
b_part = 2
b_port = 2
method = 4
[[connections]]
a_part = 1
a_port = 0
b_part = 3
b_port = 2
method = 4
)";
write(file,legacy);auto d=load_machine(file);check(std::count_if(d.links.begin(),d.links.end(),[](const auto& link){return link.kind==MachineConnectionKind::Control;})==2,"Legacy ECU fan-out was lost");
check(machine_link_domain(d,d.links[0])==PortDomain::EcuAssignment,"Legacy control was reinterpreted as a signal");save_machine(d,file);const auto saved=read(file);
check(saved.find("format = 10")!=std::string::npos&&saved.find("'assignments'")!=std::string::npos&&saved.find("'ecu_assignment'")!=std::string::npos,"Current format did not serialize stable port/method keys");
const auto roundtrip=load_machine(file);check(roundtrip.links.size()==d.links.size()&&roundtrip.links[0].a.port==0&&roundtrip.links[0].b.port==2,"Stable key round-trip changed legacy wiring");
auto multiple=d;multiple.links.push_back(multiple.links.front());rejects([&]{validate_machine(multiple);},"Duplicate driver was accepted");
auto reversed=d;std::swap(reversed.links[0].a,reversed.links[0].b);rejects([&]{validate_machine(reversed);},"Noncanonical ECU connection passed document validation");
check(!machine_connection_preview(d,{1,0},{2,0}).allowed,"Preview accepted a gas/control mismatch");
const auto custom=[](bool reorder){return std::string(R"(format = 5
name = 'Stable custom ports'
[[parts]]
id = 1
kind = 'pipe'
position_m = [0,0,0]
points_m = [[-0.2,0,0],[0,0,0]]
point_diameters_m = [0.02,0.02]
[[parts]]
id = 2
kind = 'chamber'
position_m = [0,0,0]
)")+(reorder?"port_coordinates = [[1,0,0],[0,0,0]]\nport_keys = ['tap_b','tap_a']\n":"port_coordinates = [[0,0,0],[1,0,0]]\nport_keys = ['tap_a','tap_b']\n")+R"([[connections]]
a_part = 1
a_port = 'end'
b_part = 2
b_port = 'tap_a'
method = 'orifice'
)";};
write(file,custom(false));const auto original=load_machine(file);write(file,custom(true));const auto reordered=load_machine(file);
check(original.links.front().b.port==0&&reordered.links.front().b.port==1,"Stable key did not follow a reordered physical port");
check(machine_port_position(*original.find(2),0)==machine_port_position(*reordered.find(2),1),"Reordered port changed its physical endpoint");
auto invalid=custom(false);invalid.replace(invalid.find("'tap_a'",invalid.find("[[connections]]")),7,"'unknown'");write(file,invalid);rejects([&]{load_machine(file);},"Unknown serialized port key silently reconnected");
invalid=custom(false);invalid.replace(invalid.find("'orifice'"),9,"'unknown'");write(file,invalid);rejects([&]{load_machine(file);},"Unknown serialized method was accepted");
auto duplicate=original;duplicate.find(2)->port_keys={"tap_a","tap_a"};rejects([&]{validate_machine(duplicate);},"Duplicate custom identities were accepted");
write(file,R"(format = 4
[[parts]]
id = 1
kind = 'pipe'
point_diameters_m = [0.04,0.04]
[[parts]]
id = 2
kind = 'junction'
junction_method = 1
position_m = [0.15,0.25,0]
[[connections]]
a_part = 1
a_port = 1
b_part = 2
b_port = 5
method = 0
)");
const auto alias=load_machine(file);check(alias.links.front().b.port==0&&machine_port_count(*alias.find(2))==1,"Legacy junction alias did not migrate to its shared port");
for(const auto& source:machine_files()){auto machine=load_machine(source);const auto copy=temp.path/source.filename();save_machine(machine,copy);const auto loaded=load_machine(copy);check(loaded.parts.size()==machine.parts.size()&&loaded.links.size()==machine.links.size(),"Existing machine changed topology during migration");}
std::cout<<"Typed port domains, quantities, validation and version 5 migration passed\n";return 0;
}catch(const std::exception& error){std::cerr<<error.what()<<'\n';return 1;}

View File

@@ -0,0 +1,66 @@
#include "FluidSim/Machine.hpp"
#include <algorithm>
#include <chrono>
#include <cmath>
#include <fstream>
#include <iostream>
#include <stdexcept>
using namespace fluidsim::port;
namespace {
void check(bool condition,const char* why){if(!condition)throw std::runtime_error(why);}
bool close(double a,double b,double tolerance=1e-5){return std::abs(a-b)<=tolerance*std::max({1.,std::abs(a),std::abs(b)});}
template<class F>void rejects(F&& f,const char* why){bool caught=false;try{f();}catch(const std::exception&){caught=true;}check(caught,why);}
double value(const MachineSensorState& s,unsigned output=0){check(s.status==MachineSensorStatus::Valid&&s.outputs.at(output).validity==SignalValidity::Valid,"Sensor output is unexpectedly invalid");return std::get<double>(s.outputs.at(output).value);}
void run(MachineSimulation& sim,int count){for(int i=0;i<count;++i)sim.process();}
}
int main()try{
MachineDocument doc;const auto pipe=doc.add(MachinePartKind::Pipe).id,tank=doc.add(MachinePartKind::Chamber,{.3f,.25f,0}).id,ambient=doc.add(MachinePartKind::Reservoir,{-.3f,.25f,0}).id;
doc.find(tank)->pressure=200000;doc.connect({pipe,1},{tank,0});doc.connect({pipe,0},{ambient,0});
const auto pressure=doc.add_sensor(MachineSensorKind::Pressure,tank).id;
const auto difference=doc.add_sensor(MachineSensorKind::DifferentialPressure,tank).id;doc.find_sensor(difference)->reference={ambient,.5f,"Ambient reference"};
const auto temperature=doc.add_sensor(MachineSensorKind::GasTemperature,pipe,.9f).id;
const auto flow=doc.add_sensor(MachineSensorKind::MassFlow,pipe,.35f).id;
for(int i=0;i<12;++i)doc.add_sensor(MachineSensorKind::Pressure,pipe,float(i)/11);
doc.probes={{1,pipe,"Pipe flow",MachineQuantity::MassFlow,.35f}};
validate_machine(doc);MachineSimulation sensed(doc);auto bare=doc;bare.sensors.clear();MachineSimulation baseline(bare);
check(close(value(sensed.sense(pressure)),200000)&&close(value(sensed.sense(difference)),200000-101325),"Pressure taps lost absolute or differential reference");
check(close(value(sensed.sense(temperature)),300)&&value(sensed.sense(flow))==0,"Initial sensor sample is missing or has the wrong units");
check(sensed.snapshot().sensors.size()==16&&sensed.measure().count==1,"Sensors consume the instrument recording limit");
bool reverse_flow=false;for(int i=0;i<2048;++i){const auto a=sensed.process(),b=baseline.process();check(a.left==b.left&&a.right==b.right,"Ideal sensor altered machine audio");reverse_flow|=value(sensed.sense(flow))<-.00001;}
const auto measured=sensed.measure();check(close(value(sensed.sense(flow)),measured.values[0]),"Pipe sensor and targeted instrument disagree at the same location");
check(reverse_flow,"Total mass flow lost the pipe Start-to-End sign");
const auto a=sensed.snapshot(),b=baseline.snapshot();check(a.cells==b.cells&&a.substeps==b.substeps&&a.parts.size()==b.parts.size(),"Sensor added a physical volume or solver cells");
for(std::size_t i=0;i<a.parts.size();++i)check(a.parts[i].pressure==b.parts[i].pressure&&a.parts[i].flow==b.parts[i].flow,"Sensor perturbed the gas state");
for(const auto& sensor:a.sensors)for(unsigned i=0;i<sensor.count;++i)check(sensor.outputs[i].timestamp==a.time,"Sensor outputs have incoherent sample times");
for(float mesh:{10.f,150.f}){auto remeshed=doc;remeshed.cells_per_meter=mesh;MachineSimulation sim(remeshed);check(close(value(sim.sense(temperature)),300),"Sensor bound to a stale cell after remeshing");check(remeshed.find_sensor(temperature)->target.position==.9f,"Remeshing changed normalized sensor location");}
auto moved=doc;const auto original=machine_sensor_position(moved,moved.find_sensor(temperature)->target);moved.transform_selection({pipe,tank,ambient},{},{.1f,.2f,.3f});const auto after=machine_sensor_position(moved,moved.find_sensor(temperature)->target);
check(original&&after&&close(after->x-original->x,.1)&&close(after->y-original->y,.2)&&close(after->z-original->z,.3),"Sensor marker did not follow its physical host");
auto invalid=doc;invalid.find_sensor(temperature)->target.position=1.1f;rejects([&]{validate_machine(invalid);},"Out-of-pipe sensor position was accepted");
rejects([&]{doc.add_sensor(MachineSensorKind::Crank,tank);},"Crank sensor attached to a gas tank");rejects([&]{doc.add_sensor(MachineSensorKind::MassFlow,tank);},"A gas volume silently supplied an ambiguous through-flow");
auto unresolved=doc;unresolved.remove(tank);check(unresolved.find_sensor(pressure)&&unresolved.find_sensor(pressure)->target.part==0&&unresolved.find_sensor(pressure)->target.label==doc.find(tank)->name,"Deleting a host removed its sensor or lost the unresolved label");
MachineSimulation missing(unresolved);check(missing.sense(pressure).status==MachineSensorStatus::MissingHost&&missing.sense(pressure).outputs[0].validity==SignalValidity::Missing,"Deleted host supplied plausible valid data");
unresolved.add(MachinePartKind::Chamber);MachineSimulation still_missing(unresolved);check(still_missing.sense(pressure).status==MachineSensorStatus::MissingHost,"New part silently repaired a deleted sensor host");
auto wrong=doc;wrong.find_sensor(pressure)->target.part=ambient;wrong.find(ambient)->kind=MachinePartKind::Cap;MachineSimulation incompatible(wrong);check(incompatible.sense(pressure).status==MachineSensorStatus::IncompatibleHost,"Incompatible sensor binding supplied a reading");
MachineDocument rotation;const auto shaft=rotation.add(MachinePartKind::Shaft).id;rotation.find(shaft)->shaft.initial_angle=-.5f;rotation.find(shaft)->shaft.initial_speed=-2;
const auto crank=rotation.add_sensor(MachineSensorKind::Crank,shaft).id;rotation.find_sensor(crank)->angle_offset=.25f;MachineSimulation rotating(rotation,1000);
auto pickup=rotating.sense(crank);check(value(pickup)==-2&&close(value(pickup,1),2*double(pi)-.25)&&std::get<bool>(pickup.outputs[2].value),"Crank pickup lost signed speed or wrapped angle");check(pickup.timing_shaft==shaft&&close(pickup.unwrapped_angle,-.25),"Ideal crank provenance is unavailable");
run(rotating,4000);pickup=rotating.sense(crank);check(close(pickup.unwrapped_angle,-8.25)&&value(pickup,1)>=0&&value(pickup,1)<2*pi,"Crank pickup discarded unwrapped reverse crossings");
for(unsigned i=0;i<3;++i){const auto descriptor=machine_sensor_output(*rotation.find_sensor(crank),i);check(descriptor.direction==PortDirection::Output&&descriptor.cardinality==PortCardinality::Multiple,"Sensor outputs cannot fan out");check(signal_value_valid_for(pickup.outputs[i],descriptor),"Crank values do not match typed outputs");check(pickup.outputs[i].timestamp==rotating.snapshot().time,"Crank channels are not synchronized");}
auto changed=rotation;changed.find_sensor(crank)->angle_offset=1;rejects([&]{rotating.apply_tuning(changed);},"Timing attachment changed during Test");
auto stopped=rotation;stopped.find(shaft)->shaft.initial_speed=0;MachineSimulation stationary(stopped);check(value(stationary.sense(crank))==0&&stationary.sense(crank).status==MachineSensorStatus::Valid,"Stopped crank was confused with a missing pickup");
MachineDocument throttle;const auto duct=throttle.add(MachinePartKind::Pipe).id,body=throttle.add(MachinePartKind::Throttle).id;throttle.connect({duct,1},{body,0});throttle.find(body)->pedal_control=false;throttle.find(body)->opening=.35f;
const auto tps=throttle.add_sensor(MachineSensorKind::ThrottlePosition,body).id;MachineSimulation opening(throttle);opening.set_controls(.9f);run(opening,1);check(close(value(opening.sense(tps)),.35),"TPS read pedal demand instead of fixed mechanism opening");throttle.find(body)->opening=.6f;opening.apply_tuning(throttle);run(opening,1);check(close(value(opening.sense(tps)),.6),"TPS missed safe live throttle tuning");
const auto assembly=machine_extract_assembly(doc,{tank},"Tapped tank");check(assembly.sensors.size()==2&&assembly.find_sensor(difference)->reference.part==0,"Assembly lost sensors or retained an external reference ID");auto destination=doc;const auto inserted=machine_insert_assembly(destination,assembly,{1,.25f,0});check(destination.sensors.size()==18&&destination.sensors[16].target.part==inserted[0]&&destination.sensors[16].id!=pressure,"Copied sensor retained source identity or host");check(destination.sensors[17].reference.part==0,"Copied differential reference accidentally matched an existing host");
const auto whole=machine_extract_assembly(doc,{pipe,tank,ambient},"Tapped network");MachineDocument empty;machine_insert_assembly(empty,whole,{});MachineSimulation copied(empty);check(close(value(copied.sense(empty.sensors[1].id)),200000-101325),"Internal sensor reference did not follow assembly remapping");
const auto file=std::filesystem::temp_directory_path()/("fluidsim-sensors-"+std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())+".machine.toml");
save_machine(destination,file);const auto restored=load_machine(file);check(restored.sensors==destination.sensors&&restored.next_sensor_id==destination.next_sensor_id,"Sensor identities, binding labels or locations did not round-trip");
save_machine(unresolved,file);auto reloaded=load_machine(file);check(reloaded.next_id==unresolved.next_id&&reloaded.find_sensor(pressure)->target.part==0,"Reload reused deleted part IDs or rebound an unresolved sensor");
reloaded.sensors.erase(reloaded.sensors.begin());const auto reserved=reloaded.next_sensor_id;save_machine(reloaded,file);auto repeat=load_machine(file);check(repeat.next_sensor_id==reserved,"Deleted sensor IDs can be reused after reload");
{std::ofstream out(file);out<<"format=7\n[[sensors]]\nid=1\nkind='future_unknown_sensor'\n";}rejects([&]{load_machine(file);},"Unknown sensor kind silently loaded");std::filesystem::remove(file);
{const auto example=load_machine(FLUIDSIM_SENSOR_EXAMPLE_FILE);MachineSimulation bench(example);run(bench,2048);check(example.sensors.size()==6&&close(value(bench.sense(5)),188.49556)&&close(value(bench.sense(6)),.45),"Sensor example lost its shaft or mechanism measurement");for(const auto& s:bench.snapshot().sensors)check(s.status==MachineSensorStatus::Valid,"Sensor example contains an unresolved or invalid attachment");}
std::cout<<"Sensor attachments, coherent samples, typed outputs, unresolved bindings and assembly persistence passed\n";return 0;
}catch(const std::exception& error){std::cerr<<error.what()<<'\n';return 1;}

231
tests/machine_tests.cpp Normal file
View File

@@ -0,0 +1,231 @@
#include "MachineTestControls.hpp"
#include "FluidSim/Machine.hpp"
#include "FluidSim/Constants.hpp"
#include "FluidSim/ScenarioCatalog.hpp"
#include <algorithm>
#include <chrono>
#include <cmath>
#include <fstream>
#include <iostream>
#include <stdexcept>
using namespace fluidsim::port;
using namespace fluidsim::port::machine_math;
namespace {
void check(bool v,const std::string& why){if(!v)throw std::runtime_error(why);}
bool close(float a,float b,float tolerance=1e-5f){return std::abs(a-b)<=tolerance;}
template<class F>void rejects(F f,const char* why){bool rejected=false;try{f();}catch(const std::exception&){rejected=true;}check(rejected,why);}
const MachinePartState& state(const MachineSnapshot& s,unsigned id){for(auto& p:s.parts)if(p.id==id)return p;throw std::runtime_error("Missing part snapshot");}
std::uint32_t duct(MachineDocument& d,MachinePort a,MachinePort b){const auto start=machine_port_position(*d.find(a.part),a.port),end=machine_port_position(*d.find(b.part),b.port);auto& p=d.add(MachinePartKind::Pipe);const auto id=p.id;p.position={};p.points={start,end};d.connect({id,0},a);d.connect({id,1},b);return id;}
void run(MachineSimulation& sim,int frames){for(int i=0;i<frames;++i){const auto a=sim.process();check(std::isfinite(a.left)&&std::isfinite(a.right),"Nonfinite machine audio");}}
}
int main(int argc,char** argv)try{
if(argc>=2&&std::string(argv[1])=="--mixture-sweep"){
std::cout<<"case,rpm,charge_median,charge_max,cylinder_bulk_median,pipe_bulk_median,fuel_flow_g_s,fuel_cut_samples,spark_cut_samples,valve_float_samples\n";
for(const char* id:{"single-450","flat6"}){if(argc==3&&std::string(id)=="flat6")break;auto design=argc==3?load_machine(argv[2]):machine_from_engine(scenario_example(id));machine_test::drive(design,3000);unsigned cylinder=0,pipe=0,carb=0;
for(const auto& p:design.parts){if(!cylinder&&p.kind==MachinePartKind::Cylinder)cylinder=p.id;if(p.kind==MachinePartKind::Carburetor)carb=p.id;if(p.name=="Exhaust atmosphere")for(const auto& l:design.links){const auto other=l.a.part==p.id?l.b.part:l.b.part==p.id?l.a.part:0;if(const auto p=design.find(other);p&&p->kind==MachinePartKind::Pipe)pipe=other;}}
if(!pipe)for(const auto& p:design.parts)if(p.kind==MachinePartKind::Pipe)pipe=p.id;
design.probes={{1,cylinder,"Fresh charge",MachineQuantity::ChargeAfr},{2,cylinder,"Cylinder bulk",MachineQuantity::AirFuelRatio},{3,pipe,"Exhaust bulk",MachineQuantity::AirFuelRatio}};MachineSimulation sim(design);sim.set_controls(1);
for(float rpm:{3000.f,5000.f,6500.f,7000.f,7500.f,10000.f,12000.f,13000.f,14000.f,16000.f}){machine_test::target_speed(design,rpm);sim.apply_tuning(design);std::array<std::vector<float>,3> samples;unsigned fuel_cut=0,spark_cut=0,floating=0;double fuel_flow=0;unsigned count=0;
for(int i=0;i<22050;++i){sim.process();if(i>=11025&&i%16==0){const auto m=sim.measure();for(int channel=0;channel<3;++channel)if(std::isfinite(m.values[channel]))samples[channel].push_back(m.values[channel]);const auto s=sim.snapshot();const auto& c=state(s,cylinder);fuel_cut+=c.fuel_cut;spark_cut+=c.ignition_cut;floating+=c.valve_float;if(carb)fuel_flow+=state(s,carb).fuel_flow*1000;++count;}}
for(auto& channel:samples)std::sort(channel.begin(),channel.end());const auto median=[&](int channel){const auto& values=samples[channel];return values.empty()?std::numeric_limits<float>::quiet_NaN():values[values.size()/2];};
std::cout<<(argc==3?"saved":id)<<','<<rpm<<','<<median(0)<<','<<(samples[0].empty()?0:samples[0].back())<<','<<median(1)<<','<<median(2)<<','<<fuel_flow/count<<','<<fuel_cut<<','<<spark_cut<<','<<floating<<std::endl;
}
}return 0;
}
MachineDocument d;auto& p=d.add(MachinePartKind::Pipe,{.2f,.3f,-.1f});const auto pipe_id=p.id;p.points={{0,0,0},{.1f,0,0},{.1f,.2f,0}};p.diameter=.02f;p.outlet_diameter=.06f;p.rotation={20,40,15};
check(close(machine_pipe_length(p),.3f),"Pipe length must follow the edited centerline");
const auto world=machine_world_point(p,{.2f,.4f,.7f});check(length(subtract(machine_local_point(p,world),{.2f,.4f,.7f}))<1e-6f,"Part rotation inverse is incorrect");
MachineSimulation profile(d);const auto s=profile.snapshot();check(close(state(s,pipe_id).volume,machine_part_volume(p),1e-9f),"Profiled pipe cell volume differs from visible frustum volume");
run(profile,512);const auto equilibrium=profile.snapshot();for(auto pressure:state(equilibrium,pipe_id).pipe_pressures)check(close(pressure,101325,1),"Sealed equilibrium should remain steady");
// Visual path refinement must not refine the fluid mesh or shrink its CFL timestep.
MachineDocument sparse;auto& straight=sparse.add(MachinePartKind::Pipe);straight.points={{0,0,0},{.3f,0,0}};straight.diameter=.02f;straight.outlet_diameter=.06f;
auto dense=sparse;dense.parts[0].points.clear();for(int i=0;i<=64;++i)dense.parts[0].points.push_back({.3f*i/64,0,0});
MachineSimulation coarse_mesh(sparse),dense_route(dense);const auto coarse_info=coarse_mesh.snapshot(),dense_info=dense_route.snapshot();
check(coarse_info.cells==dense_info.cells&&coarse_info.cells==12,"Visual knots changed solver cell count");
check(close(coarse_info.minimum_cell_length,dense_info.minimum_cell_length,1e-7f),"Visual knots changed the CFL-limiting length");
check(close(coarse_info.parts[0].volume,dense_info.parts[0].volume,1e-9f),"Visual refinement changed tapered gas volume");
MachineDocument short_duct;short_duct.add(MachinePartKind::Pipe).points={{0,0,0},{.005f,0,0}};MachineSimulation real_short_pipe(short_duct);run(real_short_pipe,128);
const auto short_info=real_short_pipe.snapshot();check(close(short_info.minimum_cell_length,.005f,1e-8f)&&short_info.substeps>=256,"A real short duct lost its physical length or CFL substeps");
MachineDocument shaped;auto& sculpted=shaped.add(MachinePartKind::Pipe);const auto shaped_id=sculpted.id;
sculpted.points={{0,0,0},{.1f,0,0},{.105f,0,0},{.3f,0,0}};sculpted.point_diameters={.02f,.1f,.025f,.06f};
const auto frustum=[](double length,double a,double b){return float(double(pi)/12*length*(a*a+a*b+b*b));};
const float expected_volume=frustum(.1,.02,.1)+frustum(.005,.1,.025)+frustum(.195,.025,.06);
check(close(machine_part_volume(sculpted),expected_volume,1e-9f),"Per-point pipe volume is not the sum of its frustums");
check(close(machine_pipe_diameter(sculpted,.1f),.1f,1e-6f)&&close(machine_pipe_diameter(sculpted,.1025f),.0625f,1e-6f),"Per-point diameter interpolation is incorrect");
for(float density:{10.f,40.f,200.f}){auto refined=shaped;refined.cells_per_meter=density;MachineSimulation simulation(refined);const auto info=simulation.snapshot();
check(info.cells==std::uint64_t(profile_section_cells(.3f,1/density)),"Profile points forced extra fluid cells");
check(close(state(info,shaped_id).volume,expected_volume,2e-9f),"Mesh refinement changed the per-point pipe volume");try{run(simulation,256);}catch(const std::exception& e){throw std::runtime_error("Per-point profile at "+std::to_string(density)+" cells/m: "+e.what());}
const auto settled=simulation.snapshot();check(settled.positivity_repairs==0,"Per-point pipe equilibrium needed positivity repairs");for(float pressure:state(settled,shaped_id).pipe_pressures)check(close(pressure,101325,2),"Per-point pipe failed to preserve static equilibrium");
}
for(auto method:{PipeSolver::Rusanov,PipeSolver::Hlle,PipeSolver::Hllc,PipeSolver::Muscl}){auto flow=shaped;flow.cells_per_meter=200;flow.solver=method;
const auto supply=flow.add(MachinePartKind::Reservoir,machine_port_position(*flow.find(shaped_id),0)).id;flow.find(supply)->pressure=102000;
const auto outlet=flow.add(MachinePartKind::Reservoir,machine_port_position(*flow.find(shaped_id),1)).id;
flow.connect({shaped_id,0},{supply,0});flow.connect({shaped_id,1},{outlet,0});MachineSimulation simulation(flow);
try{run(simulation,2048);}catch(const std::exception& e){throw std::runtime_error(std::string("Per-point pressure-driven ")+pipe_solver_name(method)+": "+e.what());}
const auto result=simulation.snapshot();check(result.positivity_repairs==0&&state(result,shaped_id).flow>0,"Per-point constriction did not sustain stable forward flow");
}
auto inserted=sculpted;machine_insert_pipe_point(inserted,0,{.035f,0,0});check(inserted.point_diameters.size()==inserted.points.size()&&close(inserted.point_diameters[1],.048f,1e-6f),"Inserted point did not inherit its interpolated diameter");
check(close(machine_part_volume(inserted),expected_volume,1e-9f),"Inserting a profile point changed gas volume");machine_remove_pipe_point(inserted,1);check(inserted.points==sculpted.points&&inserted.point_diameters==sculpted.point_diameters,"Removing the inserted point lost the diameter profile");
auto malformed=shaped;malformed.parts[0].point_diameters.pop_back();rejects([&]{validate_machine(malformed);},"Mismatched point diameters were accepted");
malformed=shaped;malformed.parts[0].point_diameters[1]=0;rejects([&]{validate_machine(malformed);},"Zero point diameter was accepted");
auto& chamber=d.add(MachinePartKind::Chamber,{.4f,.4f,.1f});const auto chamber_id=chamber.id;chamber.size={.15f,.1f,.2f};chamber.rotation={30,25,60};
d.connect({pipe_id,1},{chamber_id,0});validate_machine(d);
d.find(chamber_id)->position.x+=.1f;d.find(chamber_id)->rotation.z+=40;d.sync_connections(chamber_id);validate_machine(d);
check(length(subtract(machine_port_position(*d.find(pipe_id),1),machine_port_position(*d.find(chamber_id),0)))<1e-6f,"Moving a component detached its pipe");
MachineSimulation with_tank(d);check(close(state(with_tank.snapshot(),chamber_id).volume,.003f,1e-8f),"Rotated tank volume must equal visible dimensions");
rejects([&]{d.connect({pipe_id,1},{chamber_id,1});},"An occupied pipe endpoint was connected twice");
auto broken=d;broken.links[0].b.part=999;rejects([&]{validate_machine(broken);},"Dangling references were accepted");
broken=d;broken.parts.front().points[1]=broken.parts.front().points[0];rejects([&]{validate_machine(broken);},"Collapsed pipe segment was accepted");
broken=d;broken.find(chamber_id)->size.x=0;rejects([&]{validate_machine(broken);},"Zero volume was accepted");
broken=d;broken.find(chamber_id)->position.y+=.1f;rejects([&]{validate_machine(broken);},"A connection with a physical gap was accepted");
{MachineDocument feedback;const auto pipe=feedback.add(MachinePartKind::Pipe).id;
const auto air=feedback.add(MachinePartKind::Reservoir,{.4f,.3f,0}).id;
const auto ecu=feedback.add(MachinePartKind::Ecu,{0,.6f,0}).id;
const auto cylinder=feedback.add(MachinePartKind::Cylinder).id;
const auto original_points=feedback.find(pipe)->points;
const auto air_preview=machine_connection_preview(feedback,{pipe,1},{air,0});
check(air_preview.allowed&&air_preview.pipe.has_value(),"Valid connection has no snapped pipe preview");
check(length(subtract(machine_port_position(*air_preview.pipe,1),machine_port_position(*feedback.find(air),0)))<1e-6f,"Preview did not snap to the target port");
check(feedback.links.empty()&&feedback.find(pipe)->points==original_points,"Connection preview mutated the design");
check(!machine_connection_preview(feedback,{pipe,1},{ecu,0}).allowed,"Air/control mismatch was accepted in preview");
check(!machine_connection_preview(feedback,{air,0},{cylinder,0}).allowed,"Preview connected two air components without a pipe");
check(machine_connection_preview(feedback,{ecu,0},{cylinder,2}).allowed,"Valid ECU control wire was rejected");
check(!machine_connection_preview(feedback,{ecu,0},{cylinder,0}).allowed,"ECU preview connected to a cylinder air port");
feedback.find(air)->position=machine_port_position(*feedback.find(pipe),0);
check(!machine_connection_preview(feedback,{pipe,1},{air,0}).allowed,"Preview allowed a collapsed pipe");
feedback.find(air)->position={.4f,.3f,0};feedback.connect({pipe,1},{air,0});
const auto branch=feedback.add(MachinePartKind::Pipe,{-.4f,.25f,0}).id;
const auto occupied=machine_connection_preview(feedback,{branch,0},{air,0});check(!occupied.allowed&&!occupied.message.empty(),"Occupied atmosphere preview lacks an explanation");
}
{auto bench=machine_engine_example();unsigned cylinder=0,throttle=0,ecu=0;
for(const auto& part:bench.parts){if(part.kind==MachinePartKind::Cylinder)cylinder=part.id;if(part.kind==MachinePartKind::Throttle)throttle=part.id;if(part.kind==MachinePartKind::Ecu)ecu=part.id;}
const auto has_check=[&](unsigned id,const std::string& title){const auto checks=machine_checks(bench);return std::any_of(checks.begin(),checks.end(),[&](const auto& item){return item.part==id&&item.title==title;});};
check(machine_has_fuel_source(bench,cylinder)&&!has_check(cylinder,"No fuel source"),"Wired injection engine was reported dry");
bench.disconnect({cylinder,2});check(!machine_has_fuel_source(bench,cylinder)&&has_check(cylinder,"No fuel source"),"Missing injection assignment was not diagnosed");
bench.find(throttle)->pedal_control=false;bench.find(throttle)->opening=0;
check(!has_check(throttle,"Fixed throttle is closed"),"ECU-controlled throttle was diagnosed from its unused fixed opening");
bench.disconnect({throttle,2});check(has_check(throttle,"Fixed throttle is closed")&&has_check(ecu,"ECU has no assigned parts"),"Closed throttle / unused ECU checks are missing");
bench.find(throttle)->kind=MachinePartKind::Carburetor;check(machine_has_fuel_source(bench,cylinder)&&!has_check(cylinder,"No fuel source"),"Connected carburetor was not recognized as a fuel source");
bench.disconnect({cylinder,0});check(!machine_has_fuel_source(bench,cylinder),"Fuel reachability crossed the cylinder from its exhaust");
}
const auto dir=std::filesystem::temp_directory_path()/("fluidsim-machine-tests-"+std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));std::filesystem::create_directories(dir);
const auto file=dir/"test.machine.toml";save_machine(d,file);const auto loaded=load_machine(file);
check(loaded.parts.size()==d.parts.size()&&loaded.links.size()==d.links.size(),"Save/load changed the machine topology");
check(length(subtract(loaded.find(chamber_id)->rotation,d.find(chamber_id)->rotation))<1e-6f,"Save/load lost 3D rotation");
check(loaded.find(pipe_id)->points==d.find(pipe_id)->points,"Save/load changed the pipe centerline");
{
auto source=machine_engine_example();const auto cylinder=std::find_if(source.parts.begin(),source.parts.end(),[](const auto& p){return p.kind==MachinePartKind::Cylinder;})->id;
source.probes.push_back({1,cylinder,"Cylinder pressure",MachineQuantity::Pressure,.5f,14.7f});
source.probes.push_back({2,0,"Shaft RPM",MachineQuantity::Rpm,.5f,14.7f});
std::vector<std::uint32_t> selected;for(const auto& part:source.parts)selected.push_back(part.id);
auto assembly=machine_extract_assembly(source,selected,"Intake module");
check(assembly.parts.size()==source.parts.size()&&assembly.links.size()==source.links.size(),"Assembly extraction lost parts or internal air/control connections");
check(assembly.probes.size()==1&&assembly.probes.front().name=="Cylinder pressure","Assembly extraction lost a component probe");
check(length(machine_selection_center(assembly,selected))<1e-6f,"Assembly was not centered for reuse");
const auto assembly_file=dir/"intake.assembly.toml";save_machine(assembly,assembly_file);assembly=load_machine(assembly_file);
MachineDocument destination=machine_pump_example();const auto prior_parts=destination.parts.size(),prior_links=destination.links.size();
destination.probes.push_back({1,destination.parts.front().id,"Tank pressure",MachineQuantity::Pressure,.5f,14.7f});
const auto inserted=machine_insert_assembly(destination,assembly,{1,.35f,.5f});
check(inserted.size()==assembly.parts.size()&&destination.parts.size()==prior_parts+assembly.parts.size(),"Assembly insertion did not remap and append parts");
check(destination.links.size()==prior_links+assembly.links.size(),"Assembly insertion did not recreate internal connections");
const auto remapped=[&](std::uint32_t id){for(std::size_t i=0;i<assembly.parts.size();++i)if(assembly.parts[i].id==id)return inserted[i];throw std::runtime_error("Missing assembly part");};
check(destination.probes.size()==2&&destination.probes.back().part==remapped(cylinder)&&destination.probes.back().id!=1,"Assembly insertion did not remap its probe");
for(std::size_t i=0;i<assembly.links.size();++i){const auto& original=assembly.links[i];const auto& copy=destination.links[prior_links+i];check(copy.a.part==remapped(original.a.part)&&copy.b.part==remapped(original.b.part)&&copy.a.port==original.a.port&&copy.b.port==original.b.port&&copy.kind==original.kind&&copy.discharge==original.discharge&&copy.loss==original.loss&&copy.inertance_length==original.inertance_length,"Copied link changed its endpoints or connection settings");}
for(std::size_t i=0;i<inserted.size();++i){const auto& copy=*destination.find(inserted[i]);check(length(subtract(copy.position,add(assembly.parts[i].position,{1,.35f,.5f})))<1e-6f&&copy.rotation==assembly.parts[i].rotation&&copy.points==assembly.parts[i].points&&copy.point_diameters==assembly.parts[i].point_diameters,"Assembly placement changed local geometry or relative positions");}
validate_machine(destination);
const auto ecu=std::find_if(source.parts.begin(),source.parts.end(),[](const auto& p){return p.kind==MachinePartKind::Ecu;})->id;
const auto intake=std::find_if(source.links.begin(),source.links.end(),[&](const auto& link){return link.b==MachinePort{cylinder,0};})->a.part;
const auto subset=machine_extract_assembly(source,{ecu,cylinder,intake},"Cylinder module");
check(subset.parts.size()==3&&subset.links.size()==2,"Subset copy retained external links or lost an internal connection");
for(const auto& link:subset.links)check(subset.find(link.a.part)&&subset.find(link.b.part),"Assembly connection reaches outside the selected parts");
rejects([&]{machine_extract_assembly(source,{},"Empty");},"An empty selection was copied");
MachineDocument empty;rejects([&]{machine_insert_assembly(destination,empty,{});},"An empty assembly was inserted");
MachineDocument offset;auto& offpipe=offset.add(MachinePartKind::Pipe,{2,1,-3});offpipe.points={{1,0,0},{3,0,0}};offpipe.rotation={0,45,0};
const auto pipe_assembly=machine_extract_assembly(offset,{offpipe.id},"Offset pipe");const auto centered_path=machine_pipe_path(pipe_assembly.parts.front());
check(length(add(centered_path.front(),centered_path.back()))<1e-6f&&close(machine_pipe_length(pipe_assembly.parts.front()),2),"Reusable pipe assembly centered its part origin instead of its visible geometry");
}
const auto shaped_file=dir/"profile.machine.toml";save_machine(shaped,shaped_file);const auto restored_profile=load_machine(shaped_file);
check(restored_profile.parts[0].point_diameters==sculpted.point_diameters&&restored_profile.parts[0].points==sculpted.points,"Save/load lost per-point diameters");
{std::ifstream input(shaped_file);const std::string text((std::istreambuf_iterator<char>(input)),{});check(text.find("point_diameters_m")!=std::string::npos&&text.find("outlet_diameter_m")==std::string::npos,"Saved pipes still use endpoint diameter fields");}
const auto legacy_file=dir/"legacy.machine.toml";
const std::string pipe_record="\n[[parts]]\nid = 1\nkind = 'pipe'\npoints_m = [[0,0,0],[0.1,0,0],[0.1,0.2,0]]\n";
for(int version:{1,2}){{std::ofstream output(legacy_file);output<<"format = "<<version<<pipe_record<<"diameter_m = 0.02\noutlet_diameter_m = 0.08\n";}
const auto legacy=load_machine(legacy_file);const auto& diameters=legacy.parts[0].point_diameters;check(diameters.size()==3&&close(diameters[0],.02f)&&close(diameters[1],.04f)&&close(diameters[2],.08f),"Legacy taper was not migrated by arclength");
check(close(machine_part_volume(legacy.parts[0]),frustum(.3,.02,.08),1e-9f),"Legacy migration changed pipe gas volume");
}
for(const auto* bad:{"","point_diameters_m = [0.02]\n","point_diameters_m = [0.02, -0.03, 0.04]\n","point_diameters_m = [0.02, nan, 0.04]\n","point_diameters_m = [0.02, 'bad', 0.04]\n"}){
{std::ofstream output(legacy_file);output<<"format = 3"<<pipe_record<<bad;}rejects([&]{load_machine(legacy_file);},"Malformed version 3 point diameters were accepted");
}
save_machine(loaded,file);check(!std::filesystem::exists(file.string()+".bak"),"Successful save left a backup behind");
{std::ofstream invalid(dir/"invalid.machine.toml");invalid<<"format = 999\nparts = []\n";}
rejects([&]{load_machine(dir/"invalid.machine.toml");},"Unsupported file version was accepted");
for(int method=0;method<3;++method){
MachineDocument network;const auto node=network.add(MachinePartKind::Junction,{0,.3f,0}).id;network.find(node)->junction_method=method;
for(int branch=0;branch<3;++branch){auto& pipe=network.add(MachinePartKind::Pipe);pipe.position={};const unsigned port=method==0?unsigned(branch):0;const auto start=machine_port_position(*network.find(node),port);pipe.points={start,add(start,branch==0?Vec3{-.2f,0,0}:branch==1?Vec3{.2f,0,0}:Vec3{0,-.2f,0})};const auto id=pipe.id;network.connect({id,0},{node,port});}
validate_machine(network);MachineSimulation simulation(network);run(simulation,1024);check(simulation.snapshot().positivity_repairs==0,"Junction equilibrium required positivity repairs");
}
// A closed throttle must isolate a high-pressure supply; opening it must charge the tank.
const auto valve_test=[](bool open){MachineDocument doc;const auto supply=doc.add(MachinePartKind::Reservoir,{-.4f,.3f,0}).id;doc.find(supply)->pressure=120000;
const auto valve=doc.add(MachinePartKind::Throttle,{0,.3f,0}).id;doc.find(valve)->pedal_control=false;doc.find(valve)->opening=open?1.f:0.f;
const auto tank=doc.add(MachinePartKind::Chamber,{.4f,.3f,0}).id;duct(doc,{supply,0},{valve,0});duct(doc,{valve,1},{tank,0});MachineSimulation sim(doc);run(sim,8820);return state(sim.snapshot(),tank).pressure;};
const float closed=valve_test(false),open=valve_test(true);check(std::abs(closed-101325)<1,"A closed throttle leaked");check(open>closed+1000,"Opening throttle did not increase downstream pressure");
auto pump=machine_pump_example();validate_machine(pump);const auto tank_id=pump.parts.front().id;
MachineSimulation pumping(pump);run(pumping,44100);const auto pump_state=pumping.snapshot();
check(state(pump_state,tank_id).pressure<99000,"Piston pump did not evacuate its tank");check(pump_state.positivity_repairs==0,"Pump example required positivity repairs");
std::cout<<"Vacuum tank after 1 s: "<<state(pump_state,tank_id).pressure<<" Pa\n";
MachineSimulation pump_coarse(pump,44100),pump_fine(pump,88200);run(pump_coarse,8820);run(pump_fine,17640);
const auto pc=pump_coarse.snapshot(),pf=pump_fine.snapshot();check(std::abs(state(pc,tank_id).pressure-state(pf,tank_id).pressure)<.01f*state(pf,tank_id).pressure,"Pump pressure failed sample-rate refinement");
auto engine=machine_engine_example();machine_test::drive(engine,1800);const auto test_load=engine.add(MachinePartKind::Brake).id;engine.find(test_load)->brake.constant=0;engine.connect({machine_primary_shaft(engine),0},{test_load,0});unsigned cylinder_probe=0,downpipe_probe=0;for(const auto& p:engine.parts){if(p.kind==MachinePartKind::Cylinder)cylinder_probe=p.id;if(p.kind==MachinePartKind::Pipe)downpipe_probe=p.id;}
engine.probes={{1,cylinder_probe,"Cylinder pressure",MachineQuantity::Pressure},{2,downpipe_probe,"Downpipe flow",MachineQuantity::MassFlow},{3,downpipe_probe,"Downpipe AFR",MachineQuantity::AirFuelRatio},{4,downpipe_probe,"Downpipe lambda",MachineQuantity::Lambda},{5,cylinder_probe,"Fresh charge",MachineQuantity::ChargeAfr},{6,0,"Tachometer",MachineQuantity::Rpm},{7,0,"Torque",MachineQuantity::Torque},{8,0,"Power",MachineQuantity::Power}};
validate_machine(engine);MachineSimulation combustion(engine);check(std::isnan(combustion.measure().values[2]),"An empty exhaust reported fabricated AFR");combustion.set_controls(.5f);float peak=0;bool fired=false,exhaust_mixture=false,charge_mixture=false;
for(int i=0;i<22050;++i){auto a=combustion.process();check(std::isfinite(a.left),"Engine audio became nonfinite");if(i%64==0){for(auto& part:combustion.snapshot().parts)if(engine.find(part.id)->kind==MachinePartKind::Cylinder){peak=std::max(peak,part.pressure);fired|=part.combustion;}
const auto readings=combustion.measure();if(std::isfinite(readings.values[2])){exhaust_mixture=true;check(close(readings.values[2]/14.7f,readings.values[3],1e-4f),"Downpipe lambda does not match transported bulk AFR");}charge_mixture|=std::isfinite(readings.values[4]);check(close(readings.values[5],1800,.01f),"Tachometer did not read the simulated shaft");}}
check(peak>500000,"Engine cylinder did not develop compression/combustion pressure");std::cout<<"Engine peak: "<<peak<<" Pa\n";
check(fired,"Assigned ECU did not inject fuel and ignite the charge");
check(exhaust_mixture&&charge_mixture,"Cylinder/exhaust instruments never measured fuel-bearing gas");
const auto unchanged=combustion.snapshot();engine.find(test_load)->brake.constant=1.5f;for(auto& p:engine.parts)if(p.kind==MachinePartKind::Ecu){p.ecu.idle_rpm=1600;p.ecu.power_afr=12.2f;}combustion.apply_tuning(engine);
check(combustion.snapshot().time==unchanged.time&&state(combustion.snapshot(),cylinder_probe).pressure==state(unchanged,cylinder_probe).pressure,"Live tuning reset time or cylinder state");
auto changed_geometry=engine;changed_geometry.parts.front().position.x+=.1f;rejects([&]{combustion.apply_tuning(changed_geometry);},"Live tuning accepted changed geometry");
auto changed_pipe=engine;changed_pipe.find(downpipe_probe)->point_diameters[0]*=1.1f;rejects([&]{combustion.apply_tuning(changed_pipe);},"Live tuning resized a pipe");
run(combustion,256);check(combustion.snapshot().time>unchanged.time,"Live tuning stopped the simulation");
const auto instrument_file=dir/"instruments.machine.toml";save_machine(engine,instrument_file);check(load_machine(instrument_file).probes==engine.probes,"Save/load lost instrument settings");
auto bad_probe=engine;bad_probe.probes[0].part=999999;rejects([&]{validate_machine(bad_probe);},"Missing probe target was accepted");bad_probe=engine;bad_probe.probes.push_back(bad_probe.probes.front());rejects([&]{validate_machine(bad_probe);},"Too many probes were accepted");
MachineSimulation cold_start(machine_engine_example());cold_start.set_controls(.15f,true);bool started=false;
for(int i=0;i<88200;++i){cold_start.process();if(i%128==0)for(auto& p:cold_start.snapshot().parts)started|=p.combustion;}
check(started&&cold_start.snapshot().rpm>500,"Starter could not start the zero-RPM EFI bench");cold_start.set_controls(0,false);run(cold_start,44100);check(cold_start.snapshot().rpm>500,"EFI bench stalled immediately after starter release");
auto dry=engine;std::erase_if(dry.links,[](auto& l){return l.kind==MachineConnectionKind::Control;});MachineSimulation motoring(dry);run(motoring,16000);for(auto& p:motoring.snapshot().parts)if(dry.find(p.id)->kind==MachinePartKind::Cylinder)check(!p.combustion&&p.fuel_mass==0&&!p.fuel_source,"An unassigned ECU generated hidden fuel");
auto no_inject=engine;for(auto& p:no_inject.parts)if(p.kind==MachinePartKind::Ecu)p.injection_enabled=false;MachineSimulation ignition_only(no_inject);run(ignition_only,16000);for(auto& p:ignition_only.snapshot().parts)if(no_inject.find(p.id)->kind==MachinePartKind::Cylinder)check(!p.combustion&&p.fuel_mass==0,"Ignition-only ECU injected fuel");
MachineDocument shared;auto& node=shared.add(MachinePartKind::Junction,{0,.3f,0});const auto node_id=node.id;
for(int i=0;i<8;++i){auto& p=shared.add(MachinePartKind::Pipe);p.position={};const float a=2*pi*i/8;p.points={{0,.3f,0},{.3f*std::cos(a),.3f,.3f*std::sin(a)}};shared.connect({p.id,0},{node_id,0});}validate_machine(shared);MachineSimulation multiway(shared);run(multiway,1024);
auto group=shared;std::vector<unsigned> group_ids;for(auto& p:group.parts)group_ids.push_back(p.id);group.transform_selection(group_ids,{0,.3f,0},{.2f,.1f,-.1f},1,45);validate_machine(group);check(group.links.size()==shared.links.size(),"Group transform lost junction links");
MachineDocument tee;const auto p1=tee.add(MachinePartKind::Pipe,{-.15f,.3f,0}).id,p2=tee.add(MachinePartKind::Pipe,{.15f,.3f,0}).id;tee.connect({p1,1},{p2,0});const auto p3=tee.add(MachinePartKind::Pipe,{0,.5f,0}).id;tee.connect({p3,0},{p1,1});validate_machine(tee);check(tee.parts.size()==4&&tee.links.size()==3,"Joining a third pipe failed to create a shared node");MachineSimulation tee_sim(tee);run(tee_sim,1024);
Ecu limiter;limiter.rev_limit_rpm=5000;limiter.rev_resume_rpm=4500;limiter.rev_limit_cut_time=.05f;limiter.step(.001f,1,5100);check(limiter.ignition_cut(),"Limiter failed to engage");for(int i=0;i<20;++i)limiter.step(.001f,1,4000);check(limiter.rev_limited(),"Limiter ignored minimum cutoff duration");for(int i=0;i<40;++i)limiter.step(.001f,1,4000);check(!limiter.rev_limited(),"Limiter did not resume below its hysteresis RPM");
for(const char* id:{"single-450","twin","flat6","v8-h"}){const auto& preset=scenario_example(id);auto imported=machine_from_engine(preset);float inlet_volume=0,inlet_length=0;
for(auto& p:imported.parts)if(p.id==1||p.name=="Inlet metering stub"||p.kind==MachinePartKind::Carburetor||p.kind==MachinePartKind::Throttle){inlet_volume+=machine_part_volume(p);inlet_length+=p.kind==MachinePartKind::Pipe?machine_pipe_length(p):p.size.x;}
const float original_length=preset.engine.geometry.pipes.front().length(),diameter=preset.engine.pipes.front().diameter;check(close(inlet_length,original_length,1e-6f)&&close(inlet_volume,original_length*pi*.25f*diameter*diameter,1e-8f),"Inlet metering assembly changed imported route length or gas volume");
save_machine(imported,dir/(std::string(id)+".machine.toml"));auto restored=load_machine(dir/(std::string(id)+".machine.toml"));check(restored.parts.size()==imported.parts.size()&&restored.links.size()==imported.links.size(),"Import roundtrip lost topology");MachineSimulation sim(restored);run(sim,2205);std::cout<<"Imported "<<id<<": "<<imported.parts.size()<<" parts\n";
if(std::string(id)=="single-450"){
machine_test::drive(restored,3500);std::vector<unsigned> controllers;unsigned carb_id=0;for(auto& p:restored.parts){if(p.kind==MachinePartKind::Ecu)controllers.push_back(p.id);if(p.kind==MachinePartKind::Carburetor)carb_id=p.id;}for(auto controller:controllers)restored.remove(controller);
bool burning=false;MachineSimulation carb(restored);carb.set_controls(.25f);for(int i=0;i<44100;++i){carb.process();if(i%64==0)for(auto& p:carb.snapshot().parts)burning|=p.combustion;}check(burning,"A connected carburetor without an ECU failed to deliver combustible fuel");
const auto fuel_rate=[&]{double sum=0;for(int i=0;i<4536;++i){carb.process();if(i%24==0)sum+=state(carb.snapshot(),carb_id).fuel_flow;}return sum;};const auto original=fuel_rate();
auto tuned=restored;tuned.find(carb_id)->carburetor.pilot_jet_diameter*=1.25f;tuned.find(carb_id)->carburetor.main_jet_diameter*=1.25f;tuned.find(carb_id)->carburetor.needle_jet_diameter*=1.25f;const auto time=carb.snapshot().time;carb.apply_tuning(tuned);check(carb.snapshot().time==time,"Live carb tuning restarted the machine");
check(fuel_rate()>original*1.05,"Changing carb jets live did not increase delivered fuel");
}
}
if(argc==3&&std::string(argv[1])=="--imports"){for(const char* id:{"single-450","twin","flat6","v8-h"})save_machine(machine_from_engine(scenario_example(id)),std::filesystem::path(argv[2])/(std::string(id)+"-imported.machine.toml"));}
if(argc>=2&&std::string(argv[1])=="--rpm-sweep"){
for(float rpm:{12000.f,16000.f,20000.f,24000.f}){auto fast=engine;machine_test::drive(fast,rpm);for(auto& p:fast.parts)if(p.kind==MachinePartKind::Ecu)p.ecu.rev_limit_enabled=false;MachineSimulation sim(fast);sim.set_controls(.75f);bool fire=false,floating=false;
for(int i=0;i<13230;++i){sim.process();if(i%64==0)for(auto& p:sim.snapshot().parts){fire|=p.combustion;floating|=p.valve_float;}}
std::cout<<"High RPM: "<<rpm<<" actual="<<sim.snapshot().rpm<<" fired="<<fire<<" valve_float="<<floating<<" repairs="<<sim.snapshot().positivity_repairs<<'\n';}
auto free=engine;machine_test::free(free,4000);for(auto& p:free.parts)if(p.kind==MachinePartKind::Ecu)p.ecu.rev_limit_enabled=false;MachineSimulation sim(free);sim.set_controls(1);for(int i=0;i<1323000;++i){sim.process();if(i%44100==0){const auto s=sim.snapshot();std::cout<<"Free RPM after "<<i/44100<<"s: "<<s.rpm<<" torque="<<s.torque<<'\n';}}
}
if(argc==3&&std::string(argv[1])=="--examples"){const std::filesystem::path output=argv[2];save_machine(pump,output/"vacuum-pump.machine.toml");save_machine(engine,output/"single-cylinder.machine.toml");}
for(auto& entry:std::filesystem::directory_iterator(dir))std::filesystem::remove(entry.path());std::filesystem::remove(dir);
std::cout<<"Machine geometry, topology, serialization, valves, junctions, pump, and engine checks passed\n";
return 0;
}catch(const std::exception& e){std::cerr<<e.what()<<'\n';return 1;}

79
tests/mechanics_tests.cpp Normal file
View File

@@ -0,0 +1,79 @@
#include "FluidSim/Mechanics.hpp"
#include <algorithm>
#include <cmath>
#include <iostream>
#include <limits>
#include <stdexcept>
using namespace fluidsim::port;
namespace {
void check(bool value,const char* why){if(!value)throw std::runtime_error(why);}
bool close(double a,double b,double tolerance=1e-10){return std::abs(a-b)<=tolerance*std::max({1.,std::abs(a),std::abs(b)});}
template<class F>void rejects(F&& f,const char* why){bool caught=false;try{f();}catch(const std::exception&){caught=true;}check(caught,why);}
void balance(const ShaftState& s){check(close(s.initial_energy+s.external_work+s.drive_work,s.kinetic_energy+s.friction_loss+s.brake_loss,2e-9),"Mechanical work/energy balance failed");check(s.friction_loss>=0&&s.brake_loss>=0,"Mechanical drag created energy");}
}
int main()try{
{
MechanicalSystem m;m.add_shaft(1,{.inertia=2});m.add_shaft(2,{.inertia=.5,.initial_speed=10});m.add_drive(3,1,{DriveMode::Torque,4});
for(int i=0;i<100;++i)m.step(.01);
check(close(m.shaft(1).speed,2)&&close(m.shaft(1).angle,1),"Constant torque does not match analytic acceleration/angle");
check(close(m.shaft(2).speed,10)&&close(m.shaft(2).angle,10),"Independent shaft was accelerated by another drive");
check(close(m.drive(3).work,4)&&close(m.shaft(1).kinetic_energy,4),"Motor work did not become shaft kinetic energy");balance(m.shaft(1));balance(m.shaft(2));
rejects([&]{m.add_flywheel(4,1,1);},"Topology changed after simulation began");
}
{
MechanicalSystem m;m.add_shaft(1,{.inertia=1});m.add_flywheel(2,1,3);m.add_drive(3,1,{DriveMode::Torque,4});m.step(1);
check(close(m.shaft(1).inertia,4)&&close(m.shaft(1).speed,1),"Flywheel inertia was omitted or counted twice");balance(m.shaft(1));
}
{
MechanicalSystem m;m.add_shaft(1,{.inertia=1,.initial_speed=3});m.add_brake(2,1,{.constant=10});m.step(1);
check(m.shaft(1).speed==0&&close(m.shaft(1).angle,.45),"Brake did not stop at the correct position within a timestep");
check(close(m.brake(2).loss,4.5)&&m.brake(2).torque<0,"Brake did not absorb initial kinetic energy");m.step(1);
check(m.shaft(1).speed==0&&close(m.shaft(1).angle,.45),"Brake pushed a stopped shaft into reverse");balance(m.shaft(1));
}
{
MechanicalSystem m;m.add_shaft(1,{.inertia=1});m.add_drive(2,1,{DriveMode::Torque,4});m.add_brake(3,1,{.constant=10});m.step(1);
check(m.shaft(1).speed==0&&close(m.brake(3).torque,-4)&&m.brake(3).power==0,"Static brake reaction or zero-speed power is wrong");
m.set_drive(2,{DriveMode::Torque,12});m.step(1);check(close(m.shaft(1).speed,2),"Drive could not overcome static brake capacity");balance(m.shaft(1));
}
{
MechanicalSystem m;m.add_shaft(1,{.inertia=1,.initial_speed=3});m.add_drive(2,1,{DriveMode::Torque,-20});m.add_brake(3,1,{.constant=10});m.step(1);
check(m.shaft(1).speed<0&&m.shaft(1).angle<0,"Drive could not stop and reverse a shaft");check(m.brake(3).loss>0,"Brake generated energy through reversal");balance(m.shaft(1));
}
{
MechanicalSystem m;m.add_shaft(1,{.inertia=2,.friction_viscous=1});m.add_drive(2,1,{DriveMode::Speed,10});m.add_brake(3,1,{.constant=3});m.step(.1);
check(close(m.shaft(1).speed,10)&&close(m.shaft(1).angle,1),"Ideal drive did not impose its speed");
check(close(m.drive(2).work,113)&&close(m.shaft(1).brake_loss,3)&&close(m.shaft(1).friction_loss,10),"Ideal drive did not account for startup work and losses");
m.step(.1);check(close(m.drive(2).torque,13)&&close(m.drive(2).power,130),"Steady speed-drive reaction does not balance losses");
m.set_drive(2,{DriveMode::Speed,0});m.step(.1);check(close(m.drive(2).power,-1000),"Ideal drive did not report recovered stopping energy");balance(m.shaft(1));
}
{
MechanicalSystem m;m.add_shaft(1,{.inertia=.5,.initial_speed=-8,.friction_constant=.3,.friction_viscous=.2,.friction_quadratic=.02});m.add_drive(2,1,{DriveMode::Speed,-8});m.add_brake(3,1,{.constant=.4,.viscous=.1});m.step(.01);
check(m.drive(2).torque<0&&m.drive(2).power>0&&m.brake(3).torque>0&&m.brake(3).power>0,"Reverse rotation used the wrong torque or power sign");balance(m.shaft(1));
}
for(double dt:{.01,.001}){
MechanicalSystem m;m.add_shaft(1,{.inertia=2,.initial_speed=10,.friction_viscous=3});const int steps=int(std::round(1/dt));for(int i=0;i<steps;++i)m.step(dt);
check(std::abs(m.shaft(1).speed-10*std::exp(-1.5))<(dt==.01?.0001:.000002),"Viscous damping failed timestep refinement against its analytic solution");balance(m.shaft(1));
}
{
MechanicalSystem m;m.add_shaft(1,{.inertia=.7,.initial_speed=4,.friction_constant=.2,.friction_viscous=.04,.friction_quadratic=.006});m.add_drive(2,1,{DriveMode::Torque,1});m.add_brake(3,1,{.constant=.1,.viscous=.02,.quadratic=.003});
for(int i=0;i<10000;++i){m.add_torque(1,std::sin(i*.01)*6);if(i%1200==0)m.set_drive(2,{DriveMode::Torque,i%2400==0?-2.:3.});m.step(.001);if(i%100==0)balance(m.shaft(1));}
balance(m.shaft(1));
}
{
MechanicalSystem m;m.add_shaft(1,{.inertia=1,.initial_speed=1,.forward_only=true});m.add_drive(2,1,{DriveMode::Torque,-10});m.step(1);
check(m.shaft(1).speed==0&&m.shaft(1).constraint_torque>0,"Forward-only timing constraint failed to hold at rest");balance(m.shaft(1));
rejects([&]{m.set_drive(2,{DriveMode::Speed,-1});},"Forward-only shaft accepted a reverse speed command");
}
{
MechanicalSystem m;m.add_shaft(1,{});m.add_drive(2,1,{DriveMode::Speed,10});
rejects([&]{m.add_drive(3,1,{DriveMode::Speed,10});},"Multiple ideal drives acquired undefined reaction sharing");
m.add_drive(3,1,{DriveMode::Torque,1});rejects([&]{m.set_drive(3,{DriveMode::Speed,10});},"Tuning introduced a conflicting speed source");
rejects([&]{m.add_brake(2,1,{});},"Duplicate mechanical ID was accepted");
rejects([&]{m.add_flywheel(4,99,1);},"Flywheel attached to a nonexistent shaft");
rejects([&]{m.add_shaft(4,{.inertia=0});},"Zero inertia was accepted");
rejects([&]{m.add_brake(4,1,{.constant=-1});},"Negative brake loss was accepted");
rejects([&]{m.step(0);},"Zero timestep was accepted");
rejects([&]{m.add_torque(1,std::numeric_limits<double>::quiet_NaN());},"Nonfinite torque was accepted");
}
std::cout<<"Independent shafts, flywheel inertia, drive/brake work and stopping/reversal passed\n";return 0;
}catch(const std::exception& error){std::cerr<<error.what()<<'\n';return 1;}

50
tests/menu_tests.cpp Normal file
View File

@@ -0,0 +1,50 @@
#include "FluidSim/Desktop/ScenarioMenu.hpp"
#include "FluidSim/Desktop/WindowPresentation.hpp"
#include "FluidSim/Machine.hpp"
#include <algorithm>
#include <iostream>
#include <stdexcept>
using namespace fluidsim::port;
using namespace fluidsim::port::desktop;
void check(bool value,const char* message){if(!value)throw std::runtime_error(message);}
void key(ScenarioMenu& menu,sf::Keyboard::Key code,sf::Vector2u size={1280,720}){menu.handle_event(sf::Event::KeyPressed{.code=code},size);}
void click(ScenarioMenu& menu,sf::Vector2i at,sf::Vector2u size={1280,720}){menu.handle_event(sf::Event::MouseButtonPressed{sf::Mouse::Button::Left,at},size);menu.handle_event(sf::Event::MouseButtonReleased{sf::Mouse::Button::Left,at},size);}
int main(int argc,char** argv)try{
if(argc==2&&std::string(argv[1])=="--window-check"){
sf::RenderWindow window(sf::VideoMode({640,480}),"FluidSim maximize check");maximize_window(window);
check(window_is_maximized(window),"Window did not enter the native maximized state");window.close();std::cout<<"Native window maximization passed\n";return 0;
}
const auto examples=scenario_examples();check(!examples.empty(),"Scenario catalog is empty");
ScenarioMenu workshop(examples,{"example.machine.toml"});check(workshop.screen()==MenuScreen::Home,"Menu did not start on Home");
key(workshop,sf::Keyboard::Key::Enter);check(workshop.screen()==MenuScreen::Workshop&&!workshop.take_choice(),"Workshop navigation launched a machine immediately");
key(workshop,sf::Keyboard::Key::Enter);auto choice=workshop.take_choice();check(choice&&!choice->example&&choice->machine.empty()&&!choice->play,"New machine choice is invalid");
key(workshop,sf::Keyboard::Key::Down);key(workshop,sf::Keyboard::Key::Space);choice=workshop.take_choice();check(choice&&choice->machine=="example.machine.toml"&&choice->play,"Saved machine Play did not preserve its path");
key(workshop,sf::Keyboard::Key::Enter);choice=workshop.take_choice();check(choice&&!choice->play&&!choice->example,"Saved machine Edit was treated as Play");
key(workshop,sf::Keyboard::Key::Escape);check(workshop.screen()==MenuScreen::Home&&!workshop.finished(),"Back closed the application instead of returning Home");
key(workshop,sf::Keyboard::Key::Down);key(workshop,sf::Keyboard::Key::Enter);check(workshop.screen()==MenuScreen::Scenarios,"Engine Scenarios did not open its own browser");
click(workshop,{350,180});check(!workshop.take_choice(),"Selecting an engine row started it immediately");
key(workshop,sf::Keyboard::Key::Enter);choice=workshop.take_choice();check(choice&&choice->example==0,"Engine browser lost catalog index mapping");
// Required keyboard focus keeps search text separate from navigation shortcuts.
click(workshop,{350,120});for(char c:std::string("no-such-engine-xyz"))workshop.handle_event(sf::Event::TextEntered{std::uint32_t(c)},{1280,720});
key(workshop,sf::Keyboard::Key::Enter);key(workshop,sf::Keyboard::Key::Enter);check(!workshop.take_choice(),"Empty search launched an engine");
key(workshop,sf::Keyboard::Key::Escape);key(workshop,sf::Keyboard::Key::Enter);check(workshop.screen()==MenuScreen::Scenarios,"Returning to browser did not clear search");
click(workshop,{100,206});key(workshop,sf::Keyboard::Key::End);key(workshop,sf::Keyboard::Key::Enter);choice=workshop.take_choice();check(choice&&choice->example&&examples[*choice->example].category==examples.front().category,"Category selection started an engine from another category");
key(workshop,sf::Keyboard::Key::B);choice=workshop.take_choice();check(choice&&!choice->example&&choice->machine.empty(),"New machine keyboard shortcut failed");
ScenarioMenu empty({});key(empty,sf::Keyboard::Key::Enter);key(empty,sf::Keyboard::Key::Enter);check(bool(empty.take_choice()),"Empty engine catalog disabled the workshop");
ScenarioMenu quit(examples);key(quit,sf::Keyboard::Key::End);key(quit,sf::Keyboard::Key::Enter);check(quit.finished(),"Quit did not close the menu");
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({1920,1080}),"Main menu review",sf::Style::Default,sf::State::Windowed,context);window.setVisible(false);sf::Font font("C:/Windows/Fonts/consola.ttf");
for(const sf::Vector2u size:{sf::Vector2u{1920,1080},sf::Vector2u{1280,720}}){
window.setSize(size);ScenarioMenu menu(examples,machine_files());
const auto capture=[&](const std::string& name){menu.draw(window,&font);sf::Texture image(size);image.update(window);check(image.copyToImage().saveToFile(directory/(std::to_string(size.x)+"-"+name+".png")),"Menu screenshot save failed");};
capture("home");key(menu,sf::Keyboard::Key::Down,size);key(menu,sf::Keyboard::Key::Enter,size);capture("engines");
click(menu,{350,120},size);const auto it=std::find_if(examples.begin(),examples.end(),[](const auto& example){return example.id=="flat6";});
if(it!=examples.end())for(char c:it->title)menu.handle_event(sf::Event::TextEntered{std::uint32_t(c)},size);key(menu,sf::Keyboard::Key::Enter,size);capture("engine-search");
key(menu,sf::Keyboard::Key::Escape,size);key(menu,sf::Keyboard::Key::Up,size);key(menu,sf::Keyboard::Key::Enter,size);capture("workshop");
key(menu,sf::Keyboard::Key::Down,size);capture("saved-machine");
}
}
std::cout<<"Main menu, workshop and scenario navigation passed\n";return 0;
}catch(const std::exception& error){std::cerr<<error.what()<<'\n';return 1;}

View File

@@ -81,10 +81,15 @@ int main() {
const auto small_intake_pulse=desktop::pressure_tint(104325),small_exhaust_pulse=desktop::pressure_tint(104325,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, 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"); "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, require(small_intake_pulse.x>atm.x&&small_intake_pulse.y>=atm.y&&small_intake_pulse.z<=atm.z,
"a small positive intake pulse must be visible as yellow"); "a positive intake pulse must move continuously toward warmer colors");
require(small_exhaust_pulse.x>.95f&&small_exhaust_pulse.y<.4f, require(small_exhaust_pulse.x>small_intake_pulse.x,
"a small positive exhaust pulse must be visible as orange-red"); "the same exhaust pulse must reach warmer colors sooner than intake");
const auto near_atmosphere=desktop::pressure_tint(101325.1f),strong_intake=desktop::pressure_tint(251325);
require(std::abs(near_atmosphere.x-atm.x)<.001f&&std::abs(near_atmosphere.y-atm.y)<.001f,
"the positive pressure palette must remain continuous at atmosphere");
require(strong_intake.x>.95f&&strong_intake.y<.9f&&strong_intake.z<.1f,
"strong intake overpressure must reach warm colors");
Ecu fast_actuator; Ecu fast_actuator;
for(int i=0;i<230;++i)fast_actuator.step(1.f/44100,1,5000); 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"); require(fast_actuator.throttle_opening()>.99f,"throttle actuator must settle within about 5 ms");
@@ -140,6 +145,7 @@ int main() {
for (int i = 8; i < 12; ++i) audio_ring.write(float(i), -float(i)); for (int i = 8; i < 12; ++i) audio_ring.write(float(i), -float(i));
require(audio_ring.read_interpolated(left, right, 1.0) == 4 && left[0] == 7.0f && left[3] == 10.0f, require(audio_ring.read_interpolated(left, right, 1.0) == 4 && left[0] == 7.0f && left[3] == 10.0f,
"ring must resume continuously after underrun and wraparound"); "ring must resume continuously after underrun and wraparound");
audio_ring.discard();require(audio_ring.available_samples()==0,"Discard must remove old playback latency while stopped");for(int i=100;i<105;++i)audio_ring.write(float(i),-float(i));require(audio_ring.read_interpolated(left,right,1)==4&&left[0]==100&&left[3]==103,"A restarted consumer replayed discarded audio");
SoundProcessor linear_source(44100, 1.0f); SoundProcessor linear_source(44100, 1.0f);
linear_source.process(0.0f); linear_source.process(0.0f);
const float response = linear_source.process(0.01f); const float response = linear_source.process(0.01f);
@@ -283,6 +289,17 @@ int main() {
require(std::abs(profiled_pipe.cell_mass(1) - 1.225f * 0.002f * 0.2f) < 1e-8f && require(std::abs(profiled_pipe.cell_mass(1) - 1.225f * 0.002f * 0.2f) < 1e-8f &&
std::abs(profiled_pipe.minimum_cell_length() - 0.1f) < 1e-8f, std::abs(profiled_pipe.minimum_cell_length() - 0.1f) < 1e-8f,
"profiled pipes must retain each cell's area and length for mass/CFL calculations"); "profiled pipes must retain each cell's area and length for mass/CFL calculations");
Pipe1D contraction{{.001f,.00005f,.001f},{.01f,.01f,.01f}};
Pipe1D uniform_comparison{.001f,.03f,3};
require(contraction.prepare_step()<uniform_comparison.prepare_step()*.1f&&std::abs(contraction.stable_timestep()-contraction.prepare_step())<1e-10f,
"sharp contractions must limit CFL by face area relative to stored volume");
for(auto method:{PipeSolver::Rusanov,PipeSolver::Hlle,PipeSolver::Hllc,PipeSolver::Muscl}){
std::vector<Pipe1D> pipes;pipes.emplace_back(.001f,1.f,40);auto& pipe=pipes[0];pipe.solver=method;
for(std::size_t i=0;i<pipe.cells.size();++i){auto& cell=pipe.cells[i];cell.rho=1.2f;cell.momentum=12;cell.energy=101325/pipe_gm1+60;cell.air=0;cell.fuel=0;cell.fuel_equivalent=i<10?.02f:0;}
const auto marker_mass=[&]{double total=0;for(std::size_t i=0;i<pipe.cells.size();++i)total+=pipe.cell_mass(int(i))*pipe.cells[i].fuel_equivalent;return total;};const auto before=marker_mass();
BoundarySystem boundaries(pipes);boundaries.add_connection(0,true,0,false);Solver transport(pipes,boundaries);for(int i=0;i<2000;++i)transport.step(1.f/44100);
require(std::abs(marker_mass()-before)<before*.0005&&pipe.cells[25].fuel_equivalent>0,"Burned-fuel origin must survive conservative gas transport");
}
Vehicle bike; Vehicle bike;
bike.set_first_gear(); bike.set_first_gear();

141
tests/sensor_ecu_tests.cpp Normal file
View File

@@ -0,0 +1,141 @@
#include "FluidSim/Machine.hpp"
#include "FluidSim/FuelInjector.hpp"
#include "FluidSim/Fluid.hpp"
#include <cmath>
#include <chrono>
#include <iostream>
#include <stdexcept>
using namespace fluidsim::port;
namespace {
void check(bool ok,const char* why){if(!ok)throw std::runtime_error(why);}
struct Pickup {
SignalValue speed,angle;
Pickup(double time,double unwrapped,double velocity=10,std::uint32_t sensor=1,std::uint32_t shaft=1){
const double period=2*double(pi);double wrapped=std::fmod(unwrapped,period);if(wrapped<0)wrapped+=period;
speed=SignalValue::number(velocity,time);angle=SignalValue::number(wrapped,time);
speed.timing=angle.timing=SignalTiming{sensor,shaft,time,unwrapped,velocity};
}
};
}
int main(int argc,char** argv)try{
if(argc==3&&std::string(argv[1])=="--write-examples"){
const std::filesystem::path directory=argv[2];save_machine(machine_sensor_engine_example(false),directory/"sensor-ignition-bench.machine.toml");save_machine(machine_sensor_engine_example(true),directory/"sensor-alpha-n-bench.machine.toml");return 0;
}
const double period=2*double(pi);
Pickup stopped(0,0,0);check(bool(ecu_crank_sample(stopped.speed,stopped.angle,0)),"Stopped pickup rejected");
check(!ecu_crank_sample(SignalValue::number(10),SignalValue::number(0),0),"Constants gained crank timing authority");
Pickup a(0,period-.2),b(.01,period+.1);
auto mismatch=b.angle;mismatch.timing->sensor=2;check(!ecu_crank_sample(b.speed,mismatch,.01),"Independent pickups accepted as a coherent pair");
mismatch=b.angle;mismatch.timing->shaft=2;check(!ecu_crank_sample(b.speed,mismatch,.01),"Different shafts accepted");
mismatch=b.angle;mismatch.value=.3;check(!ecu_crank_sample(b.speed,mismatch,.01),"Changed angle retained authority");
mismatch=b.angle;mismatch.timing->sampled_at=0;check(!ecu_crank_sample(b.speed,mismatch,.01),"Stale acquisition time accepted");
Pickup reverse(0,0,-1);check(!ecu_crank_sample(reverse.speed,reverse.angle,0),"Reverse crank drove forward-only ignition");
CrankIgnitionClock clock;check(clock.sample(a.speed,a.angle,0,0).sparks==0,"Initial sample invented a spark");
auto event=clock.sample(b.speed,b.angle,.01,0);check(event.valid&&event.sparks==1,"Wrapped crossing lost a spark");
check(!clock.sample(b.speed,b.angle,.01,0).valid,"Repeated sample generated timing");
clock.reset();Pickup before(1,-.4),after(1.01,-.2);
clock.sample(before.speed,before.angle,1,20);event=clock.sample(after.speed,after.angle,1.01,20);check(event.valid&&event.sparks==1,"BTDC crossing did not use pickup angle");
clock.reset();Pickup many(2,4*period+.1);clock.sample(b.speed,b.angle,.01,0);event=clock.sample(many.speed,many.angle,2,0);check(event.valid&&event.sparks==3,"Multiple unwrapped revolutions were lost");
clock.reset();clock.sample(stopped.speed,stopped.angle,0,10);Pickup still(.1,0,0);event=clock.sample(still.speed,still.angle,.1,30);check(event.valid&&!event.sparks,"Stationary timing fired from an advance change");
clock.reset();Pickup early(0,-.4),fired(.01,-.3),retarded(.02,.2);
clock.sample(early.speed,early.angle,0,20);check(clock.sample(fired.speed,fired.angle,.01,20).sparks==1,"Advanced spark was missed");
check(clock.sample(retarded.speed,retarded.angle,.02,-10).sparks==0,"Advance change fired twice for the same revolution");
clock.reset();Pickup phase_a(0,pi-.2),phase_b(.01,pi+.1);clock.sample(phase_a.speed,phase_a.angle,0,0,180);check(clock.sample(phase_b.speed,phase_b.angle,.01,0,180).sparks==1,"Explicit cylinder TDC calibration was ignored");
// Actual machine -> sensor reference -> arithmetic/delay demonstrates that
// only unchanged routing keeps current physical acquisition evidence.
MachineDocument doc;const auto shaft=doc.add(MachinePartKind::Shaft).id;doc.find(shaft)->shaft.initial_speed=10;
const auto sensor=doc.add_sensor(MachineSensorKind::Crank,shaft).id;
auto& g=doc.logic;const auto source=g.add(LogicNodeKind::Sensor).id;g.find(source)->sensor=sensor;g.find(source)->sensor_kind=MachineSensorKind::Crank;
const auto delay=g.add(LogicNodeKind::NumberDelay).id;g.find(delay)->quantity=SignalQuantity::Angle;g.connect({source,"angle"},{delay,"in"});
const auto gain=g.add(LogicNodeKind::GainOffset).id;g.find(gain)->quantity=SignalQuantity::Angle;g.find(gain)->gain=1;g.find(gain)->offset=0;g.connect({source,"angle"},{gain,"in"});
MachineSimulation machine(doc,1000);auto state=machine.sense(sensor);
check(state.outputs[0].timing&&state.outputs[0].timing==state.outputs[1].timing,"Physical crank channels lack shared evidence");
MachineLogicRuntime runtime(doc,[&](std::uint32_t id){return machine.sense(id);});runtime.evaluate(0,.001);
check(bool(ecu_crank_sample(runtime.value({source,"speed"}),runtime.value({source,"angle"}),0)),"Graph dropped physical crank evidence");
check(!runtime.value({gain,"out"}).timing,"Arithmetic kept timing authority even with identity calibration");
machine.process();runtime.evaluate(machine.snapshot().time,.001);
const auto delayed=runtime.value({delay,"out"});check(delayed.timing&&delayed.timestamp!=delayed.timing->sampled_at,"Delay rewrote acquisition time");
check(!ecu_crank_sample(runtime.value({source,"speed"}),delayed,machine.snapshot().time),"Delayed angle accepted as current crank timing");
{
SensorEcuConfig config;config.strategy=SensorEcuConfig::Strategy::AlphaN;
config.fuel_rpm={0,6000};config.fuel_tps={0,1};config.fuel_per_cycle={0.000002,0.00001,0.000006,0.00003};
SensorEcu ecu(config);Pickup mid(0,0,3000*period/60);
auto result=ecu.sample(mid.speed,mid.angle,SignalValue::number(.5,0),0);
check(!result.sensor_fault&&std::abs(result.fuel_per_cycle-.000012)<1e-12,"Alpha-N did not interpolate RPM/TPS table in kg/cycle");
check(std::abs(result.fuel_mass_flow-.0003)<1e-12,"Four-stroke per-cylinder kg/cycle to kg/s conversion failed");
Volume0D intake(.001f);FuelInjectorConfig rail;rail.supply=FuelInjectorConfig::Supply::IdealRail;FuelInjector delivery(rail);
delivery.deliver(intake,SignalValue::number(result.fuel_mass_flow,0),0,.02);
check(std::abs(intake.fuel_mass()-.000006)<1e-11,"Alpha-N demand failed to reach the physical gas host");
Pickup next(.01,period,mid.speed.timing->angular_speed);
result=ecu.sample(next.speed,next.angle,SignalValue{},.01);
check(result.sensor_fault&&!result.spark_events&&!result.fuel_mass_flow,"Missing required TPS retained actuator commands");
result=ecu.sample(next.speed,next.angle,SignalValue::number(.5,.01),.01);check(!result.sensor_fault&&!result.spark_events,"Recovery replayed old spark crossings");
Pickup limited(.02,2*period,7100*period/60);result=ecu.sample(limited.speed,limited.angle,SignalValue::number(1,.02),.02);
check(result.rev_limited&&!result.sensor_fault&&!result.spark_events&&!result.fuel_mass_flow,"Rev limiter failed to cut fuel and spark");
Pickup held(.03,3*period,6900*period/60);result=ecu.sample(held.speed,held.angle,SignalValue::number(1,.03),.03);check(result.rev_limited,"Limiter omitted hysteresis");
Pickup resumed(.04,4*period,6700*period/60);result=ecu.sample(resumed.speed,resumed.angle,SignalValue::number(1,.04),.04);check(!result.rev_limited&&result.fuel_mass_flow>0&&result.spark_events==1,"Limiter failed to resume cleanly");
auto bad=config;bad.fuel_rpm={100,100};bool rejected=false;try{SensorEcu invalid(bad);}catch(const std::invalid_argument&){rejected=true;}check(rejected,"Non-increasing fuel table axis accepted");
SensorEcu ignition(SensorEcuConfig{});result=ignition.sample(mid.speed,mid.angle,SignalValue{},0);check(!result.sensor_fault&&!result.fuel_mass_flow,"Ignition-only ECU demanded TPS or created fuel");
}
{
Volume0D volume(.001f);const auto initial_mass=volume.mass(),initial_energy=volume.internal_energy();
FuelInjectorConfig config;config.initial_fuel_mass=.00001;config.maximum_mass_flow=.001;
FuelInjector injector(config);
for(int i=0;i<16;++i)injector.deliver(volume,SignalValue::number(.01,i*.01),i*.01,.01);
const auto state=injector.state();
check(state.saturated&&state.empty&&state.delivered_mass<=config.initial_fuel_mass,"Injector exceeded supply or omitted capacity saturation");
check(std::abs(double(volume.fuel_mass())-state.delivered_mass)<1e-10&&std::abs(double(volume.mass()-initial_mass)-state.delivered_mass)<1e-9,"Injected fuel did not enter conserved host mass");
const double expected_energy=state.delivered_mass*volume.gas_constant/(volume.gamma-1)*config.fuel_temperature;
check(std::abs(volume.internal_energy()-initial_energy-expected_energy)<1e-4,"Injector omitted sensible energy");
check(volume.fuel_equivalent_fraction()>0,"Injector omitted transported fuel tracer");
const auto before=volume.fuel_mass();auto fault=SignalValue::number(.001,1);fault.validity=SignalValidity::Fault;
check(injector.deliver(volume,fault,1,.01).command_fault&&volume.fuel_mass()==before,"Faulting command delivered fuel");
FuelInjectorConfig ideal;ideal.supply=FuelInjectorConfig::Supply::IdealRail;FuelInjector continuous(ideal);
continuous.deliver(volume,SignalValue::number(.001,1),1,.01);check(continuous.state().mass_flow>0&&!continuous.state().empty,"Declared ideal rail failed to supply fuel");
const auto held=volume.fuel_mass();continuous.deliver(volume,SignalValue::number(.001,1),2,.01);check(continuous.state().command_fault&&volume.fuel_mass()==held,"Stale command retained injector flow");
}
{
Crankshaft crank;FourStrokeCylinder cylinder;cylinder.transported_fuel=true;cylinder.initialize(crank);
cylinder.fuel_mass=.000001f;cylinder.air_mass=cylinder.fuel_mass*14.7f;cylinder.fuel_equivalent_mass=cylinder.fuel_mass;cylinder.fuel_injected=true;
cylinder.heat_transfer=0;cylinder.energy_variation=0;
cylinder.ignition_command=FourStrokeCylinder::IgnitionCommand{false,false};
// Cross the legacy internal spark coordinate without a wired event.
crank.previous_angle=(720-cylinder.spark_advance-1)*pi/180;crank.angle=(720-cylinder.spark_advance+1)*pi/180;
cylinder.pre_step(crank,.0001f,0);check(!cylinder.combustion_active,"External timing fell back to hidden cylinder phase");
// Fire at a different physical angle: event authority is the wire.
crank.previous_angle=crank.angle=0;cylinder.ignition_command=FourStrokeCylinder::IgnitionCommand{true,false};
cylinder.pre_step(crank,.0001f,0);check(cylinder.combustion_active&&!cylinder.ignition_command->fire,"External pulse failed to ignite or was not consumed");
const auto initial_fuel=cylinder.fuel_mass;
for(int i=0;i<100;++i){crank.previous_angle=crank.angle;crank.angle+=pi/180;cylinder.pre_step(crank,.0001f,0);}
check(!cylinder.combustion_active&&cylinder.fuel_mass<initial_fuel,"External spark burn did not progress from its event angle");
const auto last_charge=cylinder.charge_afr;cylinder.ignition_command=FourStrokeCylinder::IgnitionCommand{true,false};cylinder.pre_step(crank,.0001f,0);check(cylinder.charge_afr==last_charge,"Exhaust wasted spark overwrote trapped-charge AFR");
cylinder.fuel_injected=true;cylinder.ignition_command=FourStrokeCylinder::IgnitionCommand{true,true};cylinder.pre_step(crank,.0001f,0);check(!cylinder.combustion_active,"Cut command still fired");
}
for(const bool injection:{false,true}){
auto doc=machine_sensor_engine_example(injection);std::uint32_t ecu=0,host=0,cylinder=0;for(const auto& p:doc.parts){if(p.kind==MachinePartKind::Ecu)ecu=p.id;if(p.injector)host=p.id;if(p.kind==MachinePartKind::Cylinder)cylinder=p.id;}
MachineSimulation engine(doc,22050);bool fire=false,fuel=false;for(int i=0;i<22050;++i){engine.process();if(i%16==0)for(const auto& p:engine.snapshot().parts)if(p.id==cylinder){fire|=p.combustion;fuel|=p.fuel_mass>0;}}
check(fuel,"Sensor engine example never transported fuel to cylinder");check(fire,injection?"Alpha-N example never combusted":"Carburetor ignition example never combusted");
const auto before_tuning=engine.snapshot();double old_advance=0,old_delivered=0;for(const auto& p:before_tuning.parts){if(p.id==ecu)old_advance=p.spark_advance;if(p.id==host)old_delivered=p.injector_delivered;}
auto tuned=doc;for(auto& point:tuned.find(ecu)->ecu.ignition_map)point.degrees_btdc+=5;engine.apply_tuning(tuned);check(engine.snapshot().time==before_tuning.time,"Ignition tuning restarted engine time");engine.process();for(const auto& p:engine.snapshot().parts){if(p.id==ecu)check(std::abs(p.spark_advance-old_advance-5)<.001,"Sensor ECU ignored live ignition calibration");if(injection&&p.id==host)check(p.injector_delivered>old_delivered,"Live ignition tuning reset injector delivery history");}
const auto path=std::filesystem::temp_directory_path()/("fluidsim-ecu-"+std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())+".machine.toml");save_machine(doc,path);const auto restored=load_machine(path);std::filesystem::remove(path);
check(restored.logic==doc.logic&&restored.find(ecu)->sensor_ecu==doc.find(ecu)->sensor_ecu,"ECU connector or fuel calibration failed save/load");
if(injection)check(restored.find(host)->injector==doc.find(host)->injector,"Injector attachment failed save/load");
auto wrong=doc;const auto foreign=wrong.add(MachinePartKind::Shaft).id;wrong.find(foreign)->shaft.initial_speed=3000*2*pi/60;for(auto& sensor:wrong.sensors)if(sensor.kind==MachineSensorKind::Crank)sensor.target.part=foreign;
MachineSimulation other(wrong,22050);for(int i=0;i<32;++i)other.process();for(const auto& p:other.snapshot().parts){if(p.id==cylinder)check(p.ignition_cut&&!p.combustion,"ECU accepted timing from a different engine shaft");if(injection&&p.id==host)check(p.fuel_flow==0,"Wrong-shaft timing still delivered fuel");}
auto unbound=doc;std::uint32_t connector=0;for(const auto& node:unbound.logic.nodes)if(node.kind==LogicNodeKind::Ecu)connector=node.id;unbound.logic.disconnect({connector,"angle"});bool rejected=false;try{MachineSimulation invalid(unbound);}catch(const std::invalid_argument&){rejected=true;}check(rejected,"Missing required ECU angle wire accepted");
auto fake=doc;fake.logic.disconnect({connector,"speed"});fake.logic.disconnect({connector,"angle"});const auto numeric_speed=fake.logic.add(LogicNodeKind::NumberConstant).id,numeric_angle=fake.logic.add(LogicNodeKind::NumberConstant).id;fake.logic.find(numeric_speed)->quantity=SignalQuantity::AngularSpeed;fake.logic.find(numeric_speed)->value=3000*2*pi/60;fake.logic.find(numeric_angle)->quantity=SignalQuantity::Angle;fake.logic.connect({numeric_speed,"out"},{connector,"speed"});fake.logic.connect({numeric_angle,"out"},{connector,"angle"});MachineSimulation no_pickup(fake,22050);for(int i=0;i<32;++i)no_pickup.process();const auto faulted=no_pickup.snapshot();for(const auto& p:faulted.parts){if(p.id==ecu)check(p.sensor_fault&&p.ignition_cut,"Typed numbers acquired physical timing authority");if(injection&&p.id==host)check(p.injector_delivered==0,"Fake timing created injected fuel");}for(const auto& n:faulted.logic)if(n.id==connector)check(!n.problem.empty(),"Timing rejection was invisible in graph diagnostics");
std::vector<std::uint32_t> ids;for(const auto& p:doc.parts)ids.push_back(p.id);const auto assembly=machine_extract_assembly(doc,ids,"Sensor engine assembly");MachineDocument copy;machine_insert_assembly(copy,assembly,{});MachineSimulation copied(copy,22050);copied.process();check(copy.logic.nodes.size()==doc.logic.nodes.size(),"Assembly lost ECU harness");
if(injection){
auto removed=doc;removed.remove(ecu);check(removed.find(host)->injector->ecu==0&&!removed.find(host)->injector->ecu_label.empty(),"Deleted ECU left an accidental injector binding");const auto partial=machine_extract_assembly(doc,{host},"Injector host");check(partial.find(host)->injector->ecu==0,"Partial assembly retained external injector ECU ID");
auto routed=doc;const auto source=std::find_if(routed.logic.nodes.begin(),routed.logic.nodes.end(),[](const auto& n){return n.kind==LogicNodeKind::Sensor&&n.sensor_kind==MachineSensorKind::Crank;})->id;
const auto choose=routed.logic.add(LogicNodeKind::Switch).id,constant=routed.logic.add(LogicNodeKind::NumberConstant).id,selector=routed.logic.add(LogicNodeKind::Select).id;routed.logic.find(constant)->quantity=routed.logic.find(selector)->quantity=SignalQuantity::Angle;
routed.logic.disconnect({connector,"angle"});routed.logic.connect({source,"angle"},{selector,"a"});routed.logic.connect({constant,"out"},{selector,"b"});routed.logic.connect({choose,"out"},{selector,"select"});routed.logic.connect({selector,"out"},{connector,"angle"});
MachineSimulation controlled(routed,22050);for(int i=0;i<10000;++i)controlled.process();controlled.set_logic_control(choose,true);for(int i=0;i<1000;++i)controlled.process();for(const auto& p:controlled.snapshot().parts){if(p.id==cylinder)check(p.ignition_cut&&!p.combustion,"Lost crank timing failed to stop controlled combustion");if(p.id==host)check(p.fuel_flow==0,"Lost crank timing retained fuel delivery");}
controlled.set_logic_control(choose,false);bool recovered=false;for(int i=0;i<10000;++i){controlled.process();if(i%16==0)for(const auto& p:controlled.snapshot().parts)if(p.id==cylinder)recovered|=p.combustion;}check(recovered,"Recovered physical crank timing failed to restart combustion");
auto exhausted=doc;exhausted.find(host)->injector->initial_mass=.0000001f;MachineSimulation dry(exhausted,22050);for(int i=0;i<64;++i)dry.process();for(const auto& p:dry.snapshot().parts)if(p.id==host)check(p.injector_empty&&p.fuel_flow==0&&p.injector_delivered<=exhausted.find(host)->injector->initial_mass,"Physical injector ignored finite fuel budget");
}
}
std::cout<<"Sensor-driven ignition/Alpha-N, saved machine bindings, physical combustion and injector delivery passed\n";
return 0;
}catch(const std::exception& error){std::cerr<<error.what()<<'\n';return 1;}

View File

@@ -0,0 +1,55 @@
#include "FluidSim/Machine.hpp"
#include <algorithm>
#include <chrono>
#include <cmath>
#include <iostream>
#include <stdexcept>
using namespace fluidsim::port;
namespace {
void check(bool condition,const char* why){if(!condition)throw std::runtime_error(why);}
bool close(double a,double b){return std::abs(a-b)<=1e-8*std::max({1e-12,std::abs(a),std::abs(b)});}
template<class F>void rejects(F action,const char* why){bool rejected=false;try{action();}catch(const std::exception&){rejected=true;}check(rejected,why);}
SensorEcuMeasurements measurements(double pressure=100000,double temperature=300,double rpm=3000){
SensorEcuMeasurements m;m.speed=SignalValue::number(rpm*2*double(pi)/60);m.angle=SignalValue::number(.125);m.speed.timing=m.angle.timing=SignalTiming{1,1,0,.125,std::get<double>(m.speed.value)};m.map=SignalValue::number(pressure);m.iat=SignalValue::number(temperature);return m;
}
SensorEcuState estimate(SensorEcuConfig config,SensorEcuMeasurements m){SensorEcu ecu(std::move(config));return ecu.sample(m,0);}
}
int main(int argc,char** argv)try{
if(argc==3&&std::string(argv[1])=="--write-example"){save_machine(machine_speed_density_example(),argv[2]);return 0;}
for(int i=0;i<sensor_ecu_strategy_count;++i){const auto strategy=SensorEcuConfig::Strategy(i);check(sensor_ecu_strategy_from_key(sensor_ecu_strategy_key(strategy))==strategy,"ECU strategy key failed round-trip");}
rejects([]{sensor_ecu_strategy_from_key("unknown_future_strategy");},"Unknown ECU strategy silently fell back");
SensorEcuConfig config;config.strategy=SensorEcuConfig::Strategy::SpeedDensity;config.displacement=.0005;config.target_afr=14.7;config.volumetric_efficiency={1,1,1,1};
const auto full=estimate(config,measurements());const double air_mass=100000*.0005/(287*300.);
check(!full.sensor_fault&&close(full.estimated_air_per_cycle,air_mass)&&close(full.fuel_per_cycle,air_mass/14.7),"Speed-density disagrees with the dry ideal-gas charge at 100 kPa/300 K/500 cc");
check(close(full.fuel_mass_flow,air_mass/14.7*25),"Speed-density four-stroke mass-per-cycle conversion failed");
const auto half=estimate(config,measurements(50000)),hot=estimate(config,measurements(100000,600));
check(close(half.fuel_mass_flow,full.fuel_mass_flow*.5)&&close(hot.fuel_mass_flow,full.fuel_mass_flow*.5),"Fuel estimate did not respond to wired MAP/IAT");
check(close(estimate(config,measurements(1e308,1e308)).fuel_mass_flow,full.fuel_mass_flow*300/100000),"Finite high P/T ratio overflowed into zero fueling");
auto large=config;large.displacement*=2;check(close(estimate(large,measurements()).fuel_per_cycle,full.fuel_per_cycle*2),"Displacement calibration was ignored");
auto table=config;table.volumetric_efficiency={.5,.7,.9,1.1};const auto interpolated=estimate(table,measurements(60000));check(close(interpolated.volumetric_efficiency,.8),"RPM/MAP VE table did not interpolate both axes");
const auto stopped=estimate(config,measurements(100000,300,0));check(!stopped.sensor_fault&&stopped.fuel_mass_flow==0,"Stopped crank was a fault or delivered fuel");
for(int failure=0;failure<7;++failure){auto m=measurements();switch(failure){case 0:m.map={};break;case 1:m.iat={};break;case 2:m.map=SignalValue::number(0);break;case 3:m.iat=SignalValue::number(0);break;case 4:m.map.timestamp=-1;break;case 5:m.iat=SignalValue::boolean(true);break;case 6:m.iat.validity=SignalValidity::Fault;break;}const auto state=estimate(config,m);check(state.sensor_fault&&!state.spark_events&&state.fuel_mass_flow==0,"Invalid required MAP/IAT retained ECU outputs");}
auto bad=config;bad.ve_pressure={100000,20000};rejects([&]{SensorEcu invalid(bad);},"Reversed VE pressure axis accepted");bad=config;bad.volumetric_efficiency.pop_back();rejects([&]{SensorEcu invalid(bad);},"Mismatched VE dimensions accepted");bad=config;bad.displacement=0;rejects([&]{SensorEcu invalid(bad);},"Zero displacement accepted");
LogicNode schema;schema.kind=LogicNodeKind::Ecu;schema.ecu_strategy=SensorEcuConfig::Strategy::SpeedDensity;const auto ports=logic_node_ports(schema);check(ports.size()==4&&ports[2].key=="map"&&ports[2].quantity==SignalQuantity::Pressure&&ports[2].pressure_reference==PressureReference::Absolute&&ports[3].key=="iat"&&ports[3].quantity==SignalQuantity::Temperature,"Speed-density connector schema lost MAP/IAT quantities");
for(const auto& port:ports)check(port.required&&port.direction==PortDirection::Input&&port.domain==PortDomain::Number,"Speed-density required inputs are not typed Number rings");
auto gauge=ports[2];gauge.direction=PortDirection::Output;gauge.pressure_reference=PressureReference::Gauge;check(!port_compatibility(gauge,ports[2]).allowed,"MAP accepted gauge pressure without a conversion");
auto doc=machine_speed_density_example();std::uint32_t ecu=0,host=0,cylinder=0,connector=0,pressure=0,temperature=0;
for(const auto& p:doc.parts){if(p.kind==MachinePartKind::Ecu)ecu=p.id;if(p.injector)host=p.id;if(p.kind==MachinePartKind::Cylinder)cylinder=p.id;}
for(const auto& n:doc.logic.nodes){if(n.kind==LogicNodeKind::Ecu)connector=n.id;if(n.kind==LogicNodeKind::Sensor&&n.sensor_kind==MachineSensorKind::Pressure)pressure=n.id;if(n.kind==LogicNodeKind::Sensor&&n.sensor_kind==MachineSensorKind::GasTemperature)temperature=n.id;}
check(doc.sensors.size()==3&&doc.logic.nodes.size()==4,"Basic speed-density example demanded extra sensors");
MachineSimulation sim(doc,22050);bool fuel=false,fire=false;for(int i=0;i<22050;++i){sim.process();if(i%16==0)for(const auto& p:sim.snapshot().parts)if(p.id==cylinder){fuel|=p.fuel_mass>0;fire|=p.combustion;}}
check(fuel&&fire,"Wired speed-density example failed actual fuel transport/combustion");
auto missing=doc;missing.logic.disconnect({connector,"iat"});rejects([&]{MachineSimulation invalid(missing);},"Unwired required IAT was silently defaulted");
auto gauge_doc=doc;gauge_doc.logic.find(pressure)->sensor_kind=MachineSensorKind::DifferentialPressure;check(!logic_connection_preview(gauge_doc.logic,{pressure,"value"},{connector,"map"}).allowed,"Differential-pressure source accepted as MAP");
auto calibrated=doc;calibrated.find(ecu)->sensor_ecu.displacement*=2;rejects([&]{sim.apply_tuning(calibrated);},"Displacement changed during Test without Build");
const auto path=std::filesystem::temp_directory_path()/("fluidsim-sd-"+std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())+".machine.toml");save_machine(doc,path);const auto loaded=load_machine(path);std::filesystem::remove(path);
check(loaded.logic==doc.logic&&loaded.find(ecu)->sensor_ecu==doc.find(ecu)->sensor_ecu&&loaded.find(host)->injector==doc.find(host)->injector,"Speed-density calibration, schema or injector failed save/load");
std::vector<std::uint32_t> ids;for(const auto& p:doc.parts)ids.push_back(p.id);const auto assembly=machine_extract_assembly(doc,ids,"Speed-density engine");MachineDocument pasted;machine_insert_assembly(pasted,assembly,{});MachineSimulation copied(pasted,22050);copied.process();
auto routed=doc;const auto manual=routed.logic.add(LogicNodeKind::Switch).id,zero=routed.logic.add(LogicNodeKind::NumberConstant).id,select=routed.logic.add(LogicNodeKind::Select).id;routed.logic.find(zero)->quantity=routed.logic.find(select)->quantity=SignalQuantity::Pressure;routed.logic.find(zero)->pressure_reference=routed.logic.find(select)->pressure_reference=PressureReference::Absolute;
routed.logic.disconnect({connector,"map"});routed.logic.connect({pressure,"value"},{select,"a"});routed.logic.connect({zero,"out"},{select,"b"});routed.logic.connect({manual,"out"},{select,"select"});routed.logic.connect({select,"out"},{connector,"map"});
MachineSimulation switched(routed,22050);for(int i=0;i<10000;++i)switched.process();switched.set_logic_control(manual,true);for(int i=0;i<1000;++i)switched.process();for(const auto& p:switched.snapshot().parts){if(p.id==ecu)check(p.sensor_fault&&p.ignition_cut,"Invalid MAP failed to cut ECU outputs");if(p.id==host)check(p.fuel_flow==0,"Invalid MAP retained injector delivery");if(p.id==cylinder)check(!p.combustion,"Invalid MAP retained controlled combustion");}
switched.set_logic_control(manual,false);bool recovered=false;for(int i=0;i<10000;++i){switched.process();if(i%16==0)for(const auto& p:switched.snapshot().parts)if(p.id==cylinder)recovered|=p.combustion;}check(recovered,"Restored MAP failed to recover physical combustion");
auto no_host=doc;no_host.find_sensor(no_host.logic.find(temperature)->sensor)->target.part=0;MachineSimulation absent(no_host,22050);absent.process();for(const auto& p:absent.snapshot().parts)if(p.id==host)check(p.injector_delivered==0,"Missing IAT host used hidden gas temperature");
std::cout<<"Speed-density ideal-gas/VE calibration, MAP/IAT typing, live faults, combustion and persistence passed\n";return 0;
}catch(const std::exception& error){std::cerr<<error.what()<<'\n';return 1;}

11
tools/MachineBench.hpp Normal file
View File

@@ -0,0 +1,11 @@
#pragma once
#include "FluidSim/Machine.hpp"
#include <stdexcept>
namespace fluidsim::port::bench {
inline void set_rpm(MachineDocument& design,float rpm){
const auto shaft=machine_primary_shaft(design);if(!shaft)throw std::invalid_argument("Benchmark needs a primary shaft");
const auto position=design.find(shaft)->position;design.find(shaft)->shaft.initial_speed=rpm*2*pi/60;
for(auto& part:design.parts)if(part.kind==MachinePartKind::DriveMotor&&machine_shaft_for(design,part.id)==shaft){part.drive={rpm*2*pi/60,0,true,true};return;}
auto& drive=design.add(MachinePartKind::DriveMotor,machine_math::add(position,{-.3f,0,0}));const auto id=drive.id;drive.name="Benchmark speed drive";drive.drive.target_speed=rpm*2*pi/60;design.connect({shaft,0},{id,0});
}
}

View File

@@ -0,0 +1,32 @@
#include "MachineBench.hpp"
#include "FluidSim/Machine.hpp"
#include "FluidSim/Scenario.hpp"
#include "FluidSim/ScenarioCatalog.hpp"
#include <algorithm>
#include <chrono>
#include <cmath>
#include <iostream>
#include <stdexcept>
using namespace fluidsim::port;
template<class Step> double measure(Step step,int frames){
const auto begin=std::chrono::steady_clock::now();
for(int i=0;i<frames;++i){const auto value=step();if(!std::isfinite(value.left)||!std::isfinite(value.right))throw std::runtime_error("Nonfinite benchmark audio");}
return std::chrono::duration<double>(std::chrono::steady_clock::now()-begin).count();
}
int main(int argc,char** argv)try{
const int frames=argc>1?std::stoi(argv[1]):32768;const float rpm=argc>2?std::stof(argv[2]):3000,pedal=argc>3?std::stof(argv[3]):.35f;
if(frames<1||!std::isfinite(rpm)||rpm<0||rpm>50000||!std::isfinite(pedal)||pedal<0||pedal>1)throw std::invalid_argument("Usage: fluidsim_machine_benchmark [frames>0] [rpm 0..50000] [pedal 0..1]");
std::cout<<"scenario,runtime,frames,rpm,pedal,cells,min_cell_mm,substeps_per_frame,us_per_frame,wall_per_sim,positivity_repairs\n";
for(const char* id:{"single-450","twin","flat6","v8-h"}){
const auto& preset=scenario_example(id);FourStrokeScenario native(44100,preset);native.set_controls(pedal,0,1);
const auto step=[&]{native.crankshaft.omega=rpm*2*pi/60;return native.process();};
for(int i=0;i<512;++i)step();const auto before=native.snapshot().solver_substeps;const double elapsed=measure(step,frames);const auto after=native.snapshot();
float shortest=1e9f;unsigned cells=0;for(auto& p:native.pipes){cells+=unsigned(p.cells.size());shortest=std::min(shortest,p.minimum_cell_length());}
std::cout<<id<<",native,"<<frames<<','<<rpm<<','<<pedal<<','<<cells<<','<<shortest*1000<<','<<double(after.solver_substeps-before)/frames<<','<<elapsed*1e6/frames<<','<<elapsed*44100/frames<<','<<after.pipe_positivity_repairs<<std::endl;
auto design=machine_from_engine(preset);bench::set_rpm(design,rpm);MachineSimulation machine(design);machine.set_controls(pedal);
for(int i=0;i<512;++i)machine.process();const auto start=machine.snapshot().substeps;const double wall=measure([&]{return machine.process();},frames);const auto state=machine.snapshot();
std::cout<<id<<",workshop,"<<frames<<','<<rpm<<','<<pedal<<','<<state.cells<<','<<state.minimum_cell_length*1000<<','<<double(state.substeps-start)/frames<<','<<wall*1e6/frames<<','<<wall*44100/frames<<','<<state.positivity_repairs<<std::endl;
if(state.positivity_repairs||after.pipe_positivity_repairs)throw std::runtime_error("Benchmark required positivity repairs");
}
return 0;
}catch(const std::exception& e){std::cerr<<e.what()<<'\n';return 1;}

View File

@@ -0,0 +1,41 @@
#include "MachineBench.hpp"
#include "FluidSim/Desktop/MachineBuilder.hpp"
#include "FluidSim/ScenarioCatalog.hpp"
#include <SFML/OpenGL.hpp>
#include <chrono>
#include <iostream>
#include <stdexcept>
using namespace fluidsim::port;
using namespace fluidsim::port::desktop;
int main(int argc,char** argv)try{
const int frames=argc>1?std::stoi(argv[1]):120;
const bool play=argc>2&&std::string(argv[2])=="--play";
const double speed=argc>3?std::stod(argv[3]):1.;
if(!std::isfinite(speed)||speed<.01||speed>1)throw std::invalid_argument("Playback speed must be 0.01..1");
if(frames<1)throw std::invalid_argument("frame count must be positive");
sf::ContextSettings context;context.depthBits=24;context.majorVersion=2;context.minorVersion=1;
sf::RenderWindow window(sf::VideoMode({1440,900}),"Workshop render benchmark",sf::Style::Default,sf::State::Windowed,context);
window.setVisible(false);window.setVerticalSyncEnabled(false);
sf::Font font;bool loaded=false;
for(const auto* path:{"C:/Windows/Fonts/consola.ttf","/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf"})if(std::filesystem::exists(path)&&font.openFromFile(path)){loaded=true;break;}
std::cout<<(play?"scenario,speed,frames,wall_seconds,sim_seconds,sim_per_wall,underruns,buffered\n":"scenario,frames,ms_per_frame,render_fps\n");
for(const char* id:{"flat6","v8-h"}){
MachineBuilder builder;auto design=machine_from_engine(scenario_example(id));if(play){bench::set_rpm(design,3000);unsigned cylinder=0,pipe=0;for(const auto& p:design.parts){if(!cylinder&&p.kind==MachinePartKind::Cylinder)cylinder=p.id;if(p.name=="Exhaust atmosphere")for(const auto& link:design.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=design.find(other);part&&part->kind==MachinePartKind::Pipe)pipe=other;}}
design.probes={{1,cylinder,"Cylinder pressure",MachineQuantity::Pressure},{2,pipe,"Downpipe lambda",MachineQuantity::Lambda},{3,cylinder,"Fresh charge",MachineQuantity::ChargeAfr},{4,pipe,"Downpipe AFR",MachineQuantity::AirFuelRatio},{5,pipe,"Downpipe flow",MachineQuantity::MassFlow},{6,0,"Shaft RPM",MachineQuantity::Rpm},{7,0,"Shaft torque",MachineQuantity::Torque},{8,0,"Shaft power",MachineQuantity::Power}};}
builder.set_document(std::move(design));builder.set_test_speed(speed);
if(play){window.setFramerateLimit(60);builder.start_test();if(!builder.testing())throw std::runtime_error("Could not start workshop playback");builder.handle_event(sf::Event::KeyPressed{.code=sf::Keyboard::Key::M},window.getSize());
for(int i=0;i<16;++i){builder.update();builder.draw(window,loaded?&font:nullptr);window.display();}
const auto before=builder.playback_stats();const auto begin=std::chrono::steady_clock::now();
for(int i=0;i<frames;++i){builder.update();if(!builder.testing())throw std::runtime_error("Workshop playback stopped");builder.draw(window,loaded?&font:nullptr);window.display();}
builder.update();const auto after=builder.playback_stats();const auto wall=std::chrono::duration<double>(std::chrono::steady_clock::now()-begin).count();
std::cout<<id<<','<<speed<<','<<frames<<','<<wall<<','<<after.simulation_seconds-before.simulation_seconds<<','<<(after.simulation_seconds-before.simulation_seconds)/wall<<','<<after.underruns-before.underruns<<','<<after.buffered<<std::endl;
builder.stop_test();continue;
}
for(int i=0;i<8;++i){builder.draw(window,loaded?&font:nullptr);glFinish();}
const auto begin=std::chrono::steady_clock::now();
for(int i=0;i<frames;++i){builder.draw(window,loaded?&font:nullptr);glFinish();}
const auto elapsed=std::chrono::duration<double>(std::chrono::steady_clock::now()-begin).count();
std::cout<<id<<','<<frames<<','<<elapsed*1000/frames<<','<<frames/elapsed<<std::endl;
}
return 0;
}catch(const std::exception& error){std::cerr<<error.what()<<'\n';return 1;}

View File

@@ -0,0 +1,9 @@
scenario,runtime,frames,rpm,pedal,cells,min_cell_mm,substeps_per_frame,us_per_frame,wall_per_sim,positivity_repairs
single-450,native,32768,3000,0.35,44,43.7834,1,2.89427,0.127637,0
single-450,workshop,32768,3000,0.35,44,36.6667,1,3.54769,0.156453,0
twin,native,32768,3000,0.35,59,18.9302,1.70621,6.11425,0.269638,0
twin,workshop,32768,3000,0.35,58,15,1.71796,6.89146,0.303913,0
flat6,native,32768,3000,0.35,98,34.1958,1.03406,8.42729,0.371643,0
flat6,workshop,32768,3000,0.35,98,34.1958,1.02969,8.79167,0.387713,0
v8-h,native,32768,3000,0.35,176,36.3695,1,16.5588,0.730241,0
v8-h,workshop,32768,3000,0.35,175,36.3695,1,16.0058,0.705858,0
1 scenario runtime frames rpm pedal cells min_cell_mm substeps_per_frame us_per_frame wall_per_sim positivity_repairs
2 single-450 native 32768 3000 0.35 44 43.7834 1 2.89427 0.127637 0
3 single-450 workshop 32768 3000 0.35 44 36.6667 1 3.54769 0.156453 0
4 twin native 32768 3000 0.35 59 18.9302 1.70621 6.11425 0.269638 0
5 twin workshop 32768 3000 0.35 58 15 1.71796 6.89146 0.303913 0
6 flat6 native 32768 3000 0.35 98 34.1958 1.03406 8.42729 0.371643 0
7 flat6 workshop 32768 3000 0.35 98 34.1958 1.02969 8.79167 0.387713 0
8 v8-h native 32768 3000 0.35 176 36.3695 1 16.5588 0.730241 0
9 v8-h workshop 32768 3000 0.35 175 36.3695 1 16.0058 0.705858 0

View File

@@ -0,0 +1,8 @@
single-450 native: cells=44 min_cell_mm=43.7834 substeps/frame=1 us/frame=3.17636 realtime_ratio=0.140078
single-450 workshop: cells=56 min_cell_mm=5 culprit=Inlet metering stub substeps/frame=4 us/frame=12.6059 realtime_ratio=0.555921 slowdown=3.96866
twin native: cells=59 min_cell_mm=18.9302 substeps/frame=1.70621 us/frame=6.21104 realtime_ratio=0.273907
twin workshop: cells=67 min_cell_mm=5 culprit=Inlet metering stub substeps/frame=4 us/frame=16.9562 realtime_ratio=0.747767 slowdown=2.73001
flat6 native: cells=98 min_cell_mm=34.1958 substeps/frame=1.03406 us/frame=9.63491 realtime_ratio=0.424899
flat6 workshop: cells=161 min_cell_mm=1.7773 culprit=Pipe 12 / flat6 substeps/frame=12.3495 us/frame=104.441 realtime_ratio=4.60584 slowdown=10.8398
v8-h native: cells=176 min_cell_mm=36.3695 substeps/frame=1 us/frame=15.5274 realtime_ratio=0.684757
v8-h workshop: cells=318 min_cell_mm=4.99998 culprit=Inlet metering stub substeps/frame=4.08749 us/frame=67.5066 realtime_ratio=2.97704 slowdown=4.34759

View File

@@ -0,0 +1,9 @@
scenario,runtime,frames,rpm,pedal,cells,min_cell_mm,substeps_per_frame,us_per_frame,wall_per_sim,positivity_repairs
single-450,native,32768,6000,0.7,44,43.7834,1,2.9588,0.130483,0
single-450,workshop,32768,6000,0.7,44,36.6667,1,3.56339,0.157145,0
twin,native,32768,6000,0.7,59,18.9302,2.03198,7.14645,0.315158,0
twin,workshop,32768,6000,0.7,58,15,1.98846,8.3636,0.368835,0
flat6,native,32768,6000,0.7,98,34.1958,1.04791,9.21917,0.406565,0
flat6,workshop,32768,6000,0.7,98,34.1958,1.04437,9.31432,0.410762,0
v8-h,native,32768,6000,0.7,176,36.3695,1.0159,16.3243,0.719902,0
v8-h,workshop,32768,6000,0.7,175,36.3695,1,16.1124,0.710557,0
1 scenario runtime frames rpm pedal cells min_cell_mm substeps_per_frame us_per_frame wall_per_sim positivity_repairs
2 single-450 native 32768 6000 0.7 44 43.7834 1 2.9588 0.130483 0
3 single-450 workshop 32768 6000 0.7 44 36.6667 1 3.56339 0.157145 0
4 twin native 32768 6000 0.7 59 18.9302 2.03198 7.14645 0.315158 0
5 twin workshop 32768 6000 0.7 58 15 1.98846 8.3636 0.368835 0
6 flat6 native 32768 6000 0.7 98 34.1958 1.04791 9.21917 0.406565 0
7 flat6 workshop 32768 6000 0.7 98 34.1958 1.04437 9.31432 0.410762 0
8 v8-h native 32768 6000 0.7 176 36.3695 1.0159 16.3243 0.719902 0
9 v8-h workshop 32768 6000 0.7 175 36.3695 1 16.1124 0.710557 0

View File

@@ -0,0 +1,3 @@
scenario,frames,wall_seconds,sim_seconds,sim_per_wall,underruns,buffered
flat6,120,2.00608,2.02014,1.00701,0,4096
v8-h,120,2.0706,2.08181,1.00541,0,4096
1 scenario frames wall_seconds sim_seconds sim_per_wall underruns buffered
2 flat6 120 2.00608 2.02014 1.00701 0 4096
3 v8-h 120 2.0706 2.08181 1.00541 0 4096

View File

@@ -0,0 +1,3 @@
scenario,frames,ms_per_frame,render_fps
flat6,120,0.696523,1435.7
v8-h,120,0.963612,1037.76
1 scenario frames ms_per_frame render_fps
2 flat6 120 0.696523 1435.7
3 v8-h 120 0.963612 1037.76

View File

@@ -0,0 +1,3 @@
scenario,frames,ms_per_frame,render_fps
flat6,120,1.40442,712.035
v8-h,120,1.77209,564.305
1 scenario frames ms_per_frame render_fps
2 flat6 120 1.40442 712.035
3 v8-h 120 1.77209 564.305

View File

@@ -0,0 +1,95 @@
# Workshop performance, 2026-10-30
The flat-six workshop now runs within the real-time audio budget. The measured
simulation cost fell from 104.44 to 8.79 microseconds per sample (11.9 times faster).
At 44,100 Hz the available budget is 22.68 microseconds per sample.
## Cause and changes
The workshop used a different assembly path from the native scenarios. It split
fluid sections at every visual pipe knot, inserted 5 mm inlet stubs, required at
least four fluid substeps per sample, and repeated mechanical/control work in
each substep. Flat6 consequently had a 1.78 mm cell and averaged 12.35 substeps
per sample. Its native scenario had a 34.20 mm minimum cell and about one substep.
- Mesh each pipe by total arclength and its diameter profile. Visual knots remain
in the editor and renderer; exact frustum averages preserve gas volume.
- Advance shaft, ECU, cams, combustion, carburetors and pump displacement at the
audio sample rate, matching native engine scheduling. Keep CFL stepping for
fluid transport and pressure-operated check valves. Compile active part/gate
lists and cache fixed restriction areas.
- Place the importer's metering assembly farther upstream within its existing
inlet segment, allowing a sensible outlet stub. The inlet endpoints, route
length, diameter and total gas volume stay the same. Existing custom pipes are
never lengthened automatically; actual short pipes still constrain CFL steps.
- Cache local pipe vertices, normals and indices, shared primitive meshes, and
the part-picker icon atlas. Transforms and pressure updates reuse geometry;
route/diameter edits rebuild the affected pipe.
Untouched stock imports in `machines/` were compared against the previous
importer's canonical output before replacement. User files, including
`paralell-twin.machine.toml`, were preserved.
## Simulation results
Windows GCC RelWithDebInfo, Rusanov, preset density 20 cells/m, 44,100 Hz,
512 warm-up samples and 32,768 measured samples, motor-driven at 3,000 RPM,
35% pedal. Timings include audio synthesis but exclude editor rendering and
snapshot collection. Paired native/workshop measurements use the same process;
before/after runs have normal timing variation.
| Preset | Workshop before, us/sample | Workshop after | Native after | Workshop cells before / after | Substeps before / after |
| --- | ---: | ---: | ---: | ---: | ---: |
| Single 450 | 12.61 | 3.55 | 2.89 | 56 / 44 | 4.00 / 1.00 |
| Twin | 16.96 | 6.89 | 6.11 | 67 / 58 | 4.00 / 1.72 |
| Flat6 | 104.44 | 8.79 | 8.43 | 161 / 98 | 12.35 / 1.03 |
| V8 H-pipe | 67.51 | 16.01 | 16.56 | 318 / 175 | 4.09 / 1.00 |
At 6,000 RPM and 70% pedal, flat6 measured 9.31 us/sample and V8 16.11.
All benchmark cases reported zero positivity repairs and finite audio.
See [baseline](workshop-before.txt), [after](workshop-after.csv) and
[higher-load results](workshop-high-load.csv).
These are performance comparisons, not claims of identical combustion or sound.
The workshop has explicit metering-body storage, connections and editable
calibration; the native scenarios assemble that hardware differently. Arbitrary
large or finely meshed custom machines can still exceed the audio budget.
## Rendering and playback
Hidden 1440 x 900 OpenGL window, 120 static frames, `glFinish`, no vsync or
simulation: flat6 fell from 1.40 to 0.70 ms/frame; V8 from 1.77 to 0.96 ms/frame.
This measures CPU/GPU render cost, not the displayed frame rate.
See [before](workshop-render-before.csv) and [after](workshop-render-after.csv).
The combined workshop/audio check ran at 60 display frames/s with a held
3,000 RPM shaft, 16 warm-up frames and 120 measured frames per machine:
| Preset | Wall seconds | Simulated seconds | Audio underruns | Buffered samples |
| --- | ---: | ---: | ---: | ---: |
| Flat6 | 2.006 | 2.020 | 0 | 4,096 |
| V8 H-pipe | 2.071 | 2.082 | 0 | 4,096 |
Simulation time includes producer buffering and snapshot timing, so a small
offset from wall time is expected. This is a short playback check, not a long
session guarantee. Raw output: [playback](workshop-playback.csv).
## Validation and reproduction
All 22 CTest cases passed. Machine tests cover import length/volume preservation,
route-knot-independent meshes, a real 5 mm pipe retaining its CFL constraint,
explicit fuel sources, cold starting and ECU cutoff. Pump pressure agreed within
1% between 44.1 and 88.2 kHz. Builder checks cover geometry-cache reuse and
invalidation, pressure coloring, transforms, deletion/ID reuse and the icon atlas.
Editor preview images were visually inspected.
From the repository root after `cmake --build --preset windows`:
```powershell
ctest --preset windows -j 8 --output-on-failure
.\out\build\windows-gcc\fluidsim_machine_benchmark.exe 32768 3000 0.35
.\out\build\windows-gcc\fluidsim_machine_benchmark.exe 32768 6000 0.7
.\out\build\windows-gcc\fluidsim_machine_render_benchmark.exe 120
.\out\build\windows-gcc\fluidsim_machine_render_benchmark.exe 120 --play
.\out\build\windows-gcc\fluidsim_machine_builder_tests.exe --preview out/workshop-performance-preview
```

View File

@@ -0,0 +1,3 @@
scenario,speed,frames,wall_seconds,sim_seconds,sim_per_wall,underruns,buffered
flat6,0.01,120,1.94593,0.0196145,0.0100798,0,41
v8-h,0.01,120,1.96952,0.0198639,0.0100857,0,41
1 scenario speed frames wall_seconds sim_seconds sim_per_wall underruns buffered
2 flat6 0.01 120 1.94593 0.0196145 0.0100798 0 41
3 v8-h 0.01 120 1.96952 0.0198639 0.0100857 0 41

View File

@@ -0,0 +1,3 @@
scenario,speed,frames,wall_seconds,sim_seconds,sim_per_wall,underruns,buffered
flat6,0.1,120,1.95923,0.198458,0.101294,0,407
v8-h,0.1,120,1.96564,0.19737,0.10041,0,409
1 scenario speed frames wall_seconds sim_seconds sim_per_wall underruns buffered
2 flat6 0.1 120 1.95923 0.198458 0.101294 0 407
3 v8-h 0.1 120 1.96564 0.19737 0.10041 0 409

View File

@@ -0,0 +1,3 @@
scenario,speed,frames,wall_seconds,sim_seconds,sim_per_wall,underruns,buffered
flat6,1,120,1.96809,1.98531,1.00875,0,4096
v8-h,1,120,2.0466,2.04989,1.0016,0,3872
1 scenario speed frames wall_seconds sim_seconds sim_per_wall underruns buffered
2 flat6 1 120 1.96809 1.98531 1.00875 0 4096
3 v8-h 1 120 2.0466 2.04989 1.0016 0 3872

View File

@@ -0,0 +1,99 @@
# Workshop instruments, slow playback and live tuning
Implemented on 2026-10-31. The workshop now supports eight nonintrusive instrument
channels, simulation-time traces, CSV export, slow playback down to 1%, paused
10 ms stepping, and live adjustment of safe component settings.
## Measurement model
Pressure, temperature and mass-flow taps read the compiled simulation. Pipe taps
sample the cell at a chosen fraction of arclength. Lumped-part flow is net into
the part; pipe flow is signed Start-to-End. RPM is instantaneous shaft speed;
torque and power use the shaft model's running averages.
Bulk AFR/lambda use a passive conserved fuel-origin tracer. Combustion does not
erase that tracer, so downstream instruments include fuel that has already burned.
It travels through pipe fluxes, component ports, storage volumes and junctions.
The extra tracer does not feed back into gas pressure, energy or mass.
Bulk AFR = (gas mass - fuel-origin mass) / fuel-origin mass; lambda divides that
by the channel's stoichiometric AFR reference, default 14.7. Fresh-charge AFR on a
cylinder instead reads its remaining unburned fresh-air/fuel mass ratio.
Undefined readings are gaps on the graph and empty cells in CSV. Sensor heating,
response lag, oxygen chemistry and fuel-specific combustion chemistry are not
added by this change. Initially clean exhaust pipes can read very lean until
purged. A probe after a collector measures the gas mixture that actually reaches
that collector's outlet, including dilution and backflow.
Channels sample every 16 physics samples: 2,756.25 Hz at a 44,100 Hz simulation
rate. The bounded capture retains 32,768 records (about 11.9 simulated seconds).
Graph compression preserves sampled min/max envelopes and leaves undefined
intervals disconnected. Frequencies above 1,378.125 Hz cannot be resolved by this
capture rate. This is not an audio-rate acquisition mode.
## Playback and tuning
Slow playback resamples audio without changing the physics timestep. Producer
batch size, prefill and target queue depth scale with playback speed. Pause stops
both audio and physics; switching speed discards queued audio while both threads
are stopped, preserving the machine's state. A paused step advances exactly 441
samples (10 ms). Graph timestamps remain simulated seconds.
Live edits are validated and applied under the simulation mutex between batches.
Carb calibration, ECU settings, throttle opening, cracking pressure, combustion
settings and shaft/load settings retain gas state, fuel film, controller state and
simulation time. Geometry, initial conditions, mesh/solver settings, cam/valve
mechanics and compiled boundary settings remain locked. The API rejects structural
changes even if a caller bypasses the UI. Live edits support undo/redo and saving.
Probes/taps can change live; changing the capture schema clears the trace.
Machine files now use version 4 for probe definitions. Versions 1 through 3 remain
readable; recordings are exported separately. Existing user machine files were
not rewritten.
## Playback checks
Windows GCC RelWithDebInfo; hidden 1440 x 900 window at a 60 Hz display target;
16 warm-up display frames, 120 measured display frames; motor-driven at 3,000 RPM.
All eight channels were active, including cylinder pressure, downpipe AFR/lambda,
fresh-charge AFR, mass flow and shaft gauges, with the scope visible.
| Machine | Requested speed | Measured simulated / wall time | Audio underruns | Buffered input samples |
| --- | ---: | ---: | ---: | ---: |
| Flat6 | 1.0 | 1.00875 | 0 | 4,096 |
| V8 H-pipe | 1.0 | 1.00160 | 0 | 3,872 |
| Flat6 | 0.1 | 0.101294 | 0 | 407 |
| V8 H-pipe | 0.1 | 0.100410 | 0 | 409 |
| Flat6 | 0.01 | 0.0100798 | 0 | 41 |
| V8 H-pipe | 0.01 | 0.0100857 | 0 | 41 |
The small offsets reflect queue depth, snapshot timing and short-run measurement
variation. These are short playback checks, not a sustained-session guarantee.
Raw results: [1x](instruments-playback-1.csv), [0.1x](instruments-playback-0.1.csv),
[0.01x](instruments-playback-0.01.csv).
## Verification
The full 22-test CTest suite passed. Targeted checks additionally cover conserved
burned-fuel transport in all four solvers, characteristic/legacy junction mixing,
finite exhaust AFR after combustion, lambda normalization, undefined dry readings,
probe persistence, live tuning without state/time reset, rejected geometry edits,
increased fuel delivery after live carb jet changes, pause/step/slow playback,
live undo/redo, CSV units and independently selectable overlapping probe markers.
Builder preview mode verifies rendered UI pixels after OpenGL scene/icon rendering
and captures pressure, downpipe lambda and live carb-tuning views. Those images
were visually inspected. Extremely small passive tracer tails are flushed like
the existing species tails to avoid subnormal arithmetic overhead; total gas mass
and energy are unchanged.
Reproduce from the repository root:
```powershell
cmake --build --preset windows
ctest --preset windows -j 8 --output-on-failure
.\out\build\windows-gcc\fluidsim_machine_builder_tests.exe --preview out/workshop-instruments-preview
.\out\build\windows-gcc\fluidsim_machine_render_benchmark.exe 120 --play 1
.\out\build\windows-gcc\fluidsim_machine_render_benchmark.exe 120 --play 0.1
.\out\build\windows-gcc\fluidsim_machine_render_benchmark.exe 120 --play 0.01
```