From 45c3e97da3c40fd3ac1bceedee0eca20637b2610 Mon Sep 17 00:00:00 2001 From: BoondockTaints Date: Mon, 4 May 2026 13:29:46 -0400 Subject: [PATCH] Updates from Overleaf --- .gitignore | 1 + nurbs_dde/CMakeLists.txt | 2 + nurbs_dde/docs/ALLOCATION_POLICY.md | 83 ++ .../docs/CURRENT_ARCHITECTURE_DIAGRAM.md | 351 +++++ .../docs/PROPOSED_ARCHITECTURE_DIAGRAM.md | 517 +++++++ nurbs_dde/engine_config.json | 2 +- nurbs_dde/src/CMakeLists.txt | 21 +- nurbs_dde/src/app/AlternateViewPanel.hpp | 71 + nurbs_dde/src/app/AnalysisPanel.cpp | 4 - nurbs_dde/src/app/AnalysisPanel.hpp | 81 -- nurbs_dde/src/app/AnalysisScene.hpp | 509 ------- nurbs_dde/src/app/AnalysisSpawner.cpp | 155 ++ nurbs_dde/src/app/AnalysisSpawner.hpp | 39 + nurbs_dde/src/app/AnimatedCurve.hpp | 84 +- nurbs_dde/src/app/ContourWindowRenderer.hpp | 84 ++ nurbs_dde/src/app/CoordDebugPanel.hpp | 7 +- nurbs_dde/src/app/FrenetFrame.hpp | 2 +- nurbs_dde/src/app/GaussianRipple.hpp | 40 +- nurbs_dde/src/app/GaussianSurface.cpp | 125 +- nurbs_dde/src/app/GaussianSurface.hpp | 13 +- nurbs_dde/src/app/GoalStatusPanel.hpp | 26 + nurbs_dde/src/app/HotkeyManager.hpp | 56 +- nurbs_dde/src/app/HoverResult.hpp | 2 +- nurbs_dde/src/app/MultiWellSpawner.cpp | 121 ++ nurbs_dde/src/app/MultiWellSpawner.hpp | 30 + nurbs_dde/src/app/PanelHost.hpp | 66 + nurbs_dde/src/app/ParticleBehaviors.hpp | 436 ++++++ nurbs_dde/src/app/ParticleFactory.hpp | 224 +++ nurbs_dde/src/app/ParticleGoals.hpp | 69 + nurbs_dde/src/app/ParticleInspectorPanel.hpp | 133 ++ nurbs_dde/src/app/ParticleRenderer.cpp | 235 --- nurbs_dde/src/app/ParticleRenderer.hpp | 86 -- nurbs_dde/src/app/ParticleSwarmFactory.hpp | 390 +++++ nurbs_dde/src/app/ParticleSystem.cpp | 278 ++++ nurbs_dde/src/app/ParticleSystem.hpp | 171 +++ nurbs_dde/src/app/ParticleTypes.hpp | 52 + nurbs_dde/src/app/ProjectedSurfaceCanvas.hpp | 144 ++ nurbs_dde/src/app/Scene.cpp | 4 - nurbs_dde/src/app/Scene.hpp | 135 -- nurbs_dde/src/app/SceneFactories.cpp | 19 +- nurbs_dde/src/app/SceneFactories.hpp | 10 +- nurbs_dde/src/app/Scene_on_frame_patch.bak | 1 - nurbs_dde/src/app/SimulationAnalysis.cpp | 215 +++ nurbs_dde/src/app/SpawnStrategy.hpp | 15 +- nurbs_dde/src/app/SurfaceRegistry.hpp | 141 ++ nurbs_dde/src/app/SurfaceSimPanels.cpp.bak | 475 +++++++ nurbs_dde/src/app/SurfaceSimScene.cpp | 1255 ----------------- nurbs_dde/src/app/SurfaceSimScene.hpp | 240 ---- nurbs_dde/src/app/SwarmRecipePanel.hpp | 109 ++ nurbs_dde/src/app/Viewport.hpp | 7 +- nurbs_dde/src/app/WavePredatorPreySpawner.cpp | 94 ++ nurbs_dde/src/app/WavePredatorPreySpawner.hpp | 27 + nurbs_dde/src/app/legacy/AnalysisPanel.cpp | 207 --- nurbs_dde/src/app/legacy/README.md | 25 - nurbs_dde/src/app/legacy/Scene.cpp | 957 ------------- .../src/app/legacy/Scene_on_frame_patch.bak | 22 - nurbs_dde/src/engine/Engine.cpp | 519 ++++++- nurbs_dde/src/engine/Engine.hpp | 55 +- nurbs_dde/src/engine/EngineAPI.hpp | 15 +- nurbs_dde/src/engine/HotkeyService.hpp | 100 ++ nurbs_dde/src/engine/IScene.hpp | 49 +- nurbs_dde/src/engine/PanelService.hpp | 106 ++ nurbs_dde/src/math/Axes.cpp | 19 +- nurbs_dde/src/math/Conics.cpp | 57 +- nurbs_dde/src/math/Conics.hpp | 4 +- .../src/math/Conics_eval_branch_public.bak | 9 - nurbs_dde/src/math/ExtremumSurface.hpp | 13 +- nurbs_dde/src/math/ExtremumTable.cpp | 10 +- nurbs_dde/src/math/ExtremumTable.hpp | 2 +- nurbs_dde/src/math/SineRationalSurface.hpp | 34 +- nurbs_dde/src/math/Surfaces.cpp | 116 +- nurbs_dde/src/math/Surfaces.hpp | 37 + nurbs_dde/src/numeric/MathTraits.hpp | 41 +- nurbs_dde/src/numeric/math_config.hpp | 16 +- nurbs_dde/src/numeric/ops.hpp | 23 +- nurbs_dde/src/renderer/Pipeline.cpp | 2 +- nurbs_dde/src/renderer/Renderer.cpp | 115 +- nurbs_dde/src/renderer/Renderer.hpp | 12 +- nurbs_dde/src/renderer/SecondWindow.cpp | 17 +- nurbs_dde/src/renderer/SecondWindow.hpp | 14 +- nurbs_dde/src/renderer/Swapchain.cpp | 25 +- nurbs_dde/src/renderer/Swapchain.hpp | 14 +- nurbs_dde/src/sim/BiasedBrownianLeader.hpp | 12 +- nurbs_dde/src/sim/BrownianMotion.hpp | 5 +- nurbs_dde/src/sim/DelayPursuitEquation.hpp | 4 +- nurbs_dde/src/sim/DirectPursuitEquation.hpp | 3 +- nurbs_dde/src/sim/DomainConfinement.hpp | 7 +- nurbs_dde/src/sim/EulerIntegrator.hpp | 3 +- nurbs_dde/src/sim/GradientWalker.hpp | 19 +- nurbs_dde/src/sim/HistoryBuffer.hpp | 16 +- nurbs_dde/src/sim/IConstraint.hpp | 4 +- nurbs_dde/src/sim/LeaderSeekerEquation.hpp | 8 +- nurbs_dde/src/sim/LevelCurveWalker.hpp | 46 +- nurbs_dde/src/sim/MilsteinIntegrator.hpp | 13 +- nurbs_dde/src/sim/MinDistConstraint.hpp | 2 +- nurbs_dde/src/sim/MomentumBearingEquation.hpp | 4 +- nurbs_dde/tests/CMakeLists.txt | 35 + nurbs_dde/tests/test_numeric.cpp | 31 + 98 files changed, 6160 insertions(+), 4144 deletions(-) create mode 100644 nurbs_dde/docs/ALLOCATION_POLICY.md create mode 100644 nurbs_dde/docs/CURRENT_ARCHITECTURE_DIAGRAM.md create mode 100644 nurbs_dde/docs/PROPOSED_ARCHITECTURE_DIAGRAM.md create mode 100644 nurbs_dde/src/app/AlternateViewPanel.hpp delete mode 100644 nurbs_dde/src/app/AnalysisPanel.cpp delete mode 100644 nurbs_dde/src/app/AnalysisPanel.hpp delete mode 100644 nurbs_dde/src/app/AnalysisScene.hpp create mode 100644 nurbs_dde/src/app/AnalysisSpawner.cpp create mode 100644 nurbs_dde/src/app/AnalysisSpawner.hpp create mode 100644 nurbs_dde/src/app/ContourWindowRenderer.hpp create mode 100644 nurbs_dde/src/app/GoalStatusPanel.hpp create mode 100644 nurbs_dde/src/app/MultiWellSpawner.cpp create mode 100644 nurbs_dde/src/app/MultiWellSpawner.hpp create mode 100644 nurbs_dde/src/app/PanelHost.hpp create mode 100644 nurbs_dde/src/app/ParticleBehaviors.hpp create mode 100644 nurbs_dde/src/app/ParticleFactory.hpp create mode 100644 nurbs_dde/src/app/ParticleGoals.hpp create mode 100644 nurbs_dde/src/app/ParticleInspectorPanel.hpp delete mode 100644 nurbs_dde/src/app/ParticleRenderer.cpp delete mode 100644 nurbs_dde/src/app/ParticleRenderer.hpp create mode 100644 nurbs_dde/src/app/ParticleSwarmFactory.hpp create mode 100644 nurbs_dde/src/app/ParticleSystem.cpp create mode 100644 nurbs_dde/src/app/ParticleSystem.hpp create mode 100644 nurbs_dde/src/app/ParticleTypes.hpp create mode 100644 nurbs_dde/src/app/ProjectedSurfaceCanvas.hpp delete mode 100644 nurbs_dde/src/app/Scene.cpp delete mode 100644 nurbs_dde/src/app/Scene.hpp delete mode 100644 nurbs_dde/src/app/Scene_on_frame_patch.bak create mode 100644 nurbs_dde/src/app/SimulationAnalysis.cpp create mode 100644 nurbs_dde/src/app/SurfaceRegistry.hpp create mode 100644 nurbs_dde/src/app/SurfaceSimPanels.cpp.bak delete mode 100644 nurbs_dde/src/app/SurfaceSimScene.cpp delete mode 100644 nurbs_dde/src/app/SurfaceSimScene.hpp create mode 100644 nurbs_dde/src/app/SwarmRecipePanel.hpp create mode 100644 nurbs_dde/src/app/WavePredatorPreySpawner.cpp create mode 100644 nurbs_dde/src/app/WavePredatorPreySpawner.hpp delete mode 100644 nurbs_dde/src/app/legacy/AnalysisPanel.cpp delete mode 100644 nurbs_dde/src/app/legacy/README.md delete mode 100644 nurbs_dde/src/app/legacy/Scene.cpp delete mode 100644 nurbs_dde/src/app/legacy/Scene_on_frame_patch.bak create mode 100644 nurbs_dde/src/engine/HotkeyService.hpp create mode 100644 nurbs_dde/src/engine/PanelService.hpp delete mode 100644 nurbs_dde/src/math/Conics_eval_branch_public.bak diff --git a/.gitignore b/.gitignore index 0155da8e..2b8fe792 100644 --- a/.gitignore +++ b/.gitignore @@ -87,3 +87,4 @@ lean/.lake/ /reports /constitution/auditor/validators/__pycache__ /constitution/auditor/plans/__pycache__ +/nurbs_dde/captures diff --git a/nurbs_dde/CMakeLists.txt b/nurbs_dde/CMakeLists.txt index 4c982239..7dd39ee9 100644 --- a/nurbs_dde/CMakeLists.txt +++ b/nurbs_dde/CMakeLists.txt @@ -1,5 +1,6 @@ cmake_minimum_required(VERSION 3.27) project(nurbs_dde VERSION 0.2.0 LANGUAGES CXX) +enable_testing() # ── C++23 ───────────────────────────────────────────────────────────────────── set(CMAKE_CXX_STANDARD 23) @@ -74,6 +75,7 @@ FetchContent_Declare(imgui FetchContent_MakeAvailable(imgui) find_package(Vulkan REQUIRED) +find_package(Python3 COMPONENTS Interpreter REQUIRED) add_library(imgui STATIC ${imgui_SOURCE_DIR}/imgui.cpp diff --git a/nurbs_dde/docs/ALLOCATION_POLICY.md b/nurbs_dde/docs/ALLOCATION_POLICY.md new file mode 100644 index 00000000..5e97aca3 --- /dev/null +++ b/nurbs_dde/docs/ALLOCATION_POLICY.md @@ -0,0 +1,83 @@ +# Allocation Policy + +The current hard rule is enforced by `tools/check_allocation_policy.py`: + +- raw `new` +- raw `delete` +- `malloc` +- `calloc` +- `realloc` +- `free` +- `std::make_unique` +- direct `std::unique_ptr` ownership + +These are allowed only inside the central memory package. + +The public allocation surface is `memory::MemoryService`. Lower-level objects +such as `BufferManager`, arena resources, and future PMR resources are +implementation details behind the service. Engine/app code should request +storage by lifetime first: + +- `memory.frame()` +- `memory.view()` +- `memory.simulation()` +- `memory.cache()` +- `memory.history()` +- `memory.persistent()` + +`std::string` still exists in app/runtime code for labels, metadata, and user +text. Direct ownership through `std::unique_ptr` should stay inside the memory +package, where it implements `memory::Unique`. Direct `std::vector` use should +stay out of hot-path/runtime code; use the policy aliases instead. + +All policy vector aliases in `memory/Containers.hpp` are now +`std::pmr::vector`. Vectors should be created through the appropriate +`MemoryService` scope when they need to bind to an engine arena: + +- `memory.frame().make_vector()` +- `memory.view().make_vector()` +- `memory.simulation().make_vector()` +- `memory.cache().make_vector()` +- `memory.history().make_vector()` +- `memory.persistent().make_vector()` + +Long-lived owner types that are default-constructed before receiving a service +reference expose explicit bind methods, for example `SurfaceMeshCache::bind_memory`, +`ParticleSystem::bind_memory`, and service `set_memory_service` methods. A scope +must not be reset while objects allocated from that scope are still alive. + +Object ownership for simulation-runtime polymorphic objects should use +`memory::Unique`, normally created by the appropriate scope: + +- `memory.simulation().make_unique()` +- `memory.history().make_unique()` +- `memory.persistent().make_unique()` + +Unlike the earlier bridge API, scope `make_unique` now constructs the object in +the scope's PMR resource. The deleter calls the concrete destructor and returns +storage to that same resource. For monotonic arenas, individual deallocation is +cheap/no-op and bulk release still happens at scope reset. Existing migrated +owners include simulation runtimes, active simulations, surfaces, particle +behavior stacks, wrapped equations, particle constraints, pair constraints, +goals, and history buffers. + +Recommended next enforcement stages: + +1. Keep the raw allocation ban always on. +2. Use the policy aliases in `memory/Containers.hpp` for dynamic arrays: + `FrameVector` for per-frame scratch, + `ViewVector` for render/input view lifetime, + `SimVector` for simulation-instance lifetime, + `CacheVector` for derived surface/geometry caches, + `HistoryVector` for trails, delay buffers, replay/export history, + and `PersistentVector` for app/service/session lifetime. +3. Protect migrated hot-path files with `tools/check_hot_path_container_policy.py`. +4. Keep `std::unique_ptr` implementation detail usage isolated to + `src/memory/Unique.hpp`. +5. Leave configuration and scalar/string metadata on ordinary STL until their + lifetime pressure justifies migration. + +Currently protected migrated areas include render packets, interaction/picking state, +view registration and mouse state, surface mesh caches, particle/trail/swarm state, +history buffers, simulation context command queues, engine panels/hotkeys, scoped +service handles, simulation runtime registry storage, and scene snapshots. diff --git a/nurbs_dde/docs/CURRENT_ARCHITECTURE_DIAGRAM.md b/nurbs_dde/docs/CURRENT_ARCHITECTURE_DIAGRAM.md new file mode 100644 index 00000000..d84e2b68 --- /dev/null +++ b/nurbs_dde/docs/CURRENT_ARCHITECTURE_DIAGRAM.md @@ -0,0 +1,351 @@ +# Current Architecture Diagram + +Generated from the live `src` tree. This document is meant to be a refactor guide, so it shows both the current component boundaries and the places where responsibilities still overlap. + +## System Layers + +```mermaid +graph TB + App["app/main.cpp"] --> Engine["engine::Engine"] + + Engine --> Runtime["SimulationRegistry + SceneSimulationRuntime"] + Engine --> EngineAPI["EngineAPI scene contract"] + Engine --> Renderer["renderer::Renderer"] + Engine --> Swapchain["renderer::Swapchain"] + Engine --> SecondWindow["renderer::SecondWindow"] + Engine --> BufferManager["memory::BufferManager"] + Engine --> Platform["platform::GlfwContext + VulkanContext"] + + Renderer --> Vulkan["Vulkan draw submission"] + Swapchain --> Vulkan + SecondWindow --> Vulkan + BufferManager --> Arena["per-frame vertex arena"] + + Runtime --> Scenes["Simulation scenes"] + EngineAPI --> Scenes + + Scenes --> AppViews["app view helpers"] + Scenes --> Particles["ParticleSystem"] + Scenes --> Surfaces["math::ISurface implementations"] + Scenes --> Panels["ImGui panels"] + Scenes --> Hotkeys["HotkeyManager"] + + AppViews --> EngineAPI + AppViews --> MeshCache["SurfaceMeshCache"] + AppViews --> Canvas["ProjectedSurfaceCanvas"] + AppViews --> Contour["ContourWindowRenderer"] + + Particles --> Behaviors["Particle behavior stack"] + Particles --> Goals["Particle goals"] + Particles --> Constraints["pair constraints"] + Behaviors --> SimContext["SimulationContext"] + Behaviors --> SimEquations["sim::IEquation legacy/adapters"] + Particles --> Numeric["numeric::ops + MathTraits"] + Surfaces --> Numeric +``` + +## Runtime And Scene Switching + +```mermaid +sequenceDiagram + participant Main as main.cpp + participant Engine as Engine + participant Registry as SimulationRegistry + participant Runtime as SceneSimulationRuntime + participant Scene as active IScene + participant API as EngineAPI + + Main->>Engine: start(config) + Engine->>Registry: register_default_simulations() + Registry->>Runtime: add Surface, Analysis, Multi-Well, Wave Predator-Prey + Engine->>Runtime: instantiate(make_api()) + Runtime->>Scene: create scene via SceneFactories + + loop every frame + Engine->>Engine: poll input, update debug stats + Engine->>Scene: on_frame(dt) + Scene->>API: acquire/submit render work + Engine->>Runtime: publish snapshot() + Engine->>Engine: render ImGui + submitted geometry + end + + Engine->>Scene: on_key_event(key, action, mods) + Scene->>Scene: local HotkeyManager handles scene hotkeys + Engine->>Registry: Ctrl+1..Ctrl+4 requests simulation switch +``` + +## Scene Component Pattern + +The four current simulations now mostly follow the same shape. + +```mermaid +classDiagram + class SimulationSceneBase { + +on_frame(dt) + +on_key_event(key, action, mods) + +snapshot() + #advance_particles(ParticleSystem, dt) + #draw_dockspace_root() + #draw_hotkey_panel() + #m_sim_time + #m_sim_speed + #m_paused + #m_goal_status + } + + class SurfaceSimScene { + GaussianSurface + ParticleSystem + SurfaceSimSpawner + SurfaceSimPanels + SurfaceSceneView + SurfaceMeshCache + Viewport + HotkeyManager + } + + class AnalysisScene { + SineRationalSurface + ParticleSystem + AnalysisSpawner + AnalysisPanels + SurfaceSceneView + SurfaceMeshCache + Viewport + HotkeyManager + } + + class MultiWellScene { + MultiWellWaveSurface + ParticleSystem + MultiWellSpawner + MultiWellPanels + SurfaceSceneView + SurfaceMeshCache + Viewport + HotkeyManager + } + + class WavePredatorPreyScene { + WavePredatorPreySurface + ParticleSystem + WavePredatorPreySpawner + WavePredatorPreyPanels + SurfaceSceneView + SurfaceMeshCache + Viewport + HotkeyManager + } + + SimulationSceneBase <|-- SurfaceSimScene + SimulationSceneBase <|-- AnalysisScene + SimulationSceneBase <|-- MultiWellScene + SimulationSceneBase <|-- WavePredatorPreyScene +``` + +## Per-Frame Scene Flow + +```mermaid +graph TD + Frame["Scene::on_frame(dt)"] --> Advance["SimulationSceneBase::advance_particles"] + Advance --> ParticleUpdate["ParticleSystem::update"] + ParticleUpdate --> Context["SimulationContext(surface, particles, rng, time)"] + ParticleUpdate --> Behaviors["Particle::behavior stack"] + Behaviors --> Velocity["sum deterministic velocity"] + Behaviors --> Noise["sum stochastic coefficients"] + Velocity --> Integrator["Euler/Milstein integrator"] + Noise --> Integrator + Integrator --> History["particle trail + history"] + ParticleUpdate --> PairConstraints["pair constraints"] + Advance --> Goals["ParticleSystem::evaluate_goals"] + Goals --> Pause["pause on success"] + + Frame --> Dock["draw_dockspace_root"] + Frame --> View["SurfaceSceneView::draw_canvas"] + View --> Mesh["SurfaceMeshCache::rebuild_if_needed"] + View --> Canvas["ProjectedSurfaceCanvas::draw"] + Canvas --> ImGuiSurface["ImGui projected surface triangles/wire"] + Canvas --> Overlay["ProjectedParticleOverlay"] + + Frame --> Panels["Scene-specific Panels::draw_all"] + Frame --> Contour["SurfaceSceneView::submit_contour"] + Contour --> ContourRenderer["submit_contour_window"] + ContourRenderer --> EngineAPI["EngineAPI::acquire + submit_to(Contour2D)"] + + Frame --> Hotkeys["draw_hotkey_panel"] +``` + +## Rendering Paths + +There are currently two rendering styles in the app layer. + +```mermaid +graph LR + subgraph ProjectedCanvas["Projected ImGui surface path"] + SurfaceSceneView --> ProjectedSurfaceCanvas + ProjectedSurfaceCanvas --> ImGuiDrawList["ImGui DrawList triangles, wire, overlays"] + ProjectedSurfaceCanvas --> ProjectedParticleOverlay + end + + subgraph EngineSubmission["Engine/Vulkan submission path"] + SurfaceSceneView --> ContourWindowRenderer + ContourWindowRenderer --> RenderSubmission["submit_generated_vertices / submit_transformed_vertices"] + RenderSubmission --> EngineAPI + EngineAPI --> BufferArena["BufferManager arena"] + EngineAPI --> RendererBackend["renderer backend"] + end + + subgraph LegacyParticleRenderer["Older 3D particle renderer"] + ParticleRenderer --> EngineAPI + end +``` + +Current note: the main 3D surface views are projected through ImGui draw lists, while the second contour window uses `EngineAPI` and the Vulkan renderer path. `PrimitiveRenderer` exists as a domain-neutral engine helper over `EngineAPI`, but the app still has direct submission helpers and an older `ParticleRenderer`. + +## Particle Model + +```mermaid +graph TB + ParticleSystem --> ParticleFactory + ParticleFactory --> ParticleBuilder + Spawner["Scene Spawner"] --> SwarmFactory["ParticleSwarmFactory"] + SwarmFactory --> ParticleBuilder + ParticleBuilder --> Particle["Particle / AnimatedCurve"] + + Particle --> Role["ParticleRole tag"] + Particle --> Trail["TrailConfig and history"] + Particle --> Equation["optional sim::IEquation via EquationBehavior"] + Particle --> BehaviorStack["vector"] + Particle --> Metadata["metadata_label()"] + + BehaviorStack --> Brownian["BrownianBehavior"] + BehaviorStack --> Seek["SeekParticleBehavior"] + BehaviorStack --> Avoid["AvoidParticleBehavior"] + BehaviorStack --> Centroid["CentroidSeekBehavior"] + BehaviorStack --> Gradient["GradientDriftBehavior"] + BehaviorStack --> Orbit["OrbitBehavior"] + BehaviorStack --> Flocking["FlockingBehavior"] + BehaviorStack --> ConstantDrift["ConstantDriftBehavior"] + + ParticleSystem --> Goals["IParticleGoal list"] + Goals --> Capture["CaptureGoal"] + Goals --> Survival["SurvivalGoal"] + ParticleSystem --> PairConstraints["IPairConstraint list"] +``` + +## Surface And Math Routing + +```mermaid +graph TB + SurfaceRegistry --> Sine["math::SineRationalSurface"] + SurfaceRegistry --> Multi["app::MultiWellWaveSurface"] + SurfaceRegistry --> Wave["app::WavePredatorPreySurface"] + SurfaceSimScene --> Gaussian["app::GaussianSurface"] + + Gaussian --> ISurface["math::ISurface"] + Sine --> ISurface + Multi --> ISurface + Wave --> ISurface + + ISurface --> NumericOps["numeric::ops"] + Behaviors["particle behaviors"] --> NumericOps + MeshCache["SurfaceMeshCache"] --> ISurface + MeshCache --> NumericOps + NumericOps --> MathTraits["MathTraits"] + MathTraits --> Config["NDDE_USE_BUILTIN_MATH / NDDE_USE_TAYLOR_SIN"] +``` + +Math intent: app, sim, and math code should route through `numeric::ops` / `MathTraits` for trigonometry and scalar operations. The Taylor sine approximation is selectable through the CMake compile definition `NDDE_USE_TAYLOR_SIN`. + +## Current Simulations + +| Hotkey | Runtime name | Scene class | Surface | Particle setup | +| --- | --- | --- | --- | --- | +| `Ctrl+1` | Surface Simulation | `SurfaceSimScene` | `GaussianSurface` | `SurfaceSimSpawner`, leader pursuit, Brownian cloud, contour band | +| `Ctrl+2` | Sine-Rational Analysis | `AnalysisScene` | `SineRationalSurface` | level walkers and analysis overlays | +| `Ctrl+3` | Multi-Well Centroid | `MultiWellScene` | `MultiWellWaveSurface` | centroid/avoider showcase | +| `Ctrl+4` | Wave Predator-Prey | `WavePredatorPreyScene` | `WavePredatorPreySurface` | predator/prey and swarm recipes | + +## Clean Boundaries + +These boundaries are now relatively healthy: + +- `EngineAPI` is the narrow scene-to-engine contract. Scenes do not need raw Vulkan access. +- `SimulationSceneBase` owns shared pause/time/snapshot/goal lifecycle. +- Each scene owns its local surface, particles, spawner, panels, viewport, hotkeys, and mesh cache. +- `SurfaceSceneView` is the shared app-level view for surface scenes. +- `ParticleSystem` owns particles, goals, pair constraints, RNG, and per-frame `SimulationContext`. +- `ParticleSwarmFactory` owns reusable swarm recipes and recipe metadata. +- `SurfaceRegistry` owns named reusable surface definitions, except Sim 1's `GaussianSurface`. + +## Refactor Pressure Points + +These are the main places to guide the next cleanup. + +### 1. Rendering Still Has Split Personalities + +`ProjectedSurfaceCanvas` renders the primary 3D surface through ImGui draw lists. `ContourWindowRenderer` submits to the engine/Vulkan path. `ParticleRenderer` and `PrimitiveRenderer` are both present, but not the single obvious path for all primitives. + +Recommended direction: + +- Keep `PrimitiveRenderer` in `engine` as the domain-neutral draw helper. +- Keep `SurfaceSceneView` in `app` as the surface-domain helper. +- Decide whether the primary 3D surface should remain an ImGui-projected preview or migrate to the engine submission path. +- If it remains projected, rename it explicitly as a projected/preview path so expectations are clear. + +### 2. `AnimatedCurve` Is Still The Concrete Particle Type + +The architecture now treats it as `Particle`, but the type name remains `AnimatedCurve`. That leaks old vocabulary into `ParticleSystem`, `SimulationContext`, renderers, and tests. + +Recommended direction: + +- Rename `AnimatedCurve` to `Particle` when the codebase is otherwise calm. +- Keep `using AnimatedCurve = Particle` temporarily if needed for staged migration. +- Move Frenet/history/trail concerns into particle-oriented file names. + +### 3. Scene Classes Still Wire Similar Objects By Hand + +The scenes are much closer now, but constructors and `on_frame` still repeat the same pattern. + +Recommended direction: + +- Introduce a `SurfaceSceneShell` or `SurfaceSimulationHost` only if duplication starts blocking work. +- Keep scene-specific panels/spawners separate for now. They are good domain boundaries. +- Extract common `SurfaceSceneViewOptions` construction only after the visual options stabilize. + +### 4. Surface Registry Is Partial + +`SineRationalSurface`, `MultiWellWaveSurface`, and `WavePredatorPreySurface` are registry-backed. `GaussianSurface` is still separate. + +Recommended direction: + +- Add Gaussian to `SurfaceRegistry`. +- Consider returning `std::unique_ptr` from the registry when scenes do not need the concrete type. +- Keep concrete return types where panels genuinely need concrete surface APIs. + +### 5. Panels Are Better But Still Scene-Specific + +Each scene has its own panel component. Shared panels exist (`ParticleInspectorPanel`, `SwarmRecipePanel`, `PerformancePanel`, `PanelHost`), but not all panel behavior is generic yet. + +Recommended direction: + +- Continue extracting reusable panel sections only when at least two scenes need the same controls. +- Avoid pushing scene-specific swarm choices into a global panel too early. + +### 6. Simulation Context Is Read-Oriented + +`SimulationContext` gives behaviors surface, time, RNG, and particle lookup. It is lightweight and good, but not yet a full thread-safe shared state/history object. + +Recommended direction: + +- Keep it per-update and stack-local for behavior evaluation. +- Add explicit snapshot/history services before making it globally shared. +- If background simulation threads return, make `ParticleSystem` publish immutable snapshots rather than exposing mutable particle vectors. + +## Suggested Next Refactor Sequence + +1. Add `GaussianSurface` to `SurfaceRegistry` so all four sims source surfaces consistently. +2. Decide the rendering direction for the primary 3D surface: projected ImGui preview versus engine/Vulkan primitive path. +3. Rename `AnimatedCurve` to `Particle` in a focused mechanical pass. +4. Replace older `ParticleRenderer` use or remove it if fully superseded by projected overlays and `PrimitiveRenderer`. +5. Introduce a small shared `SurfaceSceneFrame` helper only if the four `on_frame` methods keep converging. diff --git a/nurbs_dde/docs/PROPOSED_ARCHITECTURE_DIAGRAM.md b/nurbs_dde/docs/PROPOSED_ARCHITECTURE_DIAGRAM.md new file mode 100644 index 00000000..fca6bb1a --- /dev/null +++ b/nurbs_dde/docs/PROPOSED_ARCHITECTURE_DIAGRAM.md @@ -0,0 +1,517 @@ +# Proposed Architecture Diagram + +This is the proposed target architecture for the next major refactor. It is intentionally not thread-oriented yet; threading should be a later refactor once the simulation/service/render boundaries are clean. + +## Core Principle + +```text +Engine owns lifecycle and services. +Simulation owns math state and recalculation. +RenderService owns renderer-neutral view submission. +Renderer owns Vulkan. +Panels and hotkeys are registered capabilities, not scene-owned side effects. +``` + +## Target System Layers + +```mermaid +%%{init: {'theme': 'dark'}}%% +flowchart TB + Main["app/main.cpp"] --> Engine["Engine"] + + Engine --> Services["EngineServices / SimulationHost"] + Engine --> SimLifecycle["Simulation lifecycle"] + Engine --> Renderer["Vulkan Renderer"] + + Services --> PanelService["PanelService"] + Services --> HotkeyService["HotkeyService"] + Services --> RenderService["RenderService"] + Services --> MemoryService["Memory / Buffer / Arena Service"] + Services --> Clock["SimulationClock / TickSource"] + Services --> Config["ConfigService"] + + SimLifecycle --> ISim["ISimulation"] + ISim --> Sim1["SimulationSurfaceGaussian"] + ISim --> Sim2["SimulationSineRationalAnalysis"] + ISim --> Sim3["SimulationMultiWellCentroid"] + ISim --> Sim4["SimulationWavePredatorPrey"] + + ISim --> Context["SimulationContext"] + Context --> Surface["ISurface"] + Context --> Particles["Particle Store"] + Context --> History["History / Ring Buffers"] + Context --> Caches["Math Caches / Dirty Flags"] + Context --> TickState["Tick / Time State"] + Context --> Deformation["Deformation State"] + Context --> MemoryService + + ISim --> RenderPackets["RenderPacket / GeometryBatch"] + RenderPackets --> RenderService + RenderService --> Renderer +``` + +## Engine Services + +The engine is the composition root. Simulations should not depend on concrete `Engine`; they receive a narrowed host/services interface. + +```mermaid +%%{init: {'theme': 'dark'}}%% +classDiagram + class Engine { + +start() + +stop() + +run_frame() + -EngineServices services + -ISimulation active_simulation + } + + class EngineServices { + +panels() PanelService + +hotkeys() HotkeyService + +render() RenderService + +memory() MemoryService + +clock() SimulationClock + +config() ConfigService + } + + class SimulationHost { + +panels() PanelService + +hotkeys() HotkeyService + +render() RenderService + +memory() MemoryService + +clock() SimulationClock + } + + Engine --> EngineServices + EngineServices ..> SimulationHost + ISimulation --> SimulationHost +``` + +## Simulation Lifecycle + +Registration uses RAII handles so panels, hotkeys, render views, and commands roll back automatically when a simulation stops or is destroyed. + +```mermaid +%%{init: {'theme': 'dark'}}%% +sequenceDiagram + participant Engine + participant Host as SimulationHost + participant Sim as ISimulation + participant Panels as PanelService + participant Hotkeys as HotkeyService + participant Render as RenderService + participant Clock as SimulationClock + + Engine->>Sim: construct concrete SimulationXXX + Sim->>Sim: construct surface, particles, behaviors, goals, constraints + Engine->>Sim: on_register(host) + Sim->>Panels: register panels + Panels-->>Sim: PanelHandle + Sim->>Hotkeys: register callbacks + Hotkeys-->>Sim: HotkeyHandle + Sim->>Render: register main + alternate render views + Render-->>Sim: RenderViewHandle + + Engine->>Sim: on_start() + + loop active frame + Engine->>Clock: next_tick() + Clock-->>Engine: TickInfo + Engine->>Sim: on_tick(TickInfo) + Sim->>Sim: recalculate SimulationContext + Sim->>Render: submit RenderPackets + Engine->>Render: flush views to renderer + end + + Engine->>Sim: on_stop() + Sim->>Sim: destroy handles / unregister capabilities + Engine->>Sim: destroy simulation +``` + +## ISimulation Contract + +```mermaid +%%{init: {'theme': 'dark'}}%% +classDiagram + class ISimulation { + <> + +name() string_view + +on_register(SimulationHost&) + +on_start() + +on_tick(TickInfo) + +on_stop() + +snapshot() SimulationSnapshot + } + + class SimulationContext { + +surface() ISurface + +particles() ParticleStore + +history() HistoryStore + +math_cache() DerivedMathCache + +dirty() DirtyState + +memory() SimulationMemory + +tick() TickInfo + } + + class TickInfo { + +tick_index u64 + +dt f32 + +time f32 + +paused bool + } + + ISimulation *-- SimulationContext + ISimulation --> TickInfo +``` + +## SimulationContext As State + +`SimulationContext` is the authoritative simulation state, not just a temporary accessor. + +```mermaid +%%{init: {'theme': 'dark'}}%% +flowchart TB + Context["SimulationContext"] --> Surface["ISurface instance"] + Context --> ParticleStore["ParticleStore"] + Context --> BehaviorState["Behavior state"] + Context --> GoalState["Goal / win state"] + Context --> History["HistoryStore"] + Context --> MathCache["Derived math caches"] + Context --> Dirty["Dirty flags"] + Context --> Tick["Tick/time"] + Context --> Scratch["Scratch/frame allocation"] + Context --> Persistent["Persistent allocation"] + + ParticleStore --> Particles["Particles"] + Particles --> Behaviors["Behavior stack"] + Particles --> Constraints["Constraints"] + Particles --> Trails["Trails"] + + Surface --> Equations["equations"] + Surface --> Derivatives["derivatives"] + Surface --> Curvature["curvature / frames"] + + Scratch --> MemoryService["MemoryService"] + Persistent --> MemoryService +``` + +## Surfaces And Math + +All domain structures should use NDDE-owned types and math wrappers. Backend/GPU types stay behind aliases or conversion boundaries. + +```mermaid +%%{init: {'theme': 'dark'}}%% +flowchart TB + ISurface["ISurface"] --> Gaussian["GaussianSurface"] + ISurface --> SineRational["SineRationalSurface"] + ISurface --> MultiWell["MultiWellSurface"] + ISurface --> Wave["WavePredatorPreySurface"] + ISurface --> Deformable["IDeformableSurface capability"] + + Gaussian --> NddeTypes["NDDE scalar/vector/matrix types"] + SineRational --> NddeTypes + MultiWell --> NddeTypes + Wave --> NddeTypes + Deformable --> SurfaceState["surface deformation state"] + + NddeTypes --> NumericOps["numeric::ops"] + NumericOps --> MathTraits["MathTraits"] + MathTraits --> MathConfig["math_config / approximation switches"] + + RenderPackets["RenderPacket / GeometryBatch"] --> NddeTypes + RendererBoundary["Renderer conversion boundary"] --> GpuAliases["GPU aliases / backend layout types"] + RenderPackets --> RendererBoundary +``` + +Rules: + +- Domain, simulation, surface, particle, panel, and renderer-neutral structures use NDDE types. +- Math routes through `numeric::ops` / `MathTraits` or approved NDDE wrappers. +- Raw backend types do not leak into simulation APIs. +- GPU layout types live at renderer conversion boundaries. +- Surfaces may be static or dynamic/deformable. +- Time-varying surfaces evaluate against tick/time and deformation state. +- Deformation propagation is simulation math, not renderer logic. + +## Deformable Surface Interaction + +Deformable surfaces are simulation state. A user action, such as double-clicking a spot on the surface, becomes a simulation command that perturbs the surface and marks dependent caches/views dirty. + +```mermaid +%%{init: {'theme': 'dark'}}%% +sequenceDiagram + participant View as RenderView + participant Camera as CameraController + participant Input as Input/Command Service + participant Sim as ISimulation + participant Context as SimulationContext + participant Surface as IDeformableSurface + participant Render as RenderService + + View->>Camera: double-click screen point + Camera->>View: ray / surface-pick query + View->>Input: SurfacePerturbation command + Input->>Sim: dispatch command + Sim->>Context: write perturbation event/state + Context->>Surface: perturb(uv, amplitude, radius, falloff, seed) + Surface->>Context: mark surface/math/render dirty + + loop following ticks + Sim->>Surface: advance_deformation(tick) + Sim->>Context: rebuild dirty math/surface caches + Sim->>Render: submit updated main and alternate view geometry + end +``` + +Suggested POD command: + +```cpp +struct SurfacePerturbation { + Vec2 uv; + f32 amplitude; + f32 radius; + f32 falloff; + u32 seed; +}; +``` + +Architectural rules: + +- Deformable surface state lives in `SimulationContext`. +- Input services produce POD commands; they do not mutate the surface directly. +- Simulations decide how perturbations evolve over time. +- Surface deformation marks main render views and alternate views dirty. +- Dynamic surface caches must rebuild as needed for surface mesh, contour/isocline/vector-field views, particles, and hover math. + +## Render Views + +Replace hard-coded `Contour2D` with registered render views. A render view might be shown as a second OS window today, a docked panel tomorrow, or a capture target later. + +```mermaid +%%{init: {'theme': 'dark'}}%% +flowchart TB + Simulation["ISimulation"] --> MainView["register Main RenderView"] + Simulation --> AltView["register Alternate RenderView(s)"] + + MainView --> Camera["CameraController"] + MainView --> Overlays["ViewOverlayState"] + MainView --> Geometry["Surface / particles / trails / frames"] + + AltView --> AltMode["AlternateViewMode"] + AltMode --> Contour["Contour"] + AltMode --> Isoclines["Isoclines"] + AltMode --> LevelCurves["Level Curves"] + AltMode --> VectorField["Vector Field / Flow"] + AltMode --> Curvature["Curvature Map"] + + Geometry --> RenderPackets["RenderPackets"] + Contour --> RenderPackets + Isoclines --> RenderPackets + LevelCurves --> RenderPackets + VectorField --> RenderPackets + Curvature --> RenderPackets + + RenderPackets --> RenderService["RenderService"] + RenderService --> VulkanRenderer["Vulkan Renderer"] +``` + +## Camera And View Overlays + +Camera and axes belong to render views, not individual simulations. + +```mermaid +%%{init: {'theme': 'dark'}}%% +classDiagram + class RenderView { + +id RenderViewId + +title string + +camera CameraController + +overlays ViewOverlayState + } + + class CameraController { + +set_mode(CameraMode) + +handle_input(ViewInputFrame) + +state() CameraState + +view_projection(RenderView) Mat4 + } + + class ViewOverlayState { + +show_axes bool + +show_grid bool + +show_frame bool + +show_labels bool + +show_hover_frenet bool + +show_osculating_circle bool + } + + class CameraMode { + <> + Orbit + Fly + Locked2D + } + + RenderView *-- CameraController + RenderView *-- ViewOverlayState + CameraController --> CameraMode +``` + +Default camera model: + +- Right drag: orbit +- Shift + right drag: pan +- Wheel: zoom +- `F` or `Home`: frame surface/all particles +- Later: double-click sets orbit pivot under cursor +- Optional later mode: fly camera with right mouse look and WASD/QE + +Axes/grid/frame overlays should emit render packets just like simulation geometry, so captures and alternate views remain consistent. + +## Hover Math Overlays + +Hover inspection is a render-view feature backed by simulation math queries. The view determines what is under the cursor; the simulation answers the math question. + +```mermaid +%%{init: {'theme': 'dark'}}%% +graph TB + Cursor["cursor in RenderView"] --> Picking["view picking / nearest trail point"] + Picking --> Query["Surface/Particle math query"] + Query --> Frenet["Frenet frame"] + Query --> OscCircle["Osculating circle"] + Query --> Curvature["curvature / derivative values"] + + Frenet --> OverlayPackets["overlay RenderPackets"] + OscCircle --> OverlayPackets + Curvature --> PanelReadout["optional panel readout"] + OverlayPackets --> RenderService["RenderService"] +``` + +Rules: + +- Frenet frame and osculating circle overlays remain first-class features. +- Hover overlays are controlled by `ViewOverlayState`. +- Overlay geometry is renderer-neutral and goes through `RenderService`. +- Picking should use a standard path: screen point -> render view camera -> surface hit or nearest particle/trail point -> simulation math query. +- The simulation provides derivative/frame/curvature data; the renderer only draws the resulting geometry. + +## Memory And Allocation Rule + +All dynamic allocation goes through a central allocation facility. Stack allocation is allowed. + +```mermaid +%%{init: {'theme': 'dark'}}%% +flowchart TB + MemoryService["MemoryService"] --> PersistentArena["Persistent Arena"] + MemoryService --> FrameArena["Frame / Render Arena"] + MemoryService --> ScratchArena["Scratch Arena"] + MemoryService --> RingBuffers["History Ring Buffers"] + MemoryService --> Pools["Stable Pools"] + + SimulationContext --> PersistentArena + SimulationContext --> ScratchArena + RenderService --> FrameArena + HistoryStore --> RingBuffers + ParticleStore --> Pools + + Forbidden["new/delete/malloc/free"] -. only allowed in .-> MemoryService +``` + +Rules: + +- `new`, `delete`, `malloc`, `free`, and raw heap allocation appear only inside allocator/memory facility code. +- Standard containers are allowed only when backed by approved allocators, or in explicitly exempt boundary/setup code. +- Callback storage allocation is owned by services, not scattered through simulation code. +- Start with audit/report mode, then enforce after migration. + +## Panel And Hotkey Registration + +Panels and hotkeys are registered against services and return handles. Simulations can register callbacks, but the services own callback storage and allocation policy. + +```mermaid +%%{init: {'theme': 'dark'}}%% +flowchart TB + Simulation["SimulationXXX"] --> PanelDefs["Panel descriptors / panel callbacks"] + Simulation --> HotkeyDefs["Hotkey descriptors / command callbacks"] + + PanelDefs --> PanelService["PanelService"] + HotkeyDefs --> HotkeyService["HotkeyService"] + + PanelService --> PanelHandle["PanelHandle RAII"] + HotkeyService --> HotkeyHandle["HotkeyHandle RAII"] + + PanelService --> EngineUI["Engine UI frame"] + HotkeyService --> EngineInput["Engine input dispatch"] + + EngineUI --> PanelCallbacks["invoke active simulation panel callbacks"] + EngineInput --> HotkeyCallbacks["invoke active simulation hotkey callbacks"] +``` + +## Proposed Frame Flow + +```mermaid +%%{init: {'theme': 'dark'}}%% +flowchart TD + Frame["Engine::run_frame"] --> Input["Poll input"] + Input --> Hotkeys["HotkeyService dispatch"] + Frame --> Tick["SimulationClock next_tick"] + Tick --> SimTick["Active ISimulation::on_tick"] + + SimTick --> Recalc["Recalculate SimulationContext"] + Recalc --> SurfaceDirty["Rebuild surface/math caches if dirty"] + Recalc --> ParticleStep["Update particles/behaviors/goals"] + Recalc --> AltDirty["Rebuild alternate views if dirty"] + + SurfaceDirty --> MainPackets["Main view RenderPackets"] + ParticleStep --> MainPackets + ParticleStep --> AltPackets["Alternate view RenderPackets"] + AltDirty --> AltPackets + + MainPackets --> RenderService["RenderService queue"] + AltPackets --> RenderService + RenderService --> Overlays["Axes/grid/camera overlays"] + Overlays --> Vulkan["Vulkan renderer flush"] + + Frame --> Panels["PanelService draw registered panels"] + Panels --> Commands["Panel commands mutate sim state / dirty flags"] +``` + +## Current Refactor Scope + +In scope for this architecture refactor: + +- `ISimulation` +- concrete `SimulationXXX` objects +- engine-owned service access through `SimulationHost` +- `PanelService` +- `HotkeyService` +- `RenderService` +- render views and alternate views +- reusable camera controller and axes/grid overlays +- `SimulationContext` as state container +- central allocation facility policy +- NDDE type/math routing constraints + +Out of scope for this pass: + +- multithreaded simulation/render separation +- config-authored simulations +- full editor tooling +- scripting/plugin runtime + +## Implementation / Audit Item + +Add a CI/static audit check for forbidden allocation and type usage patterns, with an allowlist for allocator/memory facility code and temporary explicit exemptions. + +Candidate scan patterns: + +- `\bnew\b` +- `\bdelete\b` +- `malloc` +- `free` +- unapproved `std::vector` +- unapproved `std::string` +- unapproved `std::make_unique` +- `std::function` outside service/registration code + +Start as warning/report-only. Make it enforcing after the allocation migration is complete. diff --git a/nurbs_dde/engine_config.json b/nurbs_dde/engine_config.json index 767e7b23..8beee144 100644 --- a/nurbs_dde/engine_config.json +++ b/nurbs_dde/engine_config.json @@ -5,7 +5,7 @@ "title": "NDDE Engine" }, "render": { - "vsync": true, + "vsync": false, "max_frames_in_flight": 2 }, "camera": { diff --git a/nurbs_dde/src/CMakeLists.txt b/nurbs_dde/src/CMakeLists.txt index f7fd5e7a..40d56680 100644 --- a/nurbs_dde/src/CMakeLists.txt +++ b/nurbs_dde/src/CMakeLists.txt @@ -15,6 +15,11 @@ if(NDDE_USE_BUILTIN_MATH) target_compile_definitions(ndde_numeric INTERFACE NDDE_USE_BUILTIN_MATH=1) endif() +option(NDDE_USE_TAYLOR_SIN "Route ndde::numeric sine through the Taylor approximation" OFF) +if(NDDE_USE_TAYLOR_SIN) + target_compile_definitions(ndde_numeric INTERFACE NDDE_USE_TAYLOR_SIN=1) +endif() + # ── ndde_math ───────────────────────────────────────────────────────────────── add_library(ndde_math STATIC math/Conics.cpp @@ -28,6 +33,7 @@ target_link_libraries(ndde_math PUBLIC ndde_numeric glm::glm) # ── ndde_memory ─────────────────────────────────────────────────────────────── add_library(ndde_memory STATIC memory/BufferManager.cpp + memory/MemoryService.cpp ) target_include_directories(ndde_memory PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_link_libraries(ndde_memory PUBLIC ndde_math volk) @@ -60,6 +66,7 @@ target_link_libraries(ndde_renderer PUBLIC add_library(ndde_engine STATIC engine/AppConfig.cpp engine/Engine.cpp + engine/SimulationRuntime.cpp ) target_include_directories(ndde_engine PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_link_libraries(ndde_engine PUBLIC @@ -73,10 +80,18 @@ target_link_libraries(ndde_engine PUBLIC # ── nurbs_dde executable ────────────────────────────────────────────────────── add_executable(nurbs_dde app/main.cpp + app/AnalysisSpawner.cpp app/GaussianSurface.cpp + app/MultiWellSpawner.cpp + app/ParticleSystem.cpp app/SceneFactories.cpp - app/SurfaceSimScene.cpp - app/ParticleRenderer.cpp + app/SimulationAnalysis.cpp + app/SimulationMultiWell.cpp + app/SimulationSurfaceGaussian.cpp + app/SimulationWavePredatorPrey.cpp + app/SurfaceMeshCache.cpp + app/SurfaceSimSpawner.cpp + app/WavePredatorPreySpawner.cpp ) target_include_directories(nurbs_dde PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) target_link_libraries(nurbs_dde PRIVATE ndde_engine imgui) @@ -115,10 +130,12 @@ set(ASSETS_OUTPUT_DIR "${CMAKE_BINARY_DIR}/assets") target_compile_definitions(nurbs_dde PRIVATE SHADER_DIR="${CMAKE_BINARY_DIR}/shaders" ASSETS_DIR="${ASSETS_OUTPUT_DIR}" + NDDE_PROJECT_DIR="${CMAKE_SOURCE_DIR}" ) target_compile_definitions(ndde_engine PRIVATE SHADER_DIR="${CMAKE_BINARY_DIR}/shaders" ASSETS_DIR="${ASSETS_OUTPUT_DIR}" + NDDE_PROJECT_DIR="${CMAKE_SOURCE_DIR}" ) # ── Assets copy ─────────────────────────────────────────────────────────────── diff --git a/nurbs_dde/src/app/AlternateViewPanel.hpp b/nurbs_dde/src/app/AlternateViewPanel.hpp new file mode 100644 index 00000000..6b5bf3aa --- /dev/null +++ b/nurbs_dde/src/app/AlternateViewPanel.hpp @@ -0,0 +1,71 @@ +#pragma once +// app/AlternateViewPanel.hpp +// Shared controls for renderer-owned alternate mathematical views. + +#include "engine/RenderService.hpp" + +#include + +#include + +namespace ndde { + +class AlternateViewPanel { +public: + static void draw(RenderService& render, RenderViewId view) { + RenderViewDescriptor* descriptor = render.descriptor(view); + if (!descriptor || descriptor->kind != RenderViewKind::Alternate) + return; + + ImGui::SeparatorText("Alternate view"); + const std::array modes{ + "Contour", "Level curves", "Vector field", "Isoclines", "Flow" + }; + int mode = static_cast(descriptor->alternate_mode); + if (ImGui::Combo("Mode", &mode, modes.data(), static_cast(modes.size()))) + descriptor->alternate_mode = static_cast(mode); + + AlternateViewSettings& settings = descriptor->alternate; + switch (descriptor->alternate_mode) { + case AlternateViewMode::Contour: + case AlternateViewMode::LevelCurves: + ImGui::TextDisabled("Uses cached scalar height contours"); + break; + case AlternateViewMode::Isoclines: + ImGui::SliderFloat("Direction", &settings.isocline_direction_angle, -3.14159f, 3.14159f, "%.2f rad"); + ImGui::SliderFloat("Target slope", &settings.isocline_target_slope, -4.f, 4.f, "%.2f"); + ImGui::SliderFloat("Tolerance", &settings.isocline_tolerance, 0.01f, 3.f, "%.2f"); + slider_u32("Bands", settings.isocline_bands, 1, 15); + break; + case AlternateViewMode::VectorField: + case AlternateViewMode::Flow: + draw_vector_settings(settings); + if (descriptor->alternate_mode == AlternateViewMode::Flow) { + slider_u32("Seeds", settings.flow_seed_count, 3, 21); + slider_u32("Steps", settings.flow_steps, 4, 96); + ImGui::SliderFloat("Step size", &settings.flow_step_size, 0.02f, 0.5f, "%.2f"); + } + break; + } + } + +private: + static void draw_vector_settings(AlternateViewSettings& settings) { + const std::array vector_modes{ + "Gradient", "Negative gradient", "Level tangent", "Particle velocity" + }; + int vector_mode = static_cast(settings.vector_mode); + if (ImGui::Combo("Vector field", &vector_mode, vector_modes.data(), static_cast(vector_modes.size()))) + settings.vector_mode = static_cast(vector_mode); + slider_u32("Density", settings.vector_samples, 4, 40); + ImGui::SliderFloat("Scale", &settings.vector_scale, 0.1f, 4.f, "%.2f"); + } + + static void slider_u32(const char* label, u32& value, int min_value, int max_value) { + int v = static_cast(value); + if (ImGui::SliderInt(label, &v, min_value, max_value)) + value = static_cast(v); + } +}; + +} // namespace ndde diff --git a/nurbs_dde/src/app/AnalysisPanel.cpp b/nurbs_dde/src/app/AnalysisPanel.cpp deleted file mode 100644 index 21a15134..00000000 --- a/nurbs_dde/src/app/AnalysisPanel.cpp +++ /dev/null @@ -1,4 +0,0 @@ -// This file has been moved to src/app/legacy/AnalysisPanel.cpp (E1 refactor). -// It is no longer compiled. Do not add it back to CMakeLists.txt. -// See src/app/legacy/README.md for details. -#error "AnalysisPanel.cpp is dead code. See src/app/legacy/AnalysisPanel.cpp." diff --git a/nurbs_dde/src/app/AnalysisPanel.hpp b/nurbs_dde/src/app/AnalysisPanel.hpp deleted file mode 100644 index f965510b..00000000 --- a/nurbs_dde/src/app/AnalysisPanel.hpp +++ /dev/null @@ -1,81 +0,0 @@ -#pragma once -// app/AnalysisPanel.hpp - -#include "engine/EngineAPI.hpp" -#include "app/HoverResult.hpp" - -namespace ndde { - -class AnalysisPanel { -public: - void draw(const HoverResult& hover, EngineAPI& api); - - // ── Feature visibility ──────────────────────────────────────────────────── - bool show_epsilon_ball() const { return m_show_epsilon_ball; } - bool show_secant() const { return m_show_secant; } - bool show_tangent() const { return m_show_tangent; } - bool show_interval_lines() const { return m_show_interval_lines; } - bool show_lipschitz_cone() const { return m_show_lipschitz; } - - // Frenet frame - bool show_unit_tangent() const { return m_show_T; } - bool show_unit_normal() const { return m_show_N; } - bool show_unit_binormal() const { return m_show_B; } - - // Osculating circle / sphere - bool show_curvature_circle() const { return m_show_osc_circle; } - - // ── Quantities ──────────────────────────────────────────────────────────── - float get_epsilon_ball_radius() const { return m_epsilon; } - float get_epsilon_interval() const { return m_epsilon_interval; } - float get_delta() const { return m_delta; } - float get_snap_px_radius() const { return m_snap_px_radius; } - - // Arrow scale for T/N/B display (world units per unit vector) - float get_frame_scale() const { return m_frame_scale; } - - // ── Colours ─────────────────────────────────────────────────────────────── - const float* secant_colour() const { return m_secant_colour; } - const float* tangent_colour() const { return m_tangent_colour; } - const float* interval_colour() const { return m_interval_colour; } - const float* T_colour() const { return m_T_colour; } - const float* N_colour() const { return m_N_colour; } - const float* B_colour() const { return m_B_colour; } - const float* osc_colour() const { return m_osc_colour; } - -private: - // ── Control state ───────────────────────────────────────────────────────── - bool m_show_epsilon_ball = true; - float m_epsilon = 0.1f; - float m_epsilon_interval = 0.1f; - float m_delta = 0.1f; - float m_snap_px_radius = 20.f; - - bool m_show_secant = false; - bool m_show_tangent = false; - bool m_show_interval_lines = false; - bool m_show_lipschitz = false; - - // Frenet frame toggles - bool m_show_T = true; ///< Unit tangent (red) - bool m_show_N = true; ///< Principal normal (green) - bool m_show_B = true; ///< Binormal (blue) - float m_frame_scale = 0.3f; ///< Arrow length in world units - - // Osculating circle/sphere - bool m_show_osc_circle = true; - - // Colours - float m_secant_colour[3] = { 1.f, 1.f, 0.f }; // yellow - float m_tangent_colour[3] = { 0.3f, 1.f, 0.5f }; // mint green - float m_interval_colour[3] = { 0.6f, 0.4f, 1.0f }; // purple - float m_T_colour[3] = { 1.f, 0.25f,0.25f}; // red - float m_N_colour[3] = { 0.25f,1.f, 0.25f}; // green - float m_B_colour[3] = { 0.25f,0.5f, 1.f }; // blue - float m_osc_colour[3] = { 1.f, 0.7f, 0.2f }; // orange - - void draw_control_panel(const HoverResult& hover); - void draw_readout_panel(const HoverResult& hover, EngineAPI& api); -}; - -} // namespace ndde diff --git a/nurbs_dde/src/app/AnalysisScene.hpp b/nurbs_dde/src/app/AnalysisScene.hpp deleted file mode 100644 index 45187770..00000000 --- a/nurbs_dde/src/app/AnalysisScene.hpp +++ /dev/null @@ -1,509 +0,0 @@ -#pragma once -// app/AnalysisScene.hpp -// AnalysisScene: differential geometry analysis on the SineRationalSurface. -// -// Surface -// ─────── -// f(x,y) = [3/(1+(x+y+1)²)] sin(2x) cos(2y) + 0.1 sin(5x) sin(5y) -// Domain: [-4, 4]² -// -// This scene demonstrates level-curve tracing. Particles (LevelCurveWalker) -// spawn at a random position and lock onto the level curve f(x,y) = z₀ where -// z₀ is the height at the spawn point. An ε-band confinement term keeps them -// from drifting off the curve. -// -// Panel layout (each is a separate, independently moveable ImGui window) -// ───────────────────────────────────────────────────────────────────── -// "Analysis – Surface" surface geometry readout (K, H, grad, z at cursor) -// "Analysis – Walkers" spawn controls, per-walker params (z₀, ε, speed) -// "Analysis – Camera" yaw/pitch/zoom, reset -// "Analysis – Debug" PerformancePanel + CoordDebugPanel toggles -// -// The 3D canvas ("Analysis 3D") fills the dockspace exactly as in -// SurfaceSimScene, but only the surface and particle trails are drawn — -// no contour second window. - -#include "engine/IScene.hpp" -#include "engine/EngineAPI.hpp" -#include "app/SceneFactories.hpp" -#include "app/AnimatedCurve.hpp" -#include "app/FrenetFrame.hpp" -#include "app/ParticleRenderer.hpp" -#include "app/HotkeyManager.hpp" -#include "app/PerformancePanel.hpp" -#include "app/Viewport.hpp" -#include "math/SineRationalSurface.hpp" -#include "sim/LevelCurveWalker.hpp" -#include "sim/EulerIntegrator.hpp" -#include "sim/MilsteinIntegrator.hpp" -#include "sim/BrownianMotion.hpp" -#include "sim/IConstraint.hpp" -#include "math/GeometryTypes.hpp" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace ndde { - -class AnalysisScene final : public IScene { -public: - explicit AnalysisScene(EngineAPI api) - : m_api(std::move(api)) - , m_surface(std::make_unique(4.f)) - , m_particle_renderer(m_api) - , m_rng(std::random_device{}()) - { - m_vp3d.base_extent = 5.f; - m_vp3d.zoom = 1.f; - m_vp3d.yaw = 0.6f; - m_vp3d.pitch = 0.45f; - - // Register hotkeys - m_hotkeys.register_action(Chord::ctrl(ImGuiKey_W), "Spawn walker", - [this]{ spawn_walker(); }, "Walkers"); - m_hotkeys.register_action(Chord::ctrl(ImGuiKey_P), "Pause / unpause", - [this]{ m_paused = !m_paused; }, "Simulation"); - m_hotkeys.register_toggle(Chord::ctrl(ImGuiKey_H), "Hotkey panel", - m_show_hotkeys, "Panels"); - - // Pre-warm with one walker - spawn_walker(); - } - - // ── IScene ──────────────────────────────────────────────────────────────── - - void on_frame(f32 dt) override { - m_hotkeys.dispatch(); - - if (!m_paused) { - m_sim_time += dt; - for (auto& c : m_curves) - c.advance(dt, m_sim_speed); - } - - // Dockspace root - const ImGuiViewport* vp = ImGui::GetMainViewport(); - ImGui::SetNextWindowPos(vp->WorkPos); - ImGui::SetNextWindowSize(vp->WorkSize); - ImGui::SetNextWindowViewport(vp->ID); - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize,0.f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.f, 0.f)); - constexpr ImGuiWindowFlags dock_flags = - ImGuiWindowFlags_NoDocking | ImGuiWindowFlags_NoTitleBar | - ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize | - ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoBringToFrontOnFocus | - ImGuiWindowFlags_NoNavFocus | ImGuiWindowFlags_NoBackground; - ImGui::Begin("##analysis_dock", nullptr, dock_flags); - ImGui::PopStyleVar(3); - const ImGuiID dock_id = ImGui::GetID("AnalysisDockSpace"); - ImGui::DockSpace(dock_id, ImVec2(0.f, 0.f), ImGuiDockNodeFlags_None); - ImGui::End(); - - draw_canvas_3d(); - draw_panel_surface(); - draw_panel_walkers(); - draw_panel_camera(); - draw_panel_debug(); - m_hotkeys.draw_panel("Hotkeys [Ctrl+H]", m_show_hotkeys); - m_perf.draw(m_api.debug_stats()); - } - - [[nodiscard]] std::string_view name() const override { return "Analysis – Sine-Rational"; } - -private: - // ── Core state ──────────────────────────────────────────────────────────── - EngineAPI m_api; - std::unique_ptr m_surface; - ndde::sim::EulerIntegrator m_integrator; - ndde::sim::MilsteinIntegrator m_milstein; - std::vector m_curves; - ParticleRenderer m_particle_renderer; - HotkeyManager m_hotkeys; - PerformancePanel m_perf; - Viewport m_vp3d; - std::mt19937 m_rng; - - float m_sim_time = 0.f; - float m_sim_speed = 1.f; - bool m_paused = false; - bool m_show_hotkeys = false; - - // Geometry cache - bool m_wire_dirty = true; - u32 m_cached_grid = 0; - std::vector m_wire_cache; - u32 m_wire_vcount = 0; - std::vector m_fill_cache; - u32 m_fill_vcount = 0; - u32 m_grid_lines = 60; - float m_curv_scale = 3.f; - - // Spawn params - float m_epsilon = 0.15f; - float m_walk_speed = 0.7f; - float m_noise_sigma = 0.0f; - u32 m_spawn_count = 0; - - // ── Spawn ───────────────────────────────────────────────────────────────── - - void spawn_walker() { - // Pick a random position in the domain - std::uniform_real_distribution du( - m_surface->u_min() + 0.5f, m_surface->u_max() - 0.5f); - std::uniform_real_distribution dv( - m_surface->v_min() + 0.5f, m_surface->v_max() - 0.5f); - - const float u0 = du(m_rng); - const float v0 = dv(m_rng); - const float z0 = m_surface->height(u0, v0); - - ndde::sim::LevelCurveWalker::Params p; - p.z0 = z0; - p.epsilon = m_epsilon; - p.walk_speed = m_walk_speed; - - auto eq = std::make_unique(p); - - // Optionally add Brownian noise on top via a composed approach: - // for now the walker handles its own dynamics; noise can be layered - // by using MilsteinIntegrator with noise_coefficient. - const ndde::sim::IIntegrator* integrator = (m_noise_sigma > 1e-6f) - ? static_cast(&m_milstein) - : static_cast(&m_integrator); - - AnimatedCurve c = AnimatedCurve::with_equation( - u0, v0, - AnimatedCurve::Role::Leader, - m_spawn_count % AnimatedCurve::MAX_SLOTS, - m_surface.get(), std::move(eq), integrator); - - // Pre-warm 120 frames so trail is visible immediately - for (int i = 0; i < 120; ++i) - c.advance(1.f/60.f, m_sim_speed); - - m_curves.push_back(std::move(c)); - ++m_spawn_count; - } - - // ── MVP ─────────────────────────────────────────────────────────────────── - - [[nodiscard]] Mat4 canvas_mvp(const ImVec2& cpos, const ImVec2& csz) const noexcept { - const Vec2 sw = m_api.viewport_size(); - const float swx = sw.x > 0.f ? sw.x : 1.f; - const float swy = sw.y > 0.f ? sw.y : 1.f; - const float cw = csz.x > 0.f ? csz.x : 1.f; - const float ch = csz.y > 0.f ? csz.y : 1.f; - - const float sx = cw / swx; - const float sy = ch / swy; - const float bx = 2.f*cpos.x/swx + sx - 1.f; - const float by = -(2.f*cpos.y/swy + sy - 1.f); - - Mat4 remap(0.f); - remap[0][0] = sx; remap[1][1] = sy; remap[2][2] = 1.f; - remap[3][0] = bx; remap[3][1] = by; remap[3][3] = 1.f; - - const float dist = m_vp3d.base_extent / m_vp3d.zoom * 3.f; - const float ex = dist * std::cos(m_vp3d.pitch) * std::sin(m_vp3d.yaw); - const float ey = dist * std::sin(m_vp3d.pitch); - const float ez = dist * std::cos(m_vp3d.pitch) * std::cos(m_vp3d.yaw); - const Mat4 proj = glm::perspective(glm::radians(45.f), cw/ch, 0.01f, 500.f); - const Mat4 view = glm::lookAt(glm::vec3(ex,ey,ez), - glm::vec3(0.f,0.f,0.f), - glm::vec3(0.f,1.f,0.f)); - return remap * proj * view; - } - - // ── Surface geometry ────────────────────────────────────────────────────── - - static Vec4 height_color(float z, float scale) noexcept { - const float t = std::clamp(z / (scale + 1e-9f), -1.f, 1.f); - if (t >= 0.f) - return {0.50f + t*0.35f, 0.50f - t*0.38f, 0.50f - t*0.42f, 0.82f}; - const float s = -t; - return {0.50f - s*0.40f, 0.50f + s*0.18f - s*s*0.46f, 0.50f + s*0.35f, 0.82f}; - } - - void rebuild_geometry_if_needed() { - const bool dirty = m_wire_dirty || (m_grid_lines != m_cached_grid); - if (!dirty) return; - - // Wireframe - const u32 wn = m_surface->wireframe_vertex_count(m_grid_lines, m_grid_lines); - m_wire_cache.resize(wn); - m_surface->tessellate_wireframe({m_wire_cache.data(), wn}, - m_grid_lines, m_grid_lines, 0.f, - {0.3f, 0.6f, 0.9f, 0.55f}); - m_wire_vcount = wn; - - // Filled (curvature-coloured) - const u32 N = m_grid_lines; - const u32 fn = N * N * 6; - m_fill_cache.resize(fn); - const float u0 = m_surface->u_min(), u1 = m_surface->u_max(); - const float v0 = m_surface->v_min(), v1 = m_surface->v_max(); - const float du = (u1-u0)/static_cast(N); - const float dv = (v1-v0)/static_cast(N); - u32 idx = 0; - for (u32 i = 0; i < N; ++i) { - const float ua = u0 + static_cast(i)*du; - const float ub = ua + du; - for (u32 j = 0; j < N; ++j) { - const float va = v0 + static_cast(j)*dv; - const float vb = va + dv; - const Vec3 p00 = m_surface->evaluate(ua,va); - const Vec3 p10 = m_surface->evaluate(ub,va); - const Vec3 p01 = m_surface->evaluate(ua,vb); - const Vec3 p11 = m_surface->evaluate(ub,vb); - const float K = m_surface->gaussian_curvature((ua+ub)*.5f,(va+vb)*.5f); - const Vec4 col = height_color(K, m_curv_scale); - m_fill_cache[idx++]={p00,col}; m_fill_cache[idx++]={p10,col}; - m_fill_cache[idx++]={p11,col}; m_fill_cache[idx++]={p00,col}; - m_fill_cache[idx++]={p11,col}; m_fill_cache[idx++]={p01,col}; - } - } - m_fill_vcount = idx; - - m_cached_grid = m_grid_lines; - m_wire_dirty = false; - } - - // ── 3D canvas window ────────────────────────────────────────────────────── - - void draw_canvas_3d() { - ImGui::SetNextWindowPos(ImVec2(330.f, 20.f), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowSize(ImVec2(750.f, 660.f), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowBgAlpha(0.f); - const ImGuiWindowFlags flags = - ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse; - ImGui::Begin("Analysis 3D", nullptr, flags); - - const ImVec2 cpos = ImGui::GetCursorScreenPos(); - const ImVec2 csz = ImGui::GetContentRegionAvail(); - m_vp3d.fb_w = csz.x; m_vp3d.fb_h = csz.y; - m_vp3d.dp_w = csz.x; m_vp3d.dp_h = csz.y; - - ImGui::InvisibleButton("3d_canvas", csz, - ImGuiButtonFlags_MouseButtonLeft | - ImGuiButtonFlags_MouseButtonRight | - ImGuiButtonFlags_MouseButtonMiddle); - - if (ImGui::IsItemHovered()) { - const ImGuiIO& io = ImGui::GetIO(); - if (std::abs(io.MouseWheel) > 0.f) - m_vp3d.zoom = std::clamp(m_vp3d.zoom*(1.f+0.12f*io.MouseWheel), 0.05f, 20.f); - if (ImGui::IsMouseDragging(ImGuiMouseButton_Right) || - ImGui::IsMouseDragging(ImGuiMouseButton_Middle)) - m_vp3d.orbit(io.MouseDelta.x, io.MouseDelta.y); - } - - const Mat4 mvp = canvas_mvp(cpos, csz); - rebuild_geometry_if_needed(); - - // Filled surface - if (m_fill_vcount > 0) { - auto sl = m_api.acquire(m_fill_vcount); - std::memcpy(sl.vertices(), m_fill_cache.data(), m_fill_vcount*sizeof(Vertex)); - m_api.submit_to(RenderTarget::Primary3D, sl, Topology::TriangleList, DrawMode::VertexColor, {1,1,1,1}, mvp); - } - // Wireframe overlay - if (m_wire_vcount > 0) { - auto sl = m_api.acquire(m_wire_vcount); - std::memcpy(sl.vertices(), m_wire_cache.data(), m_wire_vcount*sizeof(Vertex)); - m_api.submit_to(RenderTarget::Primary3D, sl, Topology::LineList, DrawMode::VertexColor, {1,1,1,1}, mvp); - } - - // Particles - m_particle_renderer.show_frenet = false; - m_particle_renderer.submit_all(m_curves, *m_surface, m_sim_time, - mvp, -1, -1, false); - - // Level-band indicator lines per walker - for (const auto& c : m_curves) { - if (!c.has_trail()) continue; - if (const auto* eq = dynamic_cast(c.equation())) { - const float z0 = eq->params().z0; - const float eps = eq->params().epsilon; - // Draw small dot at head coloured by how close we are to z0 - const Vec3 hp = c.head_world(); - const float dz = std::abs(hp.z - z0); - const float t = std::clamp(dz / (eps + 1e-6f), 0.f, 1.f); - const Vec4 col = {1.f - t, 0.3f + 0.5f*(1.f-t), t*0.8f, 1.f}; - auto sl = m_api.acquire(1); - sl.vertices()[0] = {hp, col}; - m_api.submit_to(RenderTarget::Primary3D, sl, Topology::LineStrip, DrawMode::UniformColor, col, mvp); - } - } - - ImDrawList* dl = ImGui::GetWindowDrawList(); - dl->AddText(ImVec2(cpos.x+8, cpos.y+6), - IM_COL32(200,200,200,180), - "Right-drag: orbit Scroll: zoom Ctrl+W: add walker"); - if (m_paused) - dl->AddText(ImVec2(cpos.x+8, cpos.y+22), - IM_COL32(255,210,60,240), "PAUSED [Ctrl+P]"); - - ImGui::End(); - } - - // ── Panels ──────────────────────────────────────────────────────────────── - - void draw_panel_surface() { - ImGui::SetNextWindowPos(ImVec2(20.f, 20.f), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowSize(ImVec2(290.f, 340.f), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowBgAlpha(0.88f); - if (!ImGui::Begin("Analysis \xe2\x80\x93 Surface")) { ImGui::End(); return; } - - ImGui::SeparatorText("Function"); - ImGui::TextDisabled("f(x,y) = [3/(1+(x+y+1)\xc2\xb2)] sin(2x)cos(2y)"); - ImGui::TextDisabled(" + 0.1 sin(5x) sin(5y)"); - ImGui::Spacing(); - - ImGui::SeparatorText("Geometry"); - { - int gl = static_cast(m_grid_lines); - if (ImGui::SliderInt("Grid lines##as", &gl, 8, 120)) { - m_grid_lines = static_cast(gl); - m_wire_dirty = true; - } - if (ImGui::SliderFloat("K scale##as", &m_curv_scale, 0.1f, 10.f, "%.2f")) - m_wire_dirty = true; - ImGui::TextDisabled("~%u k verts", (4u*m_grid_lines*(m_grid_lines+1u))/1000u); - } - - ImGui::SeparatorText("At cursor (u,v)"); - // Show surface values at arbitrary position entered by user - { - static float probe_u = 0.f, probe_v = 0.f; - ImGui::SetNextItemWidth(90.f); - ImGui::InputFloat("u##probe", &probe_u, 0.1f, 0.5f, "%.2f"); - ImGui::SameLine(); - ImGui::SetNextItemWidth(90.f); - ImGui::InputFloat("v##probe", &probe_v, 0.1f, 0.5f, "%.2f"); - probe_u = std::clamp(probe_u, m_surface->u_min(), m_surface->u_max()); - probe_v = std::clamp(probe_v, m_surface->v_min(), m_surface->v_max()); - - const float z = m_surface->height(probe_u, probe_v); - const float gx = m_surface->du(probe_u, probe_v).z; - const float gy = m_surface->dv(probe_u, probe_v).z; - const float gm = std::sqrt(gx*gx + gy*gy); - const float K = m_surface->gaussian_curvature(probe_u, probe_v); - const float H = m_surface->mean_curvature(probe_u, probe_v); - - ImGui::TextDisabled("z = %.4f", z); - ImGui::TextDisabled("|grad| = %.4f", gm); - ImGui::TextDisabled("grad = (%.3f, %.3f)", gx, gy); - ImGui::TextDisabled("K = %.5f", K); - ImGui::TextDisabled("H = %.5f", H); - const float perpx = -gy / (gm + 1e-9f); - const float perpy = gx / (gm + 1e-9f); - ImGui::TextDisabled("level-tangent = (%.3f, %.3f)", perpx, perpy); - } - - ImGui::End(); - } - - void draw_panel_walkers() { - ImGui::SetNextWindowPos(ImVec2(20.f, 370.f), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowSize(ImVec2(290.f, 380.f), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowBgAlpha(0.88f); - if (!ImGui::Begin("Analysis \xe2\x80\x93 Walkers")) { ImGui::End(); return; } - - ImGui::SeparatorText("Spawn parameters"); - ImGui::SliderFloat("Epsilon (band)##w", &m_epsilon, 0.02f, 1.f, "%.3f"); - ImGui::SliderFloat("Walk speed##w", &m_walk_speed, 0.1f, 2.f, "%.2f"); - ImGui::SliderFloat("Noise sigma##w", &m_noise_sigma,0.f, 0.5f, "%.3f"); - ImGui::SliderFloat("Sim speed##w", &m_sim_speed, 0.1f, 5.f, "%.2f"); - ImGui::Spacing(); - - if (ImGui::Button("Spawn walker [Ctrl+W]", ImVec2(-1.f, 0.f))) - spawn_walker(); - if (ImGui::Button("Clear all", ImVec2(-1.f, 0.f))) { - m_curves.clear(); - m_spawn_count = 0; - } - - ImGui::SeparatorText("Active walkers"); - ImGui::TextDisabled("%zu walker(s)", m_curves.size()); - ImGui::Spacing(); - - // Per-walker live readout and editable params - for (u32 wi = 0; wi < static_cast(m_curves.size()); ++wi) { - ImGui::PushID(static_cast(wi)); - auto& c = m_curves[wi]; - auto* eq = dynamic_cast(c.equation()); - if (!eq) { ImGui::PopID(); continue; } - - auto& p = eq->params(); - const Vec3 hp = c.head_world(); - const float dz = hp.z - p.z0; - - const ImVec4 col = std::abs(dz) < p.epsilon - ? ImVec4(0.4f,1.f,0.4f,1.f) : ImVec4(1.f,0.5f,0.2f,1.f); - ImGui::TextColored(col, "Walker %u z=%.3f dz=%+.3f", wi, hp.z, dz); - - ImGui::SetNextItemWidth(120.f); - ImGui::SliderFloat("z0##lw", &p.z0, -2.f, 2.f, "%.3f"); - ImGui::SameLine(); - ImGui::SetNextItemWidth(100.f); - ImGui::SliderFloat("eps##lw", &p.epsilon, 0.02f, 1.f, "%.3f"); - - ImGui::SetNextItemWidth(120.f); - ImGui::SliderFloat("spd##lw", &p.walk_speed, 0.1f, 2.f, "%.2f"); - - ImGui::PopID(); - ImGui::Separator(); - } - ImGui::End(); - } - - void draw_panel_camera() { - ImGui::SetNextWindowPos(ImVec2(20.f, 760.f), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowSize(ImVec2(290.f, 160.f), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowBgAlpha(0.88f); - if (!ImGui::Begin("Analysis \xe2\x80\x93 Camera")) { ImGui::End(); return; } - - ImGui::SliderFloat("Yaw##ac", &m_vp3d.yaw, - -std::numbers::pi_v, std::numbers::pi_v, "%.2f"); - ImGui::SliderFloat("Pitch##ac", &m_vp3d.pitch, -1.5f, 1.5f, "%.2f"); - ImGui::SliderFloat("Zoom##ac", &m_vp3d.zoom, 0.1f, 8.f, "%.2f"); - if (ImGui::Button("Reset##ac")) m_vp3d.reset(); - - ImGui::SeparatorText("Pause"); - ImGui::Checkbox("Paused [Ctrl+P]", &m_paused); - ImGui::End(); - } - - void draw_panel_debug() { - ImGui::SetNextWindowPos(ImVec2(20.f, 930.f), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowSize(ImVec2(290.f, 100.f), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowBgAlpha(0.88f); - if (!ImGui::Begin("Analysis \xe2\x80\x93 Debug")) { ImGui::End(); return; } - - ImGui::SeparatorText("Scene"); - if (ImGui::Button("Switch to Surface Sim", ImVec2(-1.f, 0.f))) { - m_api.switch_scene(make_surface_sim_scene); - } - ImGui::Spacing(); - ImGui::SeparatorText("Stats"); - if (ImGui::Button("Perf stats")) m_perf.visible() = !m_perf.visible(); - const auto& s = m_api.debug_stats(); - const ImVec4 fc = s.fps >= 55.f ? ImVec4(0.4f,1.f,0.4f,1.f) - : s.fps >= 30.f ? ImVec4(1.f,0.8f,0.f,1.f) - : ImVec4(1.f,0.3f,0.3f,1.f); - ImGui::SameLine(); - ImGui::TextColored(fc, "%.0f fps", s.fps); - ImGui::End(); - } -}; - -} // namespace ndde diff --git a/nurbs_dde/src/app/AnalysisSpawner.cpp b/nurbs_dde/src/app/AnalysisSpawner.cpp new file mode 100644 index 00000000..c7fac6e1 --- /dev/null +++ b/nurbs_dde/src/app/AnalysisSpawner.cpp @@ -0,0 +1,155 @@ +#include "app/AnalysisSpawner.hpp" + +#include "app/ParticleBehaviors.hpp" +#include "sim/LevelCurveWalker.hpp" + +#include +#include +#include +#include + +namespace ndde { + +AnalysisSpawner::AnalysisSpawner(ndde::math::SineRationalSurface& surface, + ParticleSystem& particles, + u32& spawn_count, + float& epsilon, + float& walk_speed, + float& noise_sigma, + float& sim_time, + float& sim_speed, + GoalStatus& goal_status) noexcept + : m_surface(surface) + , m_particles(particles) + , m_spawn_count(spawn_count) + , m_epsilon(epsilon) + , m_walk_speed(walk_speed) + , m_noise_sigma(noise_sigma) + , m_sim_time(sim_time) + , m_sim_speed(sim_speed) + , m_goal_status(goal_status) +{} + +namespace { +SwarmBuildResult make_analysis_result(std::string name, u32 count, bool goal) { + return SwarmBuildResult{.metadata = SwarmRecipeMetadata{ + .family_name = std::move(name), + .requested_count = count, + .roles_emitted = {ParticleRole::Leader, ParticleRole::Chaser}, + .goals_added = goal + }}; +} +} + +SwarmBuildResult AnalysisSpawner::clear_all() noexcept { + m_particles.clear(); + m_particles.clear_goals(); + m_spawn_count = 0; + m_goal_status = GoalStatus::Running; + return make_analysis_result("Analysis Clear", 0u, false); +} + +SwarmBuildResult AnalysisSpawner::spawn_showcase_service() { + (void)clear_all(); + m_sim_time = 0.f; + + ndde::sim::LevelCurveWalker::Params p; + p.z0 = m_surface.height(-1.25f, 0.65f); + p.epsilon = 0.18f; + p.walk_speed = 0.72f; + p.tangent_floor = 0.42f; + + ParticleBuilder leader_builder = m_particles.factory().particle(); + leader_builder + .named("Leader - Level Curve - Brownian") + .role(ParticleRole::Leader) + .at({-1.25f, 0.65f}) + .history(640, 1.f / 120.f) + .trail({TrailMode::Finite, AnimatedCurve::MAX_TRAIL, 0.012f}) + .stochastic() + .with_equation_type(p) + .with_behavior(0.20f, BrownianBehavior::Params{ + .sigma = 0.045f, + .drift_strength = 0.f + }); + AnimatedCurve& leader = m_particles.spawn(std::move(leader_builder)); + ++m_spawn_count; + + for (int i = 0; i < 180; ++i) { + m_sim_time += 1.f / 60.f; + m_particles.update(1.f / 60.f, 1.f, m_sim_time); + } + + SeekParticleBehavior::Params seek; + seek.target = TargetSelector::nearest(ParticleRole::Leader); + seek.speed = 0.86f; + seek.delay_seconds = 0.85f; + + const glm::vec2 leader_uv = leader.head_uv(); + ParticleBuilder seeker_builder = m_particles.factory().particle(); + seeker_builder + .named("Chaser - Delayed Seek - Brownian") + .role(ParticleRole::Chaser) + .at({leader_uv.x + 1.0f, leader_uv.y - 0.75f}) + .trail({TrailMode::Finite, AnimatedCurve::MAX_TRAIL, 0.012f}) + .stochastic() + .with_behavior(seek) + .with_behavior(0.18f, BrownianBehavior::Params{ + .sigma = 0.035f, + .drift_strength = 0.f + }); + m_particles.spawn(std::move(seeker_builder)); + ++m_spawn_count; + + m_particles.add_goal(CaptureGoal::Params{ + .seeker_role = ParticleRole::Chaser, + .target_role = ParticleRole::Leader, + .radius = 0.18f + }); + m_goal_status = GoalStatus::Running; + return make_analysis_result("Analysis Leader/Delayed Seeker", m_spawn_count, true); +} + +SwarmBuildResult AnalysisSpawner::spawn_walker() { + std::uniform_real_distribution du(m_surface.u_min() + 0.5f, m_surface.u_max() - 0.5f); + std::uniform_real_distribution dv(m_surface.v_min() + 0.5f, m_surface.v_max() - 0.5f); + + const float u0 = du(m_particles.rng()); + const float v0 = dv(m_particles.rng()); + const float z0 = m_surface.height(u0, v0); + + ndde::sim::LevelCurveWalker::Params p; + p.z0 = z0; + p.epsilon = m_epsilon; + p.walk_speed = m_walk_speed; + + ParticleBuilder builder = m_particles.factory().particle(); + builder.named("Walker") + .role(ParticleRole::Leader) + .at({u0, v0}) + .trail({TrailMode::Finite, AnimatedCurve::MAX_TRAIL, 0.015f}) + .with_equation_type(p); + + if (m_noise_sigma > 1e-6f) { + builder.stochastic() + .with_behavior(BrownianBehavior::Params{ + .sigma = m_noise_sigma, + .drift_strength = 0.f + }); + } + + AnimatedCurve& c = m_particles.spawn(std::move(builder)); + + SimulationContext context = m_particles.context(m_sim_time); + context.set_time(m_sim_time); + c.set_behavior_context(&context); + + for (int i = 0; i < 120; ++i) { + context.set_time(m_sim_time + static_cast(i) / 60.f); + c.advance(1.f / 60.f, m_sim_speed); + } + ++m_spawn_count; + return make_analysis_result("Analysis Level Walker", 1u, false); +} + +} // namespace ndde diff --git a/nurbs_dde/src/app/AnalysisSpawner.hpp b/nurbs_dde/src/app/AnalysisSpawner.hpp new file mode 100644 index 00000000..b118c6e4 --- /dev/null +++ b/nurbs_dde/src/app/AnalysisSpawner.hpp @@ -0,0 +1,39 @@ +#pragma once + +#include "app/AnimatedCurve.hpp" +#include "app/ParticleGoals.hpp" +#include "app/ParticleSwarmFactory.hpp" +#include "app/ParticleSystem.hpp" +#include "math/SineRationalSurface.hpp" + +namespace ndde { + +class AnalysisSpawner { +public: + AnalysisSpawner(ndde::math::SineRationalSurface& surface, + ParticleSystem& particles, + u32& spawn_count, + float& epsilon, + float& walk_speed, + float& noise_sigma, + float& sim_time, + float& sim_speed, + GoalStatus& goal_status) noexcept; + + [[nodiscard]] SwarmBuildResult spawn_showcase_service(); + [[nodiscard]] SwarmBuildResult spawn_walker(); + [[nodiscard]] SwarmBuildResult clear_all() noexcept; + +private: + ndde::math::SineRationalSurface& m_surface; + ParticleSystem& m_particles; + u32& m_spawn_count; + float& m_epsilon; + float& m_walk_speed; + float& m_noise_sigma; + float& m_sim_time; + float& m_sim_speed; + GoalStatus& m_goal_status; +}; + +} // namespace ndde diff --git a/nurbs_dde/src/app/AnimatedCurve.hpp b/nurbs_dde/src/app/AnimatedCurve.hpp index 0e056bd8..39e161c9 100644 --- a/nurbs_dde/src/app/AnimatedCurve.hpp +++ b/nurbs_dde/src/app/AnimatedCurve.hpp @@ -2,7 +2,7 @@ // app/AnimatedCurve.hpp // AnimatedCurve: one walker particle on a parametric surface. // -// Moved from GaussianSurface.hpp (B1 refactor) so that ParticleRenderer and +// Moved from GaussianSurface.hpp (B1 refactor) so that particle helpers and // SpawnStrategy can include this type without pulling in GaussianSurface. // // Ownership model @@ -11,10 +11,10 @@ // non-owning. All three must outlive the AnimatedCurve. // // with_equation() factory: the particle OWNS its equation via m_owned_equation -// (unique_ptr). m_equation is an alias to m_owned_equation.get(). This +// (memory::Unique). m_equation is an alias to m_owned_equation.get(). This // enables per-particle SDE equations (BrownianMotion, DelayPursuitEquation). // -// Move semantics: AnimatedCurve is moveable but not copyable. The unique_ptr +// Move semantics: AnimatedCurve is moveable but not copyable. The owned pointer // members (m_owned_equation, m_history) move safely; m_equation (raw pointer // alias) remains valid because unique_ptr move preserves the heap address. // @@ -23,7 +23,7 @@ // enable_history() allocates a ring buffer. push_history() records the // current parameter-space position after each advance() call. query_history() // interpolates to an arbitrary past time. The buffer pointer is stable across -// vector reallocations (unique_ptr move does not change heap address). +// vector reallocations (memory::Unique move does not change object address). // // Live equation access // ───────────────────── @@ -40,15 +40,22 @@ #include "sim/IIntegrator.hpp" #include "sim/IConstraint.hpp" #include "sim/HistoryBuffer.hpp" +#include "app/ParticleBehaviors.hpp" #include "app/FrenetFrame.hpp" +#include "app/ParticleTypes.hpp" #include "math/GeometryTypes.hpp" +#include "memory/Containers.hpp" +#include "memory/MemoryService.hpp" +#include "memory/Unique.hpp" #include -#include #include -#include +#include +#include namespace ndde { +class SimulationContext; + class AnimatedCurve { public: enum class Role : u8 { Leader, Chaser }; @@ -64,17 +71,19 @@ class AnimatedCurve { u32 colour_slot, const ndde::math::ISurface* surface, ndde::sim::IEquation* equation, - const ndde::sim::IIntegrator* integrator); + const ndde::sim::IIntegrator* integrator, + memory::MemoryService* memory = nullptr); // Factory: construct a particle that OWNS its equation. - // The unique_ptr is moved into m_owned_equation; m_equation is set to + // The owned pointer is moved into m_owned_equation; m_equation is set to // m_owned_equation.get(). All other pointers remain non-owning. static AnimatedCurve with_equation( f32 start_x, f32 start_y, Role role, u32 colour_slot, const ndde::math::ISurface* surface, - std::unique_ptr owned_equation, - const ndde::sim::IIntegrator* integrator); + memory::Unique owned_equation, + const ndde::sim::IIntegrator* integrator, + memory::MemoryService* memory = nullptr); // AnimatedCurve is moveable (unique_ptr members) but not copyable. AnimatedCurve(const AnimatedCurve&) = delete; @@ -96,6 +105,17 @@ class AnimatedCurve { // Note: this is the navigation coordinate -- arrival is detected by // neighbourhood radius, not exact equality. [[nodiscard]] glm::vec2 head_uv() const noexcept { return m_walk.uv; } + [[nodiscard]] ParticleId id() const noexcept { return m_id; } + [[nodiscard]] ParticleRole particle_role() const noexcept { return m_particle_role; } + void set_particle_role(ParticleRole role) noexcept { m_particle_role = role; } + void set_label(std::string label) { m_label = std::move(label); } + void set_trail_config(TrailConfig cfg) noexcept { m_trail_config = cfg; } + [[nodiscard]] const TrailConfig& trail_config() const noexcept { return m_trail_config; } + + void bind_behavior_stack() noexcept; + void set_behavior_context(const SimulationContext* context) noexcept; + [[nodiscard]] ParticleMetadata metadata() const; + [[nodiscard]] std::string metadata_label() const; [[nodiscard]] u32 trail_size() const noexcept { return static_cast(m_trail.size()); } [[nodiscard]] bool has_trail() const noexcept { return m_trail.size() >= 4; } @@ -120,15 +140,35 @@ class AnimatedCurve { [[nodiscard]] ndde::sim::IEquation* equation() noexcept { return m_equation; } [[nodiscard]] const ndde::sim::IEquation* equation() const noexcept { return m_equation; } + template + [[nodiscard]] Equation* find_equation() noexcept { + if (auto* eq = dynamic_cast(m_equation)) + return eq; + if (auto* stack = dynamic_cast(m_equation)) + return stack->find_equation(); + return nullptr; + } + + template + [[nodiscard]] const Equation* find_equation() const noexcept { + if (const auto* eq = dynamic_cast(m_equation)) + return eq; + if (const auto* stack = dynamic_cast(m_equation)) + return stack->find_equation(); + return nullptr; + } + // Add a constraint applied after every integration sub-step. - // AnimatedCurve owns the constraint via unique_ptr. + // AnimatedCurve owns the constraint via memory::Unique. // Constraints are applied in insertion order. - void add_constraint(std::unique_ptr c) { + void add_constraint(memory::Unique c) { m_constraints.push_back(std::move(c)); } + void bind_memory(memory::MemoryService* memory); + // Mutable access to the particle state for pairwise constraint application. - // Called only by SurfaceSimScene::apply_pairwise_constraints(). + // Prefer ParticleSystem for new simulation code. // Do not use from equation or integrator code. [[nodiscard]] ndde::sim::ParticleState& walk_state() noexcept { return m_walk; } @@ -140,18 +180,28 @@ class AnimatedCurve { private: ndde::sim::ParticleState m_walk; - std::vector m_trail; + memory::HistoryVector m_trail; const ndde::math::ISurface* m_surface; // non-owning, never null ndde::sim::IEquation* m_equation; // non-owning OR alias to m_owned_equation - std::unique_ptr m_owned_equation; // null when using shared equation + memory::Unique m_owned_equation; // null when using shared equation const ndde::sim::IIntegrator* m_integrator; // non-owning, never null - std::unique_ptr m_history; // null unless enable_history() called - std::vector> m_constraints; // applied after each sub-step + memory::Unique m_history; // null unless enable_history() called + memory::SimVector> m_constraints; // applied after each sub-step + memory::MemoryService* m_memory = nullptr; Role m_role; u32 m_colour_slot; f32 m_start_x, m_start_y; + ParticleId m_id = next_id(); + ParticleRole m_particle_role = ParticleRole::Neutral; + TrailConfig m_trail_config{}; + std::string m_label; void step(f32 dt, f32 speed_scale); + + [[nodiscard]] static ParticleId next_id() noexcept { + static std::atomic id{0}; + return ++id; + } }; } // namespace ndde diff --git a/nurbs_dde/src/app/ContourWindowRenderer.hpp b/nurbs_dde/src/app/ContourWindowRenderer.hpp new file mode 100644 index 00000000..27cb49fa --- /dev/null +++ b/nurbs_dde/src/app/ContourWindowRenderer.hpp @@ -0,0 +1,84 @@ +#pragma once +// app/ContourWindowRenderer.hpp +// Shared submission path for the second-window contour map. + +#include "app/ParticleSystem.hpp" +#include "app/SurfaceMeshCache.hpp" +#include "engine/EngineAPI.hpp" +#include "engine/RenderSubmission.hpp" +#include "memory/Containers.hpp" + +#include +#include + +namespace ndde { + +struct ContourWindowOptions { + float extent = 4.f; + bool draw_wire = false; + Vec4 wire_color{0.95f, 0.95f, 1.f, 0.32f}; + float trail_alpha_floor = 0.70f; + bool draw_heads = false; + float head_radius = 0.055f; +}; + +inline void submit_contour_window(EngineAPI& api, + const SurfaceMeshCache& mesh, + const ParticleSystem& particles, + const ContourWindowOptions& options) +{ + const Vec2 sz = api.viewport_size2(); + if (sz.x <= 0.f || sz.y <= 0.f || mesh.contour_count() == 0) return; + + const float aspect = sz.x / std::max(sz.y, 1.f); + const Mat4 mvp = aspect >= 1.f + ? glm::ortho(-options.extent * aspect, options.extent * aspect, -options.extent, options.extent, -1.f, 1.f) + : glm::ortho(-options.extent, options.extent, -options.extent / aspect, options.extent / aspect, -1.f, 1.f); + + submit_transformed_vertices(api, RenderTarget::Contour2D, mesh.contour_vertices(), mesh.contour_count(), + Topology::TriangleList, DrawMode::VertexColor, {1,1,1,1}, mvp, + [](Vertex& vertex, u32) { + vertex.pos.z = 0.f; + }); + + if (options.draw_wire && mesh.wire_count() > 0) { + submit_transformed_vertices(api, RenderTarget::Contour2D, mesh.wire_vertices(), mesh.wire_count(), + Topology::LineList, DrawMode::VertexColor, {1,1,1,1}, mvp, + [&options](Vertex& vertex, u32) { + vertex.pos.z = 0.f; + vertex.color = options.wire_color; + }); + } + + for (const Particle& particle : particles.particles()) { + const u32 n = particle.trail_vertex_count(); + if (n < 2) continue; + + memory::FrameVector trail_vertices(n); + particle.tessellate_trail({trail_vertices.data(), n}); + submit_transformed_vertices(api, RenderTarget::Contour2D, trail_vertices, n, + Topology::LineStrip, DrawMode::VertexColor, {1,1,1,1}, mvp, + [&options](Vertex& vertex, u32) { + vertex.pos.z = 0.f; + vertex.color.a = std::max(vertex.color.a, options.trail_alpha_floor); + }); + + if (options.draw_heads) { + const glm::vec2 uv = particle.head_uv(); + const Vec4 col = particle.head_colour(); + const float r = options.head_radius; + submit_generated_vertices(api, RenderTarget::Contour2D, 4u, + Topology::LineList, DrawMode::VertexColor, {1,1,1,1}, mvp, + [uv, col, r](Vertex& out, u32 i) { + switch (i) { + case 0: out = {{uv.x - r, uv.y, 0.f}, col}; break; + case 1: out = {{uv.x + r, uv.y, 0.f}, col}; break; + case 2: out = {{uv.x, uv.y - r, 0.f}, col}; break; + default: out = {{uv.x, uv.y + r, 0.f}, col}; break; + } + }); + } + } +} + +} // namespace ndde diff --git a/nurbs_dde/src/app/CoordDebugPanel.hpp b/nurbs_dde/src/app/CoordDebugPanel.hpp index c543907c..7a0e7545 100644 --- a/nurbs_dde/src/app/CoordDebugPanel.hpp +++ b/nurbs_dde/src/app/CoordDebugPanel.hpp @@ -7,10 +7,11 @@ #include "app/Viewport.hpp" #include "app/HoverResult.hpp" #include "math/Scalars.hpp" +#include "memory/Containers.hpp" +#include "numeric/ops.hpp" #include #include -#include #include #include @@ -74,7 +75,7 @@ class CoordDebugPanel { std::string snap_curve_name; // Curve snap cache sizes - std::vector> curve_cache_sizes; + memory::PersistentVector> curve_cache_sizes; int frame = 0; }; @@ -295,7 +296,7 @@ class CoordDebugPanel { } static void row_err(const char* label, float a, float b) { - const float mag = std::sqrt(a*a + b*b); + const float mag = ops::sqrt(a*a + b*b); const ImVec4 col = (mag < 0.5f) ? ImVec4(0.5f, 1.f, 0.5f, 1.f) : (mag < 2.f) diff --git a/nurbs_dde/src/app/FrenetFrame.hpp b/nurbs_dde/src/app/FrenetFrame.hpp index 5e5488c7..5266b5ad 100644 --- a/nurbs_dde/src/app/FrenetFrame.hpp +++ b/nurbs_dde/src/app/FrenetFrame.hpp @@ -3,7 +3,7 @@ // FrenetFrame, SurfaceFrame, and make_surface_frame. // // Moved from GaussianSurface.hpp (B1 refactor) so that AnimatedCurve.hpp -// and ParticleRenderer.hpp can include these types without dragging in the +// and particle overlay helpers can include these types without dragging in the // full GaussianSurface definition. // // Mathematical context diff --git a/nurbs_dde/src/app/GaussianRipple.hpp b/nurbs_dde/src/app/GaussianRipple.hpp index e6b6773b..c3650e0a 100644 --- a/nurbs_dde/src/app/GaussianRipple.hpp +++ b/nurbs_dde/src/app/GaussianRipple.hpp @@ -33,7 +33,7 @@ // the leading and trailing edges of each ring. // // Usage: -// auto* ripple = new GaussianRipple(); +// GaussianRipple ripple; // ripple->set_epicentre(u0, v0); // place impact at (u0,v0) // // each frame: // ripple->advance(dt); // tick internal clock @@ -41,6 +41,7 @@ #include "math/Surfaces.hpp" // IDeformableSurface #include "app/GaussianSurface.hpp" // eval_static, XMIN/XMAX/YMIN/YMAX +#include "numeric/ops.hpp" #include namespace ndde { @@ -69,21 +70,40 @@ class GaussianRipple final : public ndde::math::IDeformableSurface { // Non-periodic: particles reflect at the domain boundary. [[nodiscard]] bool is_periodic_u() const override { return false; } [[nodiscard]] bool is_periodic_v() const override { return false; } + [[nodiscard]] ndde::math::SurfaceMetadata metadata(float t = 0.f) const override { + ndde::math::SurfaceMetadata data = ndde::math::IDeformableSurface::metadata(t); + data.name = "Gaussian Ripple"; + data.formula = "base Gaussian field + decaying radial perturbation"; + data.has_analytic_derivatives = false; + data.parameters = {{ + {.name = "amplitude", .value = m_p.amplitude, .description = "wave height"}, + {.name = "damping", .value = m_p.damping, .description = "exponential decay rate"}, + {.name = "wavelength", .value = m_p.wavelength, .description = "spatial period"}, + {.name = "speed", .value = m_p.speed, .description = "wave propagation speed"}, + {.name = "sigma", .value = m_p.sigma, .description = "Gaussian envelope width"}, + {.name = "epicentre u", .value = m_p.epicentre_u, .description = "impact u coordinate"}, + {.name = "epicentre v", .value = m_p.epicentre_v, .description = "impact v coordinate"} + }}; + data.parameter_count = 7u; + return data; + } // ── Geometry ────────────────────────────────────────────────────────────── [[nodiscard]] Vec3 evaluate(float u, float v, float t = 0.f) const override { - return Vec3{ u, v, height(u, v, t) }; + return Vec3{ u, v, height(u, v, effective_time(t)) }; } // Central FD on height -- exact same structure as GaussianSurface::du/dv [[nodiscard]] Vec3 du(float u, float v, float t = 0.f) const override { constexpr float h = 1e-3f; - return Vec3{ 1.f, 0.f, (height(u+h,v,t) - height(u-h,v,t)) / (2.f*h) }; + const float te = effective_time(t); + return Vec3{ 1.f, 0.f, (height(u+h,v,te) - height(u-h,v,te)) / (2.f*h) }; } [[nodiscard]] Vec3 dv(float u, float v, float t = 0.f) const override { constexpr float h = 1e-3f; - return Vec3{ 0.f, 1.f, (height(u,v+h,t) - height(u,v-h,t)) / (2.f*h) }; + const float te = effective_time(t); + return Vec3{ 0.f, 1.f, (height(u,v+h,te) - height(u,v-h,te)) / (2.f*h) }; } // ── Deformation control ─────────────────────────────────────────────────── @@ -104,20 +124,24 @@ class GaussianRipple final : public ndde::math::IDeformableSurface { private: Params m_p; + [[nodiscard]] float effective_time(float t) const noexcept { + return t > 0.f ? t : m_time; + } + // f_base(u,v) + f_ripple(u,v,t) [[nodiscard]] float height(float u, float v, float t) const noexcept { const float z_base = GaussianSurface::eval_static(u, v); const float du_ = u - m_p.epicentre_u; const float dv_ = v - m_p.epicentre_v; - const float r = std::sqrt(du_*du_ + dv_*dv_); + const float r = ops::sqrt(du_*du_ + dv_*dv_); // Decaying radial wave with Gaussian spatial envelope const float wave = m_p.amplitude - * std::exp(-m_p.damping * t) - * std::sin(2.f * 3.14159265f * r / m_p.wavelength - m_p.speed * t) - * std::exp(-0.5f * r * r / (m_p.sigma * m_p.sigma)); + * ops::exp(-m_p.damping * t) + * ops::sin(ops::two_pi_v * r / m_p.wavelength - m_p.speed * t) + * ops::exp(-0.5f * r * r / (m_p.sigma * m_p.sigma)); return z_base + wave; } diff --git a/nurbs_dde/src/app/GaussianSurface.cpp b/nurbs_dde/src/app/GaussianSurface.cpp index d1f3f16d..582b6aff 100644 --- a/nurbs_dde/src/app/GaussianSurface.cpp +++ b/nurbs_dde/src/app/GaussianSurface.cpp @@ -2,10 +2,12 @@ #include "app/GaussianSurface.hpp" #include "app/AnimatedCurve.hpp" #include "app/FrenetFrame.hpp" +#include "app/ParticleBehaviors.hpp" #include "sim/IEquation.hpp" #include "sim/IIntegrator.hpp" #include "sim/HistoryBuffer.hpp" #include "sim/DomainConfinement.hpp" +#include "numeric/ops.hpp" #include #include #include @@ -19,14 +21,14 @@ namespace ndde { // The static alias eval() in the header forwards here. f32 GaussianSurface::eval_static(f32 x, f32 y) noexcept { - const f32 g0 = 1.6f * std::exp(-((x-1.5f)*(x-1.5f)/0.6f + (y-0.4f)*(y-0.4f)/0.9f)); - const f32 g1 = -1.3f * std::exp(-((x+1.3f)*(x+1.3f)/0.8f + (y+1.1f)*(y+1.1f)/0.5f)); - const f32 g2 = 1.1f * std::exp(-((x+0.2f)*(x+0.2f)/1.2f + (y-2.0f)*(y-2.0f)/0.4f)); - const f32 g3 = -0.8f * std::exp(-((x-2.5f)*(x-2.5f)/0.5f + (y+0.7f)*(y+0.7f)/1.0f)); - const f32 g4 = 0.7f * std::exp(-((x-0.8f)*(x-0.8f)/0.7f + (y+2.3f)*(y+2.3f)/0.6f)); - const f32 g5 = -0.5f * std::exp(-((x+2.8f)*(x+2.8f)/0.3f + (y-1.4f)*(y-1.4f)/0.8f)); - const f32 s0 = 0.15f * std::sin(2.0f*x) * std::sin(3.0f*y); - const f32 s1 = 0.12f * std::cos(1.5f*x - 1.0f) * std::sin(2.5f*y + 0.7f); + const f32 g0 = 1.6f * ops::exp(-((x-1.5f)*(x-1.5f)/0.6f + (y-0.4f)*(y-0.4f)/0.9f)); + const f32 g1 = -1.3f * ops::exp(-((x+1.3f)*(x+1.3f)/0.8f + (y+1.1f)*(y+1.1f)/0.5f)); + const f32 g2 = 1.1f * ops::exp(-((x+0.2f)*(x+0.2f)/1.2f + (y-2.0f)*(y-2.0f)/0.4f)); + const f32 g3 = -0.8f * ops::exp(-((x-2.5f)*(x-2.5f)/0.5f + (y+0.7f)*(y+0.7f)/1.0f)); + const f32 g4 = 0.7f * ops::exp(-((x-0.8f)*(x-0.8f)/0.7f + (y+2.3f)*(y+2.3f)/0.6f)); + const f32 g5 = -0.5f * ops::exp(-((x+2.8f)*(x+2.8f)/0.3f + (y-1.4f)*(y-1.4f)/0.8f)); + const f32 s0 = 0.15f * ops::sin(2.0f*x) * ops::sin(3.0f*y); + const f32 s1 = 0.12f * ops::cos(1.5f*x - 1.0f) * ops::sin(2.5f*y + 0.7f); return g0 + g1 + g2 + g3 + g4 + g5 + s0 + s1; } @@ -60,7 +62,7 @@ Vec3 GaussianSurface::dv(float u, float v, float /*t*/) const { Vec3 GaussianSurface::unit_normal(f32 x, f32 y) noexcept { const auto [fx, fy] = grad(x, y); - const f32 len = std::sqrt(fx*fx + fy*fy + 1.f); + const f32 len = ops::sqrt(fx*fx + fy*fy + 1.f); return { -fx/len, -fy/len, 1.f/len }; } @@ -73,10 +75,10 @@ f32 GaussianSurface::gaussian_curvature(f32 x, f32 y) noexcept { const f32 fyy = (eval(x,y+h) - 2.f*eval(x,y) + eval(x,y-h)) / (h*h); const f32 fxy = (eval(x+h,y+h) - eval(x+h,y-h) - eval(x-h,y+h) + eval(x-h,y-h)) / (4.f*h*h); const f32 E = 1.f+fx*fx, F = fx*fy, G = 1.f+fy*fy; - const f32 n = std::sqrt(1.f + fx*fx + fy*fy); + const f32 n = ops::sqrt(1.f + fx*fx + fy*fy); const f32 L = fxx/n, M = fxy/n, N2 = fyy/n; const f32 denom = E*G - F*F; - return std::abs(denom) < 1e-10f ? 0.f : (L*N2 - M*M) / denom; + return ops::abs(denom) < 1e-10f ? 0.f : (L*N2 - M*M) / denom; } // == GaussianSurface::mean_curvature ========================================== @@ -88,10 +90,10 @@ f32 GaussianSurface::mean_curvature(f32 x, f32 y) noexcept { const f32 fyy = (eval(x,y+h) - 2.f*eval(x,y) + eval(x,y-h)) / (h*h); const f32 fxy = (eval(x+h,y+h) - eval(x+h,y-h) - eval(x-h,y+h) + eval(x-h,y-h)) / (4.f*h*h); const f32 E = 1.f+fx*fx, F = fx*fy, G = 1.f+fy*fy; - const f32 n = std::sqrt(1.f + fx*fx + fy*fy); + const f32 n = ops::sqrt(1.f + fx*fx + fy*fy); const f32 L = fxx/n, M = fxy/n, N2 = fyy/n; const f32 denom = 2.f*(E*G - F*F); - return std::abs(denom) < 1e-10f ? 0.f : (E*N2 + G*L - 2.f*F*M) / denom; + return ops::abs(denom) < 1e-10f ? 0.f : (E*N2 + G*L - 2.f*F*M) / denom; } // == GaussianSurface::height_color ============================================ @@ -219,18 +221,50 @@ AnimatedCurve::AnimatedCurve(f32 start_x, f32 start_y, Role role, u32 colour_slot, const ndde::math::ISurface* surface, ndde::sim::IEquation* equation, - const ndde::sim::IIntegrator* integrator) + const ndde::sim::IIntegrator* integrator, + memory::MemoryService* mem) : m_surface(surface) , m_equation(equation) , m_owned_equation(nullptr) // shared equation -- ownership is external , m_integrator(integrator) + , m_constraints(mem ? mem->simulation().resource() : std::pmr::get_default_resource()) + , m_memory(mem) , m_role(role) , m_colour_slot(colour_slot % MAX_SLOTS) , m_start_x(start_x) , m_start_y(start_y) { m_walk = ndde::sim::ParticleState{ glm::vec2{start_x, start_y}, 0.f, 0.f }; - m_constraints.push_back(std::make_unique()); + m_particle_role = role == Role::Leader ? ParticleRole::Leader : ParticleRole::Chaser; + m_constraints.push_back(memory::make_unique( + m_constraints.get_allocator().resource())); +} + +void AnimatedCurve::bind_memory(memory::MemoryService* memory) { + m_memory = memory; + const std::pmr::memory_resource* trail_resource = m_trail.get_allocator().resource(); + std::pmr::memory_resource* desired_trail = memory ? memory->history().resource() + : std::pmr::get_default_resource(); + if (trail_resource != desired_trail) { + memory::HistoryVector rebound{desired_trail}; + rebound.reserve(m_trail.size()); + for (Vec3& point : m_trail) + rebound.push_back(point); + std::destroy_at(&m_trail); + std::construct_at(&m_trail, std::move(rebound)); + } + + const std::pmr::memory_resource* constraint_resource = m_constraints.get_allocator().resource(); + std::pmr::memory_resource* desired_constraints = memory ? memory->simulation().resource() + : std::pmr::get_default_resource(); + if (constraint_resource != desired_constraints) { + memory::SimVector> rebound{desired_constraints}; + rebound.reserve(m_constraints.size()); + for (auto& constraint : m_constraints) + rebound.push_back(std::move(constraint)); + std::destroy_at(&m_constraints); + std::construct_at(&m_constraints, std::move(rebound)); + } } // static factory: particle owns its equation @@ -239,14 +273,14 @@ AnimatedCurve AnimatedCurve::with_equation( f32 start_x, f32 start_y, Role role, u32 colour_slot, const ndde::math::ISurface* surface, - std::unique_ptr owned_equation, - const ndde::sim::IIntegrator* integrator) + memory::Unique owned_equation, + const ndde::sim::IIntegrator* integrator, + memory::MemoryService* mem) { AnimatedCurve c(start_x, start_y, role, colour_slot, - surface, owned_equation.get(), integrator); + surface, owned_equation.get(), integrator, mem); c.m_owned_equation = std::move(owned_equation); // m_equation already points to m_owned_equation.get() via the constructor - c.m_constraints.push_back(std::make_unique()); return c; } @@ -275,17 +309,62 @@ void AnimatedCurve::step(f32 dt, f32 speed_scale) { c->apply(m_walk, *m_surface); const Vec3 pt = m_surface->evaluate(m_walk.uv.x, m_walk.uv.y); - if (m_trail.empty() || glm::length(pt - m_trail.back()) > 0.015f) { + if (m_trail_config.mode != TrailMode::None && + (m_trail.empty() || glm::length(pt - m_trail.back()) > m_trail_config.min_spacing)) { m_trail.push_back(pt); - if (m_trail.size() > MAX_TRAIL) + const std::size_t max_points = m_trail_config.max_points > 0 + ? static_cast(m_trail_config.max_points) + : static_cast(MAX_TRAIL); + if (m_trail_config.mode == TrailMode::Finite && m_trail.size() > max_points) m_trail.erase(m_trail.begin()); } } +void AnimatedCurve::bind_behavior_stack() noexcept { + if (auto* stack = dynamic_cast(m_equation)) + stack->set_owner(m_id); +} + +void AnimatedCurve::set_behavior_context(const SimulationContext* context) noexcept { + if (auto* stack = dynamic_cast(m_equation)) + stack->set_context(context); +} + +ParticleMetadata AnimatedCurve::metadata() const { + ParticleMetadata md; + md.role = std::string(role_name(m_particle_role)); + if (auto* stack = dynamic_cast(m_equation)) { + md.behaviors = stack->behavior_labels(); + } else if (m_equation) { + md.behaviors.push_back(m_equation->name()); + } + md.constraints.reserve(m_constraints.size()); + for (const auto& constraint : m_constraints) { + if (constraint) md.constraints.push_back(constraint->name()); + } + md.label = metadata_label(); + return md; +} + +std::string AnimatedCurve::metadata_label() const { + std::string out = m_label.empty() ? std::string(role_name(m_particle_role)) : m_label; + if (auto* stack = dynamic_cast(m_equation)) { + for (const std::string& label : stack->behavior_labels()) + out += " - " + label; + } else if (m_equation) { + out += " - " + m_equation->name(); + } + return out; +} + // == AnimatedCurve::history methods ========================================== void AnimatedCurve::enable_history(std::size_t capacity, float dt_min) { - m_history = std::make_unique(capacity, dt_min); + std::pmr::memory_resource* owner_resource = m_memory ? m_memory->history().resource() + : m_trail.get_allocator().resource(); + m_history = memory::make_unique( + owner_resource, + capacity, dt_min, owner_resource); } void AnimatedCurve::push_history(float t) { @@ -413,7 +492,7 @@ FrenetFrame AnimatedCurve::frenet_at(u32 idx) const noexcept { const Vec3 B01 = B01_raw / b01l; const Vec3 B12 = B12_raw / b12l; const f32 cos_a = std::clamp(glm::dot(B01, B12), -1.f, 1.f); - const f32 alpha = std::acos(cos_a); + const f32 alpha = ops::acos(cos_a); const f32 s = glm::dot(glm::cross(B01, B12), T1); const f32 sign = (s >= 0.f) ? 1.f : -1.f; const f32 ds = 0.5f * (la + l1); diff --git a/nurbs_dde/src/app/GaussianSurface.hpp b/nurbs_dde/src/app/GaussianSurface.hpp index 6dae74f2..ee9d1a7a 100644 --- a/nurbs_dde/src/app/GaussianSurface.hpp +++ b/nurbs_dde/src/app/GaussianSurface.hpp @@ -4,9 +4,7 @@ // Implements ndde::math::ISurface -- p(u,v) = (u, v, f(u,v)). // // Static helpers (grad, unit_normal, curvature, tessellate_*, height_color) -// remain for the graph-surface-specific rendering in SurfaceSimScene -// (heatmap, contour lines). They are guarded by dynamic_cast in Step 3. -// They will be progressively removed as the rendering layer is refactored. +// remain for graph-surface rendering helpers such as heatmaps and contours. // // FrenetFrame, SurfaceFrame, make_surface_frame, AnimatedCurve are defined // here temporarily. They will move to their own headers in later steps. @@ -17,7 +15,6 @@ #include "app/FrenetFrame.hpp" // FrenetFrame, SurfaceFrame, make_surface_frame #include "app/AnimatedCurve.hpp" // AnimatedCurve #include -#include #include namespace ndde { @@ -50,6 +47,14 @@ class GaussianSurface : public ndde::math::ISurface { [[nodiscard]] float u_max(float = 0.f) const override { return XMAX; } [[nodiscard]] float v_min(float = 0.f) const override { return YMIN; } [[nodiscard]] float v_max(float = 0.f) const override { return YMAX; } + [[nodiscard]] ndde::math::SurfaceMetadata metadata(float t = 0.f) const override { + ndde::math::SurfaceMetadata data = ndde::math::ISurface::metadata(t); + data.name = "Gaussian Surface"; + data.formula = "six Gaussian height field + sinusoidal ripple texture"; + data.has_analytic_derivatives = true; + return data; + } + [[nodiscard]] float extent() const noexcept { return XMAX; } // ── Static helpers (unchanged -- existing call sites keep working) ───────── // eval_static() is the renamed form of the old eval(). diff --git a/nurbs_dde/src/app/GoalStatusPanel.hpp b/nurbs_dde/src/app/GoalStatusPanel.hpp new file mode 100644 index 00000000..1c224ffb --- /dev/null +++ b/nurbs_dde/src/app/GoalStatusPanel.hpp @@ -0,0 +1,26 @@ +#pragma once +// app/GoalStatusPanel.hpp +// Reusable display for simulation goal/win-condition state. + +#include "app/SimulationPanelModels.hpp" + +#include + +namespace ndde { + +class GoalStatusPanel { +public: + static void draw(const SimulationMetadata& metadata) { + ImGui::SeparatorText("Goal"); + if (metadata.goal_succeeded) { + ImGui::TextColored({0.4f, 1.f, 0.4f, 1.f}, "Succeeded"); + } else { + ImGui::TextDisabled("Running"); + } + ImGui::TextDisabled("status %s", metadata.status.c_str()); + ImGui::TextDisabled("particles %llu", + static_cast(metadata.particle_count)); + } +}; + +} // namespace ndde diff --git a/nurbs_dde/src/app/HotkeyManager.hpp b/nurbs_dde/src/app/HotkeyManager.hpp index b099ba5f..128ae94d 100644 --- a/nurbs_dde/src/app/HotkeyManager.hpp +++ b/nurbs_dde/src/app/HotkeyManager.hpp @@ -27,8 +27,9 @@ // // Dispatch // ──────── -// Call dispatch() once per frame, after ImGui::NewFrame() and before -// rendering. It reads ImGuiIO and fires callbacks on rising edges. +// Prefer handle_key_event() from the engine/GLFW key callback for snappy, +// guaranteed delivery. dispatch() is still available for pure ImGui polling +// contexts. // // If ImGui has captured keyboard input (io.WantCaptureKeyboard) dispatch() // returns immediately and fires nothing — text fields get priority. @@ -70,9 +71,11 @@ // hk.draw_panel("Hotkeys [Ctrl+H]", m_hotkey_panel_open); #include +#define GLFW_INCLUDE_NONE +#include +#include "memory/Containers.hpp" #include #include -#include #include #include @@ -220,6 +223,28 @@ class HotkeyManager { } } + // Handle a GLFW-style key callback event. Returns true when a registered + // hotkey consumed the event. Repeat/release events are ignored. + bool handle_key_event(int key, int action, int mods) { + if (action != GLFW_PRESS) return false; + + const ImGuiIO& io = ImGui::GetIO(); + if (io.WantTextInput) return false; + + const ImGuiKey imgui_key = glfw_key_to_imgui(key); + if (imgui_key == ImGuiKey_None) return false; + + const uint8_t chord_mods = mods_from_glfw(mods); + for (auto& e : m_entries) { + if (e.chord.key == imgui_key && e.chord.mods == chord_mods) { + e.callback(); + e.prev = true; + return true; + } + } + return false; + } + // ── Hotkey reference panel ───────────────────────────────────────────────── // Render a floating ImGui window listing all registered hotkeys. @@ -242,7 +267,7 @@ class HotkeyManager { } // Collect group order (insertion order of first appearance). - std::vector group_order; + memory::FrameVector group_order; for (const auto& e : m_entries) { const bool seen = std::any_of(group_order.begin(), group_order.end(), [&](const std::string& g){ return g == e.group; }); @@ -285,8 +310,29 @@ class HotkeyManager { bool prev = false; ///< previous-frame key state (edge detection) }; - std::vector m_entries; + memory::SimVector m_entries; HotkeyID m_next_id = 0u; + + [[nodiscard]] static uint8_t mods_from_glfw(int mods) noexcept { + uint8_t out = Chord::None; + if ((mods & GLFW_MOD_CONTROL) != 0) out |= Chord::Ctrl; + if ((mods & GLFW_MOD_SHIFT) != 0) out |= Chord::Shift; + return out; + } + + [[nodiscard]] static ImGuiKey glfw_key_to_imgui(int key) noexcept { + if (key >= GLFW_KEY_A && key <= GLFW_KEY_Z) + return static_cast(ImGuiKey_A + (key - GLFW_KEY_A)); + if (key >= GLFW_KEY_F1 && key <= GLFW_KEY_F12) + return static_cast(ImGuiKey_F1 + (key - GLFW_KEY_F1)); + switch (key) { + case GLFW_KEY_SPACE: return ImGuiKey_Space; + case GLFW_KEY_ENTER: return ImGuiKey_Enter; + case GLFW_KEY_TAB: return ImGuiKey_Tab; + case GLFW_KEY_ESCAPE: return ImGuiKey_Escape; + default: return ImGuiKey_None; + } + } }; } // namespace ndde diff --git a/nurbs_dde/src/app/HoverResult.hpp b/nurbs_dde/src/app/HoverResult.hpp index 33567ebb..cd2978b7 100644 --- a/nurbs_dde/src/app/HoverResult.hpp +++ b/nurbs_dde/src/app/HoverResult.hpp @@ -1,7 +1,7 @@ #pragma once // app/HoverResult.hpp // Data produced each frame by curve-hover hit testing. -// Consumed by AnalysisPanel::draw() and all geometry submission in Scene. +// Shared hover/snap payload for geometry debug panels and render overlays. namespace ndde { diff --git a/nurbs_dde/src/app/MultiWellSpawner.cpp b/nurbs_dde/src/app/MultiWellSpawner.cpp new file mode 100644 index 00000000..3786e8e0 --- /dev/null +++ b/nurbs_dde/src/app/MultiWellSpawner.cpp @@ -0,0 +1,121 @@ +#include "app/MultiWellSpawner.hpp" + +#include "app/ParticleBehaviors.hpp" +#include "memory/Containers.hpp" +#include "numeric/ops.hpp" + +#include + +namespace ndde { + +namespace { +SwarmBuildResult make_multiwell_result(std::string name, u32 count, + memory::FrameVector roles, + bool goal = false) { + return SwarmBuildResult{.metadata = SwarmRecipeMetadata{ + .family_name = std::move(name), + .requested_count = count, + .roles_emitted = std::move(roles), + .goals_added = goal + }}; +} +} + +MultiWellSpawner::MultiWellSpawner(ParticleSystem& particles, + u32& spawn_count, + float& sim_time, + GoalStatus& goal_status) noexcept + : m_particles(particles) + , m_spawn_count(spawn_count) + , m_sim_time(sim_time) + , m_goal_status(goal_status) +{} + +SwarmBuildResult MultiWellSpawner::clear_all() noexcept { + m_particles.clear(); + m_particles.clear_goals(); + m_spawn_count = 0; + m_goal_status = GoalStatus::Running; + return make_multiwell_result("Multi-Well Clear", 0u, {}); +} + +SwarmBuildResult MultiWellSpawner::spawn_showcase_service() { + (void)clear_all(); + m_sim_time = 0.f; + + spawn_avoider_at({-2.15f, -1.15f}, {0.18f, 0.05f}); + spawn_avoider_at({ 1.55f, 1.35f}, {-0.12f, 0.10f}); + + for (int i = 0; i < 90; ++i) { + m_sim_time += 1.f / 60.f; + m_particles.update(1.f / 60.f, 1.f, m_sim_time); + } + + spawn_centroid_seeker_at({-0.20f, 2.55f}); + m_particles.add_goal(CaptureGoal::Params{ + .seeker_role = ParticleRole::Chaser, + .target_role = ParticleRole::Avoider, + .radius = 0.20f + }); + m_goal_status = GoalStatus::Running; + return make_multiwell_result("Multi-Well Avoider/Centroid Showcase", + m_spawn_count, {ParticleRole::Avoider, ParticleRole::Chaser}, true); +} + +SwarmBuildResult MultiWellSpawner::spawn_avoider() { + const float a = static_cast(m_spawn_count) * 1.73f; + const float r = 1.3f + 0.25f * static_cast(m_spawn_count % 3u); + spawn_avoider_at({r * ops::cos(a), r * ops::sin(a)}, + {0.10f * ops::cos(a + 1.f), 0.10f * ops::sin(a + 1.f)}); + return make_multiwell_result("Multi-Well Avoider", 1u, {ParticleRole::Avoider}); +} + +void MultiWellSpawner::spawn_avoider_at(glm::vec2 uv, glm::vec2 drift) { + AvoidParticleBehavior::Params avoid; + avoid.target = TargetSelector::nearest(ParticleRole::Chaser); + avoid.speed = 0.55f; + avoid.delay_seconds = 0.35f; + + ParticleBuilder builder = m_particles.factory().particle(); + builder + .named("Avoider - Delayed Avoid - Brownian") + .role(ParticleRole::Avoider) + .at(uv) + .trail({TrailMode::Finite, AnimatedCurve::MAX_TRAIL, 0.012f}) + .stochastic() + .with_behavior(avoid) + .with_behavior(0.35f, drift) + .with_behavior(0.35f, BrownianBehavior::Params{ + .sigma = 0.10f, + .drift_strength = 0.04f + }); + m_particles.spawn(std::move(builder)); + ++m_spawn_count; +} + +SwarmBuildResult MultiWellSpawner::spawn_centroid_seeker() { + spawn_centroid_seeker_at({0.f, 0.f}); + return make_multiwell_result("Multi-Well Centroid Seeker", 1u, {ParticleRole::Chaser}); +} + +void MultiWellSpawner::spawn_centroid_seeker_at(glm::vec2 uv) { + ParticleBuilder builder = m_particles.factory().particle(); + builder + .named("Chaser - Centroid Seek - Brownian") + .role(ParticleRole::Chaser) + .at(uv) + .trail({TrailMode::Finite, AnimatedCurve::MAX_TRAIL, 0.012f}) + .stochastic() + .with_behavior(CentroidSeekBehavior::Params{ + .role = ParticleRole::Avoider, + .speed = 0.78f + }) + .with_behavior(0.18f, BrownianBehavior::Params{ + .sigma = 0.055f, + .drift_strength = 0.f + }); + m_particles.spawn(std::move(builder)); + ++m_spawn_count; +} + +} // namespace ndde diff --git a/nurbs_dde/src/app/MultiWellSpawner.hpp b/nurbs_dde/src/app/MultiWellSpawner.hpp new file mode 100644 index 00000000..67432c1a --- /dev/null +++ b/nurbs_dde/src/app/MultiWellSpawner.hpp @@ -0,0 +1,30 @@ +#pragma once + +#include "app/ParticleGoals.hpp" +#include "app/ParticleSwarmFactory.hpp" +#include "app/ParticleSystem.hpp" + +namespace ndde { + +class MultiWellSpawner { +public: + MultiWellSpawner(ParticleSystem& particles, + u32& spawn_count, + float& sim_time, + GoalStatus& goal_status) noexcept; + + [[nodiscard]] SwarmBuildResult spawn_showcase_service(); + [[nodiscard]] SwarmBuildResult spawn_avoider(); + [[nodiscard]] SwarmBuildResult spawn_centroid_seeker(); + void spawn_avoider_at(glm::vec2 uv, glm::vec2 drift); + void spawn_centroid_seeker_at(glm::vec2 uv); + [[nodiscard]] SwarmBuildResult clear_all() noexcept; + +private: + ParticleSystem& m_particles; + u32& m_spawn_count; + float& m_sim_time; + GoalStatus& m_goal_status; +}; + +} // namespace ndde diff --git a/nurbs_dde/src/app/PanelHost.hpp b/nurbs_dde/src/app/PanelHost.hpp new file mode 100644 index 00000000..f68be119 --- /dev/null +++ b/nurbs_dde/src/app/PanelHost.hpp @@ -0,0 +1,66 @@ +#pragma once +// app/PanelHost.hpp +// Small ImGui panel helper for scene-specific panels. + +#include "memory/Containers.hpp" + +#include +#include +#include +#include +#include + +namespace ndde { + +struct PanelSpec { + std::string title; + ImVec2 default_pos{20.f, 20.f}; + ImVec2 default_size{300.f, 220.f}; + float bg_alpha = 0.88f; + bool* visible = nullptr; + std::function draw_body; +}; + +class PanelHost { +public: + void clear() { m_panels.clear(); } + + void add(PanelSpec spec) { + m_panels.push_back(std::move(spec)); + } + + void draw_all() { + for (PanelSpec& panel : m_panels) + draw(panel); + } + + void draw_visibility_menu(std::string_view title = "Panels") { + if (!ImGui::BeginMenu(title.data())) return; + for (PanelSpec& panel : m_panels) { + if (!panel.visible) continue; + ImGui::MenuItem(panel.title.c_str(), nullptr, panel.visible); + } + ImGui::EndMenu(); + } + +private: + memory::ViewVector m_panels; + + static void draw(PanelSpec& panel) { + if (panel.visible && !*panel.visible) return; + ImGui::SetNextWindowPos(panel.default_pos, ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(panel.default_size, ImGuiCond_FirstUseEver); + ImGui::SetNextWindowBgAlpha(panel.bg_alpha); + bool open = panel.visible ? *panel.visible : true; + if (!ImGui::Begin(panel.title.c_str(), panel.visible ? &open : nullptr)) { + if (panel.visible) *panel.visible = open; + ImGui::End(); + return; + } + if (panel.visible) *panel.visible = open; + if (panel.draw_body) panel.draw_body(); + ImGui::End(); + } +}; + +} // namespace ndde diff --git a/nurbs_dde/src/app/ParticleBehaviors.hpp b/nurbs_dde/src/app/ParticleBehaviors.hpp new file mode 100644 index 00000000..a77ab916 --- /dev/null +++ b/nurbs_dde/src/app/ParticleBehaviors.hpp @@ -0,0 +1,436 @@ +#pragma once +// app/ParticleBehaviors.hpp +// Composable behavior stack for particle dynamics. + +#include "app/SimulationContext.hpp" +#include "memory/Containers.hpp" +#include "memory/MemoryService.hpp" +#include "memory/Unique.hpp" +#include "sim/IEquation.hpp" +#include "numeric/ops.hpp" +#include +#include +#include + +namespace ndde { + +struct TargetSelector { + enum class Kind : u8 { ById, FirstRole, NearestRole, CentroidRole }; + + Kind kind = Kind::FirstRole; + ParticleId id = 0; + ParticleRole role = ParticleRole::Leader; + + [[nodiscard]] static TargetSelector by_id(ParticleId id) noexcept { + TargetSelector s; s.kind = Kind::ById; s.id = id; return s; + } + [[nodiscard]] static TargetSelector first(ParticleRole role) noexcept { + TargetSelector s; s.kind = Kind::FirstRole; s.role = role; return s; + } + [[nodiscard]] static TargetSelector nearest(ParticleRole role) noexcept { + TargetSelector s; s.kind = Kind::NearestRole; s.role = role; return s; + } + [[nodiscard]] static TargetSelector centroid(ParticleRole role) noexcept { + TargetSelector s; s.kind = Kind::CentroidRole; s.role = role; return s; + } +}; + +class IParticleBehavior { +public: + virtual ~IParticleBehavior() = default; + + [[nodiscard]] virtual glm::vec2 velocity( + ndde::sim::ParticleState& state, + const ndde::math::ISurface& surface, + float t, + const SimulationContext& context, + ParticleId owner) const = 0; + + [[nodiscard]] virtual glm::vec2 noise_coefficient( + const ndde::sim::ParticleState& /*state*/, + const ndde::math::ISurface& /*surface*/, + float /*t*/, + const SimulationContext& /*context*/, + ParticleId /*owner*/) const + { + return {0.f, 0.f}; + } + + [[nodiscard]] virtual float phase_rate() const { return 0.f; } + [[nodiscard]] virtual std::string metadata_label() const = 0; +}; + +class EquationBehavior final : public IParticleBehavior { +public: + explicit EquationBehavior(memory::Unique equation) + : m_equation(std::move(equation)) + {} + + [[nodiscard]] glm::vec2 velocity(ndde::sim::ParticleState& state, + const ndde::math::ISurface& surface, + float t, + const SimulationContext&, + ParticleId) const override { + return m_equation ? m_equation->update(state, surface, t) : glm::vec2{0.f, 0.f}; + } + + [[nodiscard]] glm::vec2 noise_coefficient(const ndde::sim::ParticleState& state, + const ndde::math::ISurface& surface, + float t, + const SimulationContext&, + ParticleId) const override { + return m_equation ? m_equation->noise_coefficient(state, surface, t) : glm::vec2{0.f, 0.f}; + } + + [[nodiscard]] float phase_rate() const override { + return m_equation ? m_equation->phase_rate() : 0.f; + } + + [[nodiscard]] std::string metadata_label() const override { + return m_equation ? m_equation->name() : "Equation"; + } + + [[nodiscard]] ndde::sim::IEquation* equation() noexcept { return m_equation.get(); } + [[nodiscard]] const ndde::sim::IEquation* equation() const noexcept { return m_equation.get(); } + +private: + memory::Unique m_equation; +}; + +class BrownianBehavior final : public IParticleBehavior { +public: + struct Params { + float sigma = 0.4f; + float drift_strength = 0.f; + }; + + explicit BrownianBehavior(Params p = {}) : m_p(p) {} + + [[nodiscard]] glm::vec2 velocity(ndde::sim::ParticleState& state, + const ndde::math::ISurface& surface, + float, + const SimulationContext&, + ParticleId) const override { + if (ops::abs(m_p.drift_strength) < 1e-7f) return {0.f, 0.f}; + const glm::vec3 du = surface.du(state.uv.x, state.uv.y); + const glm::vec3 dv = surface.dv(state.uv.x, state.uv.y); + const float gn = ops::sqrt(du.z * du.z + dv.z * dv.z) + 1e-7f; + return {m_p.drift_strength * du.z / gn, m_p.drift_strength * dv.z / gn}; + } + + [[nodiscard]] glm::vec2 noise_coefficient(const ndde::sim::ParticleState&, + const ndde::math::ISurface&, + float, + const SimulationContext&, + ParticleId) const override { + return {m_p.sigma, m_p.sigma}; + } + + [[nodiscard]] std::string metadata_label() const override { + return ops::abs(m_p.drift_strength) > 1e-7f ? "Brownian + Bias Drift" : "Brownian"; + } + +private: + Params m_p; +}; + +class ConstantDriftBehavior final : public IParticleBehavior { +public: + explicit ConstantDriftBehavior(glm::vec2 velocity) : m_velocity(velocity) {} + + [[nodiscard]] glm::vec2 velocity(ndde::sim::ParticleState&, + const ndde::math::ISurface&, + float, + const SimulationContext&, + ParticleId) const override { + return m_velocity; + } + + [[nodiscard]] std::string metadata_label() const override { + return "Constant Drift"; + } + +private: + glm::vec2 m_velocity{0.f, 0.f}; +}; + +class SeekParticleBehavior final : public IParticleBehavior { +public: + struct Params { + TargetSelector target = TargetSelector::first(ParticleRole::Leader); + float speed = 0.8f; + float delay_seconds = 0.f; + }; + + explicit SeekParticleBehavior(Params p = {}) : m_p(p) {} + + [[nodiscard]] glm::vec2 velocity(ndde::sim::ParticleState& state, + const ndde::math::ISurface& surface, + float t, + const SimulationContext& context, + ParticleId owner) const override { + return direction_to_target(state.uv, surface, t, context, owner) * m_p.speed; + } + + [[nodiscard]] std::string metadata_label() const override { + return m_p.delay_seconds > 0.f ? "Delayed Seek" : "Seek"; + } + +private: + Params m_p; + + [[nodiscard]] glm::vec2 direction_to_target(glm::vec2 from, + const ndde::math::ISurface& surface, + float t, + const SimulationContext& context, + ParticleId owner) const; +}; + +class AvoidParticleBehavior final : public IParticleBehavior { +public: + struct Params { + TargetSelector target = TargetSelector::nearest(ParticleRole::Chaser); + float speed = 0.8f; + float delay_seconds = 0.f; + }; + + explicit AvoidParticleBehavior(Params p = {}) : m_p(p) {} + + [[nodiscard]] glm::vec2 velocity(ndde::sim::ParticleState& state, + const ndde::math::ISurface& surface, + float t, + const SimulationContext& context, + ParticleId owner) const override; + + [[nodiscard]] std::string metadata_label() const override { + return m_p.delay_seconds > 0.f ? "Delayed Avoid" : "Avoid"; + } + +private: + SeekParticleBehavior::Params seek_params() const { + return {.target = m_p.target, .speed = 1.f, .delay_seconds = m_p.delay_seconds}; + } + Params m_p; +}; + +class CentroidSeekBehavior final : public IParticleBehavior { +public: + struct Params { + ParticleRole role = ParticleRole::Chaser; + float speed = 0.8f; + }; + + explicit CentroidSeekBehavior(Params p = {}) : m_p(p) {} + + [[nodiscard]] glm::vec2 velocity(ndde::sim::ParticleState& state, + const ndde::math::ISurface& surface, + float, + const SimulationContext& context, + ParticleId owner) const override; + + [[nodiscard]] std::string metadata_label() const override { + return "Centroid Seek"; + } + +private: + Params m_p; +}; + +class GradientDriftBehavior final : public IParticleBehavior { +public: + enum class Mode : u8 { + Uphill, + Downhill, + LevelTangent + }; + + struct Params { + Mode mode = Mode::Uphill; + float speed = 0.6f; + }; + + explicit GradientDriftBehavior(Params p = {}) : m_p(p) {} + + [[nodiscard]] glm::vec2 velocity(ndde::sim::ParticleState& state, + const ndde::math::ISurface& surface, + float, + const SimulationContext&, + ParticleId) const override; + + [[nodiscard]] std::string metadata_label() const override; + +private: + Params m_p; +}; + +class OrbitBehavior final : public IParticleBehavior { +public: + struct Params { + glm::vec2 center{0.f, 0.f}; + float radius = 1.f; + float angular_speed = 0.7f; + float radial_strength = 0.45f; + bool clockwise = false; + }; + + explicit OrbitBehavior(Params p = {}) : m_p(p) {} + + [[nodiscard]] glm::vec2 velocity(ndde::sim::ParticleState& state, + const ndde::math::ISurface& surface, + float, + const SimulationContext&, + ParticleId) const override; + + [[nodiscard]] std::string metadata_label() const override { return "Orbit"; } + +private: + Params m_p; +}; + +class FlockingBehavior final : public IParticleBehavior { +public: + struct Params { + ParticleRole role = ParticleRole::Neutral; + float neighbor_radius = 1.2f; + float separation_radius = 0.35f; + float separation_weight = 1.0f; + float cohesion_weight = 0.45f; + float alignment_weight = 0.35f; + float speed = 0.65f; + }; + + explicit FlockingBehavior(Params p = {}) : m_p(p) {} + + [[nodiscard]] glm::vec2 velocity(ndde::sim::ParticleState& state, + const ndde::math::ISurface& surface, + float, + const SimulationContext& context, + ParticleId owner) const override; + + [[nodiscard]] std::string metadata_label() const override { return "Flocking"; } + +private: + Params m_p; +}; + +struct SlopeVelocityTransform { + bool enabled = false; + float intercept = 1.f; + float slope_gain = 0.f; + float min_scale = 0.f; + float max_scale = std::numeric_limits::infinity(); + + [[nodiscard]] static SlopeVelocityTransform identity() noexcept { return {}; } +}; + +class BehaviorStack final : public ndde::sim::IEquation { +public: + BehaviorStack() = default; + BehaviorStack(const BehaviorStack&) = delete; + BehaviorStack& operator=(const BehaviorStack&) = delete; + BehaviorStack(BehaviorStack&&) noexcept = default; + BehaviorStack& operator=(BehaviorStack&&) noexcept = default; + + void bind_memory(memory::MemoryService* memory) { + std::pmr::memory_resource* resource = memory ? memory->simulation().resource() + : std::pmr::get_default_resource(); + if (resource == m_behaviors.get_allocator().resource()) + return; + std::destroy_at(&m_behaviors); + std::construct_at(&m_behaviors, resource); + } + + void set_context(const SimulationContext* context) noexcept { m_context = context; } + void set_owner(ParticleId owner) noexcept { m_owner = owner; } + + void add(memory::Unique behavior, float weight = 1.f) { + m_behaviors.push_back({std::move(behavior), weight}); + } + + void set_velocity_transform(SlopeVelocityTransform transform) noexcept { + m_velocity_transform = transform; + } + + [[nodiscard]] glm::vec2 update(ndde::sim::ParticleState& state, + const ndde::math::ISurface& surface, + float t) const override { + if (!m_context) return {0.f, 0.f}; + glm::vec2 sum{0.f, 0.f}; + for (const auto& entry : m_behaviors) + sum += entry.weight * entry.behavior->velocity(state, surface, t, *m_context, m_owner); + return apply_velocity_transform(sum, state, surface); + } + + [[nodiscard]] glm::vec2 noise_coefficient(const ndde::sim::ParticleState& state, + const ndde::math::ISurface& surface, + float t) const override { + if (!m_context) return {0.f, 0.f}; + glm::vec2 sum{0.f, 0.f}; + for (const auto& entry : m_behaviors) + sum += entry.weight * entry.behavior->noise_coefficient(state, surface, t, *m_context, m_owner); + return sum; + } + + [[nodiscard]] float phase_rate() const override { + float rate = 0.f; + for (const auto& entry : m_behaviors) + rate += entry.weight * entry.behavior->phase_rate(); + return rate; + } + + [[nodiscard]] std::string name() const override; + [[nodiscard]] memory::FrameVector behavior_labels() const; + + template + [[nodiscard]] Equation* find_equation() noexcept { + for (const auto& entry : m_behaviors) { + auto* wrapper = dynamic_cast(entry.behavior.get()); + if (!wrapper) continue; + if (auto* eq = dynamic_cast(wrapper->equation())) + return eq; + } + return nullptr; + } + + template + [[nodiscard]] const Equation* find_equation() const noexcept { + for (const auto& entry : m_behaviors) { + const auto* wrapper = dynamic_cast(entry.behavior.get()); + if (!wrapper) continue; + if (const auto* eq = dynamic_cast(wrapper->equation())) + return eq; + } + return nullptr; + } + +private: + struct Entry { + memory::Unique behavior; + float weight = 1.f; + }; + + memory::SimVector m_behaviors; + const SimulationContext* m_context = nullptr; + ParticleId m_owner = 0; + SlopeVelocityTransform m_velocity_transform{}; + + [[nodiscard]] glm::vec2 apply_velocity_transform(glm::vec2 velocity, + const ndde::sim::ParticleState& state, + const ndde::math::ISurface& surface) const noexcept { + if (!m_velocity_transform.enabled) return velocity; + const float speed = ops::length(velocity); + if (speed < 1e-7f) return velocity; + + const glm::vec2 dir = velocity / speed; + const glm::vec3 du = surface.du(state.uv.x, state.uv.y); + const glm::vec3 dv = surface.dv(state.uv.x, state.uv.y); + const float directional_derivative = du.z * dir.x + dv.z * dir.y; + const float scale = ops::clamp( + m_velocity_transform.intercept + m_velocity_transform.slope_gain * directional_derivative, + m_velocity_transform.min_scale, + m_velocity_transform.max_scale); + return velocity * scale; + } +}; + +} // namespace ndde diff --git a/nurbs_dde/src/app/ParticleFactory.hpp b/nurbs_dde/src/app/ParticleFactory.hpp new file mode 100644 index 00000000..b5a3c033 --- /dev/null +++ b/nurbs_dde/src/app/ParticleFactory.hpp @@ -0,0 +1,224 @@ +#pragma once +// app/ParticleFactory.hpp +// Fluent builder for composable particles. + +#include "app/AnimatedCurve.hpp" +#include "app/ParticleBehaviors.hpp" +#include "memory/Containers.hpp" +#include "memory/MemoryService.hpp" +#include "sim/DomainConfinement.hpp" +#include "sim/EulerIntegrator.hpp" +#include "sim/IConstraint.hpp" +#include "sim/MilsteinIntegrator.hpp" +#include +#include +#include + +namespace ndde { + +class ParticleBuilder { +public: + explicit ParticleBuilder(const ndde::math::ISurface* surface, + memory::MemoryService* memory = nullptr) + : m_surface(surface) + , m_memory(memory) + , m_constraints(memory ? memory->simulation().resource() : std::pmr::get_default_resource()) + { + m_stack.bind_memory(memory); + } + ParticleBuilder(const ParticleBuilder&) = delete; + ParticleBuilder& operator=(const ParticleBuilder&) = delete; + ParticleBuilder(ParticleBuilder&&) noexcept = default; + ParticleBuilder& operator=(ParticleBuilder&&) noexcept = default; + + ParticleBuilder& named(std::string label) & { + m_label = std::move(label); + return *this; + } + ParticleBuilder&& named(std::string label) && { + named(std::move(label)); + return std::move(*this); + } + + ParticleBuilder& role(ParticleRole role) & noexcept { + m_role = role; + return *this; + } + ParticleBuilder&& role(ParticleRole role) && noexcept { + this->role(role); + return std::move(*this); + } + + ParticleBuilder& at(glm::vec2 uv) & noexcept { + m_uv = uv; + return *this; + } + ParticleBuilder&& at(glm::vec2 uv) && noexcept { + this->at(uv); + return std::move(*this); + } + + ParticleBuilder& trail(TrailConfig cfg) & noexcept { + m_trail = cfg; + return *this; + } + ParticleBuilder&& trail(TrailConfig cfg) && noexcept { + this->trail(cfg); + return std::move(*this); + } + + ParticleBuilder& history(std::size_t capacity = 4096, float dt_min = 1.f / 120.f) & noexcept { + m_history_capacity = capacity; + m_history_dt_min = dt_min; + m_enable_history = true; + return *this; + } + ParticleBuilder&& history(std::size_t capacity = 4096, float dt_min = 1.f / 120.f) && noexcept { + this->history(capacity, dt_min); + return std::move(*this); + } + + ParticleBuilder& stochastic(bool enabled = true) & noexcept { + m_stochastic = enabled; + return *this; + } + ParticleBuilder&& stochastic(bool enabled = true) && noexcept { + this->stochastic(enabled); + return std::move(*this); + } + + ParticleBuilder& slope_velocity_transform(SlopeVelocityTransform transform) & noexcept { + transform.enabled = true; + m_stack.set_velocity_transform(transform); + return *this; + } + ParticleBuilder&& slope_velocity_transform(SlopeVelocityTransform transform) && noexcept { + this->slope_velocity_transform(transform); + return std::move(*this); + } + + ParticleBuilder& slope_velocity_transform(float intercept, + float slope_gain, + float min_scale = 0.f, + float max_scale = std::numeric_limits::infinity()) & noexcept { + return slope_velocity_transform(SlopeVelocityTransform{ + .enabled = true, + .intercept = intercept, + .slope_gain = slope_gain, + .min_scale = min_scale, + .max_scale = max_scale + }); + } + ParticleBuilder&& slope_velocity_transform(float intercept, + float slope_gain, + float min_scale = 0.f, + float max_scale = std::numeric_limits::infinity()) && noexcept { + this->slope_velocity_transform(intercept, slope_gain, min_scale, max_scale); + return std::move(*this); + } + + template + ParticleBuilder& with_behavior(float weight, Args&&... args) & { + m_stack.add(make_sim_unique(std::forward(args)...), weight); + return *this; + } + template + ParticleBuilder&& with_behavior(float weight, Args&&... args) && { + this->with_behavior(weight, std::forward(args)...); + return std::move(*this); + } + + template + ParticleBuilder& with_behavior(Args&&... args) & { + return with_behavior(1.f, std::forward(args)...); + } + template + ParticleBuilder&& with_behavior(Args&&... args) && { + this->with_behavior(1.f, std::forward(args)...); + return std::move(*this); + } + + ParticleBuilder& with_equation(memory::Unique equation, float weight = 1.f) & { + m_stack.add(make_sim_unique(std::move(equation)), weight); + return *this; + } + ParticleBuilder&& with_equation(memory::Unique equation, float weight = 1.f) && { + this->with_equation(std::move(equation), weight); + return std::move(*this); + } + + template + ParticleBuilder& with_equation(float weight, Args&&... args) & { + return with_equation(make_sim_unique(std::forward(args)...), weight); + } + + template + ParticleBuilder&& with_equation(float weight, Args&&... args) && { + this->with_equation(weight, std::forward(args)...); + return std::move(*this); + } + + template + ParticleBuilder& with_equation_type(Args&&... args) & { + return with_equation(1.f, std::forward(args)...); + } + + template + ParticleBuilder&& with_equation_type(Args&&... args) && { + this->with_equation_type(std::forward(args)...); + return std::move(*this); + } + + template + ParticleBuilder& with_constraint(Args&&... args) & { + m_constraints.push_back(make_sim_unique(std::forward(args)...)); + return *this; + } + template + ParticleBuilder&& with_constraint(Args&&... args) && { + this->with_constraint(std::forward(args)...); + return std::move(*this); + } + + [[nodiscard]] AnimatedCurve build(const ndde::sim::IIntegrator* deterministic, + const ndde::sim::IIntegrator* stochastic = nullptr); + +private: + const ndde::math::ISurface* m_surface = nullptr; + memory::MemoryService* m_memory = nullptr; + glm::vec2 m_uv{0.f, 0.f}; + ParticleRole m_role = ParticleRole::Neutral; + std::string m_label; + TrailConfig m_trail{}; + bool m_enable_history = false; + std::size_t m_history_capacity = 4096; + float m_history_dt_min = 1.f / 120.f; + bool m_stochastic = false; + BehaviorStack m_stack; + memory::SimVector> m_constraints; + + template + [[nodiscard]] memory::Unique make_sim_unique(Args&&... args) const { + return m_memory ? m_memory->simulation().make_unique(std::forward(args)...) + : memory::make_unique(std::pmr::get_default_resource(), std::forward(args)...); + } +}; + +class ParticleFactory { +public: + explicit ParticleFactory(const ndde::math::ISurface* surface, + memory::MemoryService* memory = nullptr) + : m_surface(surface) + , m_memory(memory) + {} + + [[nodiscard]] ParticleBuilder particle() const { return ParticleBuilder(m_surface, m_memory); } + +private: + const ndde::math::ISurface* m_surface = nullptr; + memory::MemoryService* m_memory = nullptr; +}; + +using Particle = AnimatedCurve; + +} // namespace ndde diff --git a/nurbs_dde/src/app/ParticleGoals.hpp b/nurbs_dde/src/app/ParticleGoals.hpp new file mode 100644 index 00000000..c49c528b --- /dev/null +++ b/nurbs_dde/src/app/ParticleGoals.hpp @@ -0,0 +1,69 @@ +#pragma once +// app/ParticleGoals.hpp +// Scene-level goals / win conditions over composable particles. + +#include "app/SimulationContext.hpp" +#include "app/AnimatedCurve.hpp" +#include "numeric/ops.hpp" +#include + +namespace ndde { + +enum class GoalStatus : u8 { + Running, + Succeeded, + Failed +}; + +class IParticleGoal { +public: + virtual ~IParticleGoal() = default; + + [[nodiscard]] virtual GoalStatus evaluate(const SimulationContext& context) = 0; + [[nodiscard]] virtual std::string metadata_label() const = 0; +}; + +class CaptureGoal final : public IParticleGoal { +public: + struct Params { + ParticleRole seeker_role = ParticleRole::Chaser; + ParticleRole target_role = ParticleRole::Leader; + float radius = 0.25f; + }; + + explicit CaptureGoal(Params p = {}) : m_p(p) {} + + [[nodiscard]] GoalStatus evaluate(const SimulationContext& context) override { + for (const auto& seeker : context.particles()) { + if (seeker.particle_role() != m_p.seeker_role) continue; + const AnimatedCurve* target = context.nearest(m_p.target_role, seeker.head_uv(), seeker.id()); + if (!target) continue; + const Vec3 a = context.surface().evaluate(seeker.head_uv().x, seeker.head_uv().y); + const Vec3 b = context.surface().evaluate(target->head_uv().x, target->head_uv().y); + if (ops::length(a - b) <= m_p.radius) + return GoalStatus::Succeeded; + } + return GoalStatus::Running; + } + + [[nodiscard]] std::string metadata_label() const override { return "Capture"; } + +private: + Params m_p; +}; + +class SurvivalGoal final : public IParticleGoal { +public: + explicit SurvivalGoal(float duration_seconds) : m_duration(duration_seconds) {} + + [[nodiscard]] GoalStatus evaluate(const SimulationContext& context) override { + return context.time() >= m_duration ? GoalStatus::Succeeded : GoalStatus::Running; + } + + [[nodiscard]] std::string metadata_label() const override { return "Survival"; } + +private: + float m_duration = 0.f; +}; + +} // namespace ndde diff --git a/nurbs_dde/src/app/ParticleInspectorPanel.hpp b/nurbs_dde/src/app/ParticleInspectorPanel.hpp new file mode 100644 index 00000000..124db572 --- /dev/null +++ b/nurbs_dde/src/app/ParticleInspectorPanel.hpp @@ -0,0 +1,133 @@ +#pragma once +// app/ParticleInspectorPanel.hpp +// Shared ImGui inspector for scene-owned particles. + +#include "app/AnimatedCurve.hpp" +#include "app/ParticleBehaviors.hpp" +#include "memory/Containers.hpp" +#include "sim/BrownianMotion.hpp" +#include "sim/LevelCurveWalker.hpp" + +#include +#include +#include + +namespace ndde { + +struct ParticleInspectorOptions { + const char* label = "Active particles"; + bool show_level_curve_controls = true; + bool show_brownian_controls = true; + bool show_trail_controls = true; +}; + +class ParticleInspectorPanel { +public: + static void draw(memory::SimVector& particles, + const ParticleInspectorOptions& options = {}) { + ImGui::SeparatorText(options.label); + ImGui::TextDisabled("%zu particle(s)", particles.size()); + ImGui::Spacing(); + + for (u32 i = 0; i < static_cast(particles.size()); ++i) { + AnimatedCurve& particle = particles[i]; + ImGui::PushID(static_cast(i)); + draw_particle(particle, options); + ImGui::PopID(); + ImGui::Separator(); + } + } + +private: + static void draw_particle(AnimatedCurve& particle, const ParticleInspectorOptions& options) { + const Vec3 h = particle.head_world(); + const glm::vec2 uv = particle.head_uv(); + + ImGui::TextColored(role_color(particle.particle_role()), + "%s", particle.metadata_label().c_str()); + ImGui::TextDisabled("id=%llu role=%s", + static_cast(particle.id()), + role_name(particle.particle_role()).data()); + ImGui::TextDisabled("uv=(%.3f, %.3f) p=(%.3f, %.3f, %.3f)", + uv.x, uv.y, h.x, h.y, h.z); + ImGui::TextDisabled("trail=%u", particle.trail_size()); + + if (options.show_trail_controls) + draw_trail_controls(particle); + + if (options.show_level_curve_controls) { + if (auto* level = particle.find_equation()) + draw_level_curve_controls(*level, h.z); + } + + if (options.show_brownian_controls) { + if (auto* brownian = particle.find_equation()) + draw_brownian_controls(*brownian); + } + } + + static void draw_trail_controls(AnimatedCurve& particle) { + TrailConfig cfg = particle.trail_config(); + int mode = static_cast(cfg.mode); + constexpr const char* modes[] = {"None", "Finite", "Persistent", "Static curve"}; + if (ImGui::Combo("Trail##mode", &mode, modes, IM_ARRAYSIZE(modes))) { + cfg.mode = static_cast(std::clamp(mode, 0, 3)); + particle.set_trail_config(cfg); + } + + int max_points = static_cast(cfg.max_points); + if (cfg.mode == TrailMode::Finite) { + ImGui::SetNextItemWidth(120.f); + if (ImGui::SliderInt("Max##trail", &max_points, 16, 12000)) { + cfg.max_points = static_cast(std::max(max_points, 16)); + particle.set_trail_config(cfg); + } + ImGui::SameLine(); + } + + ImGui::SetNextItemWidth(120.f); + if (ImGui::SliderFloat("Spacing##trail", &cfg.min_spacing, 0.f, 0.12f, "%.3f")) + particle.set_trail_config(cfg); + } + + static void draw_level_curve_controls(ndde::sim::LevelCurveWalker& level, float current_z) { + auto& p = level.params(); + const float dz = current_z - p.z0; + const ImVec4 col = std::abs(dz) < p.epsilon + ? ImVec4(0.4f, 1.f, 0.4f, 1.f) + : ImVec4(1.f, 0.5f, 0.2f, 1.f); + ImGui::TextColored(col, "level z=%.3f dz=%+.3f", current_z, dz); + + ImGui::SetNextItemWidth(120.f); + ImGui::SliderFloat("z0##lw", &p.z0, -2.f, 2.f, "%.3f"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(100.f); + ImGui::SliderFloat("eps##lw", &p.epsilon, 0.02f, 1.f, "%.3f"); + ImGui::SetNextItemWidth(120.f); + ImGui::SliderFloat("speed##lw", &p.walk_speed, 0.1f, 2.f, "%.2f"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(100.f); + ImGui::SliderFloat("floor##lw", &p.tangent_floor, 0.f, 1.f, "%.2f"); + } + + static void draw_brownian_controls(ndde::sim::BrownianMotion& brownian) { + auto& p = brownian.params(); + ImGui::SetNextItemWidth(120.f); + ImGui::SliderFloat("sigma##bm", &p.sigma, 0.01f, 2.f, "%.3f"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(120.f); + ImGui::SliderFloat("drift##bm", &p.drift_strength, -1.f, 1.f, "%.3f"); + } + + static ImVec4 role_color(ParticleRole role) noexcept { + switch (role) { + case ParticleRole::Leader: return {0.40f, 0.70f, 1.f, 1.f}; + case ParticleRole::Chaser: return {1.f, 0.55f, 0.35f, 1.f}; + case ParticleRole::Avoider: return {0.78f, 0.88f, 1.f, 1.f}; + case ParticleRole::Neutral: + default: return {0.78f, 0.82f, 0.88f, 1.f}; + } + } +}; + +} // namespace ndde diff --git a/nurbs_dde/src/app/ParticleRenderer.cpp b/nurbs_dde/src/app/ParticleRenderer.cpp deleted file mode 100644 index 70915abc..00000000 --- a/nurbs_dde/src/app/ParticleRenderer.cpp +++ /dev/null @@ -1,235 +0,0 @@ -// app/ParticleRenderer.cpp -// All submit_* methods moved from SurfaceSimScene (B2 refactor). -// submit_all() consolidates the per-curve rendering loop that previously -// lived inside draw_surface_3d_window(). - -#include "app/ParticleRenderer.hpp" -#include -#include -#include -#include -#include - -namespace ndde { - -// ── submit_arrow ────────────────────────────────────────────────────────────── - -void ParticleRenderer::submit_arrow(Vec3 origin, Vec3 dir, Vec4 color, - float length, const Mat4& mvp, Topology topo) -{ - auto slice = m_api.acquire(2); - Vertex* v = slice.vertices(); - v[0] = { origin, color }; - v[1] = { origin + dir*length, color }; - m_api.submit_to(RenderTarget::Primary3D, slice, topo, DrawMode::VertexColor, color, mvp); -} - -// ── submit_trail_3d ─────────────────────────────────────────────────────────── - -void ParticleRenderer::submit_trail_3d(const AnimatedCurve& c, const Mat4& mvp) { - const u32 n = c.trail_vertex_count(); - if (n < 2) return; - auto slice = m_api.acquire(n); - c.tessellate_trail({slice.vertices(), n}); - m_api.submit_to(RenderTarget::Primary3D, slice, Topology::LineStrip, DrawMode::VertexColor, {1,1,1,1}, mvp); -} - -// ── submit_head_dot_3d ──────────────────────────────────────────────────────── - -void ParticleRenderer::submit_head_dot_3d(const AnimatedCurve& c, const Mat4& mvp) { - const Vec3 hp = c.head_world(); - const Vec4 col = c.head_colour(); - auto slice = m_api.acquire(1); - slice.vertices()[0] = { hp, col }; - m_api.submit_to(RenderTarget::Primary3D, slice, Topology::LineStrip, DrawMode::UniformColor, col, mvp); -} - -// ── submit_frenet_3d ────────────────────────────────────────────────────────── - -void ParticleRenderer::submit_frenet_3d(const AnimatedCurve& c, u32 trail_idx, - const Mat4& mvp) { - if (!c.has_trail() || trail_idx == 0) return; - const FrenetFrame fr = c.frenet_at(trail_idx); - const Vec3 o = c.trail_pt(trail_idx); - if (show_T) submit_arrow(o, fr.T, {1.f,0.35f,0.05f,1.f}, frame_scale, mvp); - if (show_N) submit_arrow(o, fr.N, {0.15f,1.f,0.3f,1.f}, frame_scale, mvp); - if (show_B) submit_arrow(o, fr.B, {0.2f,0.5f,1.f,1.f}, frame_scale, mvp); -} - -// ── submit_osc_circle_3d ───────────────────────────────────────────────────── - -void ParticleRenderer::submit_osc_circle_3d(const AnimatedCurve& c, u32 trail_idx, - const Mat4& mvp) { - if (!c.has_trail() || trail_idx == 0) return; - const FrenetFrame fr = c.frenet_at(trail_idx); - if (fr.kappa < 1e-5f) return; - const float R = 1.f / fr.kappa; - const Vec3 o = c.trail_pt(trail_idx); - const Vec3 centre = o + fr.N * R; - constexpr u32 SEG = 64; - const Vec4 col = {0.7f,0.3f,1.f,0.75f}; - auto slice = m_api.acquire(SEG+1); - Vertex* v = slice.vertices(); - for (u32 i = 0; i <= SEG; ++i) { - const float theta = (static_cast(i)/SEG)*2.f*std::numbers::pi_v; - v[i] = { centre + R*(-std::cos(theta)*fr.N + std::sin(theta)*fr.T), col }; - } - m_api.submit_to(RenderTarget::Primary3D, slice, Topology::LineStrip, DrawMode::VertexColor, col, mvp); -} - -// ── submit_surface_frame_3d ─────────────────────────────────────────────────── -// Dx cyan, Dy magenta, D_T (directional derivative in T) yellow. -// Now takes explicit surface + sim_time instead of reading scene members. - -void ParticleRenderer::submit_surface_frame_3d(const AnimatedCurve& c, - u32 trail_idx, - const Mat4& mvp, - const ndde::math::ISurface& surface, - float sim_time) { - if (!c.has_trail() || trail_idx == 0) return; - const Vec3 p = c.trail_pt(trail_idx); - const SurfaceFrame sf = make_surface_frame(surface, p.x, p.y, sim_time); - const float scale = frame_scale; - - submit_arrow(p, glm::normalize(sf.Dx), {0.1f, 0.9f, 0.9f, 1.f}, - std::clamp(glm::length(sf.Dx)*scale, 0.04f, 1.5f), mvp); - submit_arrow(p, glm::normalize(sf.Dy), {0.9f, 0.1f, 0.9f, 1.f}, - std::clamp(glm::length(sf.Dy)*scale, 0.04f, 1.5f), mvp); - - const FrenetFrame fr = c.frenet_at(trail_idx); - const Vec3 T_xy_raw = Vec3{fr.T.x, fr.T.y, 0.f}; - if (glm::length(T_xy_raw) < 1e-6f) return; - const Vec3 T_xy = glm::normalize(T_xy_raw); - const Vec3 Tv = T_xy.x * sf.Dx + T_xy.y * sf.Dy; - if (glm::length(Tv) < 1e-6f) return; - submit_arrow(p, glm::normalize(Tv), {1.f, 0.9f, 0.05f, 1.f}, - std::clamp(glm::length(Tv)*scale, 0.04f, 1.5f), mvp); -} - -// ── submit_normal_plane_3d ──────────────────────────────────────────────────── -// Amber rectangle in the plane spanned by surface normal n and curve tangent T. -// Now takes explicit surface + sim_time instead of reading scene members. - -void ParticleRenderer::submit_normal_plane_3d(const AnimatedCurve& c, - u32 trail_idx, - const Mat4& mvp, - const ndde::math::ISurface& surface, - float sim_time) { - if (!c.has_trail() || trail_idx == 0) return; - const Vec3 p = c.trail_pt(trail_idx); - const FrenetFrame fr = c.frenet_at(trail_idx); - const SurfaceFrame sf = make_surface_frame(surface, p.x, p.y, sim_time, &fr); - - const float osc_r = (fr.kappa > 1e-5f) ? 1.f / fr.kappa : 2.f; - const float half_T = std::clamp(osc_r * 0.3f * frame_scale, 0.04f, 1.2f); - const float half_n = std::clamp(std::sqrt(sf.E) * frame_scale, 0.04f, 1.2f); - - const Vec3 a = fr.T * half_T; - const Vec3 b = sf.normal * half_n; - const Vec3 corners[5] = { p-a-b, p+a-b, p+a+b, p-a+b, p-a-b }; - - const Vec4 col = {0.9f, 0.6f, 0.1f, 0.85f}; - auto slice = m_api.acquire(5); - Vertex* v = slice.vertices(); - for (u32 i = 0; i < 5; ++i) v[i] = { corners[i], col }; - m_api.submit_to(RenderTarget::Primary3D, slice, Topology::LineStrip, DrawMode::VertexColor, col, mvp); - - auto diag = m_api.acquire(2); - diag.vertices()[0] = { p-a+b, {col.r, col.g, col.b, 0.5f} }; - diag.vertices()[1] = { p+a-b, {col.r, col.g, col.b, 0.5f} }; - m_api.submit_to(RenderTarget::Primary3D, diag, Topology::LineList, DrawMode::VertexColor, col, mvp); -} - -// ── submit_torsion_3d ───────────────────────────────────────────────────────── -// Geometric meaning: dB/ds = -tau*N (third Frenet-Serret equation). -// Visualisation: dB/ds arrow + twist fan in the osculating plane. -// Colour: orange (tau > 0, right twist), cyan (tau < 0, left twist). - -void ParticleRenderer::submit_torsion_3d(const AnimatedCurve& c, u32 trail_idx, - const Mat4& mvp) { - if (!c.has_trail() || trail_idx == 0) return; - const FrenetFrame fr = c.frenet_at(trail_idx); - if (fr.kappa < 1e-5f) return; - - const Vec3 o = c.trail_pt(trail_idx); - const float tau = fr.tau; - const float scl = frame_scale; - - const Vec4 col = tau > 0.f - ? Vec4{1.f, 0.55f, 0.1f, 0.9f} // orange: positive torsion - : Vec4{0.3f, 0.8f, 1.f, 0.9f}; // cyan: negative torsion - - // (1) dB/ds arrow — direction is -tau*N - const float dB_len = std::clamp(std::abs(tau) * scl * 2.f, 0.02f, 1.5f); - const Vec3 dB_dir = fr.N * (tau > 0.f ? -1.f : 1.f); - submit_arrow(o, dB_dir, col, dB_len, mvp); - - // (2) Twist fan in the osculating plane - if (std::abs(tau) < 1e-4f) return; - - const float helix_angle = std::atan(std::abs(tau) / fr.kappa); - const float fan_angle = std::clamp(helix_angle, 0.01f, std::numbers::pi_v / 3.f); - const float fan_r = scl * 0.7f; - constexpr u32 FAN_SEG = 20; - const Vec3 side = tau > 0.f ? -fr.B : fr.B; - - auto fan = m_api.acquire(FAN_SEG + 2); - Vertex* vf = fan.vertices(); - vf[0] = { o, col }; - for (u32 i = 0; i <= FAN_SEG; ++i) { - const float a = (static_cast(i) / FAN_SEG) * fan_angle; - const Vec3 spoke = std::cos(a) * fr.N + std::sin(a) * side; - vf[i + 1] = { o + spoke * fan_r, {col.r, col.g, col.b, 0.45f} }; - } - m_api.submit_to(RenderTarget::Primary3D, fan, Topology::TriangleList, DrawMode::VertexColor, col, mvp); - - auto outline = m_api.acquire(FAN_SEG + 2); - Vertex* vo = outline.vertices(); - vo[0] = { o, col }; - for (u32 i = 0; i <= FAN_SEG; ++i) { - const float a = (static_cast(i) / FAN_SEG) * fan_angle; - const Vec3 sp = std::cos(a) * fr.N + std::sin(a) * side; - vo[i + 1] = { o + sp * fan_r, col }; - } - m_api.submit_to(RenderTarget::Primary3D, outline, Topology::LineStrip, DrawMode::VertexColor, col, mvp); -} - -// ── submit_all ──────────────────────────────────────────────────────────────── -// The per-curve rendering loop, moved from draw_surface_3d_window(). -// Submits: trail + head dot for every curve; overlays for the active curve. - -void ParticleRenderer::submit_all(const std::vector& curves, - const ndde::math::ISurface& surface, - float sim_time, - const Mat4& mvp, - int snap_curve, - int snap_idx, - bool snap_on_curve) -{ - for (u32 ci = 0; ci < static_cast(curves.size()); ++ci) { - const AnimatedCurve& c = curves[ci]; - if (!c.has_trail()) continue; - submit_trail_3d(c, mvp); - submit_head_dot_3d(c, mvp); - - // Overlays on the snapped curve, or curve 0 when nothing is snapped. - const bool is_active = (snap_on_curve && snap_curve == static_cast(ci)) - || (!snap_on_curve && ci == 0); - if (!is_active) continue; - - const u32 sidx = (snap_on_curve && snap_curve == static_cast(ci) - && snap_idx >= 0) - ? static_cast(snap_idx) - : (c.trail_size() > 2 ? c.trail_size()-2 : 0); - - if (show_frenet) { - submit_frenet_3d(c, sidx, mvp); - if (show_osc) submit_osc_circle_3d(c, sidx, mvp); - } - if (show_dir_deriv) submit_surface_frame_3d(c, sidx, mvp, surface, sim_time); - if (show_normal_plane) submit_normal_plane_3d(c, sidx, mvp, surface, sim_time); - if (show_torsion) submit_torsion_3d(c, sidx, mvp); - } -} - -} // namespace ndde diff --git a/nurbs_dde/src/app/ParticleRenderer.hpp b/nurbs_dde/src/app/ParticleRenderer.hpp deleted file mode 100644 index aa00d92e..00000000 --- a/nurbs_dde/src/app/ParticleRenderer.hpp +++ /dev/null @@ -1,86 +0,0 @@ -#pragma once -// app/ParticleRenderer.hpp -// ParticleRenderer: submits all 3D particle geometry to the primary render window. -// -// Extracted from SurfaceSimScene (B2 refactor). Owns a copy of EngineAPI -// (a value type -- all std::function members, cheap to copy) and all the -// submit_* methods that previously lived in SurfaceSimScene. -// -// Usage -// ───── -// Each frame, SurfaceSimScene syncs the visibility flags from its hotkey state -// and then calls submit_all(). The renderer is stateless between frames -// (it holds no particle data, only the API handle and visibility flags). -// -// Note: this renderer uses RenderTarget::Primary3D only. -// The 2D contour second window (RenderTarget::Contour2D) stays in SurfaceSimScene -// because it has its own MVP and scene-specific rendering logic. - -#include "engine/EngineAPI.hpp" -#include "app/AnimatedCurve.hpp" -#include "app/FrenetFrame.hpp" -#include "math/Surfaces.hpp" -#include "math/Scalars.hpp" -#include - -namespace ndde { - -class ParticleRenderer { -public: - explicit ParticleRenderer(EngineAPI api) : m_api(std::move(api)) {} - - // ── Visibility flags ────────────────────────────────────────────────────── - // Set by SurfaceSimScene from hotkey / panel state before each submit_all(). - bool show_frenet = true; - bool show_T = true; - bool show_N = true; - bool show_B = true; - bool show_osc = true; - bool show_dir_deriv = false; - bool show_normal_plane = false; - bool show_torsion = false; - float frame_scale = 0.25f; - - // ── Primary entry point ─────────────────────────────────────────────────── - // Submit trails, head dots, and overlays for all curves in one call. - // snap_curve / snap_idx: trail point the cursor is hovering (active curve). - // sim_time: passed to surface geometry queries inside overlay methods. - void submit_all(const std::vector& curves, - const ndde::math::ISurface& surface, - float sim_time, - const Mat4& mvp, - int snap_curve, - int snap_idx, - bool snap_on_curve); - - // ── Individual submit methods (public for future composability) ─────────── - void submit_trail_3d (const AnimatedCurve& c, const Mat4& mvp); - void submit_head_dot_3d (const AnimatedCurve& c, const Mat4& mvp); - - void submit_frenet_3d (const AnimatedCurve& c, u32 trail_idx, const Mat4& mvp); - void submit_osc_circle_3d(const AnimatedCurve& c, u32 trail_idx, const Mat4& mvp); - - // Surface-dependent overlays receive explicit surface + sim_time parameters - // (they previously read m_surface / m_sim_time from SurfaceSimScene). - void submit_surface_frame_3d(const AnimatedCurve& c, - u32 trail_idx, - const Mat4& mvp, - const ndde::math::ISurface& surface, - float sim_time); - - void submit_normal_plane_3d (const AnimatedCurve& c, - u32 trail_idx, - const Mat4& mvp, - const ndde::math::ISurface& surface, - float sim_time); - - void submit_torsion_3d (const AnimatedCurve& c, u32 trail_idx, const Mat4& mvp); - -private: - EngineAPI m_api; - - void submit_arrow(Vec3 origin, Vec3 dir, Vec4 color, float length, - const Mat4& mvp, Topology topo = Topology::LineList); -}; - -} // namespace ndde diff --git a/nurbs_dde/src/app/ParticleSwarmFactory.hpp b/nurbs_dde/src/app/ParticleSwarmFactory.hpp new file mode 100644 index 00000000..635254e5 --- /dev/null +++ b/nurbs_dde/src/app/ParticleSwarmFactory.hpp @@ -0,0 +1,390 @@ +#pragma once +// app/ParticleSwarmFactory.hpp +// Recipe layer for emitting particle groups with shared behavior/goals. + +#include "app/ParticleBehaviors.hpp" +#include "app/ParticleGoals.hpp" +#include "app/ParticleSystem.hpp" +#include "memory/Containers.hpp" +#include "sim/LevelCurveWalker.hpp" +#include "numeric/ops.hpp" +#include +#include +#include +#include + +namespace ndde { + +struct SwarmRecipeMetadata { + std::string family_name; + u32 requested_count = 0; + memory::FrameVector roles_emitted; + bool goals_added = false; + + [[nodiscard]] std::string roles_label() const { + std::string out; + for (ParticleRole role : roles_emitted) { + const std::string_view name = role_name(role); + if (out.find(name) != std::string::npos) continue; + if (!out.empty()) out += ", "; + out += name; + } + return out.empty() ? "None" : out; + } +}; + +struct SwarmBuildResult { + memory::FrameVector particle_ids; + SwarmRecipeMetadata metadata; + + [[nodiscard]] std::size_t size() const noexcept { return particle_ids.size(); } + [[nodiscard]] bool empty() const noexcept { return particle_ids.empty(); } +}; + +class ParticleSwarmFactory { +public: + explicit ParticleSwarmFactory(ParticleSystem& system) : m_system(system) {} + + struct BrownianCloudParams { + u32 count = 16; + glm::vec2 center{0.f, 0.f}; + float radius = 1.f; + ParticleRole role = ParticleRole::Neutral; + BrownianBehavior::Params brownian{.sigma = 0.16f, .drift_strength = 0.f}; + TrailConfig trail{TrailMode::Finite, 600u, 0.015f}; + std::string label = "Brownian Cloud"; + }; + + struct LeaderPursuitParams { + u32 leader_count = 1; + u32 chaser_count = 6; + glm::vec2 center{0.f, 0.f}; + float leader_radius = 0.7f; + float chaser_radius = 2.2f; + float leader_speed = 0.42f; + float chaser_speed = 0.86f; + float delay_seconds = 0.35f; + float capture_radius = 0.20f; + BrownianBehavior::Params leader_noise{.sigma = 0.08f, .drift_strength = 0.03f}; + BrownianBehavior::Params chaser_noise{.sigma = 0.055f, .drift_strength = 0.f}; + TrailConfig leader_trail{TrailMode::Persistent, 1600u, 0.012f}; + TrailConfig chaser_trail{TrailMode::Finite, 1200u, 0.012f}; + bool add_capture_goal = true; + std::string leader_label = "Prey - Avoid - Brownian"; + std::string chaser_label = "Predator - Delayed Seek - Brownian"; + }; + + struct ContourBandParams { + u32 count = 10; + glm::vec2 center{0.f, 0.f}; + float radius = 1.2f; + bool shared_level = true; + ParticleRole role = ParticleRole::Leader; + ndde::sim::LevelCurveWalker::Params walker{}; + BrownianBehavior::Params noise{.sigma = 0.025f, .drift_strength = 0.f}; + TrailConfig trail{TrailMode::Persistent, 1800u, 0.010f}; + std::string label = "Contour Band"; + }; + + struct GradientDriftParams { + u32 count = 14; + glm::vec2 center{0.f, 0.f}; + float radius = 1.4f; + ParticleRole role = ParticleRole::Neutral; + GradientDriftBehavior::Params gradient{.mode = GradientDriftBehavior::Mode::Uphill, .speed = 0.55f}; + BrownianBehavior::Params noise{.sigma = 0.035f, .drift_strength = 0.f}; + TrailConfig trail{TrailMode::Finite, 1000u, 0.012f}; + std::string label = "Gradient Drift Swarm"; + }; + + struct AvoidanceParams { + u32 count = 12; + glm::vec2 center{0.f, 0.f}; + float radius = 1.3f; + ParticleRole role = ParticleRole::Avoider; + ParticleRole avoid_role = ParticleRole::Chaser; + float speed = 0.65f; + float delay_seconds = 0.f; + BrownianBehavior::Params noise{.sigma = 0.045f, .drift_strength = 0.f}; + TrailConfig trail{TrailMode::Finite, 900u, 0.014f}; + std::string label = "Avoidance Swarm"; + }; + + struct CentroidSwarmParams { + u32 count = 10; + glm::vec2 center{0.f, 0.f}; + float radius = 1.8f; + ParticleRole role = ParticleRole::Chaser; + ParticleRole target_role = ParticleRole::Chaser; + float speed = 0.55f; + BrownianBehavior::Params noise{.sigma = 0.035f, .drift_strength = 0.f}; + TrailConfig trail{TrailMode::Finite, 1000u, 0.014f}; + std::string label = "Centroid Swarm"; + }; + + struct RingOrbitParams { + u32 count = 16; + glm::vec2 center{0.f, 0.f}; + float radius = 1.8f; + ParticleRole role = ParticleRole::Neutral; + OrbitBehavior::Params orbit{}; + BrownianBehavior::Params noise{.sigma = 0.02f, .drift_strength = 0.f}; + TrailConfig trail{TrailMode::Persistent, 1800u, 0.010f}; + std::string label = "Ring Orbit Swarm"; + }; + + struct FlockingParams { + u32 count = 18; + glm::vec2 center{0.f, 0.f}; + float radius = 1.6f; + ParticleRole role = ParticleRole::Neutral; + FlockingBehavior::Params flock{}; + BrownianBehavior::Params noise{.sigma = 0.025f, .drift_strength = 0.f}; + TrailConfig trail{TrailMode::Finite, 1200u, 0.012f}; + std::string label = "Flocking Swarm"; + }; + + [[nodiscard]] SwarmBuildResult brownian_cloud(const BrownianCloudParams& p) { + SwarmBuildResult result; + result.metadata = recipe_metadata(p.label, p.count, {p.role}, false); + result.particle_ids.reserve(p.count); + for (u32 i = 0; i < p.count; ++i) { + Particle& particle = m_system.spawn(m_system.factory().particle() + .named(p.label) + .role(p.role) + .at(spawn_disc(p.center, p.radius, i, p.count)) + .trail(p.trail) + .stochastic() + .with_behavior(p.brownian)); + result.particle_ids.push_back(particle.id()); + } + return result; + } + + [[nodiscard]] SwarmBuildResult leader_pursuit(const LeaderPursuitParams& p) { + SwarmBuildResult result; + result.metadata = recipe_metadata("Leader Pursuit", p.leader_count + p.chaser_count, + {ParticleRole::Leader, ParticleRole::Chaser}, + p.add_capture_goal); + result.particle_ids.reserve(static_cast(p.leader_count) + p.chaser_count); + + for (u32 i = 0; i < p.leader_count; ++i) { + const float a = angle(i, std::max(p.leader_count, 1u)); + AvoidParticleBehavior::Params avoid; + avoid.target = TargetSelector::nearest(ParticleRole::Chaser); + avoid.speed = p.leader_speed; + avoid.delay_seconds = p.delay_seconds * 0.55f; + + Particle& particle = m_system.spawn(m_system.factory().particle() + .named(p.leader_label) + .role(ParticleRole::Leader) + .at(p.center + glm::vec2{p.leader_radius * ops::cos(a), p.leader_radius * ops::sin(a)}) + .trail(p.leader_trail) + .history() + .stochastic() + .with_behavior(avoid) + .with_behavior(0.55f, p.leader_noise)); + result.particle_ids.push_back(particle.id()); + } + + for (u32 i = 0; i < p.chaser_count; ++i) { + const float a = angle(i, std::max(p.chaser_count, 1u)) + 0.35f; + SeekParticleBehavior::Params seek; + seek.target = TargetSelector::nearest(ParticleRole::Leader); + seek.speed = p.chaser_speed; + seek.delay_seconds = p.delay_seconds; + + Particle& particle = m_system.spawn(m_system.factory().particle() + .named(p.chaser_label) + .role(ParticleRole::Chaser) + .at(p.center + glm::vec2{p.chaser_radius * ops::cos(a), p.chaser_radius * ops::sin(a)}) + .trail(p.chaser_trail) + .stochastic() + .with_behavior(seek) + .with_behavior(0.30f, p.chaser_noise)); + result.particle_ids.push_back(particle.id()); + } + + if (p.add_capture_goal) { + m_system.add_goal(CaptureGoal::Params{ + .seeker_role = ParticleRole::Chaser, + .target_role = ParticleRole::Leader, + .radius = p.capture_radius + }); + } + return result; + } + + [[nodiscard]] SwarmBuildResult contour_band(const ContourBandParams& p) { + SwarmBuildResult result; + result.metadata = recipe_metadata(p.label, p.count, {p.role}, false); + result.particle_ids.reserve(p.count); + const float z0 = surface().evaluate(p.center.x, p.center.y).z; + + for (u32 i = 0; i < p.count; ++i) { + const glm::vec2 uv = spawn_disc(p.center, p.radius, i, p.count); + auto walker_params = p.walker; + walker_params.z0 = p.shared_level ? z0 : surface().evaluate(uv.x, uv.y).z; + + Particle& particle = m_system.spawn(m_system.factory().particle() + .named(p.label) + .role(p.role) + .at(uv) + .trail(p.trail) + .stochastic() + .with_equation_type(walker_params) + .with_behavior(0.18f, p.noise)); + result.particle_ids.push_back(particle.id()); + } + return result; + } + + [[nodiscard]] SwarmBuildResult gradient_drift(const GradientDriftParams& p) { + SwarmBuildResult result; + result.metadata = recipe_metadata(p.label, p.count, {p.role}, false); + result.particle_ids.reserve(p.count); + for (u32 i = 0; i < p.count; ++i) { + Particle& particle = m_system.spawn(m_system.factory().particle() + .named(p.label) + .role(p.role) + .at(spawn_disc(p.center, p.radius, i, p.count)) + .trail(p.trail) + .stochastic() + .with_behavior(p.gradient) + .with_behavior(0.25f, p.noise)); + result.particle_ids.push_back(particle.id()); + } + return result; + } + + [[nodiscard]] SwarmBuildResult avoidance_swarm(const AvoidanceParams& p) { + SwarmBuildResult result; + result.metadata = recipe_metadata(p.label, p.count, {p.role}, false); + result.particle_ids.reserve(p.count); + AvoidParticleBehavior::Params avoid; + avoid.target = TargetSelector::nearest(p.avoid_role); + avoid.speed = p.speed; + avoid.delay_seconds = p.delay_seconds; + + for (u32 i = 0; i < p.count; ++i) { + Particle& particle = m_system.spawn(m_system.factory().particle() + .named(p.label) + .role(p.role) + .at(spawn_disc(p.center, p.radius, i, p.count)) + .trail(p.trail) + .stochastic() + .with_behavior(avoid) + .with_behavior(0.30f, p.noise)); + result.particle_ids.push_back(particle.id()); + } + return result; + } + + [[nodiscard]] SwarmBuildResult centroid_swarm(const CentroidSwarmParams& p) { + SwarmBuildResult result; + result.metadata = recipe_metadata(p.label, p.count, {p.role}, false); + result.particle_ids.reserve(p.count); + for (u32 i = 0; i < p.count; ++i) { + Particle& particle = m_system.spawn(m_system.factory().particle() + .named(p.label) + .role(p.role) + .at(spawn_disc(p.center, p.radius, i, p.count)) + .trail(p.trail) + .stochastic() + .with_behavior(CentroidSeekBehavior::Params{ + .role = p.target_role, + .speed = p.speed + }) + .with_behavior(0.25f, p.noise)); + result.particle_ids.push_back(particle.id()); + } + return result; + } + + [[nodiscard]] SwarmBuildResult ring_orbit(const RingOrbitParams& p) { + SwarmBuildResult result; + result.metadata = recipe_metadata(p.label, p.count, {p.role}, false); + result.particle_ids.reserve(p.count); + for (u32 i = 0; i < p.count; ++i) { + const float a = angle(i, std::max(p.count, 1u)); + auto orbit = p.orbit; + orbit.center = p.center; + if (orbit.radius <= 0.f) orbit.radius = p.radius; + const glm::vec2 uv = { + ops::clamp(p.center.x + p.radius * ops::cos(a), surface().u_min(), surface().u_max()), + ops::clamp(p.center.y + p.radius * ops::sin(a), surface().v_min(), surface().v_max()) + }; + Particle& particle = m_system.spawn(m_system.factory().particle() + .named(p.label) + .role(p.role) + .at(uv) + .trail(p.trail) + .stochastic() + .with_behavior(orbit) + .with_behavior(0.20f, p.noise)); + result.particle_ids.push_back(particle.id()); + } + return result; + } + + [[nodiscard]] SwarmBuildResult flocking_swarm(const FlockingParams& p) { + SwarmBuildResult result; + result.metadata = recipe_metadata(p.label, p.count, {p.role}, false); + result.particle_ids.reserve(p.count); + auto flock = p.flock; + flock.role = p.role; + for (u32 i = 0; i < p.count; ++i) { + const float a = angle(i, std::max(p.count, 1u)); + const glm::vec2 tangent{-ops::sin(a), ops::cos(a)}; + Particle& particle = m_system.spawn(m_system.factory().particle() + .named(p.label) + .role(p.role) + .at(spawn_disc(p.center, p.radius, i, p.count)) + .trail(p.trail) + .history() + .stochastic() + .with_behavior(flock) + .with_behavior(0.25f, tangent * 0.18f) + .with_behavior(0.20f, p.noise)); + result.particle_ids.push_back(particle.id()); + } + return result; + } + +private: + ParticleSystem& m_system; + + [[nodiscard]] const ndde::math::ISurface& surface() const noexcept { + return *m_system.surface(); + } + + [[nodiscard]] static float angle(u32 i, u32 count) noexcept { + return ops::two_pi_v * static_cast(i) / static_cast(std::max(count, 1u)); + } + + [[nodiscard]] glm::vec2 spawn_disc(glm::vec2 center, float radius, u32 i, u32 count) { + std::uniform_real_distribution jitter(0.82f, 1.16f); + const float a = angle(i, std::max(count, 1u)) + 0.37f * static_cast(i % 5u); + const float r = radius * jitter(m_system.rng()); + const glm::vec2 raw = center + glm::vec2{r * ops::cos(a), r * ops::sin(a)}; + return { + ops::clamp(raw.x, surface().u_min(), surface().u_max()), + ops::clamp(raw.y, surface().v_min(), surface().v_max()) + }; + } + + [[nodiscard]] static SwarmRecipeMetadata recipe_metadata(std::string family_name, + u32 requested_count, + memory::FrameVector roles, + bool goals_added) + { + return SwarmRecipeMetadata{ + .family_name = std::move(family_name), + .requested_count = requested_count, + .roles_emitted = std::move(roles), + .goals_added = goals_added + }; + } +}; + +} // namespace ndde diff --git a/nurbs_dde/src/app/ParticleSystem.cpp b/nurbs_dde/src/app/ParticleSystem.cpp new file mode 100644 index 00000000..848f24a7 --- /dev/null +++ b/nurbs_dde/src/app/ParticleSystem.cpp @@ -0,0 +1,278 @@ +#include "app/ParticleFactory.hpp" +#include "app/SimulationContext.hpp" +#include "app/ParticleBehaviors.hpp" +#include "app/AnimatedCurve.hpp" + +#include + +namespace ndde { + +namespace { + +[[nodiscard]] glm::vec2 shortest_delta(glm::vec2 target, + glm::vec2 from, + const ndde::math::ISurface& surface) noexcept { + glm::vec2 delta = target - from; + if (surface.is_periodic_u()) { + const float span = surface.u_max() - surface.u_min(); + if (delta.x > span * 0.5f) delta.x -= span; + if (delta.x < -span * 0.5f) delta.x += span; + } + if (surface.is_periodic_v()) { + const float span = surface.v_max() - surface.v_min(); + if (delta.y > span * 0.5f) delta.y -= span; + if (delta.y < -span * 0.5f) delta.y += span; + } + return delta; +} + +[[nodiscard]] glm::vec2 normalize_or_zero(glm::vec2 v) noexcept { + const float d = ops::length(v); + return d > 1e-7f ? v / d : glm::vec2{0.f, 0.f}; +} + +[[nodiscard]] glm::vec2 target_position(const TargetSelector& selector, + glm::vec2 from, + const ndde::math::ISurface& surface, + float t, + const SimulationContext& context, + ParticleId owner, + float delay) noexcept { + const AnimatedCurve* target = nullptr; + switch (selector.kind) { + case TargetSelector::Kind::ById: + target = context.find(selector.id); + break; + case TargetSelector::Kind::FirstRole: + target = context.first(selector.role, owner); + break; + case TargetSelector::Kind::NearestRole: + target = context.nearest(selector.role, from, owner); + break; + case TargetSelector::Kind::CentroidRole: + return context.centroid(selector.role, owner); + } + + if (!target) return from; + if (delay > 0.f) { + if (const auto* history = target->history()) + return history->query(t - delay); + } + (void)surface; + return target->head_uv(); +} + +} // namespace + +const AnimatedCurve* SimulationContext::find(ParticleId id) const noexcept { + if (!m_particles) return nullptr; + for (const auto& particle : *m_particles) { + if (particle.id() == id) return &particle; + } + return nullptr; +} + +const AnimatedCurve* SimulationContext::first(ParticleRole role, ParticleId exclude) const noexcept { + if (!m_particles) return nullptr; + for (const auto& particle : *m_particles) { + if (particle.id() != exclude && particle.particle_role() == role) + return &particle; + } + return nullptr; +} + +const AnimatedCurve* SimulationContext::nearest(ParticleRole role, glm::vec2 from, ParticleId exclude) const noexcept { + if (!m_particles || !m_surface) return nullptr; + const AnimatedCurve* best = nullptr; + float best_d2 = std::numeric_limits::max(); + for (const auto& particle : *m_particles) { + if (particle.id() == exclude || particle.particle_role() != role) continue; + const glm::vec2 d = shortest_delta(particle.head_uv(), from, *m_surface); + const float d2 = d.x * d.x + d.y * d.y; + if (d2 < best_d2) { + best_d2 = d2; + best = &particle; + } + } + return best; +} + +glm::vec2 SimulationContext::centroid(ParticleRole role, ParticleId exclude) const noexcept { + if (!m_particles) return {0.f, 0.f}; + glm::vec2 sum{0.f, 0.f}; + u32 count = 0; + for (const auto& particle : *m_particles) { + if (particle.id() == exclude || particle.particle_role() != role) continue; + sum += particle.head_uv(); + ++count; + } + return count > 0 ? sum / static_cast(count) : glm::vec2{0.f, 0.f}; +} + +glm::vec2 SeekParticleBehavior::direction_to_target(glm::vec2 from, + const ndde::math::ISurface& surface, + float t, + const SimulationContext& context, + ParticleId owner) const { + const glm::vec2 target = target_position(m_p.target, from, surface, t, context, owner, m_p.delay_seconds); + return normalize_or_zero(shortest_delta(target, from, surface)); +} + +glm::vec2 AvoidParticleBehavior::velocity(ndde::sim::ParticleState& state, + const ndde::math::ISurface& surface, + float t, + const SimulationContext& context, + ParticleId owner) const { + const glm::vec2 target = target_position(m_p.target, state.uv, surface, t, context, owner, m_p.delay_seconds); + return -normalize_or_zero(shortest_delta(target, state.uv, surface)) * m_p.speed; +} + +glm::vec2 CentroidSeekBehavior::velocity(ndde::sim::ParticleState& state, + const ndde::math::ISurface& surface, + float, + const SimulationContext& context, + ParticleId owner) const { + const glm::vec2 target = context.centroid(m_p.role, owner); + return normalize_or_zero(shortest_delta(target, state.uv, surface)) * m_p.speed; +} + +glm::vec2 GradientDriftBehavior::velocity(ndde::sim::ParticleState& state, + const ndde::math::ISurface& surface, + float, + const SimulationContext&, + ParticleId) const { + const glm::vec3 du = surface.du(state.uv.x, state.uv.y); + const glm::vec3 dv = surface.dv(state.uv.x, state.uv.y); + glm::vec2 grad{du.z, dv.z}; + if (ops::length(grad) < 1e-7f) + return glm::vec2{ops::cos(state.angle), ops::sin(state.angle)} * m_p.speed; + + switch (m_p.mode) { + case Mode::Downhill: + grad = -grad; + break; + case Mode::LevelTangent: + grad = {-grad.y, grad.x}; + break; + case Mode::Uphill: + default: + break; + } + return normalize_or_zero(grad) * m_p.speed; +} + +std::string GradientDriftBehavior::metadata_label() const { + switch (m_p.mode) { + case Mode::Downhill: return "Gradient Downhill"; + case Mode::LevelTangent: return "Gradient Level Tangent"; + case Mode::Uphill: + default: return "Gradient Uphill"; + } +} + +glm::vec2 OrbitBehavior::velocity(ndde::sim::ParticleState& state, + const ndde::math::ISurface& surface, + float, + const SimulationContext&, + ParticleId) const { + const glm::vec2 radial = shortest_delta(state.uv, m_p.center, surface); + const float r = ops::length(radial); + const glm::vec2 outward = r > 1e-7f ? radial / r : glm::vec2{1.f, 0.f}; + glm::vec2 tangent = m_p.clockwise + ? glm::vec2{outward.y, -outward.x} + : glm::vec2{-outward.y, outward.x}; + const glm::vec2 radial_correction = -outward * ((r - m_p.radius) * m_p.radial_strength); + return tangent * (m_p.angular_speed * std::max(m_p.radius, 0.01f)) + radial_correction; +} + +glm::vec2 FlockingBehavior::velocity(ndde::sim::ParticleState& state, + const ndde::math::ISurface& surface, + float, + const SimulationContext& context, + ParticleId owner) const { + glm::vec2 separation{0.f, 0.f}; + glm::vec2 cohesion_sum{0.f, 0.f}; + glm::vec2 alignment_sum{0.f, 0.f}; + u32 neighbor_count = 0; + u32 alignment_count = 0; + + for (const Particle& other : context.particles()) { + if (other.id() == owner || other.particle_role() != m_p.role) continue; + const glm::vec2 delta = shortest_delta(other.head_uv(), state.uv, surface); + const float d = ops::length(delta); + if (d > m_p.neighbor_radius || d < 1e-7f) continue; + + cohesion_sum += delta; + ++neighbor_count; + + if (d < m_p.separation_radius) + separation -= delta / (d * d + 1e-4f); + + if (other.trail_size() >= 2) { + const Vec3 a = other.trail_pt(other.trail_size() - 2u); + const Vec3 b = other.trail_pt(other.trail_size() - 1u); + const glm::vec2 heading{b.x - a.x, b.y - a.y}; + if (ops::length(heading) > 1e-7f) { + alignment_sum += normalize_or_zero(heading); + ++alignment_count; + } + } + } + + glm::vec2 velocity{0.f, 0.f}; + if (neighbor_count > 0) + velocity += m_p.cohesion_weight * normalize_or_zero(cohesion_sum / static_cast(neighbor_count)); + velocity += m_p.separation_weight * normalize_or_zero(separation); + if (alignment_count > 0) + velocity += m_p.alignment_weight * normalize_or_zero(alignment_sum / static_cast(alignment_count)); + + return normalize_or_zero(velocity) * m_p.speed; +} + +std::string BehaviorStack::name() const { + if (m_behaviors.empty()) return "BehaviorStack"; + std::string out; + for (const auto& entry : m_behaviors) { + if (!out.empty()) out += " + "; + out += entry.behavior->metadata_label(); + } + return out; +} + +memory::FrameVector BehaviorStack::behavior_labels() const { + memory::FrameVector labels; + labels.reserve(m_behaviors.size()); + for (const auto& entry : m_behaviors) + labels.push_back(entry.behavior->metadata_label()); + if (m_velocity_transform.enabled) + labels.push_back("Slope Velocity Transform"); + return labels; +} + +AnimatedCurve ParticleBuilder::build(const ndde::sim::IIntegrator* deterministic, + const ndde::sim::IIntegrator* stochastic) { + const ndde::sim::IIntegrator* integrator = (m_stochastic && stochastic) ? stochastic : deterministic; + auto stack = make_sim_unique(std::move(m_stack)); + + AnimatedCurve particle = AnimatedCurve::with_equation( + m_uv.x, m_uv.y, + m_role == ParticleRole::Chaser ? AnimatedCurve::Role::Chaser : AnimatedCurve::Role::Leader, + 0u, + m_surface, + std::move(stack), + integrator, + m_memory); + + particle.bind_memory(m_memory); + particle.set_particle_role(m_role); + particle.set_label(m_label); + particle.set_trail_config(m_trail); + if (m_enable_history) + particle.enable_history(m_history_capacity, m_history_dt_min); + for (auto& constraint : m_constraints) + particle.add_constraint(std::move(constraint)); + particle.bind_behavior_stack(); + return particle; +} + +} // namespace ndde diff --git a/nurbs_dde/src/app/ParticleSystem.hpp b/nurbs_dde/src/app/ParticleSystem.hpp new file mode 100644 index 00000000..04f8b2aa --- /dev/null +++ b/nurbs_dde/src/app/ParticleSystem.hpp @@ -0,0 +1,171 @@ +#pragma once +// app/ParticleSystem.hpp +// Scene-local owner for particles, particle constraints, and particle goals. + +#include "app/AnimatedCurve.hpp" +#include "app/ParticleFactory.hpp" +#include "app/ParticleGoals.hpp" +#include "app/SimulationContext.hpp" +#include "engine/IScene.hpp" +#include "memory/Containers.hpp" +#include "memory/MemoryService.hpp" +#include "sim/EulerIntegrator.hpp" +#include "sim/IConstraint.hpp" +#include "sim/MilsteinIntegrator.hpp" +#include +#include +#include + +namespace ndde { + +class ParticleSystem { +public: + explicit ParticleSystem(const ndde::math::ISurface* surface, + std::uint32_t seed = std::random_device{}()) + : m_surface(surface), m_rng(seed) + {} + + void bind_memory(memory::MemoryService* memory) { + m_memory = memory; + rebind_vector(m_particles, memory ? memory->simulation().resource() : std::pmr::get_default_resource()); + rebind_vector(m_pair_constraints, memory ? memory->simulation().resource() : std::pmr::get_default_resource()); + rebind_vector(m_goals, memory ? memory->simulation().resource() : std::pmr::get_default_resource()); + for (Particle& particle : m_particles) + particle.bind_memory(memory); + } + + void set_surface(const ndde::math::ISurface* surface) noexcept { m_surface = surface; } + [[nodiscard]] const ndde::math::ISurface* surface() const noexcept { return m_surface; } + + [[nodiscard]] ParticleFactory factory() const noexcept { return ParticleFactory(m_surface, m_memory); } + [[nodiscard]] std::mt19937& rng() noexcept { return m_rng; } + + [[nodiscard]] memory::SimVector& particles() noexcept { return m_particles; } + [[nodiscard]] const memory::SimVector& particles() const noexcept { return m_particles; } + [[nodiscard]] bool empty() const noexcept { return m_particles.empty(); } + [[nodiscard]] std::size_t size() const noexcept { return m_particles.size(); } + + [[nodiscard]] memory::FrameVector snapshot_particles() const { + memory::FrameVector out = + m_memory ? m_memory->frame().make_vector() : memory::FrameVector{}; + out.reserve(m_particles.size()); + for (const auto& particle : m_particles) { + const glm::vec2 uv = particle.head_uv(); + const Vec3 head = particle.head_world(); + out.push_back(ParticleSnapshot{ + .id = particle.id(), + .role = std::string(role_name(particle.particle_role())), + .label = particle.metadata_label(), + .u = uv.x, + .v = uv.y, + .x = head.x, + .y = head.y, + .z = head.z + }); + } + return out; + } + + Particle& spawn(ParticleBuilder builder) { + m_particles.push_back(builder.build(&m_euler, &m_milstein)); + return m_particles.back(); + } + + void clear() { m_particles.clear(); } + void clear_goals() { m_goals.clear(); } + void clear_pair_constraints() { m_pair_constraints.clear(); } + void clear_all() { + m_particles.clear(); + m_pair_constraints.clear(); + m_goals.clear(); + } + + void update(float dt, float speed_scale, float sim_time) { + SimulationContext context(m_surface, &m_particles, &m_rng); + context.set_time(sim_time); + for (auto& particle : m_particles) { + particle.set_behavior_context(&context); + particle.advance(dt, speed_scale); + particle.push_history(sim_time); + } + apply_pair_constraints(); + } + + [[nodiscard]] SimulationContext context(float sim_time) { + SimulationContext c(m_surface, &m_particles, &m_rng); + c.set_time(sim_time); + return c; + } + + template + Constraint& add_pair_constraint(Args&&... args) { + auto c = make_sim_unique(std::forward(args)...); + Constraint& ref = *c; + m_pair_constraints.push_back(std::move(c)); + return ref; + } + + template + Goal& add_goal(Args&&... args) { + auto g = make_sim_unique(std::forward(args)...); + Goal& ref = *g; + m_goals.push_back(std::move(g)); + return ref; + } + + [[nodiscard]] GoalStatus evaluate_goals(float sim_time) { + SimulationContext c = context(sim_time); + GoalStatus aggregate = GoalStatus::Running; + for (const auto& goal : m_goals) { + if (!goal) continue; + const GoalStatus status = goal->evaluate(c); + if (status != GoalStatus::Running) + aggregate = status; + } + return aggregate; + } + +private: + const ndde::math::ISurface* m_surface = nullptr; + memory::MemoryService* m_memory = nullptr; + ndde::sim::EulerIntegrator m_euler; + ndde::sim::MilsteinIntegrator m_milstein; + memory::SimVector m_particles; + memory::SimVector> m_pair_constraints; + memory::SimVector> m_goals; + std::mt19937 m_rng; + + template + static void rebind_vector(memory::SimVector& vector, std::pmr::memory_resource* resource) { + if (vector.get_allocator().resource() == resource) + return; + memory::SimVector rebound{resource}; + rebound.reserve(vector.size()); + for (auto& item : vector) + rebound.push_back(std::move(item)); + std::destroy_at(&vector); + std::construct_at(&vector, std::move(rebound)); + } + + template + [[nodiscard]] memory::Unique make_sim_unique(Args&&... args) const { + return m_memory ? m_memory->simulation().make_unique(std::forward(args)...) + : memory::make_unique(std::pmr::get_default_resource(), std::forward(args)...); + } + + void apply_pair_constraints() { + if (!m_surface || m_pair_constraints.empty()) return; + for (auto& constraint : m_pair_constraints) { + if (!constraint) continue; + for (std::size_t i = 0; i < m_particles.size(); ++i) { + for (std::size_t j = i + 1; j < m_particles.size(); ++j) { + constraint->apply(m_particles[i].walk_state(), + m_particles[j].walk_state(), + *m_surface); + } + } + } + } +}; + +} // namespace ndde diff --git a/nurbs_dde/src/app/ParticleTypes.hpp b/nurbs_dde/src/app/ParticleTypes.hpp new file mode 100644 index 00000000..0ad01a35 --- /dev/null +++ b/nurbs_dde/src/app/ParticleTypes.hpp @@ -0,0 +1,52 @@ +#pragma once +// app/ParticleTypes.hpp +// Shared particle vocabulary: roles, metadata, and visual trail policy. + +#include "memory/Containers.hpp" +#include "math/Scalars.hpp" +#include +#include +#include + +namespace ndde { + +using ParticleId = std::uint64_t; + +enum class ParticleRole : u8 { + Neutral, + Leader, + Chaser, + Avoider +}; + +[[nodiscard]] inline std::string_view role_name(ParticleRole role) noexcept { + switch (role) { + case ParticleRole::Leader: return "Leader"; + case ParticleRole::Chaser: return "Chaser"; + case ParticleRole::Avoider: return "Avoider"; + case ParticleRole::Neutral: default: return "Neutral"; + } +} + +enum class TrailMode : u8 { + None, + Finite, + Persistent, + StaticCurve +}; + +struct TrailConfig { + TrailMode mode = TrailMode::Finite; + u32 max_points = 1200; + float min_spacing = 0.015f; +}; + +struct ParticleMetadata { + std::string label; + std::string role; + memory::FrameVector behaviors; + memory::FrameVector constraints; + memory::FrameVector goals; +}; + +} // namespace ndde diff --git a/nurbs_dde/src/app/ProjectedSurfaceCanvas.hpp b/nurbs_dde/src/app/ProjectedSurfaceCanvas.hpp new file mode 100644 index 00000000..951b8981 --- /dev/null +++ b/nurbs_dde/src/app/ProjectedSurfaceCanvas.hpp @@ -0,0 +1,144 @@ +#pragma once +// app/ProjectedSurfaceCanvas.hpp +// Shared ImGui projected surface canvas for analytic surface scenes. + +#include "app/ProjectedParticleOverlay.hpp" +#include "app/ParticleSystem.hpp" +#include "app/SurfaceMeshCache.hpp" +#include "app/Viewport.hpp" +#include "engine/CanvasInput.hpp" +#include "numeric/ops.hpp" + +#include +#include + +namespace ndde { + +struct ProjectedSurfaceCanvasOptions { + u32 grid_lines = 64; + float color_scale = 1.f; + Vec4 wire_color{0.92f, 0.96f, 1.f, 0.32f}; + SurfaceFillColorMode fill_color_mode = SurfaceFillColorMode::HeightCell; + bool show_frenet = true; + bool show_osculating_circle = true; + float overlay_frame_scale = 0.34f; + const char* canvas_id = "projected_surface_canvas"; + const char* help_text = nullptr; + const char* subtitle = nullptr; + bool paused = false; +}; + +class ProjectedSurfaceCanvas { +public: + template + static void draw(Surface& surface, + SurfaceMeshCache& mesh, + Viewport& viewport, + const ParticleSystem& particles, + const ProjectedSurfaceCanvasOptions& options) + { + const ImVec2 csz = ImGui::GetContentRegionAvail(); + const CanvasInputFrame canvas = begin_canvas_input(options.canvas_id, csz); + const ImVec2 cpos = canvas.pos; + viewport.fb_w = csz.x; viewport.fb_h = csz.y; + viewport.dp_w = csz.x; viewport.dp_h = csz.y; + + if (canvas.hovered) { + if (ops::abs(canvas.mouse_wheel) > 0.f) + viewport.zoom = ops::clamp(viewport.zoom * (1.f + 0.12f * canvas.mouse_wheel), 0.05f, 20.f); + if (canvas.orbit_drag) + viewport.orbit(canvas.mouse_delta.x, canvas.mouse_delta.y); + } + + rebuild(surface, mesh, options); + draw_surface(mesh, viewport, surface.extent(), cpos, csz); + + ImDrawList* dl = ImGui::GetWindowDrawList(); + [[maybe_unused]] const ProjectedParticleOverlayResult overlay = + draw_projected_particle_overlay(dl, particles.particles(), cpos, csz, + [&viewport, ext = surface.extent(), cpos, csz](Vec3 p) { + return projected_to_canvas(project_point(p, viewport), cpos, csz, ext); + }, + ProjectedParticleOverlayOptions{ + .hover_enabled = canvas.hovered, + .show_frenet = options.show_frenet, + .show_osculating_circle = options.show_osculating_circle, + .frame_scale = options.overlay_frame_scale + }); + + if (options.help_text) + dl->AddText(ImVec2(cpos.x + 8.f, cpos.y + 6.f), IM_COL32(220, 220, 220, 190), options.help_text); + if (options.subtitle) + dl->AddText(ImVec2(cpos.x + 8.f, cpos.y + 24.f), IM_COL32(160, 210, 255, 180), options.subtitle); + if (options.paused) + dl->AddText(ImVec2(cpos.x + 8.f, cpos.y + (options.subtitle ? 42.f : 26.f)), + IM_COL32(255, 210, 60, 240), "PAUSED [Ctrl+P]"); + } + + [[nodiscard]] static Vec3 project_point(Vec3 p, const Viewport& viewport) noexcept { + const float cy = ops::cos(viewport.yaw); + const float sy = ops::sin(viewport.yaw); + const float cp = ops::cos(viewport.pitch); + const float sp = ops::sin(viewport.pitch); + const float xr = cy * p.x - sy * p.y; + const float yr = sy * p.x + cy * p.y; + const float screen_y = cp * yr - sp * p.z; + const float inv_zoom = 1.f / ops::clamp(viewport.zoom, 0.05f, 20.f); + return {xr * inv_zoom, screen_y * inv_zoom, (sp * yr + cp * p.z) * 0.01f}; + } + + [[nodiscard]] static ImVec2 projected_to_canvas(Vec3 p, const ImVec2& cpos, const ImVec2& csz, float extent) noexcept { + const float aspect = csz.x / std::max(csz.y, 1.f); + const float half_x = aspect >= 1.f ? extent * aspect : extent; + const float half_y = aspect >= 1.f ? extent : extent / aspect; + const float nx = (p.x + half_x) / (2.f * half_x); + const float ny = 1.f - ((p.y + half_y) / (2.f * half_y)); + return {cpos.x + nx * csz.x, cpos.y + ny * csz.y}; + } + + [[nodiscard]] static ImU32 imgui_color(Vec4 c) noexcept { + const auto u8 = [](float v) { return static_cast(ops::clamp(v, 0.f, 1.f) * 255.f + 0.5f); }; + return IM_COL32(u8(c.r), u8(c.g), u8(c.b), u8(c.a)); + } + +private: + template + static void rebuild(Surface& surface, SurfaceMeshCache& mesh, const ProjectedSurfaceCanvasOptions& options) { + mesh.rebuild_if_needed(surface, SurfaceMeshOptions{ + .grid_lines = options.grid_lines, + .time = 0.f, + .color_scale = options.color_scale, + .wire_color = options.wire_color, + .fill_color_mode = options.fill_color_mode, + .build_contour = true + }); + } + + static void draw_surface(const SurfaceMeshCache& mesh, const Viewport& viewport, float extent, + const ImVec2& cpos, const ImVec2& csz) + { + ImDrawList* dl = ImGui::GetWindowDrawList(); + dl->PushClipRect(cpos, ImVec2(cpos.x + csz.x, cpos.y + csz.y), true); + + for (u32 i = 0; i + 2 < mesh.fill_count(); i += 3) { + const Vertex& a = mesh.fill_vertices()[i]; + const Vertex& b = mesh.fill_vertices()[i + 1]; + const Vertex& c = mesh.fill_vertices()[i + 2]; + dl->AddTriangleFilled( + projected_to_canvas(project_point(a.pos, viewport), cpos, csz, extent), + projected_to_canvas(project_point(b.pos, viewport), cpos, csz, extent), + projected_to_canvas(project_point(c.pos, viewport), cpos, csz, extent), + imgui_color(a.color)); + } + + for (u32 i = 0; i + 1 < mesh.wire_count(); i += 2) { + dl->AddLine(projected_to_canvas(project_point(mesh.wire_vertices()[i].pos, viewport), cpos, csz, extent), + projected_to_canvas(project_point(mesh.wire_vertices()[i + 1].pos, viewport), cpos, csz, extent), + imgui_color(mesh.wire_vertices()[i].color), 1.f); + } + + dl->PopClipRect(); + } +}; + +} // namespace ndde diff --git a/nurbs_dde/src/app/Scene.cpp b/nurbs_dde/src/app/Scene.cpp deleted file mode 100644 index 5339dfa1..00000000 --- a/nurbs_dde/src/app/Scene.cpp +++ /dev/null @@ -1,4 +0,0 @@ -// This file has been moved to src/app/legacy/Scene.cpp (E1 refactor). -// It is no longer compiled. Do not add it back to CMakeLists.txt. -// See src/app/legacy/README.md for details. -#error "Scene.cpp is dead code. See src/app/legacy/Scene.cpp." diff --git a/nurbs_dde/src/app/Scene.hpp b/nurbs_dde/src/app/Scene.hpp deleted file mode 100644 index 44d2a306..00000000 --- a/nurbs_dde/src/app/Scene.hpp +++ /dev/null @@ -1,135 +0,0 @@ -#pragma once -// app/Scene.hpp - -#include "engine/EngineAPI.hpp" -#include "engine/AppConfig.hpp" -#include "math/Axes.hpp" -#include "math/Conics.hpp" -#include "math/Surfaces.hpp" -#include "app/AnalysisPanel.hpp" -#include "app/HoverResult.hpp" -#include "app/PerformancePanel.hpp" -#include "app/Viewport.hpp" -#include "app/CoordDebugPanel.hpp" - -#include -#include -#include -#include -#include - -namespace ndde { - -// ── ConicEntry ──────────────────────────────────────────────────────────────── -// Holds one curve of any supported type. -// type: 0=Parabola 1=Hyperbola 2=Helix 3=ParaboloidCurve - -struct ConicEntry { - std::string name; - bool enabled = true; - Vec4 color = { 1.f, 1.f, 1.f, 1.f }; - u32 tessellation = 256; - std::unique_ptr conic; - int type = 0; - - // Parabola params - float par_a = -1.f, par_b = 0.f, par_c = 0.f; - float par_tmin = -2.f, par_tmax = 2.f; - - // Hyperbola params - float hyp_a = 1.f, hyp_b = 1.f, hyp_h = 0.f, hyp_k = 0.f, hyp_range = 2.f; - bool hyp_two_branch = true; - int hyp_axis = 0; - - // Helix params - float hel_radius = 1.f; - float hel_pitch = 0.5f; - float hel_tmin = 0.f; - float hel_tmax = 6.2832f * 2.f; - - // ParaboloidCurve params - float pc_a = 1.f; // must match the Paraboloid's a - float pc_theta = 0.f; // azimuthal angle (radians) - float pc_tmin = 0.f; - float pc_tmax = 1.5f; - - bool needs_rebuild = true; - void mark_dirty() { needs_rebuild = true; } - - std::vector> snap_cache; - std::vector snap_cache3d; - void rebuild(); -}; - -// ── SurfaceEntry ────────────────────────────────────────────────────────────── -// Holds one parametric 2-manifold surface. -// Currently only type 0 = Paraboloid is implemented. - -struct SurfaceEntry { - std::string name; - bool enabled = true; - Vec4 color = { 0.3f, 0.5f, 0.8f, 0.55f }; - std::unique_ptr surface; - int type = 0; - - // Paraboloid params - float par_a = 1.f; - float par_umax = 1.5f; - u32 u_lines = 16; // wireframe grid density - u32 v_lines = 24; - - bool needs_rebuild = true; - void mark_dirty() { needs_rebuild = true; } - void rebuild(); -}; - -// ── Scene ───────────────────────────────────────────────────────────────────── - -class Scene { -public: - explicit Scene(EngineAPI api); - ~Scene() = default; - - void on_frame(); - -private: - EngineAPI m_api; - Viewport m_vp; - math::AxesConfig m_axes_cfg; - AnalysisPanel m_analysis_panel; - PerformancePanel m_perf_panel; - HoverResult m_hover; - CoordDebugPanel m_coord_debug; - std::vector m_conics; - std::vector m_surfaces; - - void sync_viewport(); - void update_camera(); - void update_hover(); - void update_hover_3d(); - - void submit_grid(); - void submit_axes(); - void submit_conics(); - void submit_surfaces(); ///< wireframe 2-manifolds - void submit_epsilon_ball(); - void submit_epsilon_sphere(); - void submit_interval_lines(); - void submit_interval_labels(); - void submit_secant_line(); - void submit_tangent_line(); - void submit_frenet_frame(); - void submit_osc_circle(); - - void draw_main_panel(); - void draw_conic_panel(ConicEntry& e, int idx); - void draw_surface_panel(SurfaceEntry& e, int idx); - - void add_parabola(); - void add_hyperbola(); - void add_helix(); - void add_paraboloid(); ///< adds paraboloid surface + its meridional curve together - void add_paraboloid_curve(); ///< adds a standalone surface curve -}; - -} // namespace ndde diff --git a/nurbs_dde/src/app/SceneFactories.cpp b/nurbs_dde/src/app/SceneFactories.cpp index 86a15306..73ef8aa2 100644 --- a/nurbs_dde/src/app/SceneFactories.cpp +++ b/nurbs_dde/src/app/SceneFactories.cpp @@ -1,18 +1,17 @@ #include "app/SceneFactories.hpp" -#include "app/AnalysisScene.hpp" -#include "app/SurfaceSimScene.hpp" - -#include +#include "app/SimulationAnalysis.hpp" +#include "app/SimulationMultiWell.hpp" +#include "app/SimulationSurfaceGaussian.hpp" +#include "app/SimulationWavePredatorPrey.hpp" namespace ndde { -std::unique_ptr make_surface_sim_scene(EngineAPI api) { - return std::make_unique(std::move(api)); -} - -std::unique_ptr make_analysis_scene(EngineAPI api) { - return std::make_unique(std::move(api)); +void register_default_simulations(SimulationRegistry& registry) { + registry.add_runtime("Surface Simulation"); + registry.add_runtime("Sine-Rational Analysis"); + registry.add_runtime("Multi-Well Centroid"); + registry.add_runtime("Wave Predator-Prey"); } } // namespace ndde diff --git a/nurbs_dde/src/app/SceneFactories.hpp b/nurbs_dde/src/app/SceneFactories.hpp index 674d0879..dedc5acd 100644 --- a/nurbs_dde/src/app/SceneFactories.hpp +++ b/nurbs_dde/src/app/SceneFactories.hpp @@ -1,14 +1,12 @@ #pragma once // app/SceneFactories.hpp -// Type-erased scene construction helpers used by Engine and scene switch UI. +// Simulation registration helpers used by Engine. -#include "engine/EngineAPI.hpp" -#include "engine/IScene.hpp" -#include +#include "engine/SimulationRuntime.hpp" namespace ndde { -[[nodiscard]] std::unique_ptr make_surface_sim_scene(EngineAPI api); -[[nodiscard]] std::unique_ptr make_analysis_scene(EngineAPI api); +void register_default_simulations(SimulationRegistry& registry); } // namespace ndde + diff --git a/nurbs_dde/src/app/Scene_on_frame_patch.bak b/nurbs_dde/src/app/Scene_on_frame_patch.bak deleted file mode 100644 index 3edc8c1e..00000000 --- a/nurbs_dde/src/app/Scene_on_frame_patch.bak +++ /dev/null @@ -1 +0,0 @@ -// Moved to src/app/legacy/Scene_on_frame_patch.bak (E1 refactor). diff --git a/nurbs_dde/src/app/SimulationAnalysis.cpp b/nurbs_dde/src/app/SimulationAnalysis.cpp new file mode 100644 index 00000000..cf19ebf7 --- /dev/null +++ b/nurbs_dde/src/app/SimulationAnalysis.cpp @@ -0,0 +1,215 @@ +#include "app/SimulationAnalysis.hpp" + +#include "app/AlternateViewPanel.hpp" +#include "app/SimulationRenderPackets.hpp" +#include "memory/Containers.hpp" + +#include +#include + +namespace ndde { + +SimulationAnalysis::SimulationAnalysis(memory::MemoryService* memory) + : m_surface(SurfaceRegistry::make_sine_rational(memory, 4.f)) + , m_particles(m_surface.get(), 2102u) + , m_spawner(*m_surface, m_particles, m_spawn_count, m_epsilon, m_walk_speed, + m_noise_sigma, m_sim_time, m_sim_speed, m_goal_status) +{ + sync_context(); +} + +void SimulationAnalysis::on_register(SimulationHost& host) { + m_host = &host; + sync_context(); + m_panel_handles.add(host.panels().register_panel(PanelDescriptor{ + .title = "Sim - Controls", + .category = "Simulation", + .scope = PanelScope::Simulation, + .draw = [this] { draw_control_panel(); } + })); + m_panel_handles.add(host.panels().register_panel(PanelDescriptor{ + .title = "Sim - Swarms", + .category = "Simulation", + .scope = PanelScope::Simulation, + .draw = [this] { draw_swarm_panel(); } + })); + m_panel_handles.add(host.panels().register_panel(PanelDescriptor{ + .title = "Sim - Particles", + .category = "Simulation", + .scope = PanelScope::Simulation, + .draw = [this] { draw_particle_panel(); } + })); + m_panel_handles.add(host.panels().register_panel(PanelDescriptor{ + .title = "Sim - Goals", + .category = "Simulation", + .scope = PanelScope::Simulation, + .draw = [this] { draw_goal_panel(); } + })); + m_spawn_hotkey = host.hotkeys().register_action(HotkeyDescriptor{ + .chord = {.key = 'W', .mods = 2}, + .label = "Spawn analysis walker", + .callback = [this] { spawn_walker(); } + }); + m_reset_hotkey = host.hotkeys().register_action(HotkeyDescriptor{ + .chord = {.key = 'R', .mods = 2}, + .label = "Reset analysis showcase", + .callback = [this] { reset_showcase(); } + }); + m_main_handle = host.render().register_view(RenderViewDescriptor{ + .title = "Analysis 3D", + .kind = RenderViewKind::Main, + .overlays = {.show_axes = true, .show_grid = true, .show_hover_frenet = true, .show_osculating_circle = true} + }, &m_main_view); + m_alt_handle = host.render().register_view(RenderViewDescriptor{ + .title = "Analysis Alternate", + .kind = RenderViewKind::Alternate, + .alternate_mode = AlternateViewMode::LevelCurves, + .projection = CameraProjection::Orthographic, + .overlays = {.show_axes = true}, + .alternate = { + .isocline_direction_angle = 0.6f, + .isocline_target_slope = 0.f, + .isocline_tolerance = 0.8f, + .isocline_bands = 7u, + .vector_mode = VectorFieldMode::LevelTangent, + .vector_samples = 20u, + .vector_scale = 1.f, + .flow_seed_count = 9u, + .flow_steps = 36u, + .flow_step_size = 0.09f + } + }, &m_alternate_view); +} + +void SimulationAnalysis::on_start() { + reset_showcase(); +} + +void SimulationAnalysis::on_tick(const TickInfo& tick) { + m_context.set_tick(tick); + if (!tick.paused && !m_paused) { + m_sim_time = tick.time; + m_particles.update(tick.dt, m_sim_speed, m_sim_time); + m_context.dirty().mark_particles_changed(); + } + submit_geometry(); +} + +void SimulationAnalysis::on_stop() { + m_panel_handles.clear(); + m_spawn_hotkey.reset(); + m_reset_hotkey.reset(); + m_main_handle.reset(); + m_alt_handle.reset(); + m_host = nullptr; +} + +SceneSnapshot SimulationAnalysis::snapshot() const { + return SceneSnapshot{ + .name = std::string(name()), + .paused = m_paused, + .sim_time = m_sim_time, + .sim_speed = m_sim_speed, + .particle_count = m_particles.size(), + .status = m_goal_status == GoalStatus::Succeeded ? "Succeeded" : "Running", + .particles = m_particles.snapshot_particles() + }; +} + +SimulationMetadata SimulationAnalysis::metadata() const { + const ndde::math::SurfaceMetadata surface = m_surface->metadata(m_sim_time); + return SimulationMetadata{ + .name = std::string(name()), + .surface_name = std::string(surface.name), + .surface_formula = std::string(surface.formula), + .status = m_goal_status == GoalStatus::Succeeded ? "Succeeded" : "Running", + .sim_time = m_sim_time, + .sim_speed = m_sim_speed, + .particle_count = m_particles.size(), + .paused = m_paused, + .goal_succeeded = m_goal_status == GoalStatus::Succeeded, + .surface_has_analytic_derivatives = surface.has_analytic_derivatives, + .surface_deformable = surface.deformable, + .surface_time_varying = surface.time_varying + }; +} + +void SimulationAnalysis::sync_context() { + if (m_host) m_particles.bind_memory(&m_host->memory()); + m_particles.set_surface(m_surface.get()); + m_context.set_surface(m_surface.get()); + m_context.set_particles(&m_particles.particles()); + m_context.set_rng(&m_particles.rng()); + m_context.set_time(m_sim_time); +} + +void SimulationAnalysis::spawn_walker() { + m_last_swarm = m_spawner.spawn_walker(); + sync_context(); +} + +void SimulationAnalysis::reset_showcase() { + m_last_swarm = m_spawner.spawn_showcase_service(); + sync_context(); +} + +void SimulationAnalysis::draw_control_panel() { + ImGui::SetNextWindowPos(ImVec2(24.f, 72.f), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(300.f, 260.f), ImGuiCond_FirstUseEver); + if (!ImGui::Begin("Sim - Controls")) { ImGui::End(); return; } + SimulationControlPanel::draw(metadata(), SimulationControls{ + .paused = &m_paused, + .sim_speed = &m_sim_speed, + .reset = [this] { reset_showcase(); } + }); + ImGui::SeparatorText("Walker parameters"); + ImGui::SliderFloat("epsilon", &m_epsilon, 0.01f, 0.5f); + ImGui::SliderFloat("walk speed", &m_walk_speed, 0.05f, 2.f); + ImGui::SliderFloat("noise", &m_noise_sigma, 0.f, 0.3f); + if (m_host) AlternateViewPanel::draw(m_host->render(), m_alternate_view); + ImGui::End(); +} + +void SimulationAnalysis::draw_swarm_panel() { + ImGui::SetNextWindowPos(ImVec2(24.f, 350.f), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(300.f, 180.f), ImGuiCond_FirstUseEver); + if (!ImGui::Begin("Sim - Swarms")) { ImGui::End(); return; } + memory::FrameVector actions{ + {.label = "Spawn walker", .hotkey = "Ctrl+W", .spawn = [this] { spawn_walker(); return m_last_swarm; }}, + {.label = "Reset analysis", .hotkey = "Ctrl+R", .spawn = [this] { reset_showcase(); return m_last_swarm; }} + }; + SwarmRecipePanel::draw(SwarmRecipePanelState{}, actions, m_particles, m_goal_status, &m_last_swarm, + SwarmRecipePanelOptions{.show_sim_controls = false, .show_particle_inspector = false}); + ImGui::End(); +} + +void SimulationAnalysis::draw_particle_panel() { + ImGui::SetNextWindowPos(ImVec2(342.f, 72.f), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(420.f, 480.f), ImGuiCond_FirstUseEver); + if (!ImGui::Begin("Sim - Particles")) { ImGui::End(); return; } + ParticleInspectorPanel::draw(m_particles.particles()); + ImGui::End(); +} + +void SimulationAnalysis::draw_goal_panel() { + ImGui::SetNextWindowPos(ImVec2(24.f, 548.f), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(300.f, 130.f), ImGuiCond_FirstUseEver); + if (!ImGui::Begin("Sim - Goals")) { ImGui::End(); return; } + GoalStatusPanel::draw(metadata()); + ImGui::End(); +} + +void SimulationAnalysis::submit_geometry() { + if (!m_host) return; + submit_surface_sim_packets(m_host->render(), m_main_view, m_alternate_view, + *m_surface, m_mesh, m_particles, SurfaceMeshOptions{ + .grid_lines = 60u, + .time = 0.f, + .color_scale = 3.f, + .wire_color = {0.3f, 0.6f, 0.9f, 0.55f}, + .fill_color_mode = SurfaceFillColorMode::GaussianCurvatureCell, + .build_contour = true + }, &m_host->interaction(), &m_host->memory()); +} + +} // namespace ndde diff --git a/nurbs_dde/src/app/SpawnStrategy.hpp b/nurbs_dde/src/app/SpawnStrategy.hpp index 70e65f68..67110ab7 100644 --- a/nurbs_dde/src/app/SpawnStrategy.hpp +++ b/nurbs_dde/src/app/SpawnStrategy.hpp @@ -1,7 +1,6 @@ #pragma once // app/SpawnStrategy.hpp -// Lightweight spawn helpers extracted from SurfaceSimScene::handle_hotkeys() -// (B3 refactor). +// Lightweight legacy spawn helpers kept for older AnimatedCurve-based paths. // // All functions are header-only (short, inline). No .cpp needed. // @@ -17,12 +16,12 @@ // Delay-pursuit chaser: prewarm = false (must wait for leader history) #include "app/AnimatedCurve.hpp" +#include "numeric/ops.hpp" #include "math/Surfaces.hpp" +#include "memory/Containers.hpp" #include "sim/IEquation.hpp" #include "sim/IIntegrator.hpp" #include -#include -#include #include #include @@ -46,7 +45,7 @@ struct SpawnContext { // there are no curves yet. Used as the anchor for offset_spawn(). [[nodiscard]] inline glm::vec2 reference_uv( - const std::vector& curves, + const memory::SimVector& curves, const ndde::math::ISurface& surface) noexcept { if (curves.empty()) @@ -67,9 +66,9 @@ struct SpawnContext { { constexpr float margin = 0.5f; return { - std::clamp(ref_uv.x + offset_radius * std::cos(angle), + std::clamp(ref_uv.x + offset_radius * ops::cos(angle), surface.u_min() + margin, surface.u_max() - margin), - std::clamp(ref_uv.y + offset_radius * std::sin(angle), + std::clamp(ref_uv.y + offset_radius * ops::sin(angle), surface.v_min() + margin, surface.v_max() - margin) }; } @@ -101,7 +100,7 @@ struct SpawnContext { glm::vec2 uv, AnimatedCurve::Role role, u32 slot, - std::unique_ptr eq, + memory::Unique eq, const SpawnContext& ctx, bool prewarm = true) { diff --git a/nurbs_dde/src/app/SurfaceRegistry.hpp b/nurbs_dde/src/app/SurfaceRegistry.hpp new file mode 100644 index 00000000..9a14195c --- /dev/null +++ b/nurbs_dde/src/app/SurfaceRegistry.hpp @@ -0,0 +1,141 @@ +#pragma once +// app/SurfaceRegistry.hpp +// Named surface definitions used by simulation scenes. + +#include "math/SineRationalSurface.hpp" +#include "math/Surfaces.hpp" +#include "memory/MemoryService.hpp" +#include "memory/Unique.hpp" +#include "numeric/ops.hpp" + +#include + +namespace ndde { + +class MultiWellWaveSurface final : public ndde::math::ISurface { +public: + explicit MultiWellWaveSurface(float extent = 4.f) : m_extent(extent) {} + + [[nodiscard]] Vec3 evaluate(float x, float y, float = 0.f) const override { + return {x, y, height(x, y)}; + } + + [[nodiscard]] float height(float x, float y) const noexcept { + const float g0 = 1.15f * ops::exp(-((x - 1.4f) * (x - 1.4f) + (y - 0.6f) * (y - 0.6f))); + const float g1 = -0.85f * ops::exp(-(((x + 1.2f) * (x + 1.2f)) / 0.8f + + ((y + 1.0f) * (y + 1.0f)) / 1.4f)); + const float g2 = 0.65f * ops::exp(-(((x - 0.2f) * (x - 0.2f)) / 1.8f + + ((y - 1.6f) * (y - 1.6f)) / 0.6f)); + const float w0 = 0.18f * ops::sin(2.f * x) * ops::cos(2.f * y); + const float w1 = 0.08f * ops::sin(4.f * x + y) * ops::cos(3.f * y - x); + return g0 + g1 + g2 + w0 + w1; + } + + [[nodiscard]] float u_min(float = 0.f) const override { return -m_extent; } + [[nodiscard]] float u_max(float = 0.f) const override { return m_extent; } + [[nodiscard]] float v_min(float = 0.f) const override { return -m_extent; } + [[nodiscard]] float v_max(float = 0.f) const override { return m_extent; } + [[nodiscard]] ndde::math::SurfaceMetadata metadata(float t = 0.f) const override { + ndde::math::SurfaceMetadata data = ndde::math::ISurface::metadata(t); + data.name = "Multi-Well Wave Surface"; + data.formula = "three Gaussian wells + 0.18 sin(2x)cos(2y) + 0.08 sin(4x+y)cos(3y-x)"; + data.has_analytic_derivatives = false; + data.parameters = {{ + {.name = "extent", .value = m_extent, .description = "square domain half-width"} + }}; + data.parameter_count = 1u; + return data; + } + [[nodiscard]] float extent() const noexcept { return m_extent; } + +private: + float m_extent = 4.f; +}; + +class WavePredatorPreySurface final : public ndde::math::ISurface { +public: + explicit WavePredatorPreySurface(float extent = 4.f) : m_extent(extent) {} + + [[nodiscard]] Vec3 evaluate(float x, float y, float = 0.f) const override { + return {x, y, height(x, y)}; + } + + [[nodiscard]] float height(float x, float y) const noexcept { + return ops::sin(x) + ops::cos(y) + + 0.5f * (ops::sin(2.f * x) + ops::cos(2.f * y)); + } + + [[nodiscard]] float u_min(float = 0.f) const override { return -m_extent; } + [[nodiscard]] float u_max(float = 0.f) const override { return m_extent; } + [[nodiscard]] float v_min(float = 0.f) const override { return -m_extent; } + [[nodiscard]] float v_max(float = 0.f) const override { return m_extent; } + [[nodiscard]] ndde::math::SurfaceMetadata metadata(float t = 0.f) const override { + ndde::math::SurfaceMetadata data = ndde::math::ISurface::metadata(t); + data.name = "Wave Predator-Prey Surface"; + data.formula = "z = sin x + cos y + 0.5(sin 2x + cos 2y)"; + data.has_analytic_derivatives = false; + data.parameters = {{ + {.name = "extent", .value = m_extent, .description = "square domain half-width"} + }}; + data.parameter_count = 1u; + return data; + } + [[nodiscard]] float extent() const noexcept { return m_extent; } + +private: + float m_extent = 4.f; +}; + +enum class SurfaceKey : u8 { + SineRational, + MultiWell, + WavePredatorPrey +}; + +struct SurfaceDescriptor { + SurfaceKey key = SurfaceKey::SineRational; + std::string_view id; + std::string_view display_name; + std::string_view formula; +}; + +class SurfaceRegistry { +public: + [[nodiscard]] static SurfaceDescriptor describe(SurfaceKey key) noexcept { + switch (key) { + case SurfaceKey::MultiWell: + return {key, "multi-well", "Multi-Well", + "z = Gaussian wells + 0.18 sin(2x)cos(2y) + 0.08 sin(4x+y)cos(3y-x)"}; + case SurfaceKey::WavePredatorPrey: + return {key, "wave-predator-prey", "Wave Predator-Prey", + "z = sin x + cos y + 0.5(sin 2x + cos 2y)"}; + case SurfaceKey::SineRational: + default: + return {key, "sine-rational", "Sine-Rational", + "z = [3/(1+(x+y+1)^2)] sin(2x)cos(2y) + 0.1 sin(5x)sin(5y)"}; + } + } + + [[nodiscard]] static memory::Unique + make_sine_rational(memory::MemoryService* mem = nullptr, float extent = 4.f) { + return mem ? mem->simulation().make_unique(extent) + : memory::make_unique( + std::pmr::get_default_resource(), extent); + } + + [[nodiscard]] static memory::Unique + make_multi_well(memory::MemoryService* mem = nullptr, float extent = 4.f) { + return mem ? mem->simulation().make_unique(extent) + : memory::make_unique( + std::pmr::get_default_resource(), extent); + } + + [[nodiscard]] static memory::Unique + make_wave_predator_prey(memory::MemoryService* mem = nullptr, float extent = 4.f) { + return mem ? mem->simulation().make_unique(extent) + : memory::make_unique( + std::pmr::get_default_resource(), extent); + } +}; + +} // namespace ndde diff --git a/nurbs_dde/src/app/SurfaceSimPanels.cpp.bak b/nurbs_dde/src/app/SurfaceSimPanels.cpp.bak new file mode 100644 index 00000000..f1969a66 --- /dev/null +++ b/nurbs_dde/src/app/SurfaceSimPanels.cpp.bak @@ -0,0 +1,475 @@ +#include "app/SurfaceSimPanels.hpp" +#include "app/GaussianRipple.hpp" +#include "app/ParticleInspectorPanel.hpp" +#include "app/FrenetFrame.hpp" +#include "numeric/ops.hpp" +#include "sim/MilsteinIntegrator.hpp" +#include +#include +#include +#include + +namespace ndde { + +SurfaceSimPanels::SurfaceSimPanels(std::unique_ptr& surface, + ParticleSystem& particles, + std::vector& curves, + Viewport& vp3d, + Viewport& vp2d, + HotkeyManager& hotkeys, + SurfaceSelectionState& surface_state, + SurfaceDisplayState& display, + SurfaceOverlayState& overlays, + SurfaceUiState& ui, + SurfaceSpawnState& spawn, + SurfaceBehaviorParams& behavior, + SurfacePursuitState& pursuit, + SurfaceSimSpawner& spawner, + SurfaceSimController& controller, + bool& paused, + float& sim_time, + float& sim_speed, + GoalStatus& goal_status, + ExportFn export_session) + : m_surface(surface) + , m_particles(particles) + , m_curves(curves) + , m_vp3d(vp3d) + , m_vp2d(vp2d) + , m_hotkeys(hotkeys) + , m_surface_state(surface_state) + , m_display(display) + , m_overlays(overlays) + , m_ui(ui) + , m_spawn(spawn) + , m_behavior(behavior) + , m_pursuit(pursuit) + , m_spawner(spawner) + , m_controller(controller) + , m_paused(paused) + , m_sim_time(sim_time) + , m_sim_speed(sim_speed) + , m_goal_status(goal_status) + , m_export_session(std::move(export_session)) +{ + register_panels(); +} + +void SurfaceSimPanels::draw_all() { + m_host.draw_all(); +} + +// ── draw_hotkey_panel ───────────────────────────────────────────────────────── + +void SurfaceSimPanels::register_panels() { + m_host.clear(); + m_host.add({ + .title = "Sim - Surface", + .default_pos = {20.f, 20.f}, + .default_size = {300.f, 220.f}, + .bg_alpha = 0.88f, + .draw_body = [this] { draw_panel_surface(); } + }); + m_host.add({ + .title = "Sim - Particles", + .default_pos = {20.f, 250.f}, + .default_size = {300.f, 180.f}, + .bg_alpha = 0.88f, + .draw_body = [this] { draw_panel_particles(); } + }); + m_host.add({ + .title = "Sim - Overlays", + .default_pos = {20.f, 440.f}, + .default_size = {300.f, 240.f}, + .bg_alpha = 0.88f, + .draw_body = [this] { draw_panel_overlays(); } + }); + m_host.add({ + .title = "Sim - Brownian [Ctrl+B]", + .default_pos = {20.f, 690.f}, + .default_size = {300.f, 260.f}, + .bg_alpha = 0.88f, + .draw_body = [this] { draw_panel_brownian(); } + }); + m_host.add({ + .title = "Sim - Pursuit", + .default_pos = {330.f, 700.f}, + .default_size = {310.f, 320.f}, + .bg_alpha = 0.88f, + .draw_body = [this] { draw_panel_pursuit(); } + }); + m_host.add({ + .title = "Sim - Geometry", + .default_pos = {650.f, 700.f}, + .default_size = {310.f, 280.f}, + .bg_alpha = 0.88f, + .draw_body = [this] { draw_panel_geometry(); } + }); + m_host.add({ + .title = "Sim - Camera", + .default_pos = {330.f, 20.f}, + .default_size = {310.f, 170.f}, + .bg_alpha = 0.88f, + .draw_body = [this] { draw_panel_camera(); } + }); +} + +void SurfaceSimPanels::draw_hotkey_panel() { + // Delegate entirely to HotkeyManager. The panel lists every registered + // binding in group order; no hardcoded row table needed here. + m_hotkeys.draw_panel("Hotkeys [Ctrl+H]", m_ui.hotkey_panel_open); +} + +// ── draw_panel_surface ──────────────────────────────────────────────────────── + +void SurfaceSimPanels::draw_panel_surface() { + ImGui::SeparatorText("Surface type"); + { + int sel = static_cast(m_surface_state.type); + bool changed = false; + changed |= ImGui::RadioButton("Gaussian", &sel, 0); + ImGui::SameLine(); + changed |= ImGui::RadioButton("Torus", &sel, 1); + ImGui::SameLine(); + changed |= ImGui::RadioButton("Ripple", &sel, 2); + ImGui::SameLine(); + changed |= ImGui::RadioButton("Extremum##surf", &sel, 3); + if (changed) m_controller.swap_surface(static_cast(sel)); + + if (m_surface_state.type == SurfaceType::Torus) { + bool tp = false; + tp |= ImGui::SliderFloat("R##torus", &m_surface_state.torus_R, 0.5f, 5.f, "%.2f"); + tp |= ImGui::SliderFloat("r##torus", &m_surface_state.torus_r, 0.1f, m_surface_state.torus_R - 0.05f, "%.2f"); + if (tp) m_controller.swap_surface(SurfaceType::Torus); + } + + if (m_surface_state.type == SurfaceType::GaussianRipple) { + auto& p = m_surface_state.ripple_params; + bool rp = false; + rp |= ImGui::SliderFloat("Amplitude##r", &p.amplitude, 0.05f, 2.f, "%.2f"); + rp |= ImGui::SliderFloat("Damping##r", &p.damping, 0.05f, 2.f, "%.3f"); + rp |= ImGui::SliderFloat("Wavelength##r", &p.wavelength, 0.3f, 5.f, "%.2f"); + rp |= ImGui::SliderFloat("Speed##r", &p.speed, 0.1f, 6.f, "%.2f"); + rp |= ImGui::SliderFloat("Sigma##r", &p.sigma, 0.3f, 6.f, "%.2f"); + if (rp) { + if (auto* gr = dynamic_cast(m_surface.get())) + gr->params() = p; + } + ImGui::TextDisabled("Epicentre: (%.2f, %.2f) t=%.2f", + p.epicentre_u, p.epicentre_v, m_sim_time); + ImGui::SameLine(); + if (ImGui::SmallButton("Re-trigger")) { + const Vec3 h = m_curves.empty() ? Vec3{0,0,0} : m_curves[0].head_world(); + p.epicentre_u = h.x; p.epicentre_v = h.y; + if (auto* gr = dynamic_cast(m_surface.get())) + gr->set_epicentre(h.x, h.y); + m_sim_time = 0.f; + } + } + } + + ImGui::SeparatorText("Display"); + { + int mode = static_cast(m_display.mode); + bool changed = false; + changed |= ImGui::RadioButton("Wireframe", &mode, 0); + ImGui::SameLine(); + changed |= ImGui::RadioButton("Filled", &mode, 1); + ImGui::SameLine(); + changed |= ImGui::RadioButton("Both", &mode, 2); + if (changed) { + m_display.mode = static_cast(mode); + m_display.mesh.mark_dirty(); + } + if (m_display.mode != SurfaceDisplay::Wireframe) { + if (ImGui::SliderFloat("K scale##curv", &m_display.curv_scale, 0.01f, 20.f, "%.2f")) + m_display.mesh.mark_dirty(); + } + { + int gl = static_cast(m_display.grid_lines); + if (ImGui::SliderInt("Grid lines##surface", &gl, 8, 256)) { + m_display.grid_lines = static_cast(std::max(gl, 8)); + m_display.mesh.mark_dirty(); + } + ImGui::SameLine(); + ImGui::TextDisabled("~%uk verts", (4u * m_display.grid_lines * (m_display.grid_lines + 1u)) / 1000u); + } + } +} + +// ── draw_panel_particles ────────────────────────────────────────────────────── + +void SurfaceSimPanels::draw_panel_particles() { + ImGui::Checkbox("Paused [Ctrl+P]", &m_paused); + ImGui::SameLine(); + if (ImGui::Button("Reset")) { + m_sim_time = 0.f; + m_spawner.spawn_showcase_service(); + } + ImGui::SliderFloat("Speed##sim", &m_sim_speed, 0.1f, 5.f, "%.2f"); + ImGui::SliderFloat("Arrow scale", &m_display.frame_scale, 0.05f,0.8f, "%.2f"); + ImGui::Checkbox("Contour lines", &m_display.show_contours); + + ImGui::Separator(); + ImGui::TextDisabled("%zu particles (L=%u C=%u)", + m_curves.size(), m_spawn.leader_count, m_spawn.chaser_count); + ImGui::SameLine(); + if (ImGui::SmallButton("Clear all")) { + m_sim_time = 0.f; + m_spawner.spawn_showcase_service(); + } + ImGui::SameLine(); + if (ImGui::SmallButton("Export CSV")) { + const std::string path = "session_" + + std::to_string(m_curves.size()) + "p_" + + std::to_string(static_cast(m_sim_time)) + "s.csv"; + m_export_session(path); + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Write trail + history data to CSV in working directory"); + + ImGui::TextDisabled("Ctrl+L leader Ctrl+C chaser Ctrl+B brownian"); + if (m_goal_status == GoalStatus::Succeeded) + ImGui::TextColored({0.4f, 1.f, 0.4f, 1.f}, "Capture reached - paused"); + ParticleInspectorPanel::draw(m_particles.particles(), ParticleInspectorOptions{ + .label = "Active particles", + .show_level_curve_controls = false, + .show_brownian_controls = true, + .show_trail_controls = true + }); +} + +// ── draw_panel_overlays ─────────────────────────────────────────────────────── + +void SurfaceSimPanels::draw_panel_overlays() { + ImGui::SeparatorText("Frenet frame [Ctrl+F]"); + ImGui::Checkbox("Show Frenet", &m_overlays.show_frenet); + ImGui::BeginDisabled(!m_overlays.show_frenet); + ImGui::SameLine(); ImGui::Checkbox("T##ft", &m_overlays.show_T); + ImGui::SameLine(); ImGui::Checkbox("N##fn", &m_overlays.show_N); + ImGui::SameLine(); ImGui::Checkbox("B##fb", &m_overlays.show_B); + ImGui::Checkbox("Osculating circle", &m_overlays.show_osc); + ImGui::EndDisabled(); + + ImGui::SeparatorText("Other overlays"); + ImGui::Checkbox("Surface frame [Ctrl+D]", &m_overlays.show_dir_deriv); + ImGui::Checkbox("Normal plane [Ctrl+N]", &m_overlays.show_normal_plane); + ImGui::Checkbox("Torsion ribbon [Ctrl+T]", &m_overlays.show_torsion); + + // Torsion live readout when active + if (m_overlays.show_torsion && !m_curves.empty() && m_curves[0].has_trail()) { + const AnimatedCurve& c0 = m_curves[0]; + const u32 hi = c0.trail_size() > 2 ? c0.trail_size()-2 : 0; + const FrenetFrame fr = c0.frenet_at(hi); + const f32 tau = fr.tau; + const ImVec4 tau_col = + tau > 1e-4f ? ImVec4(1.f, 0.55f, 0.1f, 1.f) + : tau < -1e-4f ? ImVec4(0.45f,0.8f, 1.f, 1.f) + : ImVec4(0.7f, 0.7f, 0.7f, 1.f); + ImGui::Indent(8.f); + ImGui::TextColored(tau_col, "\xcf\x84 = %+.6f", tau); + ImGui::SameLine(); + if (tau > 1e-4f) ImGui::TextDisabled("(right twist)"); + else if (tau < -1e-4f) ImGui::TextDisabled("(left twist)"); + else ImGui::TextDisabled("(planar)"); + if (fr.kappa > 1e-5f) + ImGui::TextDisabled("|\xcf\x84/\xce\xba| = %.4f", std::abs(tau) / fr.kappa); + ImGui::Unindent(8.f); + } +} + +// ── draw_panel_brownian ─────────────────────────────────────────────────────── + +void SurfaceSimPanels::draw_panel_brownian() { + auto& p = m_behavior.brownian; + ImGui::SliderFloat("Sigma##bm", &p.sigma, 0.01f, 2.f, "%.3f"); + ImGui::SliderFloat("Drift##bm", &p.drift_strength, -1.f, 1.f, "%.3f"); + if (ImGui::Button("Spawn Brownian [Ctrl+B]", ImVec2(-1.f, 0.f))) { + m_spawner.spawn_brownian_particle(m_spawner.offset_spawn(m_spawner.reference_uv(), 1.8f, + static_cast(m_spawn.chaser_count + m_spawn.leader_count) * 0.7f + 1.0f)); + } + ImGui::TextDisabled("Milstein integrator (strong order 1.0)"); + + ImGui::SeparatorText("RNG reproducibility"); + { + static int seed_input = 0; + ImGui::SetNextItemWidth(140.f); + ImGui::InputInt("Seed (0=random)##rng", &seed_input); + ImGui::SameLine(); + if (ImGui::SmallButton("Apply##rng")) + ndde::sim::MilsteinIntegrator::set_global_seed( + static_cast(seed_input)); + if (ndde::sim::MilsteinIntegrator::seed_is_fixed()) + ImGui::TextColored({0.4f,1.f,0.4f,1.f}, "Seed fixed: %llu", + (unsigned long long)ndde::sim::MilsteinIntegrator::global_seed()); + else + ImGui::TextDisabled("Seed: random (hardware)"); + ImGui::TextDisabled("Set seed BEFORE spawning Brownian particles."); + } + + ImGui::TextDisabled("Per-particle Brownian tuning is in Sim - Particles."); +} + +// ── draw_panel_pursuit ──────────────────────────────────────────────────────── + +void SurfaceSimPanels::draw_panel_pursuit() { + ImGui::SeparatorText("Delay pursuit [Ctrl+R]"); + { + auto& p = m_behavior.delay_pursuit; + ImGui::SliderFloat("Tau (delay)##dp", &p.tau, 0.1f, 10.f, "%.2f s"); + ImGui::SliderFloat("Speed##dp", &p.pursuit_speed, 0.1f, 3.f, "%.2f"); + ImGui::SliderFloat("Noise sigma##dp", &p.noise_sigma, 0.f, 1.f, "%.3f"); + if (ImGui::Button("Spawn pursuer [Ctrl+R]", ImVec2(-1.f, 0.f))) { + if (!m_curves.empty()) { + const glm::vec2 ref = m_curves[0].head_uv(); + m_spawner.spawn_delay_pursuit_particle(m_spawner.offset_spawn(ref, 2.0f, + static_cast(m_spawn.delay_pursuit_count) * 1.1f + 0.3f)); + } + } + if (m_curves.empty() || m_curves[0].history() == nullptr) + ImGui::TextDisabled("(leader has no history yet -- spawn first)"); + else { + const auto* h = m_curves[0].history(); + const float window = h->newest_t() - h->oldest_t(); + ImGui::TextDisabled("History: %.1fs %zu records", window, h->size()); + if (window < p.tau) + ImGui::TextColored({1.f,0.8f,0.1f,1.f}, + "Warming up: %.1f/%.1fs", window, p.tau); + } + } + + ImGui::SeparatorText("Collision avoidance"); + { + bool changed = ImGui::Checkbox("Min-distance push##col", &m_pursuit.pair_collision); + if (m_pursuit.pair_collision) + changed |= ImGui::SliderFloat("Min dist##col", &m_pursuit.min_dist, 0.05f, 2.f, "%.2f"); + if (changed) + m_controller.sync_pairwise_constraints(); + } + + // Leader seeker section: only visible on Extremum surface + draw_leader_seeker_panel(); + +} + +void SurfaceSimPanels::draw_leader_seeker_panel() { + if (m_surface_state.type != SurfaceType::Extremum) return; + ImGui::SeparatorText("Leader Seeker [Ctrl+A]"); + + { + int mode = static_cast(m_pursuit.leader_mode); + bool changed = false; + changed |= ImGui::RadioButton("Deterministic##lm", &mode, 0); + ImGui::SameLine(); + changed |= ImGui::RadioButton("Biased Brownian##lm", &mode, 1); + if (changed) m_pursuit.leader_mode = static_cast(mode); + } + + if (m_pursuit.leader_mode == LeaderMode::Deterministic) { + auto& p = m_pursuit.leader_seeker; + ImGui::SliderFloat("Target grad mag##ls", &p.target_grad_magnitude, 0.f, 2.f, "%.2f"); + ImGui::SliderFloat("Epsilon##ls", &p.epsilon, 0.01f, 0.5f, "%.3f"); + ImGui::SliderFloat("Leader speed##ls", &p.pursuit_speed, 0.1f, 3.f, "%.2f"); + ImGui::SliderFloat("Leader noise##ls", &p.noise_sigma, 0.f, 1.f, "%.3f"); + ImGui::SliderFloat("Arrival radius##ls", &p.arrival_radius, 0.1f, 2.f, "%.2f"); + } else { + auto& p = m_pursuit.biased_brownian_leader; + ImGui::SliderFloat("Sigma##bbl", &p.sigma, 0.01f, 2.f, "%.3f"); + ImGui::SliderFloat("Goal drift##bbl", &p.drift_strength, 0.f, 2.f, "%.2f"); + ImGui::SliderFloat("Gradient drift##bbl", &p.gradient_drift, -1.f, 1.f, "%.3f"); + ImGui::SliderFloat("Epsilon##bbl", &p.epsilon, 0.01f, 0.5f, "%.3f"); + ImGui::SliderFloat("Arrival radius##bbl", &p.arrival_radius, 0.1f, 2.f, "%.2f"); + if (p.sigma > 1e-5f) + ImGui::TextDisabled("Peclet = %.2f (drift/sigma^2)", + p.drift_strength / (p.sigma * p.sigma)); + } + + if (ImGui::SmallButton(!m_spawn.spawning_pursuer ? "Spawn leader [Ctrl+A]" : "Spawn pursuer [Ctrl+A]")) { + if (!m_spawn.spawning_pursuer) m_spawner.spawn_leader_seeker(); + else m_spawner.spawn_pursuit_particle(); + } + + ImGui::SeparatorText("Pursuit mode"); + { + int mode = static_cast(m_pursuit.pursuit_mode); + bool changed = false; + changed |= ImGui::RadioButton("Direct##pm", &mode, 0); + ImGui::SameLine(); + changed |= ImGui::RadioButton("Delayed##pm", &mode, 1); + ImGui::SameLine(); + changed |= ImGui::RadioButton("Momentum##pm", &mode, 2); + if (changed) m_pursuit.pursuit_mode = static_cast(mode); + } + if (m_pursuit.pursuit_mode == PursuitMode::Delayed) + ImGui::SliderFloat("Tau##pm", &m_pursuit.pursuit_tau, 0.1f, 10.f, "%.2f s"); + if (m_pursuit.pursuit_mode == PursuitMode::Momentum) + ImGui::SliderFloat("Window##pm", &m_pursuit.pursuit_window, 0.1f, 5.f, "%.2f s"); + + ImGui::SeparatorText("Extremum table"); + if (m_pursuit.extremum_table.valid) { + ImGui::TextDisabled("max u=%.3f v=%.3f z=%.3f", + m_pursuit.extremum_table.max_uv.x, m_pursuit.extremum_table.max_uv.y, m_pursuit.extremum_table.max_z); + ImGui::TextDisabled("min u=%.3f v=%.3f z=%.3f", + m_pursuit.extremum_table.min_uv.x, m_pursuit.extremum_table.min_uv.y, m_pursuit.extremum_table.min_z); + if (ImGui::SmallButton("Rebuild now##ext")) + m_pursuit.extremum_table.build(*m_surface, m_sim_time); + } else { + ImGui::TextColored({1.f,0.6f,0.1f,1.f}, "Table invalid -- switch to Extremum surface"); + } +} + +// ── draw_panel_geometry ─────────────────────────────────────────────────────── + +void SurfaceSimPanels::draw_panel_geometry() { + if (m_curves.empty() || !m_curves[0].has_trail()) return; + const AnimatedCurve& c0 = m_curves[0]; + const u32 hi = c0.trail_size() > 2 ? c0.trail_size()-2 : 0; + const FrenetFrame fr = c0.frenet_at(hi); + const Vec3 hp = c0.head_world(); + + ImGui::SeparatorText("At head (particle 0)"); + if (m_surface_state.type == SurfaceType::Gaussian) + ImGui::TextDisabled("f(x,y) = %.4f", m_surface->evaluate(hp.x, hp.y, m_sim_time).z); + else + ImGui::TextDisabled("p = (%.3f, %.3f, %.3f)", + m_surface->evaluate(hp.x, hp.y, m_sim_time).x, + m_surface->evaluate(hp.x, hp.y, m_sim_time).y, + m_surface->evaluate(hp.x, hp.y, m_sim_time).z); + + ImGui::SeparatorText("Frenet\xe2\x80\x93Serret"); + ImGui::TextColored(ImVec4(1.f,0.5f,0.1f,1.f), "T (%.3f, %.3f, %.3f)", fr.T.x, fr.T.y, fr.T.z); + ImGui::TextColored(ImVec4(0.2f,1.f,0.4f,1.f), "N (%.3f, %.3f, %.3f)", fr.N.x, fr.N.y, fr.N.z); + ImGui::TextColored(ImVec4(0.3f,0.6f,1.f,1.f), "B (%.3f, %.3f, %.3f)", fr.B.x, fr.B.y, fr.B.z); + ImGui::TextDisabled("\xce\xba = %.5f osc.r = %.4f", + fr.kappa, fr.kappa > 1e-5f ? 1.f/fr.kappa : 0.f); + ImGui::TextDisabled("\xcf\x84 = %.5f", fr.tau); + + ImGui::SeparatorText("Curvature"); + const f32 K = m_surface->gaussian_curvature(hp.x, hp.y, m_sim_time); + const f32 H = m_surface->mean_curvature(hp.x, hp.y, m_sim_time); + ImGui::TextDisabled("K = %.5f H = %.5f", K, H); + + ImGui::SeparatorText("First fundamental form"); + const SurfaceFrame sf = make_surface_frame(*m_surface, hp.x, hp.y, m_sim_time, &fr); + ImGui::TextDisabled("E=%.4f F=%.4f G=%.4f", sf.E, sf.F, sf.G); + ImGui::TextDisabled("\xce\xba_n=%.5f \xce\xba_g=%.5f", sf.kappa_n, sf.kappa_g); + const f32 k2 = fr.kappa*fr.kappa; + const f32 check = sf.kappa_n*sf.kappa_n + sf.kappa_g*sf.kappa_g; + const bool ok = std::abs(k2-check) < 1e-4f || k2 < 1e-8f; + ImGui::TextColored(ok ? ImVec4(.4f,1.f,.4f,1.f) : ImVec4(1.f,.3f,.3f,1.f), + "\xce\xba\xc2\xb2=\xce\xba_n\xc2\xb2+\xce\xba_g\xc2\xb2 %s", ok ? "\xe2\x9c\x93" : "!"); +} + +// ── draw_panel_camera ───────────────────────────────────────────────────────── + +void SurfaceSimPanels::draw_panel_camera() { + ImGui::SliderFloat("Yaw", &m_vp3d.yaw, + -std::numbers::pi_v, std::numbers::pi_v, "%.2f"); + ImGui::SliderFloat("Pitch", &m_vp3d.pitch, -1.5f, 1.5f, "%.2f"); + ImGui::SliderFloat("Zoom##3d", &m_vp3d.zoom, 0.1f, 5.f, "%.2f"); + if (ImGui::Button("Reset 3D")) m_vp3d.reset(); + ImGui::SameLine(); + if (ImGui::Button("Reset 2D")) m_vp2d.reset(); +} + +} // namespace ndde diff --git a/nurbs_dde/src/app/SurfaceSimScene.cpp b/nurbs_dde/src/app/SurfaceSimScene.cpp deleted file mode 100644 index 9f82efea..00000000 --- a/nurbs_dde/src/app/SurfaceSimScene.cpp +++ /dev/null @@ -1,1255 +0,0 @@ -// app/SurfaceSimScene.cpp -// B2 refactor: submit_arrow, submit_trail_3d, submit_head_dot_3d, -// submit_frenet_3d, submit_osc_circle_3d, submit_surface_frame_3d, -// submit_normal_plane_3d, and submit_torsion_3d have been moved to -// src/app/ParticleRenderer.cpp. This file now delegates particle -// rendering to m_particle_renderer.submit_all(). -#include "app/SurfaceSimScene.hpp" -#include "app/SceneFactories.hpp" -#include "sim/GradientWalker.hpp" -#include "sim/EulerIntegrator.hpp" -#include "sim/MilsteinIntegrator.hpp" -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace ndde { - -// ── Constructor ─────────────────────────────────────────────────────────────── - -SurfaceSimScene::SurfaceSimScene(EngineAPI api) - : m_api(std::move(api)) - , m_particle_renderer(m_api) // B2: owns a copy of EngineAPI (value type) - , m_surface(std::make_unique()) -{ - m_vp3d.base_extent = 6.f; - m_vp3d.zoom = 1.0f; - m_vp3d.yaw = 0.55f; - m_vp3d.pitch = 0.42f; - - m_vp2d.base_extent = 6.f; - m_vp2d.zoom = 1.f; - m_vp2d.pan_x = 0.f; - m_vp2d.pan_y = 0.f; - - m_curves.emplace_back(-4.5f, -4.0f, AnimatedCurve::Role::Leader, 0u, - m_surface.get(), &m_equation, &m_integrator); - m_leader_count = 1; - for (int i = 0; i < 400; ++i) - m_curves[0].advance(1.f/60.f, 1.f); - - // ── Register hotkeys ────────────────────────────────────────────────────── - // Each registration stores its own edge-detection state internally. - // Groups appear as section headers in draw_hotkey_panel(). - - // Overlays - m_hotkeys.register_toggle(Chord::ctrl(ImGuiKey_F), "Frenet frame (T, N, B)", - m_show_frenet, "Overlays"); - m_hotkeys.register_toggle(Chord::ctrl(ImGuiKey_D), "Surface frame (Dx, Dy)", - m_show_dir_deriv, "Overlays"); - m_hotkeys.register_toggle(Chord::ctrl(ImGuiKey_N), "Normal plane patch", - m_show_normal_plane, "Overlays"); - m_hotkeys.register_toggle(Chord::ctrl(ImGuiKey_T), "Torsion ribbon", - m_show_torsion, "Overlays"); - - // Simulation - m_hotkeys.register_action(Chord::ctrl(ImGuiKey_P), "Pause / unpause", - [this]{ m_sim_paused = !m_sim_paused; }, "Simulation"); - - // Spawn - m_hotkeys.register_action(Chord::ctrl(ImGuiKey_L), "Leader particle (blue)", - [this]{ - const spawn::SpawnContext ctx{ m_surface.get(), &m_equation, - &m_integrator, &m_milstein, m_sim_speed }; - const glm::vec2 ref = spawn::reference_uv(m_curves, *m_surface); - const glm::vec2 uv = spawn::offset_spawn(ref, 1.5f, - static_cast(m_leader_count) * 1.1f, *m_surface); - m_curves.push_back(spawn::spawn_shared(uv, - AnimatedCurve::Role::Leader, - m_leader_count % AnimatedCurve::MAX_SLOTS, ctx)); - ++m_leader_count; - }, "Spawn"); - - m_hotkeys.register_action(Chord::ctrl(ImGuiKey_C), "Chaser particle (red)", - [this]{ - const spawn::SpawnContext ctx{ m_surface.get(), &m_equation, - &m_integrator, &m_milstein, m_sim_speed }; - const glm::vec2 ref = spawn::reference_uv(m_curves, *m_surface); - const glm::vec2 uv = spawn::offset_spawn(ref, 2.0f, - static_cast(m_chaser_count) * 1.3f + 0.5f, *m_surface); - m_curves.push_back(spawn::spawn_shared(uv, - AnimatedCurve::Role::Chaser, - m_chaser_count % AnimatedCurve::MAX_SLOTS, ctx)); - ++m_chaser_count; - }, "Spawn"); - - m_hotkeys.register_action(Chord::ctrl(ImGuiKey_B), "Brownian particle (Milstein)", - [this]{ - const spawn::SpawnContext ctx{ m_surface.get(), &m_equation, - &m_integrator, &m_milstein, m_sim_speed }; - const glm::vec2 ref = spawn::reference_uv(m_curves, *m_surface); - const glm::vec2 uv = spawn::offset_spawn(ref, 1.8f, - static_cast(m_chaser_count + m_leader_count) * 0.7f + 1.0f, - *m_surface); - m_curves.push_back(spawn::spawn_owned(uv, - AnimatedCurve::Role::Chaser, - m_chaser_count % AnimatedCurve::MAX_SLOTS, - std::make_unique(m_bm_params), - ctx)); - ++m_chaser_count; - }, "Spawn"); - - m_hotkeys.register_action(Chord::ctrl(ImGuiKey_R), "Delay-pursuit chaser", - [this]{ - if (m_curves.empty()) return; - if (m_curves[0].history() == nullptr) { - const std::size_t cap = - static_cast(std::ceil(m_dp_params.tau * 120.f * 1.5f)) + 256; - m_curves[0].enable_history(cap, 1.f / 120.f); - } - const spawn::SpawnContext ctx{ m_surface.get(), &m_equation, - &m_integrator, &m_milstein, m_sim_speed }; - const glm::vec2 ref = m_curves[0].head_uv(); - const glm::vec2 uv = spawn::offset_spawn(ref, 2.0f, - static_cast(m_dp_count) * 1.1f + 0.3f, *m_surface); - m_curves.push_back(spawn::spawn_owned(uv, - AnimatedCurve::Role::Chaser, - m_chaser_count % AnimatedCurve::MAX_SLOTS, - std::make_unique( - m_curves[0].history(), m_surface.get(), m_dp_params), - ctx, - false)); // no prewarm -- chaser needs leader history first - ++m_chaser_count; - ++m_dp_count; - }, "Spawn"); - - m_hotkeys.register_action(Chord::ctrl(ImGuiKey_A), "Leader seeker / pursuer [Ctrl+A]", - [this]{ - if (!m_spawning_pursuer) spawn_leader_seeker(); - else spawn_pursuit_particle(); - }, "Spawn"); - - // Panels - m_hotkeys.register_action(Chord::ctrl(ImGuiKey_Q), "Coordinate debug panel", - [this]{ - m_debug_open = !m_debug_open; - m_coord_debug.visible() = m_debug_open; - }, "Panels"); - - m_hotkeys.register_toggle(Chord::ctrl(ImGuiKey_H), "This hotkey panel", - m_hotkey_panel_open, "Panels"); -} - -// ── on_frame ────────────────────────────────────────────────────────────────── - -void SurfaceSimScene::on_frame(f32 dt) { - m_hotkeys.dispatch(); - advance_simulation(dt); - - const ImGuiViewport* vp = ImGui::GetMainViewport(); - ImGui::SetNextWindowPos(vp->WorkPos); - ImGui::SetNextWindowSize(vp->WorkSize); - ImGui::SetNextWindowViewport(vp->ID); - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.f, 0.f)); - constexpr ImGuiWindowFlags dock_flags = - ImGuiWindowFlags_NoDocking | ImGuiWindowFlags_NoTitleBar | - ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize | - ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoBringToFrontOnFocus | - ImGuiWindowFlags_NoNavFocus | ImGuiWindowFlags_NoBackground; - ImGui::Begin("##dockspace_root", nullptr, dock_flags); - ImGui::PopStyleVar(3); - const ImGuiID dock_id = ImGui::GetID("MainDockSpace"); - ImGui::DockSpace(dock_id, ImVec2(0.f, 0.f), ImGuiDockNodeFlags_None); - ImGui::End(); - - draw_panel_surface(); - draw_panel_particles(); - draw_panel_overlays(); - draw_panel_brownian(); - draw_panel_pursuit(); - draw_panel_geometry(); - draw_panel_camera(); - draw_panel_debug(); - draw_surface_3d_window(); - submit_contour_second_window(); - draw_hotkey_panel(); - - struct StubConic { std::string name; std::vector snap_cache; }; - const std::vector no_conics; - m_coord_debug.update(m_vp3d, m_hover, no_conics, m_api.viewport_size()); - m_coord_debug.draw(); - m_debug_open = m_coord_debug.visible(); - m_perf.draw(m_api.debug_stats()); -} - -// ── swap_surface ────────────────────────────────────────────────────────────── - -void SurfaceSimScene::swap_surface(SurfaceType type) { - switch (type) { - case SurfaceType::Gaussian: - m_surface = std::make_unique(); - break; - case SurfaceType::Torus: - m_surface = std::make_unique(m_torus_R, m_torus_r); - break; - case SurfaceType::GaussianRipple: { - auto s = std::make_unique(m_ripple_params); - m_surface = std::move(s); - break; - } - case SurfaceType::Extremum: - m_surface = std::make_unique(); - m_extremum_table.build(*m_surface); - break; - } - m_surface_type = type; - m_sim_time = 0.f; - m_wireframe_dirty = true; - m_spawning_pursuer = false; - - const u32 n = static_cast(m_curves.size()); - const f32 u0 = m_surface->u_min(), u1 = m_surface->u_max(); - const f32 v0 = m_surface->v_min(), v1 = m_surface->v_max(); - const f32 um = m_surface->is_periodic_u() ? 0.f : (u1-u0)*0.08f; - const f32 vm = m_surface->is_periodic_v() ? 0.f : (v1-v0)*0.08f; - const f32 u_lo = u0+um, u_hi = u1-um; - const f32 v_lo = v0+vm, v_hi = v1-vm; - - for (u32 i = 0; i < n; ++i) { - const f32 su = (static_cast(i)+0.5f)/static_cast(std::max(n,1u)); - const f32 sv = std::fmod(su * 2.618033988f, 1.f); - AnimatedCurve fresh(u_lo + su*(u_hi-u_lo), v_lo + sv*(v_hi-v_lo), - m_curves[i].role(), m_curves[i].colour_slot(), - m_surface.get(), &m_equation, &m_integrator); - for (int w = 0; w < 120; ++w) - fresh.advance(1.f/60.f, m_sim_speed); - m_curves[i] = std::move(fresh); - } -} - -// ── advance_simulation ──────────────────────────────────────────────────────── - -void SurfaceSimScene::advance_simulation(f32 dt) { - if (!m_sim_paused) { - if (auto* def = dynamic_cast(m_surface.get())) - def->advance(dt * m_sim_speed); - m_sim_time += dt * m_sim_speed; - - rebuild_extremum_table_if_needed(); - - for (auto& c : m_curves) - c.advance(dt, m_sim_speed); - - // Apply pairwise constraints (e.g. min-distance collision avoidance) - if (!m_pair_constraints.empty()) - apply_pairwise_constraints(); - - for (auto& c : m_curves) - c.push_history(m_sim_time); - } -} - -// ── apply_pairwise_constraints ──────────────────────────────────────────────── - -void SurfaceSimScene::apply_pairwise_constraints() { - const std::size_t n = m_curves.size(); - for (std::size_t i = 0; i < n; ++i) { - for (std::size_t j = i + 1; j < n; ++j) { - for (const auto& pc : m_pair_constraints) - pc->apply(m_curves[i].walk_state(), - m_curves[j].walk_state(), - *m_surface); - } - } -} - -// ── handle_hotkeys ──────────────────────────────────────────────────────────── -// Removed: hotkey dispatch is now owned by m_hotkeys (HotkeyManager). -// All registrations happen in the constructor. Call m_hotkeys.dispatch() per frame. - -// ── canvas_mvp_3d ───────────────────────────────────────────────────────────── - -Mat4 SurfaceSimScene::canvas_mvp_3d(const ImVec2& cpos, const ImVec2& csz) const noexcept { - const Vec2 sw_sz = m_api.viewport_size(); - const f32 sw = sw_sz.x > 0.f ? sw_sz.x : 1.f; - const f32 sh = sw_sz.y > 0.f ? sw_sz.y : 1.f; - const f32 cw = csz.x > 0.f ? csz.x : 1.f; - const f32 ch = csz.y > 0.f ? csz.y : 1.f; - const f32 cx = cpos.x; - const f32 cy = cpos.y; - - const f32 sx = cw / sw; - const f32 sy = ch / sh; - const f32 bx = 2.f*cx/sw + sx - 1.f; - const f32 by = -(2.f*cy/sh + sy - 1.f); - - Mat4 remap(0.f); - remap[0][0] = sx; remap[1][1] = sy; remap[2][2] = 1.f; - remap[3][0] = bx; remap[3][1] = by; remap[3][3] = 1.f; - - const f32 aspect = cw / ch; - const f32 dist = m_vp3d.base_extent / m_vp3d.zoom * 3.f; - const f32 ex = m_vp3d.pan_x + dist*std::cos(m_vp3d.pitch)*std::sin(m_vp3d.yaw); - const f32 ey = m_vp3d.pan_y + dist*std::sin(m_vp3d.pitch); - const f32 ez = dist*std::cos(m_vp3d.pitch)*std::cos(m_vp3d.yaw); - const Mat4 proj = glm::perspective(glm::radians(45.f), aspect, 0.01f, 500.f); - const Mat4 view = glm::lookAt( - glm::vec3(ex, ey, ez), - glm::vec3(m_vp3d.pan_x, m_vp3d.pan_y, 0.f), - glm::vec3(0.f, 1.f, 0.f)); - return remap * proj * view; -} - -// ── draw_hotkey_panel ───────────────────────────────────────────────────────── - -void SurfaceSimScene::draw_hotkey_panel() { - // Delegate entirely to HotkeyManager. The panel lists every registered - // binding in group order; no hardcoded row table needed here. - m_hotkeys.draw_panel("Hotkeys [Ctrl+H]", m_hotkey_panel_open); -} - -// ── draw_panel_surface ──────────────────────────────────────────────────────── - -void SurfaceSimScene::draw_panel_surface() { - ImGui::SetNextWindowPos(ImVec2(20.f, 20.f), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowSize(ImVec2(300.f, 220.f), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowBgAlpha(0.88f); - if (!ImGui::Begin("Sim \xe2\x80\x93 Surface")) { ImGui::End(); return; } - - ImGui::SeparatorText("Surface type"); - { - int sel = static_cast(m_surface_type); - bool changed = false; - changed |= ImGui::RadioButton("Gaussian", &sel, 0); - ImGui::SameLine(); - changed |= ImGui::RadioButton("Torus", &sel, 1); - ImGui::SameLine(); - changed |= ImGui::RadioButton("Ripple", &sel, 2); - ImGui::SameLine(); - changed |= ImGui::RadioButton("Extremum##surf", &sel, 3); - if (changed) swap_surface(static_cast(sel)); - - if (m_surface_type == SurfaceType::Torus) { - bool tp = false; - tp |= ImGui::SliderFloat("R##torus", &m_torus_R, 0.5f, 5.f, "%.2f"); - tp |= ImGui::SliderFloat("r##torus", &m_torus_r, 0.1f, m_torus_R - 0.05f, "%.2f"); - if (tp) swap_surface(SurfaceType::Torus); - } - - if (m_surface_type == SurfaceType::GaussianRipple) { - auto& p = m_ripple_params; - bool rp = false; - rp |= ImGui::SliderFloat("Amplitude##r", &p.amplitude, 0.05f, 2.f, "%.2f"); - rp |= ImGui::SliderFloat("Damping##r", &p.damping, 0.05f, 2.f, "%.3f"); - rp |= ImGui::SliderFloat("Wavelength##r", &p.wavelength, 0.3f, 5.f, "%.2f"); - rp |= ImGui::SliderFloat("Speed##r", &p.speed, 0.1f, 6.f, "%.2f"); - rp |= ImGui::SliderFloat("Sigma##r", &p.sigma, 0.3f, 6.f, "%.2f"); - if (rp) { - if (auto* gr = dynamic_cast(m_surface.get())) - gr->params() = p; - } - ImGui::TextDisabled("Epicentre: (%.2f, %.2f) t=%.2f", - p.epicentre_u, p.epicentre_v, m_sim_time); - ImGui::SameLine(); - if (ImGui::SmallButton("Re-trigger")) { - const Vec3 h = m_curves.empty() ? Vec3{0,0,0} : m_curves[0].head_world(); - p.epicentre_u = h.x; p.epicentre_v = h.y; - if (auto* gr = dynamic_cast(m_surface.get())) - gr->set_epicentre(h.x, h.y); - m_sim_time = 0.f; - } - } - } - - ImGui::SeparatorText("Display"); - { - int mode = static_cast(m_surface_display); - bool changed = false; - changed |= ImGui::RadioButton("Wireframe", &mode, 0); - ImGui::SameLine(); - changed |= ImGui::RadioButton("Filled", &mode, 1); - ImGui::SameLine(); - changed |= ImGui::RadioButton("Both", &mode, 2); - if (changed) { - m_surface_display = static_cast(mode); - m_wireframe_dirty = true; - } - if (m_surface_display != SurfaceDisplay::Wireframe) { - if (ImGui::SliderFloat("K scale##curv", &m_curv_scale, 0.01f, 20.f, "%.2f")) - m_wireframe_dirty = true; - } - { - int gl = static_cast(m_grid_lines); - if (ImGui::SliderInt("Grid lines##surface", &gl, 8, 256)) { - m_grid_lines = static_cast(std::max(gl, 8)); - m_wireframe_dirty = true; - } - ImGui::SameLine(); - ImGui::TextDisabled("~%uk verts", (4u * m_grid_lines * (m_grid_lines + 1u)) / 1000u); - } - } - ImGui::End(); -} - -// ── draw_panel_particles ────────────────────────────────────────────────────── - -void SurfaceSimScene::draw_panel_particles() { - ImGui::SetNextWindowPos(ImVec2(20.f, 250.f), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowSize(ImVec2(300.f, 180.f), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowBgAlpha(0.88f); - if (!ImGui::Begin("Sim \xe2\x80\x93 Particles")) { ImGui::End(); return; } - - ImGui::Checkbox("Paused [Ctrl+P]", &m_sim_paused); - ImGui::SameLine(); - if (ImGui::Button("Reset") && !m_curves.empty()) m_curves[0].reset(); - ImGui::SliderFloat("Speed##sim", &m_sim_speed, 0.1f, 5.f, "%.2f"); - ImGui::SliderFloat("Arrow scale", &m_frame_scale, 0.05f,0.8f, "%.2f"); - ImGui::Checkbox("Contour lines", &m_show_contours); - - ImGui::Separator(); - ImGui::TextDisabled("%zu particles (L=%u C=%u)", - m_curves.size(), m_leader_count, m_chaser_count); - ImGui::SameLine(); - if (ImGui::SmallButton("Clear all")) { - m_curves.clear(); - m_leader_count = 0; - m_chaser_count = 0; - m_spawning_pursuer = false; - m_curves.emplace_back(-4.5f, -4.0f, AnimatedCurve::Role::Leader, 0u, - m_surface.get(), &m_equation, &m_integrator); - m_leader_count = 1; - } - ImGui::SameLine(); - if (ImGui::SmallButton("Export CSV")) { - const std::string path = "session_" + - std::to_string(m_curves.size()) + "p_" + - std::to_string(static_cast(m_sim_time)) + "s.csv"; - export_session(path); - } - if (ImGui::IsItemHovered()) - ImGui::SetTooltip("Write trail + history data to CSV in working directory"); - - ImGui::TextDisabled("Ctrl+L leader Ctrl+C chaser Ctrl+B brownian"); - ImGui::End(); -} - -// ── draw_panel_overlays ─────────────────────────────────────────────────────── - -void SurfaceSimScene::draw_panel_overlays() { - ImGui::SetNextWindowPos(ImVec2(20.f, 440.f), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowSize(ImVec2(300.f, 240.f), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowBgAlpha(0.88f); - if (!ImGui::Begin("Sim \xe2\x80\x93 Overlays")) { ImGui::End(); return; } - - ImGui::SeparatorText("Frenet frame [Ctrl+F]"); - ImGui::Checkbox("Show Frenet", &m_show_frenet); - ImGui::BeginDisabled(!m_show_frenet); - ImGui::SameLine(); ImGui::Checkbox("T##ft", &m_show_T); - ImGui::SameLine(); ImGui::Checkbox("N##fn", &m_show_N); - ImGui::SameLine(); ImGui::Checkbox("B##fb", &m_show_B); - ImGui::Checkbox("Osculating circle", &m_show_osc); - ImGui::EndDisabled(); - - ImGui::SeparatorText("Other overlays"); - ImGui::Checkbox("Surface frame [Ctrl+D]", &m_show_dir_deriv); - ImGui::Checkbox("Normal plane [Ctrl+N]", &m_show_normal_plane); - ImGui::Checkbox("Torsion ribbon [Ctrl+T]", &m_show_torsion); - - // Torsion live readout when active - if (m_show_torsion && !m_curves.empty() && m_curves[0].has_trail()) { - const AnimatedCurve& c0 = m_curves[0]; - const u32 hi = c0.trail_size() > 2 ? c0.trail_size()-2 : 0; - const FrenetFrame fr = c0.frenet_at(hi); - const f32 tau = fr.tau; - const ImVec4 tau_col = - tau > 1e-4f ? ImVec4(1.f, 0.55f, 0.1f, 1.f) - : tau < -1e-4f ? ImVec4(0.45f,0.8f, 1.f, 1.f) - : ImVec4(0.7f, 0.7f, 0.7f, 1.f); - ImGui::Indent(8.f); - ImGui::TextColored(tau_col, "\xcf\x84 = %+.6f", tau); - ImGui::SameLine(); - if (tau > 1e-4f) ImGui::TextDisabled("(right twist)"); - else if (tau < -1e-4f) ImGui::TextDisabled("(left twist)"); - else ImGui::TextDisabled("(planar)"); - if (fr.kappa > 1e-5f) - ImGui::TextDisabled("|\xcf\x84/\xce\xba| = %.4f", std::abs(tau) / fr.kappa); - ImGui::Unindent(8.f); - } - ImGui::End(); -} - -// ── draw_panel_brownian ─────────────────────────────────────────────────────── - -void SurfaceSimScene::draw_panel_brownian() { - ImGui::SetNextWindowPos(ImVec2(20.f, 690.f), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowSize(ImVec2(300.f, 260.f), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowBgAlpha(0.88f); - if (!ImGui::Begin("Sim \xe2\x80\x93 Brownian [Ctrl+B]")) { ImGui::End(); return; } - - auto& p = m_bm_params; - ImGui::SliderFloat("Sigma##bm", &p.sigma, 0.01f, 2.f, "%.3f"); - ImGui::SliderFloat("Drift##bm", &p.drift_strength, -1.f, 1.f, "%.3f"); - if (ImGui::Button("Spawn Brownian [Ctrl+B]", ImVec2(-1.f, 0.f))) { - const spawn::SpawnContext ctx{ m_surface.get(), &m_equation, - &m_integrator, &m_milstein, m_sim_speed }; - const glm::vec2 ref = spawn::reference_uv(m_curves, *m_surface); - const glm::vec2 uv = spawn::offset_spawn(ref, 1.8f, - static_cast(m_chaser_count + m_leader_count) * 0.7f + 1.0f, - *m_surface); - m_curves.push_back(spawn::spawn_owned(uv, - AnimatedCurve::Role::Chaser, - m_chaser_count % AnimatedCurve::MAX_SLOTS, - std::make_unique(m_bm_params), - ctx)); - ++m_chaser_count; - } - ImGui::TextDisabled("Milstein integrator (strong order 1.0)"); - - ImGui::SeparatorText("RNG reproducibility"); - { - static int seed_input = 0; - ImGui::SetNextItemWidth(140.f); - ImGui::InputInt("Seed (0=random)##rng", &seed_input); - ImGui::SameLine(); - if (ImGui::SmallButton("Apply##rng")) - ndde::sim::MilsteinIntegrator::set_global_seed( - static_cast(seed_input)); - if (ndde::sim::MilsteinIntegrator::seed_is_fixed()) - ImGui::TextColored({0.4f,1.f,0.4f,1.f}, "Seed fixed: %llu", - (unsigned long long)ndde::sim::MilsteinIntegrator::global_seed()); - else - ImGui::TextDisabled("Seed: random (hardware)"); - ImGui::TextDisabled("Set seed BEFORE spawning Brownian particles."); - } - - ImGui::SeparatorText("Live tuning"); - int bm_idx = 0; - for (auto& c : m_curves) { - auto* bm = dynamic_cast(c.equation()); - if (!bm) continue; - ImGui::PushID(bm_idx++); - ImGui::TextDisabled("Particle %d", bm_idx); - ImGui::SameLine(); - float sig = bm->params().sigma; - float dft = bm->params().drift_strength; - bool ch = false; - ch |= ImGui::SliderFloat("s##bmlive", &sig, 0.01f, 2.f, "%.3f"); - ImGui::SameLine(); - ch |= ImGui::SliderFloat("d##bmlive", &dft, -1.f, 1.f, "%.3f"); - if (ch) { bm->params().sigma = sig; bm->params().drift_strength = dft; } - ImGui::PopID(); - } - if (bm_idx == 0) - ImGui::TextDisabled("No Brownian particles active."); - ImGui::End(); -} - -// ── draw_panel_pursuit ──────────────────────────────────────────────────────── - -void SurfaceSimScene::draw_panel_pursuit() { - ImGui::SetNextWindowPos(ImVec2(330.f, 700.f), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowSize(ImVec2(310.f, 320.f), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowBgAlpha(0.88f); - if (!ImGui::Begin("Sim \xe2\x80\x93 Pursuit")) { ImGui::End(); return; } - - ImGui::SeparatorText("Delay pursuit [Ctrl+R]"); - { - auto& p = m_dp_params; - ImGui::SliderFloat("Tau (delay)##dp", &p.tau, 0.1f, 10.f, "%.2f s"); - ImGui::SliderFloat("Speed##dp", &p.pursuit_speed, 0.1f, 3.f, "%.2f"); - ImGui::SliderFloat("Noise sigma##dp", &p.noise_sigma, 0.f, 1.f, "%.3f"); - if (ImGui::Button("Spawn pursuer [Ctrl+R]", ImVec2(-1.f, 0.f))) { - if (!m_curves.empty()) { - if (m_curves[0].history() == nullptr) { - const std::size_t cap = - static_cast(std::ceil(p.tau * 120.f * 1.5f)) + 256; - m_curves[0].enable_history(cap, 1.f / 120.f); - } - const Vec3 ref = m_curves[0].head_world(); - const f32 ang = static_cast(m_dp_count) * 1.1f + 0.3f; - const f32 sx = std::clamp(ref.x + 2.f*std::cos(ang), - m_surface->u_min()+0.5f, m_surface->u_max()-0.5f); - const f32 sy = std::clamp(ref.y + 2.f*std::sin(ang), - m_surface->v_min()+0.5f, m_surface->v_max()-0.5f); - m_curves.push_back(AnimatedCurve::with_equation( - sx, sy, AnimatedCurve::Role::Chaser, - m_chaser_count % AnimatedCurve::MAX_SLOTS, - m_surface.get(), - std::make_unique( - m_curves[0].history(), m_surface.get(), p), - &m_milstein)); - ++m_chaser_count; ++m_dp_count; - } - } - if (m_curves.empty() || m_curves[0].history() == nullptr) - ImGui::TextDisabled("(leader has no history yet -- spawn first)"); - else { - const auto* h = m_curves[0].history(); - const float window = h->newest_t() - h->oldest_t(); - ImGui::TextDisabled("History: %.1fs %zu records", window, h->size()); - if (window < p.tau) - ImGui::TextColored({1.f,0.8f,0.1f,1.f}, - "Warming up: %.1f/%.1fs", window, p.tau); - } - } - - ImGui::SeparatorText("Collision avoidance"); - { - bool changed = ImGui::Checkbox("Min-distance push##col", &m_pair_collision); - if (m_pair_collision) - changed |= ImGui::SliderFloat("Min dist##col", &m_min_dist, 0.05f, 2.f, "%.2f"); - if (changed) { - m_pair_constraints.clear(); - if (m_pair_collision) - m_pair_constraints.push_back( - std::make_unique(m_min_dist)); - } - } - - // Leader seeker section: only visible on Extremum surface - draw_leader_seeker_panel(); - - ImGui::End(); -} - -// ── draw_panel_geometry ─────────────────────────────────────────────────────── - -void SurfaceSimScene::draw_panel_geometry() { - if (m_curves.empty() || !m_curves[0].has_trail()) return; - ImGui::SetNextWindowPos(ImVec2(650.f, 700.f), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowSize(ImVec2(310.f, 280.f), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowBgAlpha(0.88f); - if (!ImGui::Begin("Sim \xe2\x80\x93 Geometry")) { ImGui::End(); return; } - - const AnimatedCurve& c0 = m_curves[0]; - const u32 hi = c0.trail_size() > 2 ? c0.trail_size()-2 : 0; - const FrenetFrame fr = c0.frenet_at(hi); - const Vec3 hp = c0.head_world(); - - ImGui::SeparatorText("At head (particle 0)"); - if (m_surface_type == SurfaceType::Gaussian) - ImGui::TextDisabled("f(x,y) = %.4f", m_surface->evaluate(hp.x, hp.y, m_sim_time).z); - else - ImGui::TextDisabled("p = (%.3f, %.3f, %.3f)", - m_surface->evaluate(hp.x, hp.y, m_sim_time).x, - m_surface->evaluate(hp.x, hp.y, m_sim_time).y, - m_surface->evaluate(hp.x, hp.y, m_sim_time).z); - - ImGui::SeparatorText("Frenet\xe2\x80\x93Serret"); - ImGui::TextColored(ImVec4(1.f,0.5f,0.1f,1.f), "T (%.3f, %.3f, %.3f)", fr.T.x, fr.T.y, fr.T.z); - ImGui::TextColored(ImVec4(0.2f,1.f,0.4f,1.f), "N (%.3f, %.3f, %.3f)", fr.N.x, fr.N.y, fr.N.z); - ImGui::TextColored(ImVec4(0.3f,0.6f,1.f,1.f), "B (%.3f, %.3f, %.3f)", fr.B.x, fr.B.y, fr.B.z); - ImGui::TextDisabled("\xce\xba = %.5f osc.r = %.4f", - fr.kappa, fr.kappa > 1e-5f ? 1.f/fr.kappa : 0.f); - ImGui::TextDisabled("\xcf\x84 = %.5f", fr.tau); - - ImGui::SeparatorText("Curvature"); - const f32 K = m_surface->gaussian_curvature(hp.x, hp.y, m_sim_time); - const f32 H = m_surface->mean_curvature(hp.x, hp.y, m_sim_time); - ImGui::TextDisabled("K = %.5f H = %.5f", K, H); - - ImGui::SeparatorText("First fundamental form"); - const SurfaceFrame sf = make_surface_frame(*m_surface, hp.x, hp.y, m_sim_time, &fr); - ImGui::TextDisabled("E=%.4f F=%.4f G=%.4f", sf.E, sf.F, sf.G); - ImGui::TextDisabled("\xce\xba_n=%.5f \xce\xba_g=%.5f", sf.kappa_n, sf.kappa_g); - const f32 k2 = fr.kappa*fr.kappa; - const f32 check = sf.kappa_n*sf.kappa_n + sf.kappa_g*sf.kappa_g; - const bool ok = std::abs(k2-check) < 1e-4f || k2 < 1e-8f; - ImGui::TextColored(ok ? ImVec4(.4f,1.f,.4f,1.f) : ImVec4(1.f,.3f,.3f,1.f), - "\xce\xba\xc2\xb2=\xce\xba_n\xc2\xb2+\xce\xba_g\xc2\xb2 %s", ok ? "\xe2\x9c\x93" : "!"); - ImGui::End(); -} - -// ── draw_panel_camera ───────────────────────────────────────────────────────── - -void SurfaceSimScene::draw_panel_camera() { - ImGui::SetNextWindowPos(ImVec2(330.f, 20.f), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowSize(ImVec2(310.f, 170.f), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowBgAlpha(0.88f); - if (!ImGui::Begin("Sim \xe2\x80\x93 Camera")) { ImGui::End(); return; } - - ImGui::SliderFloat("Yaw", &m_vp3d.yaw, - -std::numbers::pi_v, std::numbers::pi_v, "%.2f"); - ImGui::SliderFloat("Pitch", &m_vp3d.pitch, -1.5f, 1.5f, "%.2f"); - ImGui::SliderFloat("Zoom##3d", &m_vp3d.zoom, 0.1f, 5.f, "%.2f"); - if (ImGui::Button("Reset 3D")) m_vp3d.reset(); - ImGui::SameLine(); - if (ImGui::Button("Reset 2D")) m_vp2d.reset(); - ImGui::End(); -} - -// ── draw_panel_debug ────────────────────────────────────────────────────────── - -void SurfaceSimScene::draw_panel_debug() { - ImGui::SetNextWindowPos(ImVec2(330.f, 200.f), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowSize(ImVec2(310.f, 130.f), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowBgAlpha(0.88f); - if (!ImGui::Begin("Sim \xe2\x80\x93 Debug")) { ImGui::End(); return; } - - ImGui::SeparatorText("Scene"); - if (ImGui::Button("Switch to Analysis Scene", ImVec2(-1.f, 0.f))) { - m_api.switch_scene(make_analysis_scene); - } - - ImGui::SeparatorText("Tools"); - if (ImGui::Button("Coord Debug [Ctrl+Q]")) { - m_debug_open = !m_debug_open; - m_coord_debug.visible() = m_debug_open; - } - ImGui::SameLine(); - if (ImGui::Button("Perf")) m_perf.visible() = !m_perf.visible(); - const auto& s = m_api.debug_stats(); - const ImVec4 fc = s.fps >= 55.f ? ImVec4(0.4f,1.f,0.4f,1.f) - : s.fps >= 30.f ? ImVec4(1.f,0.8f,0.f,1.f) - : ImVec4(1.f,0.3f,0.3f,1.f); - ImGui::SameLine(); - ImGui::TextColored(fc, "%.0f fps", s.fps); - ImGui::End(); -} - -// ── draw_surface_3d_window ──────────────────────────────────────────────────── - -void SurfaceSimScene::draw_surface_3d_window() { - ImGui::SetNextWindowPos(ImVec2(330.f, 20.f), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowSize(ImVec2(750.f, 660.f), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowBgAlpha(0.0f); - ImGuiWindowFlags flags = - ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse; - ImGui::Begin("Surface 3D", nullptr, flags); - - const ImVec2 cpos = ImGui::GetCursorScreenPos(); - const ImVec2 csz = ImGui::GetContentRegionAvail(); - - m_vp3d.fb_w = csz.x; m_vp3d.fb_h = csz.y; - m_vp3d.dp_w = csz.x; m_vp3d.dp_h = csz.y; - m_canvas3d_pos = cpos; - m_canvas3d_sz = csz; - - ImGui::InvisibleButton("3d_canvas", csz, - ImGuiButtonFlags_MouseButtonLeft | - ImGuiButtonFlags_MouseButtonRight | - ImGuiButtonFlags_MouseButtonMiddle); - const bool hovered = ImGui::IsItemHovered(); - - if (hovered) { - const ImGuiIO& io = ImGui::GetIO(); - if (std::abs(io.MouseWheel) > 0.f) - m_vp3d.zoom = std::clamp(m_vp3d.zoom*(1.f + 0.12f*io.MouseWheel), 0.05f, 20.f); - if (ImGui::IsMouseDragging(ImGuiMouseButton_Right) || - ImGui::IsMouseDragging(ImGuiMouseButton_Middle)) - m_vp3d.orbit(io.MouseDelta.x, io.MouseDelta.y); - update_hover(cpos, csz, true); - } else { - m_snap_on_curve = false; - m_snap_idx = -1; - } - - const Mat4 mvp3d = canvas_mvp_3d(cpos, csz); - - switch (m_surface_display) { - case SurfaceDisplay::Wireframe: submit_wireframe_3d(mvp3d); break; - case SurfaceDisplay::Filled: submit_filled_3d(mvp3d); break; - case SurfaceDisplay::Both: - submit_filled_3d(mvp3d); - submit_wireframe_3d(mvp3d); - break; - } - - // Sync flags and delegate particle rendering to ParticleRenderer (B2). - m_particle_renderer.show_frenet = m_show_frenet; - m_particle_renderer.show_T = m_show_T; - m_particle_renderer.show_N = m_show_N; - m_particle_renderer.show_B = m_show_B; - m_particle_renderer.show_osc = m_show_osc; - m_particle_renderer.show_dir_deriv = m_show_dir_deriv; - m_particle_renderer.show_normal_plane = m_show_normal_plane; - m_particle_renderer.show_torsion = m_show_torsion; - m_particle_renderer.frame_scale = m_frame_scale; - m_particle_renderer.submit_all(m_curves, *m_surface, m_sim_time, - mvp3d, m_snap_curve, m_snap_idx, m_snap_on_curve); - - ImDrawList* dl = ImGui::GetWindowDrawList(); - dl->AddText(ImVec2(cpos.x+8, cpos.y+6), - IM_COL32(200,200,200,180), - "Right-drag: orbit Scroll: zoom Ctrl+L: add particle"); - - if (m_sim_paused) - dl->AddText(ImVec2(cpos.x+8, cpos.y+24), - IM_COL32(255, 210, 60, 240), "PAUSED [Ctrl+P to resume]"); - - if (m_snap_on_curve && m_snap_idx >= 0 && m_snap_curve >= 0 && - m_snap_curve < static_cast(m_curves.size())) - { - const FrenetFrame fr = m_curves[m_snap_curve].frenet_at( - static_cast(m_snap_idx)); - char buf[128]; - std::snprintf(buf, sizeof(buf), "k=%.4f t=%.4f osc.r=%.3f", - fr.kappa, fr.tau, fr.kappa>1e-5f ? 1.f/fr.kappa : 0.f); - dl->AddText(ImVec2(cpos.x+8, cpos.y+22), IM_COL32(255,220,100,220), buf); - } - - ImGui::End(); -} - -// ── draw_contour_2d_window ──────────────────────────────────────────────────── - -void SurfaceSimScene::draw_contour_2d_window() { - // Not called from on_frame() -- second window handles 2D rendering. -} - -// ── update_hover ────────────────────────────────────────────────────────────── - -void SurfaceSimScene::update_hover(const ImVec2& canvas_pos, - const ImVec2& canvas_size, - bool is_3d) -{ - const ImGuiIO& io = ImGui::GetIO(); - const f32 mx = io.MousePos.x - canvas_pos.x; - const f32 my = io.MousePos.y - canvas_pos.y; - constexpr f32 SNAP = 20.f; - - m_snap_idx = -1; - m_snap_curve = 0; - m_snap_on_curve = false; - - if (m_curves.empty()) return; - - if (is_3d) { - const Mat4 mvp = canvas_mvp_3d(canvas_pos, canvas_size); - const Vec2 sw_sz = m_api.viewport_size(); - const f32 sw = sw_sz.x > 0.f ? sw_sz.x : 1.f; - const f32 sh = sw_sz.y > 0.f ? sw_sz.y : 1.f; - f32 best = SNAP; - for (u32 ci = 0; ci < static_cast(m_curves.size()); ++ci) { - const AnimatedCurve& c = m_curves[ci]; - for (u32 i = 0; i < c.trail_size(); ++i) { - const Vec3 wp = c.trail_pt(i); - const glm::vec4 clip = mvp * glm::vec4(wp.x, wp.y, wp.z, 1.f); - if (clip.w <= 0.f) continue; - const f32 px = (clip.x/clip.w + 1.f) * 0.5f * sw - canvas_pos.x; - const f32 py = (1.f - clip.y/clip.w) * 0.5f * sh - canvas_pos.y; - const f32 d = std::hypot(px - mx, py - my); - if (d < best) { best = d; m_snap_idx = (int)i; m_snap_curve = (int)ci; } - } - } - } else { - f32 best = SNAP; - for (u32 ci = 0; ci < static_cast(m_curves.size()); ++ci) { - const AnimatedCurve& c = m_curves[ci]; - for (u32 i = 0; i < c.trail_size(); ++i) { - const Vec3 wp = c.trail_pt(i); - const Vec2 sp = m_vp2d.world_to_pixel(wp.x, wp.y); - const f32 d = std::hypot(sp.x-mx, sp.y-my); - if (d < best) { best = d; m_snap_idx = (int)i; m_snap_curve = (int)ci; } - } - } - } - - if (m_snap_idx >= 0) m_snap_on_curve = true; -} - -// ── Surface geometry caches ─────────────────────────────────────────────────── - -void SurfaceSimScene::rebuild_wireframe_cache_if_needed() { - const bool resolution_changed = (m_grid_lines != m_cached_grid_lines); - const bool time_varying = m_surface->is_time_varying(); - if (!m_wireframe_dirty && !resolution_changed && !time_varying) return; - - const u32 n = m_surface->wireframe_vertex_count(m_grid_lines, m_grid_lines); - if (m_wireframe_cache.size() < static_cast(n)) - m_wireframe_cache.resize(n); - - m_surface->tessellate_wireframe( - std::span{ m_wireframe_cache.data(), n }, - m_grid_lines, m_grid_lines, m_sim_time); - - m_wireframe_vcount = n; - m_cached_grid_lines = m_grid_lines; - m_wireframe_dirty = false; -} - -// static -Vec4 SurfaceSimScene::curvature_color(float K, float scale) noexcept { - const float t = std::clamp(K / (scale + 1e-9f), -1.f, 1.f); - if (t >= 0.f) { - return { 0.50f + t*0.35f, 0.50f - t*0.38f, 0.50f - t*0.42f, 0.82f }; - } else { - const float s = -t; - return { 0.50f - s*0.40f, 0.50f + s*0.18f - s*s*0.46f, 0.50f + s*0.35f, 0.82f }; - } -} - -void SurfaceSimScene::rebuild_filled_cache_if_needed() { - const bool resolution_changed = (m_grid_lines != m_cached_grid_lines); - const bool time_varying = m_surface->is_time_varying(); - if (!m_wireframe_dirty && !resolution_changed && !time_varying) return; - - const u32 N = m_grid_lines; - const u32 n = N * N * 6; - if (m_filled_cache.size() < static_cast(n)) - m_filled_cache.resize(n); - - const float u0 = m_surface->u_min(m_sim_time), u1 = m_surface->u_max(m_sim_time); - const float v0 = m_surface->v_min(m_sim_time), v1 = m_surface->v_max(m_sim_time); - const float du = (u1 - u0) / static_cast(N); - const float dv = (v1 - v0) / static_cast(N); - - u32 idx = 0; - for (u32 i = 0; i < N; ++i) { - const float ua = u0 + static_cast(i) * du; - const float ub = u0 + static_cast(i+1) * du; - for (u32 j = 0; j < N; ++j) { - const float va = v0 + static_cast(j) * dv; - const float vb = v0 + static_cast(j+1) * dv; - const Vec3 p00 = m_surface->evaluate(ua, va, m_sim_time); - const Vec3 p10 = m_surface->evaluate(ub, va, m_sim_time); - const Vec3 p01 = m_surface->evaluate(ua, vb, m_sim_time); - const Vec3 p11 = m_surface->evaluate(ub, vb, m_sim_time); - const float uc = (ua + ub) * 0.5f; - const float vc = (va + vb) * 0.5f; - const float K = m_surface->gaussian_curvature(uc, vc, m_sim_time); - const Vec4 col = curvature_color(K, m_curv_scale); - m_filled_cache[idx++] = { p00, col }; - m_filled_cache[idx++] = { p10, col }; - m_filled_cache[idx++] = { p11, col }; - m_filled_cache[idx++] = { p00, col }; - m_filled_cache[idx++] = { p11, col }; - m_filled_cache[idx++] = { p01, col }; - } - } - m_filled_vcount = idx; - m_cached_grid_lines = m_grid_lines; - m_wireframe_dirty = false; -} - -void SurfaceSimScene::submit_wireframe_3d(const Mat4& mvp) { - rebuild_wireframe_cache_if_needed(); - if (m_wireframe_vcount == 0) return; - auto slice = m_api.acquire(m_wireframe_vcount); - std::memcpy(slice.vertices(), m_wireframe_cache.data(), - m_wireframe_vcount * sizeof(Vertex)); - m_api.submit_to(RenderTarget::Primary3D, slice, Topology::LineList, DrawMode::VertexColor, {1,1,1,1}, mvp); -} - -void SurfaceSimScene::submit_filled_3d(const Mat4& mvp) { - rebuild_filled_cache_if_needed(); - if (m_filled_vcount == 0) return; - auto slice = m_api.acquire(m_filled_vcount); - std::memcpy(slice.vertices(), m_filled_cache.data(), - m_filled_vcount * sizeof(Vertex)); - m_api.submit_to(RenderTarget::Primary3D, slice, Topology::TriangleList, DrawMode::VertexColor, {1,1,1,1}, mvp); -} - -// ── submit_contour_second_window ────────────────────────────────────────────── - -void SurfaceSimScene::submit_contour_second_window() { - const Vec2 sz2 = m_api.viewport_size2(); - if (sz2.x <= 0.f || sz2.y <= 0.f) return; - - const f32 domain = m_surface->u_max(); - const f32 aspect = sz2.x / sz2.y; - const f32 half_y = domain / m_vp2d.zoom; - const f32 half_x = half_y * aspect; - const Mat4 mvp2 = glm::ortho( - m_vp2d.pan_x - half_x, m_vp2d.pan_x + half_x, - m_vp2d.pan_y - half_y, m_vp2d.pan_y + half_y, - -10.f, 10.f); - - const auto* gs = dynamic_cast(m_surface.get()); - if (gs) { - constexpr u32 NX=80, NY=80; - auto slice = m_api.acquire(NX*NY*6); - Vertex* v = slice.vertices(); u32 idx = 0; - const f32 dx = (gs->u_max()-gs->u_min())/NX; - const f32 dy = (gs->v_max()-gs->v_min())/NY; - for (u32 i=0;iu_min()+i*dx, x1=x0+dx; - const f32 y0=gs->v_min()+j*dy, y1=y0+dy; - const Vec4 c00=GaussianSurface::height_color(GaussianSurface::eval(x0,y0)); - const Vec4 c10=GaussianSurface::height_color(GaussianSurface::eval(x1,y0)); - const Vec4 c01=GaussianSurface::height_color(GaussianSurface::eval(x0,y1)); - const Vec4 c11=GaussianSurface::height_color(GaussianSurface::eval(x1,y1)); - v[idx++]={Vec3{x0,y0,0},c00}; v[idx++]={Vec3{x1,y0,0},c10}; v[idx++]={Vec3{x1,y1,0},c11}; - v[idx++]={Vec3{x0,y0,0},c00}; v[idx++]={Vec3{x1,y1,0},c11}; v[idx++]={Vec3{x0,y1,0},c01}; - } - memory::ArenaSlice tr=slice; tr.vertex_count=idx; - m_api.submit_to(RenderTarget::Contour2D, tr, Topology::TriangleList, DrawMode::VertexColor, {1,1,1,1}, mvp2); - - if (m_show_contours) { - const u32 max_v = GaussianSurface::contour_max_vertices(100u, k_n_levels); - auto cslice = m_api.acquire(max_v); - const u32 actual = GaussianSurface::tessellate_contours( - {cslice.vertices(),max_v}, 100u, k_levels, k_n_levels, {1,1,1,0.8f}); - if (actual > 0) { - memory::ArenaSlice tr2=cslice; tr2.vertex_count=actual; - m_api.submit_to(RenderTarget::Contour2D, tr2, Topology::LineList, DrawMode::VertexColor, {1,1,1,1}, mvp2); - } - } - } - - for (const AnimatedCurve& c : m_curves) { - const u32 nt = c.trail_size(); - if (nt < 2) continue; - auto slice = m_api.acquire(nt); - Vertex* vt = slice.vertices(); - for (u32 i = 0; i < nt; ++i) { - const Vec3 pt = c.trail_pt(i); - const f32 t = static_cast(i)/static_cast(nt-1); - vt[i] = { Vec3{pt.x,pt.y,0}, - AnimatedCurve::trail_colour(c.role(), c.colour_slot(), t) }; - } - m_api.submit_to(RenderTarget::Contour2D, slice, Topology::LineStrip, DrawMode::VertexColor, {1,1,1,1}, mvp2); - } - - { - const int ci = (m_snap_on_curve && m_snap_curve >= 0 && - m_snap_curve < static_cast(m_curves.size())) - ? m_snap_curve : 0; - if (ci < static_cast(m_curves.size())) { - const AnimatedCurve& ac = m_curves[ci]; - const u32 nt = ac.trail_size(); - const u32 sidx = (m_snap_on_curve && m_snap_curve == ci && - m_snap_idx >= 0 && static_cast(m_snap_idx) < nt) - ? static_cast(m_snap_idx) - : (nt > 2 ? nt-2 : 0); - if (nt >= 3 && sidx > 0) { - const FrenetFrame fr = ac.frenet_at(sidx); - const Vec3 o3 = ac.trail_pt(sidx); - const Vec3 o = {o3.x, o3.y, 0}; - const f32 scl = m_frame_scale * 0.4f; - auto arr = [&](Vec3 dir, Vec4 col) { - if (glm::length(dir) < 1e-5f) return; - auto s = m_api.acquire(2); - s.vertices()[0] = {o, col}; - s.vertices()[1] = {o + glm::normalize(dir)*scl, col}; - m_api.submit_to(RenderTarget::Contour2D, s, Topology::LineList, DrawMode::VertexColor, col, mvp2); - }; - if (m_show_T) arr({fr.T.x,fr.T.y,0},{1.f,0.35f,0.05f,1.f}); - if (m_show_N) arr({fr.N.x,fr.N.y,0},{0.15f,1.f,0.3f,1.f}); - if (m_show_osc && fr.kappa > 1e-5f) { - const f32 R = 1.f/fr.kappa; - constexpr u32 SEG = 64; - auto sc2 = m_api.acquire(SEG+1); - Vertex* vc = sc2.vertices(); - const Vec3 ctr = {o3.x+fr.N.x*R, o3.y+fr.N.y*R, 0}; - for (u32 k=0; k<=SEG; ++k) { - const f32 th = (static_cast(k)/SEG)*2.f*std::numbers::pi_v; - vc[k] = { ctr+Vec3{std::cos(th)*R,std::sin(th)*R,0}, {0.7f,0.3f,1.f,0.65f} }; - } - m_api.submit_to(RenderTarget::Contour2D, sc2, Topology::LineStrip, DrawMode::VertexColor, - {0.7f,0.3f,1.f,0.65f}, mvp2); - } - if (m_show_torsion && fr.kappa > 1e-5f && std::abs(fr.tau) > 1e-4f) { - const Vec4 tau_col = fr.tau > 0.f - ? Vec4{1.f, 0.55f, 0.1f, 0.9f} - : Vec4{0.3f, 0.8f, 1.f, 0.9f}; - const Vec3 dBds_xy = { -fr.tau*fr.N.x, -fr.tau*fr.N.y, 0.f }; - arr(dBds_xy, tau_col); - const f32 len = std::clamp(std::abs(fr.tau) * scl * 2.f, 0.02f, 1.5f); - const Vec3 tip_dir = glm::length(dBds_xy) > 1e-6f - ? glm::normalize(dBds_xy) : Vec3{0,1,0}; - const Vec3 tip = o + tip_dir * len; - auto dot = m_api.acquire(1); - dot.vertices()[0] = { tip, tau_col }; - m_api.submit_to(RenderTarget::Contour2D, dot, Topology::LineStrip, DrawMode::UniformColor, tau_col, mvp2); - } - } - } - } -} - -// ── Extremum helpers ────────────────────────────────────────────────────────── - -void SurfaceSimScene::rebuild_extremum_table_if_needed() { - if (m_surface_type != SurfaceType::Extremum) return; - if (m_extremum_rebuild_countdown == 0) { - m_extremum_table.build(*m_surface, m_sim_time); - m_extremum_rebuild_countdown = 30u; - } else { - --m_extremum_rebuild_countdown; - } -} - -void SurfaceSimScene::spawn_leader_seeker() { - const float u_mid = 0.5f*(m_surface->u_min() + m_surface->u_max()); - const float v_mid = 0.5f*(m_surface->v_min() + m_surface->v_max()); - - std::unique_ptr eq; - if (m_leader_mode == LeaderMode::Deterministic) - eq = std::make_unique( - &m_extremum_table, m_ls_params); - else - eq = std::make_unique( - &m_extremum_table, m_bbl_params); - - AnimatedCurve c = AnimatedCurve::with_equation( - u_mid, v_mid, - AnimatedCurve::Role::Leader, - m_leader_count % AnimatedCurve::MAX_SLOTS, - m_surface.get(), std::move(eq), &m_milstein); - - // Enable history so pursuers can use it - const std::size_t cap = - static_cast(std::ceil(m_pursuit_tau * 120.f * 1.5f)) + 256; - c.enable_history(cap, 1.f / 120.f); - - m_curves.push_back(std::move(c)); - ++m_leader_count; - m_spawning_pursuer = true; -} - -void SurfaceSimScene::spawn_pursuit_particle() { - if (m_curves.empty()) return; - - // Ensure leader has history - if (m_curves[0].history() == nullptr) { - const std::size_t cap = - static_cast(std::ceil(m_pursuit_tau * 120.f * 1.5f)) + 256; - m_curves[0].enable_history(cap, 1.f / 120.f); - } - - const spawn::SpawnContext ctx{ m_surface.get(), &m_equation, - &m_integrator, &m_milstein, m_sim_speed }; - const glm::vec2 ref = m_curves[0].head_uv(); - const glm::vec2 uv = spawn::offset_spawn(ref, 2.0f, - static_cast(m_dp_count) * 1.1f + 0.3f, *m_surface); - - std::unique_ptr eq; - switch (m_pursuit_mode) { - case PursuitMode::Direct: - eq = std::make_unique( - [this]{ return m_curves[0].head_uv(); }, - ndde::sim::DirectPursuitEquation::Params{ - m_ls_params.pursuit_speed, 0.f }); - break; - case PursuitMode::Delayed: - eq = std::make_unique( - m_curves[0].history(), m_surface.get(), - ndde::sim::DelayPursuitEquation::Params{ - m_pursuit_tau, m_ls_params.pursuit_speed, 0.f }); - break; - case PursuitMode::Momentum: - eq = std::make_unique( - m_curves[0].history(), - ndde::sim::MomentumBearingEquation::Params{ - m_ls_params.pursuit_speed, m_pursuit_window, 0.f }); - break; - } - - m_curves.push_back(spawn::spawn_owned(uv, - AnimatedCurve::Role::Chaser, - m_chaser_count % AnimatedCurve::MAX_SLOTS, - std::move(eq), ctx, - false)); - ++m_chaser_count; - ++m_dp_count; -} - -void SurfaceSimScene::draw_leader_seeker_panel() { - if (m_surface_type != SurfaceType::Extremum) return; - ImGui::SeparatorText("Leader Seeker [Ctrl+A]"); - - // Leader mode - { - int mode = static_cast(m_leader_mode); - bool changed = false; - changed |= ImGui::RadioButton("Deterministic##lm", &mode, 0); - ImGui::SameLine(); - changed |= ImGui::RadioButton("Biased Brownian##lm", &mode, 1); - if (changed) m_leader_mode = static_cast(mode); - } - - if (m_leader_mode == LeaderMode::Deterministic) { - auto& p = m_ls_params; - ImGui::SliderFloat("Target grad mag##ls", &p.target_grad_magnitude, 0.f, 2.f, "%.2f"); - ImGui::SliderFloat("Epsilon##ls", &p.epsilon, 0.01f, 0.5f, "%.3f"); - ImGui::SliderFloat("Leader speed##ls", &p.pursuit_speed, 0.1f, 3.f, "%.2f"); - ImGui::SliderFloat("Leader noise##ls", &p.noise_sigma, 0.f, 1.f, "%.3f"); - ImGui::SliderFloat("Arrival radius##ls", &p.arrival_radius, 0.1f, 2.f, "%.2f"); - } else { - auto& p = m_bbl_params; - ImGui::SliderFloat("Sigma##bbl", &p.sigma, 0.01f, 2.f, "%.3f"); - ImGui::SliderFloat("Goal drift##bbl", &p.drift_strength, 0.f, 2.f, "%.2f"); - ImGui::SliderFloat("Gradient drift##bbl", &p.gradient_drift, -1.f, 1.f, "%.3f"); - ImGui::SliderFloat("Epsilon##bbl", &p.epsilon, 0.01f, 0.5f, "%.3f"); - ImGui::SliderFloat("Arrival radius##bbl", &p.arrival_radius, 0.1f, 2.f, "%.2f"); - if (p.sigma > 1e-5f) - ImGui::TextDisabled("Peclet = %.2f (drift/sigma^2)", - p.drift_strength / (p.sigma * p.sigma)); - } - - if (ImGui::SmallButton(!m_spawning_pursuer ? "Spawn leader [Ctrl+A]" : "Spawn pursuer [Ctrl+A]")) { - if (!m_spawning_pursuer) spawn_leader_seeker(); - else spawn_pursuit_particle(); - } - - // Pursuit mode - ImGui::SeparatorText("Pursuit mode"); - { - int mode = static_cast(m_pursuit_mode); - bool changed = false; - changed |= ImGui::RadioButton("Direct##pm", &mode, 0); - ImGui::SameLine(); - changed |= ImGui::RadioButton("Delayed##pm", &mode, 1); - ImGui::SameLine(); - changed |= ImGui::RadioButton("Momentum##pm", &mode, 2); - if (changed) m_pursuit_mode = static_cast(mode); - } - if (m_pursuit_mode == PursuitMode::Delayed) - ImGui::SliderFloat("Tau##pm", &m_pursuit_tau, 0.1f, 10.f, "%.2f s"); - if (m_pursuit_mode == PursuitMode::Momentum) - ImGui::SliderFloat("Window##pm", &m_pursuit_window, 0.1f, 5.f, "%.2f s"); - - // Extremum table readout - ImGui::SeparatorText("Extremum table"); - if (m_extremum_table.valid) { - ImGui::TextDisabled("max u=%.3f v=%.3f z=%.3f", - m_extremum_table.max_uv.x, m_extremum_table.max_uv.y, m_extremum_table.max_z); - ImGui::TextDisabled("min u=%.3f v=%.3f z=%.3f", - m_extremum_table.min_uv.x, m_extremum_table.min_uv.y, m_extremum_table.min_z); - if (ImGui::SmallButton("Rebuild now##ext")) - m_extremum_table.build(*m_surface, m_sim_time); - } else { - ImGui::TextColored({1.f,0.6f,0.1f,1.f}, "Table invalid -- switch to Extremum surface"); - } -} - -// ── export_session ──────────────────────────────────────────────────────────── - -void SurfaceSimScene::export_session(const std::string& path) const { - std::ofstream f(path); - if (!f.is_open()) return; - - f << std::fixed << std::setprecision(6); - f << "particle_id,role,equation,record_type,step,a,b,c\n"; - - for (u32 ci = 0; ci < static_cast(m_curves.size()); ++ci) { - const auto& c = m_curves[ci]; - const std::string role = (c.role() == AnimatedCurve::Role::Leader) ? "leader" : "chaser"; - const std::string eq = c.equation() ? c.equation()->name() : "unknown"; - - for (u32 i = 0; i < c.trail_size(); ++i) { - const Vec3 p = c.trail_pt(i); - f << ci << ',' << role << ',' << eq << ",trail," - << i << ',' << p.x << ',' << p.y << ',' << p.z << '\n'; - } - if (c.history()) { - const auto recs = c.history()->to_vector(); - for (std::size_t i = 0; i < recs.size(); ++i) { - f << ci << ',' << role << ',' << eq << ",history," - << i << ',' << recs[i].t << ',' - << recs[i].uv.x << ',' << recs[i].uv.y << '\n'; - } - } - } -} - -} // namespace ndde diff --git a/nurbs_dde/src/app/SurfaceSimScene.hpp b/nurbs_dde/src/app/SurfaceSimScene.hpp deleted file mode 100644 index 57bff84e..00000000 --- a/nurbs_dde/src/app/SurfaceSimScene.hpp +++ /dev/null @@ -1,240 +0,0 @@ -#pragma once -// app/SurfaceSimScene.hpp -// Thin orchestrator: owns simulation state, delegates rendering and spawning. -// -// B1: FrenetFrame / SurfaceFrame / AnimatedCurve split to their own headers. -// B2: All submit_* particle methods extracted to ParticleRenderer. -// B3: All spawn logic extracted to SpawnStrategy (ndde::spawn namespace). -// -// Responsibilities that remain here: -// - Own m_surface, m_curves, integrators, equations -// - Call advance_simulation(dt) -// - Call m_particle_renderer.submit_all() -// - Call draw_simulation_panel(), draw_hotkey_panel() -// - Surface wireframe + filled geometry caches -// - Camera orbit + canvas MVP -// - submit_contour_second_window() (uses submit2, scene-specific 2D MVP) -// - export_session() -// -// Hotkeys: -// Ctrl+L -- spawn a new Leader particle (blue trail) -// Ctrl+C -- spawn a new Chaser particle (red trail) -// Ctrl+B -- spawn a Brownian motion particle (Milstein integrator) -// Ctrl+R -- spawn a delay-pursuit chaser targeting curve 0 -// Ctrl+A -- spawn leader seeker (first press) / pursuit particle (subsequent) -// Ctrl+F -- toggle Frenet frame (T, N, B arrows + osculating circle) -// Ctrl+D -- toggle surface frame (Dx, Dy tangents + kappa_n / kappa_g readout) -// Ctrl+N -- toggle normal plane patch at particle -// Ctrl+P -- pause / unpause simulation -// Ctrl+T -- toggle torsion visualisation (tau ribbon + signed readout) -// Ctrl+Q -- toggle coordinate debug panel -// Ctrl+H -- toggle hotkey reference panel - -#include "engine/EngineAPI.hpp" -#include "engine/IScene.hpp" -#include "app/GaussianSurface.hpp" -#include "app/GaussianRipple.hpp" -#include "app/FrenetFrame.hpp" // FrenetFrame, SurfaceFrame, make_surface_frame -#include "app/AnimatedCurve.hpp" // AnimatedCurve -#include "sim/GradientWalker.hpp" -#include "sim/EulerIntegrator.hpp" -#include "sim/BrownianMotion.hpp" -#include "sim/MilsteinIntegrator.hpp" -#include "sim/HistoryBuffer.hpp" -#include "sim/DelayPursuitEquation.hpp" -#include "sim/IConstraint.hpp" -#include "sim/MinDistConstraint.hpp" -#include "math/ExtremumTable.hpp" -#include "math/ExtremumSurface.hpp" -#include "sim/LeaderSeekerEquation.hpp" -#include "sim/BiasedBrownianLeader.hpp" -#include "sim/DirectPursuitEquation.hpp" -#include "sim/MomentumBearingEquation.hpp" -#include "app/Viewport.hpp" -#include "app/HoverResult.hpp" -#include "app/PerformancePanel.hpp" -#include "app/CoordDebugPanel.hpp" -#include "app/ParticleRenderer.hpp" // B2: extracted particle rendering -#include "app/SpawnStrategy.hpp" // B3: extracted spawn helpers - -#include "app/HotkeyManager.hpp" // decoupled hotkey registration and dispatch -#include -#include -#include -#include - -namespace ndde { - -class SurfaceSimScene : public IScene { -public: - explicit SurfaceSimScene(EngineAPI api); - ~SurfaceSimScene() = default; - - void on_frame(f32 dt) override; - [[nodiscard]] std::string_view name() const override { return "Surface Simulation"; } - -private: - EngineAPI m_api; - HotkeyManager m_hotkeys; // registered in constructor - ParticleRenderer m_particle_renderer; // B2 - std::unique_ptr m_surface; // Step 3: owns the surface - ndde::sim::GradientWalker m_equation; // Step 4: default equation - ndde::sim::EulerIntegrator m_integrator; // Step 5: Euler scheme - ndde::sim::MilsteinIntegrator m_milstein; // Step 9: Milstein for SDEs - ndde::sim::BrownianMotion::Params m_bm_params; // Step 9: Brownian UI params - ndde::sim::DelayPursuitEquation::Params m_dp_params; // Step 10: delay-pursuit params - u32 m_dp_count = 0; // Step 10: spawn counter - std::vector m_curves; ///< all active particles - PerformancePanel m_perf; - CoordDebugPanel m_coord_debug; - - // Viewports - Viewport m_vp3d; // perspective - Viewport m_vp2d; // ortho -- used by contour panel + second window - - // Canvas rect for the 3D window (updated each frame) - ImVec2 m_canvas3d_pos{}; - ImVec2 m_canvas3d_sz{}; - - // Surface selector - enum class SurfaceType : u8 { Gaussian = 0, Torus = 1, GaussianRipple = 2, Extremum = 3 }; - SurfaceType m_surface_type = SurfaceType::Gaussian; - f32 m_torus_R = 2.0f; - f32 m_torus_r = 0.7f; - GaussianRipple::Params m_ripple_params; ///< UI-editable ripple parameters - - void swap_surface(SurfaceType type); - - // Simulation time - // Accumulated wall-clock simulation time (paused when m_sim_paused). - // Passed to ISurface geometry queries and wireframe tessellation - // for deforming surfaces (is_time_varying() == true). - f32 m_sim_time = 0.f; - - // Simulation state - f32 m_sim_speed = 1.f; - bool m_sim_paused = false; - f32 m_frame_scale = 0.25f; - u32 m_grid_lines = 64; - bool m_show_contours = true; - - // Visibility toggles (hotkeys) - bool m_show_frenet = true; ///< Ctrl+F - bool m_show_dir_deriv = false; ///< Ctrl+D -- surface frame Dx/Dy - bool m_show_normal_plane = false; ///< Ctrl+P -- normal plane patch - bool m_show_torsion = false; ///< Ctrl+T -- torsion ribbon - bool m_debug_open = false; ///< Ctrl+Q - bool m_hotkey_panel_open = false; ///< Ctrl+H - - // Frenet sub-toggles (Simulation panel checkboxes) - bool m_show_T = true; - bool m_show_N = true; - bool m_show_B = true; - bool m_show_osc = true; - - // Counters for colour-slot cycling within each role - u32 m_leader_count = 0; - u32 m_chaser_count = 0; - - // Pairwise constraints - // Applied once per ordered pair (i, j) with i < j, after per-particle step. - std::vector> m_pair_constraints; - bool m_pair_collision = false; ///< UI toggle for MinDistConstraint - float m_min_dist = 0.3f; ///< UI-editable minimum distance - - // Ctrl+A: ExtremumSurface + leader seeker - ndde::math::ExtremumTable m_extremum_table; ///< stable address (value member) - u32 m_extremum_rebuild_countdown = 0; - - enum class LeaderMode : u8 { Deterministic = 0, StochasticBiased = 1 }; - LeaderMode m_leader_mode = LeaderMode::Deterministic; - ndde::sim::LeaderSeekerEquation::Params m_ls_params; - ndde::sim::BiasedBrownianLeader::Params m_bbl_params; - - enum class PursuitMode : u8 { Direct = 0, Delayed = 1, Momentum = 2 }; - PursuitMode m_pursuit_mode = PursuitMode::Direct; - float m_pursuit_tau = 1.5f; ///< for Strategy B (DelayPursuit) - float m_pursuit_window = 1.5f; ///< for Strategy C (MomentumBearing) - - bool m_spawning_pursuer = false; ///< true after first Ctrl+A spawns a leader - - // Hover / snap - int m_snap_curve = 0; ///< which curve the snap belongs to - int m_snap_idx = -1; - bool m_snap_on_curve = false; - HoverResult m_hover; - - // Surface display mode - // Wireframe: line grid (LineList) -- the existing mode - // Filled: triangle mesh coloured by Gaussian curvature K - // Both: filled mesh with wireframe overlaid at reduced opacity - enum class SurfaceDisplay : u8 { Wireframe = 0, Filled = 1, Both = 2 }; - SurfaceDisplay m_surface_display = SurfaceDisplay::Wireframe; - - // Filled surface curvature colour scale - // K > 0 (elliptic / sphere-like) -> warm (amber -> red) - // K = 0 (flat / parabolic) -> neutral (grey) - // K < 0 (hyperbolic / saddle) -> cool (teal -> blue) - // k_curv_scale: maps K to [-1, 1]. Adjust per-surface so the full range - // of colour is used. Exposed as a UI slider. - f32 m_curv_scale = 2.f; ///< world units^-2 per unit colour range - - bool m_wireframe_dirty = true; ///< set true to force rebuild of both caches - u32 m_cached_grid_lines = 0; - - // Wireframe geometry cache - // Stores the tessellated line-list vertices in system RAM. - // Rebuilt only when the surface changes, the grid resolution changes, - // or the surface is time-varying (is_time_varying() == true). - std::vector m_wireframe_cache; - u32 m_wireframe_vcount = 0; - - void rebuild_wireframe_cache_if_needed(); - - // Filled surface geometry cache - std::vector m_filled_cache; - u32 m_filled_vcount = 0; - - void rebuild_filled_cache_if_needed(); - - [[nodiscard]] static Vec4 curvature_color(float K, float scale) noexcept; - - - void advance_simulation(f32 dt); - void apply_pairwise_constraints(); - void spawn_leader_seeker(); - void spawn_pursuit_particle(); - void rebuild_extremum_table_if_needed(); - void draw_leader_seeker_panel(); - - [[nodiscard]] Mat4 canvas_mvp_3d(const ImVec2& cpos, - const ImVec2& csz) const noexcept; - - void draw_panel_surface(); ///< surface selector + display mode - void draw_panel_particles(); ///< pause, speed, spawn counts, clear, export - void draw_panel_overlays(); ///< Frenet toggles, overlay checkboxes, torsion readout - void draw_panel_brownian(); ///< sigma, drift, RNG, live sliders - void draw_panel_pursuit(); ///< delay pursuit + collision + leader seeker - void draw_panel_geometry(); ///< at-head Frenet / curvature / 1st-fund-form readout - void draw_panel_camera(); ///< yaw, pitch, zoom, reset - void draw_panel_debug(); ///< coord debug, perf, fps, scene switch - void draw_hotkey_panel(); - void draw_surface_3d_window(); - void draw_contour_2d_window(); - void export_session(const std::string& path) const; - - void submit_wireframe_3d(const Mat4& mvp); - void submit_filled_3d(const Mat4& mvp); - - void submit_contour_second_window(); - - void update_hover(const ImVec2& canvas_pos, const ImVec2& canvas_size, - bool is_3d); - - static constexpr f32 k_levels[] = { - -1.2f,-0.9f,-0.6f,-0.3f, 0.f, 0.3f, 0.6f, 0.9f, 1.2f - }; - static constexpr u32 k_n_levels = 9u; -}; - -} // namespace ndde diff --git a/nurbs_dde/src/app/SwarmRecipePanel.hpp b/nurbs_dde/src/app/SwarmRecipePanel.hpp new file mode 100644 index 00000000..2e59460f --- /dev/null +++ b/nurbs_dde/src/app/SwarmRecipePanel.hpp @@ -0,0 +1,109 @@ +#pragma once +// app/SwarmRecipePanel.hpp +// Reusable controls for spawning named swarm recipes. + +#include "app/ParticleInspectorPanel.hpp" +#include "app/ParticleSwarmFactory.hpp" +#include "app/SurfaceMeshCache.hpp" +#include "memory/Containers.hpp" + +#include +#include +#include +#include + +namespace ndde { + +struct SwarmRecipeAction { + const char* label = ""; + const char* hotkey = ""; + std::function spawn; +}; + +struct SwarmRecipePanelState { + bool* paused = nullptr; + float* sim_speed = nullptr; + u32* grid_lines = nullptr; + float* color_scale = nullptr; + bool* show_frenet = nullptr; + bool* show_osculating_circle = nullptr; + SurfaceMeshCache* mesh = nullptr; +}; + +struct SwarmRecipePanelOptions { + const char* surface_formula = nullptr; + const char* grid_label = "Grid"; + const char* color_scale_label = "Color scale"; + int min_grid = 12; + int max_grid = 150; + bool show_level_curve_controls = true; + bool show_brownian_controls = true; + bool show_trail_controls = true; + bool show_sim_controls = true; + bool show_particle_inspector = true; + bool show_recipe_metadata = true; + const char* goal_success_text = nullptr; +}; + +class SwarmRecipePanel { +public: + static void draw(SwarmRecipePanelState state, + const memory::FrameVector& actions, + ParticleSystem& particles, + GoalStatus goal_status, + SwarmBuildResult* last_result, + const SwarmRecipePanelOptions& options = {}) + { + if (options.surface_formula) + ImGui::TextDisabled("%s", options.surface_formula); + if (options.show_sim_controls && state.paused) + ImGui::Checkbox("Paused [Ctrl+P]", state.paused); + if (options.show_sim_controls && state.sim_speed) + ImGui::SliderFloat("Sim speed", state.sim_speed, 0.1f, 5.f, "%.2f"); + if (options.show_sim_controls && state.grid_lines) { + int grid = static_cast(*state.grid_lines); + if (ImGui::SliderInt(options.grid_label, &grid, options.min_grid, options.max_grid)) { + *state.grid_lines = static_cast(std::max(grid, options.min_grid)); + if (state.mesh) state.mesh->mark_dirty(); + } + } + if (options.show_sim_controls && state.color_scale) + ImGui::SliderFloat(options.color_scale_label, state.color_scale, 0.2f, 4.f, "%.2f"); + if (options.show_sim_controls && state.show_frenet) + ImGui::Checkbox("Hover Frenet [Ctrl+F]", state.show_frenet); + if (options.show_sim_controls && state.show_osculating_circle) + ImGui::Checkbox("Osculating circle [Ctrl+O]", state.show_osculating_circle); + + ImGui::SeparatorText("Recipes"); + for (const SwarmRecipeAction& action : actions) { + if (!action.spawn) continue; + const std::string label = action.hotkey && action.hotkey[0] != '\0' + ? std::string(action.label) + " [" + action.hotkey + "]" + : std::string(action.label); + if (ImGui::Button(label.c_str(), ImVec2(-1.f, 0.f)) && last_result) + *last_result = action.spawn(); + } + + if (options.show_recipe_metadata && last_result && !last_result->empty()) { + ImGui::SeparatorText("Last recipe"); + ImGui::TextDisabled("%s", last_result->metadata.family_name.c_str()); + ImGui::TextDisabled("count: %u", last_result->metadata.requested_count); + ImGui::TextDisabled("roles: %s", last_result->metadata.roles_label().c_str()); + ImGui::TextDisabled("goals: %s", last_result->metadata.goals_added ? "yes" : "no"); + } + + if (goal_status == GoalStatus::Succeeded && options.goal_success_text) + ImGui::TextColored({0.4f, 1.f, 0.4f, 1.f}, "%s", options.goal_success_text); + + if (options.show_particle_inspector) { + ParticleInspectorPanel::draw(particles.particles(), ParticleInspectorOptions{ + .label = "Active particles", + .show_level_curve_controls = options.show_level_curve_controls, + .show_brownian_controls = options.show_brownian_controls, + .show_trail_controls = options.show_trail_controls + }); + } + } +}; + +} // namespace ndde diff --git a/nurbs_dde/src/app/Viewport.hpp b/nurbs_dde/src/app/Viewport.hpp index 41ed7ac1..61aa0542 100644 --- a/nurbs_dde/src/app/Viewport.hpp +++ b/nurbs_dde/src/app/Viewport.hpp @@ -17,6 +17,7 @@ // calling pixel_to_world / zoom_toward / pan_by_pixels. #include "math/Scalars.hpp" +#include "numeric/ops.hpp" #include #include #include @@ -75,9 +76,9 @@ struct Viewport { [[nodiscard]] Mat4 perspective_mvp() const noexcept { const float dist = base_extent / zoom * 3.f; - const float cx = pan_x + dist * std::cos(pitch) * std::sin(yaw); - const float cy = pan_y + dist * std::sin(pitch); - const float cz = dist * std::cos(pitch) * std::cos(yaw); + const float cx = pan_x + dist * ops::cos(pitch) * ops::sin(yaw); + const float cy = pan_y + dist * ops::sin(pitch); + const float cz = dist * ops::cos(pitch) * ops::cos(yaw); const Mat4 proj = glm::perspective(glm::radians(45.f), fb_aspect(), 0.01f, 500.f); const Mat4 view = glm::lookAt( glm::vec3(cx, cy, cz), diff --git a/nurbs_dde/src/app/WavePredatorPreySpawner.cpp b/nurbs_dde/src/app/WavePredatorPreySpawner.cpp new file mode 100644 index 00000000..55e93f07 --- /dev/null +++ b/nurbs_dde/src/app/WavePredatorPreySpawner.cpp @@ -0,0 +1,94 @@ +#include "app/WavePredatorPreySpawner.hpp" + +#include "sim/LevelCurveWalker.hpp" + +namespace ndde { + +WavePredatorPreySpawner::WavePredatorPreySpawner(ParticleSystem& particles, + float& sim_time, + GoalStatus& goal_status) noexcept + : m_particles(particles) + , m_sim_time(sim_time) + , m_goal_status(goal_status) +{} + +void WavePredatorPreySpawner::reset_particles() noexcept { + m_particles.clear(); + m_particles.clear_goals(); + m_goal_status = GoalStatus::Running; + m_sim_time = 0.f; +} + +SwarmBuildResult WavePredatorPreySpawner::clear_all() noexcept { + reset_particles(); + return SwarmBuildResult{.metadata = SwarmRecipeMetadata{ + .family_name = "Clear All", + .requested_count = 0u, + .roles_emitted = {}, + .goals_added = false + }}; +} + +SwarmBuildResult WavePredatorPreySpawner::spawn_predator_prey_showcase() { + reset_particles(); + ParticleSwarmFactory swarms(m_particles); + SwarmBuildResult swarm = swarms.leader_pursuit(ParticleSwarmFactory::LeaderPursuitParams{ + .leader_count = 3u, + .chaser_count = 9u, + .center = {0.f, 0.f}, + .leader_radius = 0.85f, + .chaser_radius = 2.85f, + .leader_speed = 0.48f, + .chaser_speed = 0.92f, + .delay_seconds = 0.42f, + .capture_radius = 0.18f, + .leader_noise = {.sigma = 0.09f, .drift_strength = -0.04f}, + .chaser_noise = {.sigma = 0.05f, .drift_strength = 0.f}, + .leader_trail = {TrailMode::Persistent, 2200u, 0.010f}, + .chaser_trail = {TrailMode::Finite, 1400u, 0.012f}, + .add_capture_goal = true, + .leader_label = "Prey - Delayed Avoid - Brownian", + .chaser_label = "Predator - Delayed Seek - Brownian" + }); + + for (int i = 0; i < 100; ++i) { + m_sim_time += 1.f / 60.f; + m_particles.update(1.f / 60.f, 1.f, m_sim_time); + } + return swarm; +} + +SwarmBuildResult WavePredatorPreySpawner::spawn_brownian_cloud() { + ParticleSwarmFactory swarms(m_particles); + return swarms.brownian_cloud(ParticleSwarmFactory::BrownianCloudParams{ + .count = 18u, + .center = {0.f, 0.f}, + .radius = 2.2f, + .role = ParticleRole::Avoider, + .brownian = {.sigma = 0.13f, .drift_strength = 0.025f}, + .trail = {TrailMode::Finite, 900u, 0.014f}, + .label = "Avoider - Brownian Cloud" + }); +} + +SwarmBuildResult WavePredatorPreySpawner::spawn_contour_band() { + ParticleSwarmFactory swarms(m_particles); + ndde::sim::LevelCurveWalker::Params walker; + walker.epsilon = 0.16f; + walker.walk_speed = 0.54f; + walker.turn_rate = 2.7f; + walker.tangent_floor = 0.45f; + return swarms.contour_band(ParticleSwarmFactory::ContourBandParams{ + .count = 12u, + .center = {0.f, 0.f}, + .radius = 1.8f, + .shared_level = true, + .role = ParticleRole::Neutral, + .walker = walker, + .noise = {.sigma = 0.018f, .drift_strength = 0.f}, + .trail = {TrailMode::Persistent, 2200u, 0.010f}, + .label = "Neutral - Contour Band - Brownian" + }); +} + +} // namespace ndde diff --git a/nurbs_dde/src/app/WavePredatorPreySpawner.hpp b/nurbs_dde/src/app/WavePredatorPreySpawner.hpp new file mode 100644 index 00000000..5629cf60 --- /dev/null +++ b/nurbs_dde/src/app/WavePredatorPreySpawner.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include "app/ParticleGoals.hpp" +#include "app/ParticleSwarmFactory.hpp" +#include "app/ParticleSystem.hpp" + +namespace ndde { + +class WavePredatorPreySpawner { +public: + WavePredatorPreySpawner(ParticleSystem& particles, + float& sim_time, + GoalStatus& goal_status) noexcept; + + void reset_particles() noexcept; + [[nodiscard]] SwarmBuildResult spawn_predator_prey_showcase(); + [[nodiscard]] SwarmBuildResult spawn_brownian_cloud(); + [[nodiscard]] SwarmBuildResult spawn_contour_band(); + [[nodiscard]] SwarmBuildResult clear_all() noexcept; + +private: + ParticleSystem& m_particles; + float& m_sim_time; + GoalStatus& m_goal_status; +}; + +} // namespace ndde diff --git a/nurbs_dde/src/app/legacy/AnalysisPanel.cpp b/nurbs_dde/src/app/legacy/AnalysisPanel.cpp deleted file mode 100644 index c6a85452..00000000 --- a/nurbs_dde/src/app/legacy/AnalysisPanel.cpp +++ /dev/null @@ -1,207 +0,0 @@ -// app/AnalysisPanel.cpp -#include "app/AnalysisPanel.hpp" -#include -#include - -namespace ndde { - -void AnalysisPanel::draw(const HoverResult& hover, EngineAPI& api) { - draw_control_panel(hover); - draw_readout_panel(hover, api); -} - -void AnalysisPanel::draw_control_panel(const HoverResult& hover) { - ImGui::SetNextWindowBgAlpha(0.80f); - ImGui::SetNextWindowSize(ImVec2(310.f, 0.f), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowPos(ImVec2(24.f, 420.f), ImGuiCond_FirstUseEver); - ImGui::Begin("Analysis"); - - // ── Snap ───────────────────────────────────────────────────────────────── - ImGui::SeparatorText("Snap"); - ImGui::TextDisabled("Snap threshold (screen px)"); - ImGui::SetNextItemWidth(-1.f); - ImGui::SliderFloat("##snap_px", &m_snap_px_radius, 2.f, 60.f, "%.0f px"); - - // ── Epsilon ball / sphere ───────────────────────────────────────────────── - ImGui::SeparatorText("Epsilon Ball / Sphere"); - ImGui::Checkbox("Show##ball", &m_show_epsilon_ball); - ImGui::SameLine(); - ImGui::SetNextItemWidth(-1.f); - ImGui::SliderFloat("e##radius", &m_epsilon, 0.001f, 1.0f, "%.4f"); - - // ── Epsilon / Delta ─────────────────────────────────────────────────────── - ImGui::SeparatorText("Epsilon / Delta"); - ImGui::SetNextItemWidth(-1.f); - ImGui::SliderFloat("e##interval", &m_epsilon_interval, 0.001f, 1.f, "%.4f"); - ImGui::SetNextItemWidth(-1.f); - ImGui::SliderFloat("d##delta", &m_delta, 0.001f, 1.f, "%.4f"); - - // ── 2D features ─────────────────────────────────────────────────────────── - ImGui::SeparatorText("2D Features"); - - ImGui::Checkbox("Secant line", &m_show_secant); - if (m_show_secant) { - ImGui::SameLine(); - ImGui::SetNextItemWidth(80.f); - ImGui::ColorEdit3("##sec_col", m_secant_colour, - ImGuiColorEditFlags_NoInputs | ImGuiColorEditFlags_NoLabel); - } - - ImGui::Checkbox("Tangent line", &m_show_tangent); - if (m_show_tangent) { - ImGui::SameLine(); - ImGui::SetNextItemWidth(80.f); - ImGui::ColorEdit3("##tan_col", m_tangent_colour, - ImGuiColorEditFlags_NoInputs | ImGuiColorEditFlags_NoLabel); - } - - ImGui::Checkbox("Interval lines", &m_show_interval_lines); - if (m_show_interval_lines) { - ImGui::SameLine(); - ImGui::SetNextItemWidth(80.f); - ImGui::ColorEdit3("##int_col", m_interval_colour, - ImGuiColorEditFlags_NoInputs | ImGuiColorEditFlags_NoLabel); - } - - ImGui::Checkbox("Lipschitz cone", &m_show_lipschitz); - - // ── Frenet-Serret frame (works in 2D and 3D) ────────────────────────────── - ImGui::SeparatorText("Frenet\xe2\x80\x93Serret Frame"); // UTF-8 en-dash - - ImGui::Checkbox("T unit tangent", &m_show_T); - if (m_show_T) { - ImGui::SameLine(); - ImGui::SetNextItemWidth(80.f); - ImGui::ColorEdit3("##Tcol", m_T_colour, - ImGuiColorEditFlags_NoInputs | ImGuiColorEditFlags_NoLabel); - } - - ImGui::Checkbox("N principal normal", &m_show_N); - if (m_show_N) { - ImGui::SameLine(); - ImGui::SetNextItemWidth(80.f); - ImGui::ColorEdit3("##Ncol", m_N_colour, - ImGuiColorEditFlags_NoInputs | ImGuiColorEditFlags_NoLabel); - } - - ImGui::Checkbox("B binormal", &m_show_B); - if (m_show_B) { - ImGui::SameLine(); - ImGui::SetNextItemWidth(80.f); - ImGui::ColorEdit3("##Bcol", m_B_colour, - ImGuiColorEditFlags_NoInputs | ImGuiColorEditFlags_NoLabel); - } - - ImGui::TextDisabled("Arrow scale (world units)"); - ImGui::SetNextItemWidth(-1.f); - ImGui::SliderFloat("##fscale", &m_frame_scale, 0.01f, 2.f, "%.3f"); - - // ── Osculating circle / sphere ──────────────────────────────────────────── - ImGui::SeparatorText("Osculating Circle / Sphere"); - ImGui::Checkbox("Show##osc", &m_show_osc_circle); - if (m_show_osc_circle) { - ImGui::SameLine(); - ImGui::SetNextItemWidth(80.f); - ImGui::ColorEdit3("##osc_col", m_osc_colour, - ImGuiColorEditFlags_NoInputs | ImGuiColorEditFlags_NoLabel); - } - - // ── Cursor ──────────────────────────────────────────────────────────────── - ImGui::SeparatorText("Cursor"); - if (hover.hit) { - ImGui::TextColored(ImVec4(0.4f, 1.f, 0.4f, 1.f), - "Snapped: (%.4f, %.4f, %.4f)", - hover.world_x, hover.world_y, hover.world_z); - } else { - ImGui::TextDisabled("Hover over a curve to snap"); - } - - ImGui::End(); -} - -void AnalysisPanel::draw_readout_panel(const HoverResult& hover, EngineAPI& api) { - ImGui::SetNextWindowBgAlpha(0.80f); - ImGui::SetNextWindowSize(ImVec2(340.f, 0.f), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowPos(ImVec2(24.f, 780.f), ImGuiCond_FirstUseEver); - ImGui::Begin("Analysis Readout"); - - // Helper: left-column label + right-column value - auto lv = [&](const char* lbl, const char* fmt, auto... args) { - api.push_math_font(false); - ImGui::Text("%s", lbl); - api.pop_math_font(); - ImGui::SameLine(160.f); - ImGui::Text(fmt, args...); - }; - - auto lv_dis = [&](const char* lbl) { - api.push_math_font(false); - ImGui::Text("%s", lbl); - api.pop_math_font(); - ImGui::SameLine(160.f); - ImGui::TextDisabled("---"); - }; - - // ── Snapped point ───────────────────────────────────────────────────────── - ImGui::SeparatorText("Snapped Point"); - if (hover.hit) { - lv("c (x)", "%.5f", hover.world_x); - lv("f(c) (y)", "%.5f", hover.world_y); - if (std::abs(hover.world_z) > 1e-6f || hover.snap_t != 0.f) - lv("z", "%.5f", hover.world_z); - lv("t", "%.5f", hover.snap_t); - lv("|p'(t)| speed", "%.5f", hover.speed); - } else { - lv_dis("c (x)"); - lv_dis("f(c) (y)"); - } - - // ── Epsilon / Delta ─────────────────────────────────────────────────────── - ImGui::SeparatorText("Epsilon / Delta"); - lv("e (output)", "%.4f", m_epsilon_interval); - lv("d (input)", "%.4f", m_delta); - - // ── Secant ─────────────────────────────────────────────────────────────── - ImGui::SeparatorText("Secant"); - if (hover.hit && hover.has_secant) { - lv("P0", "(%.4f, %.4f)", hover.secant_x0, hover.secant_y0); - lv("P1", "(%.4f, %.4f)", hover.secant_x1, hover.secant_y1); - lv("dy/dx", "%.6f", hover.slope); - } else { - ImGui::TextDisabled("---"); - } - - // ── Tangent line ───────────────────────────────────────────────────────── - ImGui::SeparatorText("Tangent"); - if (hover.hit && hover.has_tangent) { - lv("Slope", "%.6f", hover.tangent_slope); - } else { - ImGui::TextDisabled("---"); - } - - // ── Frenet-Serret ───────────────────────────────────────────────────────── - ImGui::SeparatorText("Frenet\xe2\x80\x93Serret"); - if (hover.hit && hover.has_tangent) { - lv("\xce\xba curvature", "%.6f", hover.kappa); - if (hover.kappa > 1e-8f) - lv("R = 1/\xce\xba", "%.5f", hover.osc_radius); - else - lv_dis("R = 1/\xce\xba"); - lv("\xcf\x84 torsion", "%.6f", hover.tau); - lv("|p'| speed", "%.5f", hover.speed); - - ImGui::Spacing(); - ImGui::TextColored(ImVec4(1.f,0.35f,0.35f,1.f), - "T (%.3f, %.3f, %.3f)", hover.T[0], hover.T[1], hover.T[2]); - ImGui::TextColored(ImVec4(0.35f,1.f,0.35f,1.f), - "N (%.3f, %.3f, %.3f)", hover.N[0], hover.N[1], hover.N[2]); - ImGui::TextColored(ImVec4(0.35f,0.6f,1.f,1.f), - "B (%.3f, %.3f, %.3f)", hover.B[0], hover.B[1], hover.B[2]); - } else { - ImGui::TextDisabled("---"); - } - - ImGui::End(); -} - -} // namespace ndde diff --git a/nurbs_dde/src/app/legacy/README.md b/nurbs_dde/src/app/legacy/README.md deleted file mode 100644 index 5f7cd0a9..00000000 --- a/nurbs_dde/src/app/legacy/README.md +++ /dev/null @@ -1,25 +0,0 @@ -# src/app/legacy/ - -Dead code — preserved for reference only. **Not compiled.** - -## Contents - -| File | What it was | -|------|-------------| -| `Scene.cpp` | The original conics/analysis scene (parabolas, hyperbolas, helix, paraboloid). `m_scene->on_frame()` was commented out in `Engine::run_frame()` when `SurfaceSimScene` became the active scene. Moved here by E1 refactor. | -| `AnalysisPanel.cpp` | UI panel for the conics scene (AnalysisPanel::draw). Compiled only when Scene.cpp is compiled. Moved here together with Scene.cpp. | -| `Scene_on_frame_patch.bak` | A stale patch fragment from an earlier iteration of `Scene::on_frame()`. References `m_api.math_font_body()` which no longer exists in EngineAPI (removed by D3 refactor). | - -## Headers stay in `src/app/` - -`Scene.hpp` and `AnalysisPanel.hpp` remain at their original paths because -`Engine.cpp` forward-declares `class Scene` via `Engine.hpp` and constructs -`std::unique_ptr m_scene` (the pointer is never dereferenced while -`m_scene->on_frame()` stays commented out). Moving the headers would break -that include chain. - -## To re-enable the conics scene - -1. Move `Scene.cpp` and `AnalysisPanel.cpp` back to `src/app/`. -2. Add them back to `add_executable(nurbs_dde ...)` in `src/CMakeLists.txt`. -3. Uncomment `m_scene->on_frame();` in `Engine::run_frame()`. diff --git a/nurbs_dde/src/app/legacy/Scene.cpp b/nurbs_dde/src/app/legacy/Scene.cpp deleted file mode 100644 index f24fc747..00000000 --- a/nurbs_dde/src/app/legacy/Scene.cpp +++ /dev/null @@ -1,957 +0,0 @@ -// app/Scene.cpp -// NOTE: All ImGui text strings use plain ASCII only. -// MSVC C2022: hex escape sequences like \xc2\xb2 are parsed as a single -// multi-byte escape (0xc2b2 = 50354) which exceeds char range, even with -// /utf-8. UTF-8 characters used elsewhere in the codebase (delta, epsilon, -// Greek letters for labels) are in separate string literals where the -// escape is the entire content — those are safe. Panel description strings -// use ASCII math notation instead. -#include "app/Scene.hpp" -#include -#include -#include -#include -#include -#include -#include -#include - -namespace ndde { - -// ═══════════════════════════════════════════════════════════════════════════════ -// ConicEntry -// ═══════════════════════════════════════════════════════════════════════════════ - -void ConicEntry::rebuild() { - needs_rebuild = false; - - if (type == 0) { - conic = std::make_unique(par_a, par_b, par_c, par_tmin, par_tmax); - } else if (type == 1) { - const auto axis = (hyp_axis == 0) - ? math::HyperbolaAxis::Horizontal - : math::HyperbolaAxis::Vertical; - conic = std::make_unique(hyp_a, hyp_b, hyp_h, hyp_k, axis, hyp_range); - } else if (type == 2) { - conic = std::make_unique(hel_radius, hel_pitch, hel_tmin, hel_tmax); - } else if (type == 3) { - conic = std::make_unique(pc_a, pc_theta, pc_tmin, pc_tmax); - } - - snap_cache.clear(); - snap_cache3d.clear(); - if (!conic) return; - - // Types 2 (Helix) and 3 (ParaboloidCurve) are 3D — use snap_cache3d - if (type == 2 || type == 3) { - const float t0 = conic->t_min(); - const float step = (conic->t_max() - t0) / static_cast(tessellation); - snap_cache3d.reserve((tessellation + 1u) * 3u); - for (u32 i = 0; i <= tessellation; ++i) { - const auto p = conic->evaluate(t0 + static_cast(i) * step); - snap_cache3d.push_back(p.x); - snap_cache3d.push_back(p.y); - snap_cache3d.push_back(p.z); - } - } else if (type == 1 && hyp_two_branch) { - auto* hyp = static_cast(conic.get()); - const float t0 = -hyp_range; - const float step = (2.f * hyp_range) / static_cast(tessellation); - snap_cache.reserve((tessellation + 1u) * 2u); - for (u32 i = 0; i <= tessellation; ++i) { - const auto p = hyp->eval_branch(t0 + static_cast(i) * step, true); - snap_cache.push_back({ p.x, p.y }); - } - for (u32 i = 0; i <= tessellation; ++i) { - const auto p = hyp->eval_branch(t0 + static_cast(i) * step, false); - snap_cache.push_back({ p.x, p.y }); - } - } else { - snap_cache.resize(tessellation + 1u); - const float t0 = conic->t_min(); - const float step = (conic->t_max() - t0) / static_cast(tessellation); - for (u32 i = 0; i <= tessellation; ++i) { - const auto p = conic->evaluate(t0 + static_cast(i) * step); - snap_cache[i] = { p.x, p.y }; - } - } -} - -// ═══════════════════════════════════════════════════════════════════════════════ -// SurfaceEntry -// ═══════════════════════════════════════════════════════════════════════════════ - -void SurfaceEntry::rebuild() { - needs_rebuild = false; - if (type == 0) { - surface = std::make_unique(par_a, par_umax); - } -} - -// ═══════════════════════════════════════════════════════════════════════════════ -// Scene -// ═══════════════════════════════════════════════════════════════════════════════ - -Scene::Scene(EngineAPI api) : m_api(std::move(api)) { - m_vp.base_extent = m_axes_cfg.extent * 1.2f; - add_parabola(); - add_hyperbola(); - add_helix(); - add_paraboloid(); // adds paraboloid surface + its meridional curve together -} - -void Scene::add_parabola() { - ConicEntry e; - e.name = std::format("Parabola {}", m_conics.size()); - e.type = 0; - e.color = { 1.0f, 0.8f, 0.2f, 1.0f }; - e.tessellation = m_api.config().simulation.tessellation; - e.par_a = -1.f; e.par_b = 0.f; e.par_c = 0.f; - e.par_tmin = -5.f; e.par_tmax = 5.f; - e.rebuild(); - m_conics.push_back(std::move(e)); -} - -void Scene::add_hyperbola() { - ConicEntry e; - e.name = std::format("Hyperbola {}", m_conics.size()); - e.type = 1; - e.color = { 0.4f, 0.8f, 1.0f, 1.0f }; - e.tessellation = m_api.config().simulation.tessellation; - e.hyp_range = 2.5f; - e.rebuild(); - m_conics.push_back(std::move(e)); -} - -void Scene::add_helix() { - ConicEntry e; - e.name = std::format("Helix {}", m_conics.size()); - e.type = 2; - e.color = { 0.9f, 0.4f, 0.9f, 1.0f }; - e.tessellation = m_api.config().simulation.tessellation; - e.hel_radius = 1.f; - e.hel_pitch = 0.5f; - e.hel_tmin = 0.f; - e.hel_tmax = 4.f * std::numbers::pi_v; - e.enabled = false; - e.rebuild(); - m_conics.push_back(std::move(e)); -} - -void Scene::add_paraboloid() { - SurfaceEntry s; - s.name = std::format("Paraboloid {}", m_surfaces.size()); - s.type = 0; - s.par_a = 1.f; - s.par_umax = 1.5f; - s.u_lines = 14; - s.v_lines = 20; - s.color = { 0.35f, 0.55f, 0.85f, 0.45f }; - s.enabled = false; - s.rebuild(); - m_surfaces.push_back(std::move(s)); - add_paraboloid_curve(); -} - -void Scene::add_paraboloid_curve() { - ConicEntry e; - e.name = std::format("ParaboloidCurve {}", m_conics.size()); - e.type = 3; - e.color = { 1.0f, 0.55f, 0.1f, 1.0f }; - e.tessellation = m_api.config().simulation.tessellation; - e.pc_a = 1.f; - e.pc_theta = 0.f; - e.pc_tmin = 0.f; - e.pc_tmax = 1.5f; - e.enabled = false; - e.rebuild(); - m_conics.push_back(std::move(e)); -} - -// ── on_frame ────────────────────────────────────────────────────────────────── - -void Scene::on_frame() { - sync_viewport(); - update_camera(); - - for (auto& e : m_conics) { - if (e.type == 0 && e.conic) { - const float margin = 1.0f; - if (m_vp.left() < e.par_tmin + margin || - m_vp.right() > e.par_tmax - margin) { - e.par_tmin = m_vp.left() - 1.0f; - e.par_tmax = m_vp.right() + 1.0f; - e.mark_dirty(); - } - } - } - - if (m_axes_cfg.is_3d) update_hover_3d(); - else update_hover(); - - m_coord_debug.update(m_vp, m_hover, m_conics, m_api.viewport_size()); - - draw_main_panel(); - m_analysis_panel.draw(m_hover, m_api); - m_coord_debug.draw(); - m_perf_panel.draw(m_api.debug_stats()); - submit_interval_labels(); - - submit_grid(); - submit_axes(); - submit_surfaces(); - submit_conics(); - - if (m_axes_cfg.is_3d) { - submit_epsilon_sphere(); - submit_frenet_frame(); - submit_osc_circle(); - } else { - submit_epsilon_ball(); - submit_interval_lines(); - submit_secant_line(); - submit_tangent_line(); - submit_frenet_frame(); - submit_osc_circle(); - } -} - -// ── sync_viewport ───────────────────────────────────────────────────────────── - -void Scene::sync_viewport() { - const Vec2 fb = m_api.viewport_size(); - m_vp.fb_w = fb.x; - m_vp.fb_h = fb.y; - - const ImGuiIO& io = ImGui::GetIO(); - m_vp.dp_w = io.DisplaySize.x; - m_vp.dp_h = io.DisplaySize.y; - - m_vp.base_extent = m_axes_cfg.extent * 1.2f; -} - -// ── update_camera ───────────────────────────────────────────────────────────── - -void Scene::update_camera() { - const ImGuiIO& io = ImGui::GetIO(); - if (io.WantCaptureMouse) return; - - // Correct for ViewportsEnable: MousePos is absolute screen coords. - const Vec2 wm = Viewport::screen_to_window(io.MousePos.x, io.MousePos.y); - - if (std::abs(io.MouseWheel) > 0.f) - m_vp.zoom_toward(wm.x, wm.y, io.MouseWheel); - - if (io.MouseDelta.x == 0.f && io.MouseDelta.y == 0.f) return; - - const bool middle = ImGui::IsMouseDown(ImGuiMouseButton_Middle); - const bool alt_l = io.KeyAlt && ImGui::IsMouseDown(ImGuiMouseButton_Left); - const bool right = ImGui::IsMouseDown(ImGuiMouseButton_Right); - - if (m_axes_cfg.is_3d) { - if (right || middle) m_vp.orbit(io.MouseDelta.x, io.MouseDelta.y); - } else { - if (middle || alt_l) m_vp.pan_by_pixels(io.MouseDelta.x, io.MouseDelta.y); - } -} - -// ── update_hover (2D) ───────────────────────────────────────────────────────── - -void Scene::update_hover() { - m_hover = HoverResult{}; - - const ImGuiIO& io = ImGui::GetIO(); - if (io.MousePos.x < 0.f || io.MousePos.y < 0.f) return; - if (ImGui::IsAnyItemActive() || ImGui::IsAnyItemHovered()) return; - - // When ViewportsEnable is active MousePos is absolute screen coords. - // Convert to window-relative before passing to pixel_to_world. - const Vec2 win_mouse = Viewport::screen_to_window(io.MousePos.x, io.MousePos.y); - const Vec2 mw = m_vp.pixel_to_world(win_mouse.x, win_mouse.y); - const float world_per_px = (m_vp.dp_h > 0.f) - ? (m_vp.top() - m_vp.bottom()) / m_vp.dp_h : 0.01f; - const float snap_r_world = m_analysis_panel.get_snap_px_radius() * world_per_px; - - float best_d = snap_r_world; - int best_ci = -1, best_si = -1; - float best_x = 0.f, best_y = 0.f; - - for (int ci = 0; ci < static_cast(m_conics.size()); ++ci) { - const auto& entry = m_conics[ci]; - if (!entry.enabled || entry.type == 2 || entry.type == 3) continue; - if (entry.snap_cache.empty()) continue; - for (int si = 0; si < static_cast(entry.snap_cache.size()); ++si) { - const auto [px, py] = entry.snap_cache[si]; - const float d = std::hypot(px - mw.x, py - mw.y); - if (d < best_d) { best_d = d; best_ci = ci; best_si = si; best_x = px; best_y = py; } - } - } - - if (best_ci < 0) return; - - m_hover.hit = true; - m_hover.world_x = best_x; - m_hover.world_y = best_y; - m_hover.world_z = 0.f; - m_hover.curve_idx = best_ci; - m_hover.snap_idx = best_si; - - const auto& e_ref = m_conics[best_ci]; - const auto& cache = e_ref.snap_cache; - const int total = static_cast(cache.size()); - const int branch_end = (e_ref.type == 1 && e_ref.hyp_two_branch) - ? (best_si <= static_cast(e_ref.tessellation) - ? static_cast(e_ref.tessellation) : total - 1) - : total - 1; - const int branch_start = (e_ref.type == 1 && e_ref.hyp_two_branch) - ? (best_si <= static_cast(e_ref.tessellation) - ? 0 : static_cast(e_ref.tessellation) + 1) - : 0; - - const int branch_i = best_si - branch_start; - const int branch_n = branch_end - branch_start; - const float frac = (branch_n > 0) - ? static_cast(branch_i) / static_cast(branch_n) : 0.f; - const float snap_t = e_ref.conic->t_min() + - frac * (e_ref.conic->t_max() - e_ref.conic->t_min()); - m_hover.snap_t = snap_t; - - const float secant_r = m_analysis_panel.get_epsilon_ball_radius(); - int li = best_si; - while (li > branch_start && std::hypot(cache[li-1].first - best_x, - cache[li-1].second - best_y) <= secant_r) --li; - int ri = best_si; - while (ri < branch_end && std::hypot(cache[ri+1].first - best_x, - cache[ri+1].second - best_y) <= secant_r) ++ri; - - if (li != ri) { - const auto [x0,y0] = cache[li]; const auto [x1,y1] = cache[ri]; - const float dx = x1-x0, dy = y1-y0; - m_hover.has_secant = true; - m_hover.secant_x0 = x0; m_hover.secant_y0 = y0; - m_hover.secant_x1 = x1; m_hover.secant_y1 = y1; - m_hover.slope = (std::abs(dx) > 1e-9f) ? dy/dx : 0.f; - m_hover.intercept = y0 - m_hover.slope * x0; - } - - if (e_ref.conic) { - const auto& c = *e_ref.conic; - const Vec3 T = c.unit_tangent(snap_t); - const Vec3 N = c.unit_normal(snap_t); - const Vec3 B = c.unit_binormal(snap_t); - const Vec3 d1 = c.derivative(snap_t); - m_hover.has_tangent = true; - m_hover.tangent_slope = (std::abs(T.x) > 1e-9f) ? T.y / T.x : 0.f; - m_hover.T[0] = T.x; m_hover.T[1] = T.y; m_hover.T[2] = T.z; - m_hover.N[0] = N.x; m_hover.N[1] = N.y; m_hover.N[2] = N.z; - m_hover.B[0] = B.x; m_hover.B[1] = B.y; m_hover.B[2] = B.z; - m_hover.kappa = c.curvature(snap_t); - m_hover.tau = c.torsion(snap_t); - m_hover.speed = glm::length(d1); - m_hover.osc_radius = (m_hover.kappa > 1e-8f) ? 1.f / m_hover.kappa : 0.f; - } -} - -// ── update_hover_3d ─────────────────────────────────────────────────────────── - -void Scene::update_hover_3d() { - m_hover = HoverResult{}; - - const ImGuiIO& io = ImGui::GetIO(); - if (!Viewport::mouse_valid(io.MousePos.x, io.MousePos.y)) return; - if (ImGui::IsAnyItemActive() || ImGui::IsAnyItemHovered()) return; - - const Mat4 mvp = m_vp.perspective_mvp(); - const float snap_px = m_analysis_panel.get_snap_px_radius(); - - float best_screen_d = snap_px; - int best_ci = -1, best_si = -1; - float best_x = 0.f, best_y = 0.f, best_z = 0.f; - - for (int ci = 0; ci < static_cast(m_conics.size()); ++ci) { - const auto& entry = m_conics[ci]; - if (!entry.enabled) continue; - if ((entry.type == 2 || entry.type == 3) && !entry.snap_cache3d.empty()) { - const int n = static_cast(entry.snap_cache3d.size()) / 3; - for (int si = 0; si < n; ++si) { - const float px = entry.snap_cache3d[si*3+0]; - const float py = entry.snap_cache3d[si*3+1]; - const float pz = entry.snap_cache3d[si*3+2]; - const glm::vec4 clip = mvp * glm::vec4(px, py, pz, 1.f); - if (clip.w <= 0.f) continue; - const float sx = (clip.x / clip.w + 1.f) * 0.5f * m_vp.dp_w; - const float sy = (1.f - clip.y / clip.w) * 0.5f * m_vp.dp_h; - const float d = std::hypot(sx - io.MousePos.x, sy - io.MousePos.y); - if (d < best_screen_d) { - best_screen_d = d; best_ci = ci; best_si = si; - best_x = px; best_y = py; best_z = pz; - } - } - } - } - - if (best_ci < 0) return; - - m_hover.hit = true; - m_hover.world_x = best_x; - m_hover.world_y = best_y; - m_hover.world_z = best_z; - m_hover.curve_idx = best_ci; - m_hover.snap_idx = best_si; - - const auto& e_ref = m_conics[best_ci]; - if (e_ref.conic) { - const int n = static_cast(e_ref.snap_cache3d.size()) / 3; - const float frac = (n > 1) - ? static_cast(best_si) / static_cast(n - 1) : 0.f; - const float snap_t = e_ref.conic->t_min() + - frac * (e_ref.conic->t_max() - e_ref.conic->t_min()); - m_hover.snap_t = snap_t; - - const auto& c = *e_ref.conic; - const Vec3 T = c.unit_tangent(snap_t); - const Vec3 N = c.unit_normal(snap_t); - const Vec3 B = c.unit_binormal(snap_t); - const Vec3 d1 = c.derivative(snap_t); - m_hover.has_tangent = true; - m_hover.tangent_slope = 0.f; - m_hover.T[0] = T.x; m_hover.T[1] = T.y; m_hover.T[2] = T.z; - m_hover.N[0] = N.x; m_hover.N[1] = N.y; m_hover.N[2] = N.z; - m_hover.B[0] = B.x; m_hover.B[1] = B.y; m_hover.B[2] = B.z; - m_hover.kappa = c.curvature(snap_t); - m_hover.tau = c.torsion(snap_t); - m_hover.speed = glm::length(d1); - m_hover.osc_radius = (m_hover.kappa > 1e-8f) ? 1.f / m_hover.kappa : 0.f; - } -} - -// ── submit_surfaces ─────────────────────────────────────────────────────────── - -void Scene::submit_surfaces() { - if (!m_axes_cfg.is_3d) return; - const Mat4 mvp = m_vp.perspective_mvp(); - - for (auto& entry : m_surfaces) { - if (!entry.enabled) continue; - if (entry.needs_rebuild) entry.rebuild(); - if (!entry.surface) continue; - - const u32 n = entry.surface->wireframe_vertex_count(entry.u_lines, entry.v_lines); - auto slice = m_api.acquire(n); - entry.surface->tessellate_wireframe({ slice.vertices(), n }, - entry.u_lines, entry.v_lines, 0.f, entry.color); - m_api.submit_to(RenderTarget::Primary3D, slice, Topology::LineList, DrawMode::VertexColor, entry.color, mvp); - } -} - -// ── submit_epsilon_ball (2D) ────────────────────────────────────────────────── - -void Scene::submit_epsilon_ball() { - if (!m_hover.hit || !m_analysis_panel.show_epsilon_ball()) return; - constexpr u32 seg = 64; - const float r = m_analysis_panel.get_epsilon_ball_radius(); - const Vec4 col{ 0.95f, 0.95f, 0.15f, 0.9f }; - auto slice = m_api.acquire(seg + 1); - Vertex* v = slice.vertices(); - for (u32 i = 0; i < seg; ++i) { - const float theta = (static_cast(i) / seg) * 2.f * std::numbers::pi_v; - v[i] = { Vec3{ m_hover.world_x + r*std::cos(theta), - m_hover.world_y + r*std::sin(theta), 0.f }, col }; - } - v[seg] = v[0]; - m_api.submit_to(RenderTarget::Primary3D, slice, Topology::LineStrip, DrawMode::VertexColor, col, m_vp.ortho_mvp()); -} - -// ── submit_epsilon_sphere (3D) ──────────────────────────────────────────────── - -void Scene::submit_epsilon_sphere() { - if (!m_hover.hit || !m_analysis_panel.show_epsilon_ball()) return; - constexpr int lat = 8, lon = 8, seg = 48; - const float r = m_analysis_panel.get_epsilon_ball_radius(); - const float cx = m_hover.world_x, cy = m_hover.world_y, cz = m_hover.world_z; - const Vec4 col{ 0.95f, 0.95f, 0.15f, 0.65f }; - const Mat4 mvp = m_vp.perspective_mvp(); - auto slice = m_api.acquire(static_cast((lat + lon) * (seg + 1))); - Vertex* v = slice.vertices(); - u32 idx = 0; - for (int li = 1; li <= lat; ++li) { - const float th = std::numbers::pi_v * li / (lat + 1); - const float st = std::sin(th), ct = std::cos(th); - for (int s = 0; s <= seg; ++s) { - const float ph = (static_cast(s) / seg) * 2.f * std::numbers::pi_v; - v[idx++] = { Vec3{ cx + r*st*std::cos(ph), cy + r*ct, cz + r*st*std::sin(ph) }, col }; - } - } - for (int li = 0; li < lon; ++li) { - const float ph = (static_cast(li) / lon) * 2.f * std::numbers::pi_v; - const float cp = std::cos(ph), sp = std::sin(ph); - for (int s = 0; s <= seg; ++s) { - const float th = std::numbers::pi_v * s / seg; - const float st = std::sin(th), ct = std::cos(th); - v[idx++] = { Vec3{ cx + r*st*cp, cy + r*ct, cz + r*st*sp }, col }; - } - } - memory::ArenaSlice trimmed = slice; - trimmed.vertex_count = idx; - m_api.submit_to(RenderTarget::Primary3D, trimmed, Topology::LineStrip, DrawMode::VertexColor, col, mvp); -} - -// ── submit_frenet_frame ─────────────────────────────────────────────────────── - -void Scene::submit_frenet_frame() { - if (!m_hover.hit || !m_hover.has_tangent) return; - const float scale = m_analysis_panel.get_frame_scale(); - const Vec3 o{ m_hover.world_x, m_hover.world_y, m_hover.world_z }; - const Mat4 mvp = m_axes_cfg.is_3d ? m_vp.perspective_mvp() : m_vp.ortho_mvp(); - - struct Arrow { const float* rgb; float dx,dy,dz; }; - const Arrow arrows[3] = { - { m_analysis_panel.T_colour(), m_hover.T[0], m_hover.T[1], m_hover.T[2] }, - { m_analysis_panel.N_colour(), m_hover.N[0], m_hover.N[1], m_hover.N[2] }, - { m_analysis_panel.B_colour(), m_hover.B[0], m_hover.B[1], m_hover.B[2] }, - }; - const bool show[3] = { - m_analysis_panel.show_unit_tangent(), - m_analysis_panel.show_unit_normal(), - m_analysis_panel.show_unit_binormal(), - }; - for (int i = 0; i < 3; ++i) { - if (!show[i]) continue; - const Vec4 col{ arrows[i].rgb[0], arrows[i].rgb[1], arrows[i].rgb[2], 1.f }; - auto slice = m_api.acquire(2); - Vertex* v = slice.vertices(); - v[0] = { o, col }; - v[1] = { Vec3{ o.x + arrows[i].dx*scale, - o.y + arrows[i].dy*scale, - o.z + arrows[i].dz*scale }, col }; - m_api.submit_to(RenderTarget::Primary3D, slice, Topology::LineList, DrawMode::VertexColor, col, mvp); - } -} - -// ── submit_osc_circle ───────────────────────────────────────────────────────── - -void Scene::submit_osc_circle() { - if (!m_hover.hit || !m_hover.has_tangent) return; - if (!m_analysis_panel.show_curvature_circle()) return; - if (m_hover.kappa < 1e-8f) return; - - const float R = m_hover.osc_radius; - const Vec3 o{ m_hover.world_x, m_hover.world_y, m_hover.world_z }; - const Vec3 N{ m_hover.N[0], m_hover.N[1], m_hover.N[2] }; - const Vec3 T{ m_hover.T[0], m_hover.T[1], m_hover.T[2] }; - const Vec3 centre = o + R * N; - constexpr u32 seg = 64; - const float* rgb = m_analysis_panel.osc_colour(); - const Vec4 col{ rgb[0], rgb[1], rgb[2], 0.85f }; - const Mat4 mvp = m_axes_cfg.is_3d ? m_vp.perspective_mvp() : m_vp.ortho_mvp(); - auto slice = m_api.acquire(seg + 1); - Vertex* v = slice.vertices(); - for (u32 i = 0; i <= seg; ++i) { - const float theta = (static_cast(i) / seg) * 2.f * std::numbers::pi_v; - const Vec3 pt = centre + R * (-std::cos(theta) * N + std::sin(theta) * T); - v[i] = { pt, col }; - } - m_api.submit_to(RenderTarget::Primary3D, slice, Topology::LineStrip, DrawMode::VertexColor, col, mvp); -} - -// ── submit_interval_lines ───────────────────────────────────────────────────── - -void Scene::submit_interval_lines() { - if (!m_hover.hit || !m_analysis_panel.show_interval_lines()) return; - const float cx = m_hover.world_x, cy = m_hover.world_y; - const float eps = m_analysis_panel.get_epsilon_interval(); - const float* rgb = m_analysis_panel.interval_colour(); - const Vec4 col{ rgb[0], rgb[1], rgb[2], 0.8f }; - const Vec4 ctr{ rgb[0]*0.6f, rgb[1]*0.6f, rgb[2]*0.6f, 0.55f }; - auto slice = m_api.acquire(24); - Vertex* v = slice.vertices(); - u32 idx = 0; - auto push = [&](float x0,float y0,float x1,float y1,Vec4 c){ - v[idx++]={Vec3{x0,y0,0.f},c}; v[idx++]={Vec3{x1,y1,0.f},c}; - }; - push(cx-eps,cy-eps,cx+eps,cy-eps,col); push(cx-eps,cy+eps,cx+eps,cy+eps,col); - push(cx-eps,cy-eps,cx-eps,cy+eps,col); push(cx+eps,cy-eps,cx+eps,cy+eps,col); - push(cx,0.f,cx,cy,ctr); push(0.f,cy,cx,cy,ctr); - const float tk = eps*0.4f; - push(cx-tk,0.f,cx+tk,0.f,col); push(0.f,cy-tk,0.f,cy+tk,col); - push(cx-eps,0.f,cx-eps,tk*2.f,col); push(cx+eps,0.f,cx+eps,tk*2.f,col); - push(0.f,cy-eps,tk*2.f,cy-eps,col); push(0.f,cy+eps,tk*2.f,cy+eps,col); - m_api.submit_to(RenderTarget::Primary3D, slice, Topology::LineList, DrawMode::VertexColor, col, m_vp.ortho_mvp()); -} - -// ── submit_interval_labels ──────────────────────────────────────────────────── - -void Scene::submit_interval_labels() { - if (!m_hover.hit || !m_analysis_panel.show_interval_lines()) return; - if (m_axes_cfg.is_3d) return; - - const float cx = m_hover.world_x, cy = m_hover.world_y; - const float eps = m_analysis_panel.get_epsilon_interval(); - const float* rgb = m_analysis_panel.interval_colour(); - const ImVec4 label_col{ rgb[0], rgb[1], rgb[2], 0.95f }; - constexpr float PAD = 10.f; - const bool snap_above_x = (cy > 0.f); - const bool snap_right_y = (cx > 0.f); - const float x_dy = snap_above_x ? PAD : -PAD; - const float y_dx = snap_right_y ? -PAD : PAD; - - constexpr ImGuiWindowFlags kf = - ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | - ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoInputs | - ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoNav | - ImGuiWindowFlags_NoMove | ImGuiWindowFlags_AlwaysAutoResize | - ImGuiWindowFlags_NoBackground; - - auto draw_label = [&](float wx, float wy, float ox, float oy, - const char* text, bool anchor_right = false) { - const Vec2 px = m_vp.world_to_pixel(wx, wy); - if (px.x < -200.f || px.x > m_vp.dp_w+200.f) return; - if (px.y < -200.f || px.y > m_vp.dp_h+200.f) return; - m_api.push_math_font(true); - const ImVec2 sz = ImGui::CalcTextSize(text); - m_api.pop_math_font(); - const float fx = px.x + ox - (anchor_right ? sz.x : sz.x*0.5f); - const float fy = px.y + oy - sz.y*0.5f; - std::string wn = std::string("##lbl_") + text; - ImGui::SetNextWindowPos(ImVec2(fx,fy), ImGuiCond_Always); - ImGui::SetNextWindowBgAlpha(0.f); - if (ImGui::Begin(wn.c_str(), nullptr, kf)) { - m_api.push_math_font(true); - ImGui::TextColored(label_col, "%s", text); - m_api.pop_math_font(); - } - ImGui::End(); - }; - - // Greek letters used as sole content of a string literal are safe: - // \xce\xb4 = δ (delta), \xce\xb5 = ε (epsilon) — each is the only - // content in its string, so MSVC doesn't concatenate hex digits across - // adjacent characters. - draw_label(cx-eps, 0.f, 0.f, x_dy, "c-\xce\xb4"); - draw_label(cx, 0.f, 0.f, x_dy, "c"); - draw_label(cx+eps, 0.f, 0.f, x_dy, "c+\xce\xb4"); - const bool loa = snap_right_y; - draw_label(0.f, cy-eps, y_dx, 0.f, "L(c)-\xce\xb5", loa); - draw_label(0.f, cy, y_dx, 0.f, "L(c)", loa); - draw_label(0.f, cy+eps, y_dx, 0.f, "L(c)+\xce\xb5", loa); -} - -// ── submit_secant_line ──────────────────────────────────────────────────────── - -void Scene::submit_secant_line() { - if (!m_hover.hit || !m_hover.has_secant || !m_analysis_panel.show_secant()) return; - const float x0=m_vp.left(), y0=m_hover.slope*x0+m_hover.intercept; - const float x1=m_vp.right(),y1=m_hover.slope*x1+m_hover.intercept; - const float* rgb = m_analysis_panel.secant_colour(); - const Vec4 col{rgb[0],rgb[1],rgb[2],1.f}; - auto slice = m_api.acquire(2); - Vertex* v = slice.vertices(); - v[0]={Vec3{x0,y0,0.f},col}; v[1]={Vec3{x1,y1,0.f},col}; - m_api.submit_to(RenderTarget::Primary3D, slice, Topology::LineList, DrawMode::VertexColor, col, m_vp.ortho_mvp()); -} - -// ── submit_tangent_line ─────────────────────────────────────────────────────── - -void Scene::submit_tangent_line() { - if (!m_hover.hit || !m_hover.has_tangent || !m_analysis_panel.show_tangent()) return; - const float m_ = m_hover.tangent_slope; - const float b_int = m_hover.world_y - m_*m_hover.world_x; - const float x0=m_vp.left(), y0=m_*x0+b_int; - const float x1=m_vp.right(),y1=m_*x1+b_int; - const float* rgb = m_analysis_panel.tangent_colour(); - const Vec4 col{rgb[0],rgb[1],rgb[2],1.f}; - auto slice = m_api.acquire(2); - Vertex* v = slice.vertices(); - v[0]={Vec3{x0,y0,0.f},col}; v[1]={Vec3{x1,y1,0.f},col}; - m_api.submit_to(RenderTarget::Primary3D, slice, Topology::LineList, DrawMode::VertexColor, col, m_vp.ortho_mvp()); -} - -// ── submit_grid ─────────────────────────────────────────────────────────────── - -void Scene::submit_grid() { - const Mat4 mvp = m_axes_cfg.is_3d ? m_vp.perspective_mvp() : m_vp.ortho_mvp(); - if (m_axes_cfg.is_3d) { - math::AxesConfig cfg3d = m_axes_cfg; - cfg3d.extent = m_axes_cfg.extent * 4.f; - const u32 count = math::grid_vertex_count(cfg3d); - if (!count) return; - auto slice = m_api.acquire(count); - math::build_grid({ slice.vertices(), count }, cfg3d); - m_api.submit_to(RenderTarget::Primary3D, slice, Topology::LineList, DrawMode::VertexColor, {1,1,1,1}, mvp); - } else { - const float vl=m_vp.left(),vr=m_vp.right(),vb=m_vp.bottom(),vt=m_vp.top(); - const u32 max_v = math::grid_vp_max_vertices(vl,vr,vb,vt,m_axes_cfg.minor_step); - if (!max_v) return; - auto slice = m_api.acquire(max_v); - const u32 actual = math::build_grid_viewport( - {slice.vertices(),max_v}, vl,vr,vb,vt, - m_axes_cfg.minor_step, m_axes_cfg.major_step); - if (!actual) return; - memory::ArenaSlice trimmed = slice; - trimmed.vertex_count = actual; - m_api.submit_to(RenderTarget::Primary3D, trimmed, Topology::LineList, DrawMode::VertexColor, {1,1,1,1}, mvp); - } -} - -// ── submit_axes ─────────────────────────────────────────────────────────────── - -void Scene::submit_axes() { - const Mat4 mvp = m_axes_cfg.is_3d ? m_vp.perspective_mvp() : m_vp.ortho_mvp(); - const float e = m_axes_cfg.is_3d ? m_axes_cfg.extent*4.f : m_axes_cfg.extent; - const u32 count = m_axes_cfg.is_3d ? 6u : 4u; - auto slice = m_api.acquire(count); - Vertex* v = slice.vertices(); - u32 i = 0; - if (m_axes_cfg.is_3d) { - v[i++]={Vec3{-e,0.f,0.f},math::colors::X_AXIS}; v[i++]={Vec3{e,0.f,0.f},math::colors::X_AXIS}; - v[i++]={Vec3{0.f,-e,0.f},math::colors::Y_AXIS}; v[i++]={Vec3{0.f,e,0.f},math::colors::Y_AXIS}; - v[i++]={Vec3{0.f,0.f,-e},math::colors::Z_AXIS}; v[i++]={Vec3{0.f,0.f,e},math::colors::Z_AXIS}; - } else { - v[i++]={Vec3{m_vp.left(), 0.f,0.f},math::colors::X_AXIS}; - v[i++]={Vec3{m_vp.right(),0.f,0.f},math::colors::X_AXIS}; - v[i++]={Vec3{0.f,m_vp.bottom(),0.f},math::colors::Y_AXIS}; - v[i++]={Vec3{0.f,m_vp.top(), 0.f},math::colors::Y_AXIS}; - } - m_api.submit_to(RenderTarget::Primary3D, slice, Topology::LineList, DrawMode::VertexColor, {1,1,1,1}, mvp); -} - -// ── submit_conics ───────────────────────────────────────────────────────────── - -void Scene::submit_conics() { - const Mat4 mvp = m_axes_cfg.is_3d ? m_vp.perspective_mvp() : m_vp.ortho_mvp(); - for (auto& entry : m_conics) { - if (!entry.enabled) continue; - if (entry.needs_rebuild) entry.rebuild(); - if (!entry.conic) continue; - if ((entry.type == 2 || entry.type == 3) && !m_axes_cfg.is_3d) continue; - if (entry.type == 1 && entry.hyp_two_branch) { - auto* hyp = static_cast(entry.conic.get()); - const u32 n = hyp->two_branch_vertex_count(entry.tessellation); - auto slice = m_api.acquire(n); - hyp->tessellate_two_branch({slice.vertices(),n}, entry.tessellation, entry.color); - m_api.submit_to(RenderTarget::Primary3D, slice, Topology::LineStrip, DrawMode::VertexColor, entry.color, mvp); - } else { - const u32 n = entry.conic->vertex_count(entry.tessellation); - auto slice = m_api.acquire(n); - entry.conic->tessellate({slice.vertices(),n}, entry.tessellation, entry.color); - m_api.submit_to(RenderTarget::Primary3D, slice, Topology::LineStrip, DrawMode::VertexColor, entry.color, mvp); - } - } -} - -// ── draw_main_panel ─────────────────────────────────────────────────────────── - -void Scene::draw_main_panel() { - ImGui::SetNextWindowPos(ImVec2(20.f, 20.f), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowSize(ImVec2(320.f, 0.f), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowBgAlpha(0.88f); - ImGui::Begin("Scene"); - - ImGui::SeparatorText("Grid"); - bool ext_changed = ImGui::SliderFloat("Extent",&m_axes_cfg.extent,0.5f,10.f,"%.1f"); - ImGui::SliderFloat("Major step",&m_axes_cfg.major_step,0.1f,2.f,"%.2f"); - ImGui::SliderFloat("Minor step",&m_axes_cfg.minor_step,0.05f,1.f,"%.2f"); - ImGui::Checkbox("3D mode", &m_axes_cfg.is_3d); - if (ext_changed) m_vp.base_extent = m_axes_cfg.extent * 1.2f; - - ImGui::SeparatorText("View"); - if (!m_axes_cfg.is_3d) - ImGui::Text("Pan (%.3f, %.3f) Zoom %.2fx", m_vp.pan_x, m_vp.pan_y, m_vp.zoom); - else - ImGui::Text("Yaw %.2f Pitch %.2f Zoom %.2fx", m_vp.yaw, m_vp.pitch, m_vp.zoom); - if (ImGui::Button("Reset View")) m_vp.reset(); - ImGui::TextDisabled(!m_axes_cfg.is_3d - ? "Mid-drag/Alt+drag: pan | Scroll: zoom" - : "Right/mid-drag: orbit | Scroll: zoom"); - - ImGui::SeparatorText("Debug"); - if (ImGui::Button("Coord Debug")) m_coord_debug.visible() = !m_coord_debug.visible(); - ImGui::SameLine(); - if (m_perf_panel.visible()) { - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f,0.5f,0.2f,1.f)); - if (ImGui::Button("Perf Stats")) m_perf_panel.visible() = !m_perf_panel.visible(); - ImGui::PopStyleColor(); - } else { - if (ImGui::Button("Perf Stats")) m_perf_panel.visible() = !m_perf_panel.visible(); - } - { - const auto& s = m_api.debug_stats(); - const ImVec4 fps_col = (s.fps >= 55.f) ? ImVec4(0.4f,1.f,0.4f,1.f) - : (s.fps >= 30.f ? ImVec4(1.f,0.8f,0.f,1.f) : ImVec4(1.f,0.3f,0.3f,1.f)); - ImGui::SameLine(); - ImGui::TextColored(fps_col, "%.0f fps", s.fps); - ImGui::TextDisabled(" Arena: %.1f%% %llu verts %u DC", - s.arena_utilisation*100.f, - static_cast(s.arena_vertex_count), s.draw_calls); - } - - ImGui::Separator(); - ImGui::SeparatorText("Surfaces"); - for (int i = 0; i < static_cast(m_surfaces.size()); ++i) { - ImGui::PushID(100 + i); - auto& e = m_surfaces[i]; - ImGui::Checkbox("##en", &e.enabled); - ImGui::SameLine(); - if (ImGui::CollapsingHeader(e.name.c_str())) - draw_surface_panel(e, i); - ImGui::PopID(); - } - if (ImGui::Button("+ Paraboloid", ImVec2(-1.f,0.f))) add_paraboloid(); - - ImGui::Separator(); - ImGui::SeparatorText("Curves"); - for (int i = 0; i < static_cast(m_conics.size()); ++i) { - ImGui::PushID(i); - auto& e = m_conics[i]; - ImGui::Checkbox("##en", &e.enabled); - ImGui::SameLine(); - if (ImGui::CollapsingHeader(e.name.c_str())) - draw_conic_panel(e, i); - ImGui::PopID(); - } - ImGui::Separator(); - if (ImGui::Button("+ Parabola", ImVec2(-1.f,0.f))) add_parabola(); - if (ImGui::Button("+ Hyperbola", ImVec2(-1.f,0.f))) add_hyperbola(); - if (ImGui::Button("+ Helix", ImVec2(-1.f,0.f))) add_helix(); - if (ImGui::Button("+ Paraboloid Curve",ImVec2(-1.f,0.f))) add_paraboloid_curve(); - - ImGui::End(); -} - -// ── draw_surface_panel ──────────────────────────────────────────────────────── - -void Scene::draw_surface_panel(SurfaceEntry& e, int /*idx*/) { - ImGui::Indent(); - float col[4] = { e.color.r, e.color.g, e.color.b, e.color.a }; - if (ImGui::ColorEdit4("Color##s", col)) - e.color = Vec4{col[0],col[1],col[2],col[3]}; - - ImGui::Separator(); - if (e.type == 0) { - ImGui::TextDisabled("z = a(x^2 + y^2) p(u,v) = (u*cos(v), u*sin(v), a*u^2)"); - - bool d = false; - d |= ImGui::SliderFloat("a##s", &e.par_a, 0.1f, 4.f, "%.3f"); - d |= ImGui::SliderFloat("u max##s", &e.par_umax, 0.3f, 3.f, "%.2f"); - - int ul = static_cast(e.u_lines); - int vl = static_cast(e.v_lines); - if (ImGui::SliderInt("u lines##s", &ul, 4, 32)) { e.u_lines = static_cast(ul); } - if (ImGui::SliderInt("v lines##s", &vl, 4, 48)) { e.v_lines = static_cast(vl); } - - if (ImGui::Button("Reset##s")) { - e.par_a = 1.f; e.par_umax = 1.5f; - e.u_lines = 14; e.v_lines = 20; d = true; - } - if (d) e.mark_dirty(); - - if (e.surface) { - const auto* p = static_cast(e.surface.get()); - ImGui::TextColored(ImVec4(0.7f,0.9f,1.f,1.f), - "K(apex)=%.4f K(u=%.1f)=%.4f", - p->gaussian_curvature(0.f, 0.f), - e.par_umax * 0.5f, - p->gaussian_curvature(e.par_umax * 0.5f, 0.f)); - ImGui::TextColored(ImVec4(0.7f,0.9f,1.f,1.f), - "H(apex)=%.4f k1=%.4f k2=%.4f", - p->mean_curvature(0.f, 0.f), - p->kappa1(0.f), p->kappa2(0.f)); - } - } - ImGui::Unindent(); -} - -// ── draw_conic_panel ────────────────────────────────────────────────────────── - -void Scene::draw_conic_panel(ConicEntry& e, int /*idx*/) { - ImGui::Indent(); - float col[4] = { e.color.r, e.color.g, e.color.b, e.color.a }; - if (ImGui::ColorEdit4("Color##c", col)) - e.color = Vec4{col[0],col[1],col[2],col[3]}; - int tess = static_cast(e.tessellation); - if (ImGui::SliderInt("Segments##c", &tess, 16, 1024)) - e.tessellation = static_cast(tess); - ImGui::Separator(); - - if (e.type == 0) { - ImGui::Text("y = a*t^2 + b*t + c"); - bool d = false; - d |= ImGui::SliderFloat("a##p", &e.par_a, -4.f, 4.f, "%.3f"); - d |= ImGui::SliderFloat("b##p", &e.par_b, -4.f, 4.f, "%.3f"); - d |= ImGui::SliderFloat("c##p", &e.par_c, -2.f, 2.f, "%.3f"); - d |= ImGui::SliderFloat("t min##p", &e.par_tmin,-10.f, 0.f, "%.2f"); - d |= ImGui::SliderFloat("t max##p", &e.par_tmax, 0.f,10.f, "%.2f"); - if (ImGui::Button("Reset##p")) { - e.par_a=-1.f; e.par_b=0.f; e.par_c=0.f; - e.par_tmin=-5.f; e.par_tmax=5.f; d=true; - } - if (d) e.mark_dirty(); - if (e.conic) ImGui::TextDisabled("k(0)=%.5f R=%.4f", - e.conic->curvature(0.f), - e.conic->curvature(0.f)>1e-8f ? 1.f/e.conic->curvature(0.f) : 0.f); - - } else if (e.type == 1) { - ImGui::Text("(x-h)^2/a^2 - (y-k)^2/b^2 = 1"); - bool d = false; - d |= ImGui::SliderFloat("a##h", &e.hyp_a, 0.1f, 4.f, "%.2f"); - d |= ImGui::SliderFloat("b##h", &e.hyp_b, 0.1f, 4.f, "%.2f"); - d |= ImGui::SliderFloat("h##h", &e.hyp_h, -2.f, 2.f, "%.2f"); - d |= ImGui::SliderFloat("k##h", &e.hyp_k, -2.f, 2.f, "%.2f"); - d |= ImGui::SliderFloat("range##h", &e.hyp_range,0.5f, 5.f, "%.2f"); - const char* axis_names[] = { "Horizontal", "Vertical" }; - if (ImGui::Combo("Axis##h", &e.hyp_axis, axis_names, 2)) d = true; - ImGui::Checkbox("Two branches##h", &e.hyp_two_branch); - if (ImGui::Button("Reset##h")) { - e.hyp_a=1.f; e.hyp_b=1.f; e.hyp_h=0.f; e.hyp_k=0.f; e.hyp_range=2.5f; d=true; - } - if (d) e.mark_dirty(); - - } else if (e.type == 2) { - ImGui::TextDisabled("p(t) = (r cos t, r sin t, b t), b = pitch/2pi"); - bool d = false; - d |= ImGui::SliderFloat("radius##h", &e.hel_radius, 0.1f, 3.f, "%.3f"); - d |= ImGui::SliderFloat("pitch##h", &e.hel_pitch, 0.05f,2.f, "%.3f"); - d |= ImGui::SliderFloat("t min##h", &e.hel_tmin, -20.f, 0.f, "%.2f"); - d |= ImGui::SliderFloat("t max##h", &e.hel_tmax, 0.f,20.f, "%.2f"); - if (ImGui::Button("Reset##helx")) { - e.hel_radius=1.f; e.hel_pitch=0.5f; - e.hel_tmin=0.f; e.hel_tmax=4.f*std::numbers::pi_v; d=true; - } - if (d) e.mark_dirty(); - if (e.conic) { - const float kappa = e.conic->curvature(0.f); - const float tau = e.conic->torsion(0.f); - ImGui::TextColored(ImVec4(0.8f,0.9f,1.f,1.f), - "k=%.5f R=%.4f t=%.5f", kappa, - kappa>1e-8f ? 1.f/kappa : 0.f, tau); - } - - } else if (e.type == 3) { - ImGui::TextDisabled("p(t) = (t*cos(theta), t*sin(theta), a*t^2)"); - ImGui::TextDisabled("Meridional parabola on z = a(x^2 + y^2)"); - bool d = false; - d |= ImGui::SliderFloat("a##pc", &e.pc_a, 0.1f, 4.f, "%.3f"); - float deg = e.pc_theta * (180.f / std::numbers::pi_v); - if (ImGui::SliderFloat("theta (deg)##pc", °, -180.f, 180.f, "%.1f")) { - e.pc_theta = deg * (std::numbers::pi_v / 180.f); - d = true; - } - d |= ImGui::SliderFloat("t min##pc", &e.pc_tmin, 0.f, 2.f, "%.3f"); - d |= ImGui::SliderFloat("t max##pc", &e.pc_tmax, 0.1f, 3.f, "%.3f"); - if (ImGui::Button("Reset##pc")) { - e.pc_a=1.f; e.pc_theta=0.f; e.pc_tmin=0.f; e.pc_tmax=1.5f; d=true; - } - if (d) e.mark_dirty(); - if (e.conic) { - const float tmid = (e.pc_tmin + e.pc_tmax) * 0.5f; - const float kappa = e.conic->curvature(tmid); - ImGui::TextColored(ImVec4(1.f,0.7f,0.3f,1.f), - "k(t=%.2f)=%.5f R=%.4f tau=%.3f", - tmid, kappa, kappa>1e-8f ? 1.f/kappa : 0.f, e.conic->torsion(tmid)); - ImGui::TextDisabled("k = kappa1 of paraboloid | torsion = 0 (planar)"); - } - } - ImGui::Unindent(); -} - -} // namespace ndde diff --git a/nurbs_dde/src/app/legacy/Scene_on_frame_patch.bak b/nurbs_dde/src/app/legacy/Scene_on_frame_patch.bak deleted file mode 100644 index a4eb8fda..00000000 --- a/nurbs_dde/src/app/legacy/Scene_on_frame_patch.bak +++ /dev/null @@ -1,22 +0,0 @@ -// ── on_frame ────────────────────────────────────────────────────────────────── - -void Scene::on_frame() { - sync_viewport(); - update_camera(); - update_hover(); - - // Update coord debug AFTER hover so it captures the complete snap state - m_coord_debug.update(m_vp, m_hover, m_conics, m_api.viewport_size()); - - draw_main_panel(); - m_analysis_panel.draw(m_hover, m_api.math_font_body()); - m_coord_debug.draw(); // dedicated floating window - - submit_grid(); - submit_axes(); - submit_conics(); - submit_epsilon_ball(); - submit_interval_lines(); - submit_secant_line(); - submit_tangent_line(); -} diff --git a/nurbs_dde/src/engine/Engine.cpp b/nurbs_dde/src/engine/Engine.cpp index cf6e1c8a..8947d6b8 100644 --- a/nurbs_dde/src/engine/Engine.cpp +++ b/nurbs_dde/src/engine/Engine.cpp @@ -3,26 +3,65 @@ #include #include "engine/Engine.hpp" -#include "app/Scene.hpp" #include "app/SceneFactories.hpp" +#include +#include #include #include +#include +#include +#include +#include +#include #include +#include #include +#include #define GLFW_INCLUDE_NONE #include +#ifndef NDDE_PROJECT_DIR +#define NDDE_PROJECT_DIR "." +#endif + namespace ndde { -Engine::Engine() = default; +namespace { +std::unordered_map g_hotkey_engines; + +std::string_view render_kind_name(RenderViewKind kind) noexcept { + return kind == RenderViewKind::Main ? "Main" : "Alternate"; +} + +std::string_view alternate_mode_name(AlternateViewMode mode) noexcept { + switch (mode) { + case AlternateViewMode::Contour: return "Contour"; + case AlternateViewMode::LevelCurves: return "Level Curves"; + case AlternateViewMode::VectorField: return "Vector Field"; + case AlternateViewMode::Isoclines: return "Isoclines"; + case AlternateViewMode::Flow: return "Flow"; + } + return "Unknown"; +} + +} // namespace + +void engine_key_callback(GLFWwindow* window, int key, int scancode, int action, int mods) { + ImGui_ImplGlfw_KeyCallback(window, key, scancode, action, mods); + if (auto it = g_hotkey_engines.find(window); it != g_hotkey_engines.end()) + it->second->on_key_event(key, action, mods); +} + +Engine::Engine() + : m_simulation_host(m_services.simulation_host()) + , m_simulations(m_services.memory()) {} Engine::~Engine() { - m_active.reset(); - m_scene.reset(); + uninstall_global_hotkeys(); m_second_win.destroy(); m_renderer.destroy(); - m_buffer_manager.destroy(); + m_services.memory().destroy(); m_swapchain.destroy(); m_vk.destroy(); m_glfw.destroy(); @@ -41,11 +80,12 @@ void Engine::start(const std::string& config_path) { throw std::runtime_error("[Engine] volkInitialize() failed"); m_vk.init(m_glfw.window(), m_config.window.title); - m_swapchain.init(m_vk, m_glfw.width(), m_glfw.height()); + m_swapchain.init(m_vk, m_glfw.width(), m_glfw.height(), m_config.render.vsync); m_renderer.init(m_vk, m_swapchain, SHADER_DIR, ASSETS_DIR, m_glfw.window()); + install_global_hotkeys(); - m_buffer_manager.init(m_vk.device(), m_vk.physical_device(), - m_config.simulation.arena_size_mb); + m_services.memory().init_frame_gpu_arena(m_vk.device(), m_vk.physical_device(), + m_config.simulation.arena_size_mb); // Second window for the 2D contour view. // Place it to the right of the primary window. glfwGetMonitors lets us @@ -63,7 +103,7 @@ void Engine::start(const std::string& config_path) { m_second_win.init(m_vk, x, y, static_cast(vm->width), static_cast(vm->height), - "Contour 2D", SHADER_DIR); + "Contour 2D", SHADER_DIR, m_config.render.vsync); } else { // Single monitor: place second window at right half int mx=0, my=0; @@ -72,27 +112,61 @@ void Engine::start(const std::string& config_path) { const u32 hw = static_cast(vm->width / 2); m_second_win.init(m_vk, mx + static_cast(hw), my, hw, static_cast(vm->height), - "Contour 2D", SHADER_DIR); + "Contour 2D", SHADER_DIR, m_config.render.vsync); } } // Also maximise the primary window to fill its half / first monitor glfwMaximizeWindow(m_glfw.window()); - m_active = make_surface_sim_scene(make_api()); + register_global_panels(); + register_default_simulations(m_simulations); + + m_active_sim = 0; + active_runtime().instantiate(m_simulation_host); + active_runtime().start(); m_last_frame_time = glfwGetTime(); m_running = true; + m_event_log.push_back("Engine ready"); std::cout << "[Engine] Ready.\n"; } -void Engine::switch_scene(SceneFactory factory) { +void Engine::install_global_hotkeys() { + GLFWwindow* window = m_glfw.window(); + if (!window) return; + g_hotkey_engines[window] = this; + glfwSetKeyCallback(window, engine_key_callback); +#if defined(_WIN32) + std::cout << "[Engine] Global hotkeys: GLFW event callback (Win32 backend)\n"; +#elif defined(__linux__) + std::cout << "[Engine] Global hotkeys: GLFW event callback (Linux backend)\n"; +#else + std::cout << "[Engine] Global hotkeys: GLFW event callback\n"; +#endif +} + +void Engine::uninstall_global_hotkeys() noexcept { + GLFWwindow* window = m_glfw.window(); + if (!window) return; + g_hotkey_engines.erase(window); + glfwSetKeyCallback(window, nullptr); +} + +void Engine::switch_simulation(std::size_t index) { + if (index >= m_simulations.size() || index == m_active_sim) return; vkDeviceWaitIdle(m_vk.device()); - // Reset renderer per-frame sync state so the new scene's first frame - // acquires with clean, unsignaled semaphores and a signaled fence. m_renderer.reset_frame_state(); - auto next = factory(make_api()); - m_active = std::move(next); + active_runtime().stop(); + m_services.render().clear_packets(); + m_services.memory().reset_simulation(); + m_services.memory().reset_cache(); + m_services.memory().reset_history(); + m_active_sim = index; + active_runtime().instantiate(m_simulation_host); + active_runtime().start(); + m_event_log.push_back(std::format("Switched simulation: {}", active_runtime().name())); + std::cout << std::format("[Engine] Active simulation: {}\n", active_runtime().name()); } void Engine::run() { @@ -116,17 +190,21 @@ void Engine::run_frame() { const f32 frame_ms = static_cast(delta_s * 1000.0); const f32 fps = (frame_ms > 0.f) ? 1000.f / frame_ms : 0.f; - m_buffer_manager.reset(); + // Destroy previous-frame packet payloads before releasing frame PMR memory. + m_services.render().clear_packets(); + m_services.memory().begin_frame(); // ── Primary window (3D surface + ImGui) ───────────────────────────────── if (!m_renderer.begin_frame(m_swapchain)) { handle_resize(); return; } m_renderer.imgui_new_frame(); + dispatch_global_hotkeys(); + update_render_view_input(); // ── Populate DebugStats ─────────────────────────────────────────────────── const auto& sc_ext = m_swapchain.extent(); m_debug_stats = DebugStats{ - .arena_bytes_used = m_buffer_manager.bytes_used(), // 0 after reset — updated lazily - .arena_bytes_total = m_buffer_manager.bytes_total(), + .arena_bytes_used = m_services.memory().frame_gpu_bytes_used(), // 0 after reset — updated lazily + .arena_bytes_total = m_services.memory().frame_gpu_bytes_total(), .arena_utilisation = 0.f, // updated after scene runs .arena_vertex_count = 0, .draw_calls = m_renderer.draw_call_count(), // previous frame @@ -136,37 +214,396 @@ void Engine::run_frame() { .fps = fps, }; - // Run only the surface simulation scene. - // m_scene->on_frame(); // original conics scene — re-enable to switch - // ── Second window begin ────────────────────────────────────────────────────── const bool second_ok = m_second_win.valid() && m_second_win.begin_frame(); - m_active->on_frame(frame_ms / 1000.f); + active_runtime().tick(m_services.clock().next(frame_ms / 1000.f, active_runtime().paused())); + flush_render_service(); + m_services.panels().draw_registered_panels(); - // ── Second window end ───────────────────────────────────────────────────── + // ── Update arena stats after scene geometry has been written ───────────── + m_debug_stats.arena_bytes_used = m_services.memory().frame_gpu_bytes_used(); + m_debug_stats.arena_utilisation = m_services.memory().frame_gpu_utilisation(); + m_debug_stats.arena_vertex_count = + m_services.memory().frame_gpu_bytes_used() / static_cast(sizeof(Vertex)); + + m_renderer.imgui_render(); + const bool primary_ok = m_renderer.end_frame(m_swapchain); + + // Present the auxiliary contour window after the primary window. FIFO + // present can block, and letting the secondary surface block first makes + // main-window frame pacing visibly worse on some drivers. if (second_ok) { const bool present_ok = m_second_win.end_frame(); if (!present_ok) { /* resize handled internally */ } } - // ── Update arena stats after scene geometry has been written ───────────── - m_debug_stats.arena_bytes_used = m_buffer_manager.bytes_used(); - m_debug_stats.arena_utilisation = m_buffer_manager.utilisation(); - m_debug_stats.arena_vertex_count = - m_buffer_manager.bytes_used() / static_cast(sizeof(Vertex)); + if (!primary_ok) handle_resize(); - m_renderer.imgui_render(); - if (!m_renderer.end_frame(m_swapchain)) handle_resize(); + apply_pending_simulation_switch(); +} + +void Engine::register_global_panels() { + m_global_panels.clear(); + m_global_panels.push_back(m_services.panels().register_panel(PanelDescriptor{ + .title = "Engine - Global", + .category = "Engine", + .scope = PanelScope::Global, + .draw = [this] { draw_global_status_panel(); } + })); + m_global_panels.push_back(m_services.panels().register_panel(PanelDescriptor{ + .title = "Debug - Coordinates", + .category = "Debug", + .scope = PanelScope::Global, + .draw = [this] { draw_debug_coordinates_panel(); } + })); + m_global_panels.push_back(m_services.panels().register_panel(PanelDescriptor{ + .title = "Simulation - Metadata", + .category = "Debug", + .scope = PanelScope::Global, + .draw = [this] { draw_simulation_metadata_panel(); } + })); + m_global_panels.push_back(m_services.panels().register_panel(PanelDescriptor{ + .title = "Engine - Log", + .category = "Engine", + .scope = PanelScope::Global, + .draw = [this] { draw_event_log_panel(); } + })); +} + +void Engine::draw_global_status_panel() { + ImGui::SetNextWindowPos(ImVec2(12.f, 34.f), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(260.f, 150.f), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowBgAlpha(0.86f); + if (!ImGui::Begin("Engine - Global")) { ImGui::End(); return; } + + ImGui::SeparatorText("Simulations"); + for (std::size_t i = 0; i < m_simulations.size(); ++i) { + auto* sim = m_simulations.get(i); + if (!sim) continue; + const std::string label = std::format("Ctrl+{} {}", i + 1, sim->name()); + if (ImGui::Selectable(label.c_str(), i == m_active_sim)) + m_pending_sim = i; + } + + ImGui::SeparatorText("Stats"); + const auto sim_snapshot = active_runtime().snapshot(); + ImGui::TextDisabled("%s %s", sim_snapshot.name.c_str(), sim_snapshot.status.c_str()); + if (sim_snapshot.sim_speed > 0.f || sim_snapshot.particle_count > 0) { + ImGui::TextDisabled("t %.2f speed %.2f %llu particles", + sim_snapshot.sim_time, + sim_snapshot.sim_speed, + static_cast(sim_snapshot.particle_count)); + } + ImGui::TextDisabled("%.1f ms %.0f fps", m_debug_stats.frame_ms, m_debug_stats.fps); + ImGui::TextDisabled("%llu verts %llu / %llu bytes", + static_cast(m_debug_stats.arena_vertex_count), + static_cast(m_debug_stats.arena_bytes_used), + static_cast(m_debug_stats.arena_bytes_total)); + ImGui::TextDisabled("F12 capture Ctrl+Shift+P pause+capture"); + bool axes = m_services.render().axes_visible(); + if (ImGui::Checkbox("Axes", &axes)) + m_services.render().set_axes_visible(axes); + ImGui::SeparatorText("Camera"); + if (ImGui::Button("Home")) m_services.render().reset_main_cameras(CameraPreset::Home); + ImGui::SameLine(); + if (ImGui::Button("Top")) m_services.render().reset_main_cameras(CameraPreset::Top); + ImGui::SameLine(); + if (ImGui::Button("Front")) m_services.render().reset_main_cameras(CameraPreset::Front); + ImGui::SameLine(); + if (ImGui::Button("Side")) m_services.render().reset_main_cameras(CameraPreset::Side); + ImGui::TextDisabled("RMB drag orbit MMB/Shift+RMB pan Wheel zoom"); + ImGui::TextDisabled("Double-click surface perturb"); + ImGui::End(); +} + +void Engine::draw_debug_coordinates_panel() { + ImGui::SetNextWindowPos(ImVec2(290.f, 34.f), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(340.f, 220.f), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowBgAlpha(0.86f); + if (!ImGui::Begin("Debug - Coordinates")) { ImGui::End(); return; } + + const ImGuiIO& io = ImGui::GetIO(); + const Vec2 fb{static_cast(m_glfw.width()), static_cast(m_glfw.height())}; + ImGui::SeparatorText("Mouse"); + ImGui::TextDisabled("display %.0f x %.0f", io.DisplaySize.x, io.DisplaySize.y); + ImGui::TextDisabled("framebuffer %.0f x %.0f", fb.x, fb.y); + ImGui::TextDisabled("mouse %.1f, %.1f", io.MousePos.x, io.MousePos.y); + + const RenderViewId main = m_services.render().first_active_main_view(); + if (main != 0) { + const RenderViewDomain d = m_services.render().view_domain(main); + const float nx = io.DisplaySize.x > 0.f ? std::clamp(io.MousePos.x / io.DisplaySize.x, 0.f, 1.f) : 0.f; + const float ny = io.DisplaySize.y > 0.f ? std::clamp(io.MousePos.y / io.DisplaySize.y, 0.f, 1.f) : 0.f; + const Vec2 uv{ + d.u_min + nx * (d.u_max - d.u_min), + d.v_max - ny * (d.v_max - d.v_min) + }; + ImGui::SeparatorText("Active Main View"); + ImGui::TextDisabled("domain u[%.2f, %.2f] v[%.2f, %.2f]", d.u_min, d.u_max, d.v_min, d.v_max); + ImGui::TextDisabled("mapped uv %.3f, %.3f", uv.x, uv.y); + if (const auto* desc = m_services.render().descriptor(main)) { + const auto& cam = desc->camera; + ImGui::TextDisabled("camera yaw %.2f pitch %.2f zoom %.2f", cam.yaw, cam.pitch, cam.zoom); + ImGui::TextDisabled("target %.2f, %.2f, %.2f", cam.target.x, cam.target.y, cam.target.z); + ImGui::TextDisabled("view %.0f x %.0f aspect %.3f", + desc->viewport_size.x, desc->viewport_size.y, desc->viewport_aspect); + } + } + const HoverMetadata& hover = m_services.interaction().hover_metadata(); + ImGui::SeparatorText("Hover"); + ImGui::TextDisabled("view %llu mouse %.1f, %.1f", + static_cast(hover.view), + hover.mouse_pixel.x, + hover.mouse_pixel.y); + if (hover.surface.hit) { + ImGui::TextDisabled("surface uv %.3f, %.3f", hover.surface.uv.x, hover.surface.uv.y); + ImGui::TextDisabled("world %.3f, %.3f, %.3f", + hover.surface.world.x, hover.surface.world.y, hover.surface.world.z); + } else { + ImGui::TextDisabled("surface: no hit"); + } + if (hover.particle.hit) { + ImGui::TextDisabled("particle %llu idx %u d %.1f px", + static_cast(hover.particle.particle_id), + hover.particle.trail_index, + hover.particle.pixel_distance); + ImGui::TextDisabled("k %.5f tau %.5f", hover.particle.curvature, hover.particle.torsion); + } else { + ImGui::TextDisabled("particle: no trail snap"); + } + ImGui::End(); +} + +void Engine::draw_simulation_metadata_panel() { + ImGui::SetNextWindowPos(ImVec2(650.f, 34.f), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(380.f, 420.f), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowBgAlpha(0.86f); + if (!ImGui::Begin("Simulation - Metadata")) { ImGui::End(); return; } + + const SimulationMetadata metadata = active_runtime().metadata(); + const SceneSnapshot snapshot = active_runtime().snapshot(); + ImGui::SeparatorText("Simulation"); + ImGui::TextDisabled("%s", metadata.name.c_str()); + if (!metadata.surface_name.empty()) + ImGui::TextDisabled("%s", metadata.surface_name.c_str()); + if (!metadata.surface_formula.empty()) + ImGui::TextDisabled("%s", metadata.surface_formula.c_str()); + ImGui::TextDisabled("surface: %s derivatives %s %s", + metadata.surface_has_analytic_derivatives ? "analytic" : "finite-diff", + metadata.surface_deformable ? "deformable" : "static", + metadata.surface_time_varying ? "time-varying" : "time-invariant"); + ImGui::TextDisabled("status %s paused %s", metadata.status.c_str(), metadata.paused ? "yes" : "no"); + ImGui::TextDisabled("t %.2f speed %.2f particles %llu", + metadata.sim_time, + metadata.sim_speed, + static_cast(metadata.particle_count)); + + ImGui::SeparatorText("Render Views"); + for (const RenderViewSnapshot& view : m_services.render().active_view_snapshots()) { + if (ImGui::TreeNode(std::format("{}##view{}", view.title, view.id).c_str())) { + ImGui::TextDisabled("id %llu %s", static_cast(view.id), render_kind_name(view.kind).data()); + if (view.kind == RenderViewKind::Alternate) + ImGui::TextDisabled("mode %s", alternate_mode_name(view.alternate_mode).data()); + ImGui::TextDisabled("domain u[%.2f, %.2f] v[%.2f, %.2f]", + view.domain.u_min, view.domain.u_max, view.domain.v_min, view.domain.v_max); + ImGui::TextDisabled("axes %s hover %s osc %s", + view.overlays.show_axes ? "on" : "off", + view.overlays.show_hover_frenet ? "on" : "off", + view.overlays.show_osculating_circle ? "on" : "off"); + ImGui::TreePop(); + } + } + + ImGui::SeparatorText("Particles"); + for (const auto& particle : snapshot.particles) { + ImGui::TextDisabled("#%llu %s", static_cast(particle.id), particle.label.c_str()); + ImGui::SameLine(); + ImGui::TextDisabled("(%.2f, %.2f, %.2f)", particle.x, particle.y, particle.z); + } + ImGui::End(); +} - apply_pending_scene_switch(); +void Engine::draw_event_log_panel() { + ImGui::SetNextWindowPos(ImVec2(1048.f, 34.f), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(320.f, 180.f), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowBgAlpha(0.86f); + if (!ImGui::Begin("Engine - Log")) { ImGui::End(); return; } + for (const auto& line : m_event_log) + ImGui::TextDisabled("%s", line.c_str()); + ImGui::End(); } -void Engine::apply_pending_scene_switch() { - if (!m_pending_scene_switch) return; - auto factory = std::move(m_pending_scene_switch); - m_pending_scene_switch = {}; - switch_scene(std::move(factory)); +void Engine::apply_pending_simulation_switch() { + if (m_pending_sim == static_cast(-1)) return; + const std::size_t next = m_pending_sim; + m_pending_sim = static_cast(-1); + switch_simulation(next); +} + +void Engine::on_key_event(int key, int action, int mods) { + if (action != GLFW_PRESS) return; + + const ImGuiIO& io = ImGui::GetIO(); + if (io.WantTextInput) return; + + const bool ctrl = (mods & GLFW_MOD_CONTROL) != 0; + const bool shift = (mods & GLFW_MOD_SHIFT) != 0; + + if (ctrl && !shift && key == GLFW_KEY_1) { + m_pending_sim = 0; + return; + } + if (ctrl && !shift && key == GLFW_KEY_2) { + m_pending_sim = 1; + return; + } + if (ctrl && !shift && key == GLFW_KEY_3) { + m_pending_sim = 2; + return; + } + if (ctrl && !shift && key == GLFW_KEY_4) { + m_pending_sim = 3; + return; + } + if (!ctrl && !shift && key == GLFW_KEY_F12) { + request_capture(false); + return; + } + if (ctrl && shift && key == GLFW_KEY_P) { + request_capture(true); + return; + } + + (void)m_services.hotkeys().dispatch(KeyChord{.key = key, .mods = mods}); +} + +void Engine::dispatch_global_hotkeys() { + // Global hotkeys are delivered by the GLFW key callback installed after + // ImGui. This function intentionally remains as a frame-loop hook for + // future event queue draining, but no longer polls key state. +} + +void Engine::update_render_view_input() { + ImGuiIO& io = ImGui::GetIO(); + m_services.render().set_viewport_size(RenderViewKind::Main, + Vec2{static_cast(m_glfw.width()), static_cast(m_glfw.height())}); + if (m_second_win.valid()) { + m_services.render().set_viewport_size(RenderViewKind::Alternate, + Vec2{static_cast(m_second_win.width()), static_cast(m_second_win.height())}); + } + const bool mouse_valid = io.MousePos.x > -3.0e37f && io.MousePos.y > -3.0e37f; + const RenderViewId main_view = m_services.render().first_active_main_view(); + if (main_view != 0 && io.DisplaySize.x > 0.f && io.DisplaySize.y > 0.f) { + const float nx = std::clamp(io.MousePos.x / io.DisplaySize.x, 0.f, 1.f); + const float ny = std::clamp(io.MousePos.y / io.DisplaySize.y, 0.f, 1.f); + m_services.interaction().set_mouse(main_view, + Vec2{io.MousePos.x, io.MousePos.y}, + Vec2{nx * 2.f - 1.f, 1.f - ny * 2.f}, + mouse_valid && !io.WantCaptureMouse); + } + if (io.WantCaptureMouse) return; + + if (io.MouseWheel != 0.f) + m_services.render().zoom_main_cameras(io.MouseWheel); + + const bool right_drag = ImGui::IsMouseDragging(ImGuiMouseButton_Right); + const bool middle_drag = ImGui::IsMouseDragging(ImGuiMouseButton_Middle); + const bool shift = io.KeyShift; + if (right_drag && !shift) + m_services.render().orbit_main_cameras(io.MouseDelta.x, io.MouseDelta.y); + if (middle_drag || (right_drag && shift)) + m_services.render().pan_main_cameras(io.MouseDelta.x, io.MouseDelta.y); + + if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { + const RenderViewId view = m_services.render().first_active_main_view(); + if (view != 0 && io.DisplaySize.x > 0.f && io.DisplaySize.y > 0.f) { + const RenderViewDomain domain = m_services.render().view_domain(view); + const float nx = std::clamp(io.MousePos.x / io.DisplaySize.x, 0.f, 1.f); + const float ny = std::clamp(io.MousePos.y / io.DisplaySize.y, 0.f, 1.f); + m_services.interaction().queue_surface_pick(SurfacePickRequest{ + .view = view, + .fallback_uv = { + domain.u_min + nx * (domain.u_max - domain.u_min), + domain.v_max - ny * (domain.v_max - domain.v_min) + }, + .screen_ndc = {nx * 2.f - 1.f, 1.f - ny * 2.f}, + .amplitude = 0.25f, + .radius = 1.0f, + .falloff = 1.f, + .seed = m_surface_perturb_seed++ + }); + } + } +} + +void Engine::request_capture(bool pause_first) { + if (pause_first) + active_runtime().pause(); + m_event_log.push_back("PNG capture requested"); + m_renderer.request_png_capture(make_capture_path()); +} + +std::filesystem::path Engine::make_capture_path() const { + std::string name = std::string(active_runtime().name()); + for (char& c : name) { + const unsigned char uc = static_cast(c); + if (!std::isalnum(uc)) c = '_'; + } + name.erase(std::unique(name.begin(), name.end(), + [](char a, char b){ return a == '_' && b == '_'; }), + name.end()); + if (!name.empty() && name.back() == '_') name.pop_back(); + if (name.empty()) name = "simulation"; + + const std::time_t now = std::time(nullptr); + std::tm tm{}; + localtime_s(&tm, &now); + std::ostringstream stamp; + stamp << std::put_time(&tm, "%Y%m%d_%H%M%S"); + return std::filesystem::path{NDDE_PROJECT_DIR} / "captures" / (name + "_" + stamp.str() + ".png"); +} + +SimulationRuntime& Engine::active_runtime() { + auto* runtime = m_simulations.get(m_active_sim); + if (!runtime) throw std::runtime_error("[Engine] No active simulation runtime"); + return *runtime; +} + +const SimulationRuntime& Engine::active_runtime() const { + const auto* runtime = m_simulations.get(m_active_sim); + if (!runtime) throw std::runtime_error("[Engine] No active simulation runtime"); + return *runtime; +} + +void Engine::flush_render_service() { + for (const RenderPacket& packet : m_services.render().packets()) { + if (packet.vertices.empty()) continue; + auto slice = m_services.memory().allocate_frame_vertices(static_cast(packet.vertices.size())); + auto verts = slice.vertices(); + for (u32 i = 0; i < static_cast(packet.vertices.size()); ++i) + verts[i] = packet.vertices[i]; + + renderer::DrawCall dc{ + .slice = slice, + .topology = packet.topology, + .mode = packet.mode, + .color = packet.color, + .mvp = packet.mvp + }; + + const RenderTarget target = m_services.render().view_kind(packet.view) == RenderViewKind::Alternate + ? RenderTarget::Contour2D + : RenderTarget::Primary3D; + switch (target) { + case RenderTarget::Primary3D: + m_renderer.draw(dc); + break; + case RenderTarget::Contour2D: + if (m_second_win.valid()) m_second_win.draw(dc); + break; + } + } } void Engine::handle_resize() { @@ -174,7 +611,7 @@ void Engine::handle_resize() { const u32 h = m_glfw.height(); if (w == 0 || h == 0) return; vkDeviceWaitIdle(m_vk.device()); - m_swapchain.recreate(m_vk, w, h); + m_swapchain.recreate(m_vk, w, h, m_config.render.vsync); m_renderer.on_swapchain_recreated(m_swapchain); std::cout << std::format("[Engine] Swapchain {}x{}\n", w, h); } @@ -183,7 +620,7 @@ EngineAPI Engine::make_api() { EngineAPI api; api.acquire = [this](u32 n) -> memory::ArenaSlice { - return m_buffer_manager.acquire(n); + return m_services.memory().allocate_frame_vertices(n); }; api.submit_to = [this](RenderTarget target, @@ -221,8 +658,8 @@ EngineAPI Engine::make_api() { }; api.debug_stats = [this]() -> const DebugStats& { return m_debug_stats; }; - api.switch_scene = [this](SceneFactory factory) { - m_pending_scene_switch = std::move(factory); + api.switch_simulation = [this](std::size_t index) { + m_pending_sim = index; }; return api; diff --git a/nurbs_dde/src/engine/Engine.hpp b/nurbs_dde/src/engine/Engine.hpp index a53730a1..c0bc71d9 100644 --- a/nurbs_dde/src/engine/Engine.hpp +++ b/nurbs_dde/src/engine/Engine.hpp @@ -4,24 +4,23 @@ #include "engine/AppConfig.hpp" #include "engine/EngineAPI.hpp" #include "engine/IScene.hpp" +#include "engine/SimulationHost.hpp" +#include "engine/SimulationRuntime.hpp" #include "platform/GlfwContext.hpp" #include "platform/VulkanContext.hpp" #include "renderer/Swapchain.hpp" #include "renderer/Renderer.hpp" #include "renderer/SecondWindow.hpp" -#include "memory/BufferManager.hpp" -#include +#include "memory/Containers.hpp" +#include #include -#include namespace ndde { -class Scene; // legacy forward-declaration (type still needed by Engine.cpp include) +void engine_key_callback(GLFWwindow* window, int key, int scancode, int action, int mods); class Engine { public: - using SceneFactory = std::function(EngineAPI)>; - Engine(); ~Engine(); @@ -31,35 +30,59 @@ class Engine { void start(const std::string& config_path = "engine_config.json"); void run(); - // Replace the active scene with a new one. - // Engine flushes the GPU before destroying the old scene. - // factory is called with a fresh EngineAPI so the new scene is - // fully initialised before the old one is destroyed. - void switch_scene(SceneFactory factory); + void switch_simulation(std::size_t index); [[nodiscard]] const AppConfig& config() const noexcept { return m_config; } + [[nodiscard]] EngineServices& services() noexcept { return m_services; } + [[nodiscard]] const EngineServices& services() const noexcept { return m_services; } + [[nodiscard]] PanelService& getPanelService() noexcept { return m_services.panels(); } + [[nodiscard]] HotkeyService& getHotkeyService() noexcept { return m_services.hotkeys(); } + [[nodiscard]] InteractionService& getInteractionService() noexcept { return m_services.interaction(); } + [[nodiscard]] RenderService& getRenderService() noexcept { return m_services.render(); } + [[nodiscard]] SimulationClock& getSimulationClock() noexcept { return m_services.clock(); } private: AppConfig m_config; + EngineServices m_services; + SimulationHost m_simulation_host; platform::GlfwContext m_glfw; platform::VulkanContext m_vk; renderer::Swapchain m_swapchain; renderer::Renderer m_renderer; - memory::BufferManager m_buffer_manager; - std::unique_ptr m_scene; ///< legacy slot (null — kept for compat) - std::unique_ptr m_active; ///< the live scene - SceneFactory m_pending_scene_switch; + SimulationRegistry m_simulations; + std::size_t m_active_sim = 0; + std::size_t m_pending_sim = static_cast(-1); renderer::SecondWindow m_second_win; ///< 2D contour window bool m_running = false; // Per-frame state double m_last_frame_time = 0.0; DebugStats m_debug_stats; + u32 m_surface_perturb_seed = 1; + memory::PersistentVector m_global_panels; + memory::PersistentVector m_event_log; void run_frame(); void handle_resize(); - void apply_pending_scene_switch(); + void apply_pending_simulation_switch(); + void register_global_panels(); + void draw_global_status_panel(); + void draw_debug_coordinates_panel(); + void draw_simulation_metadata_panel(); + void draw_event_log_panel(); + void flush_render_service(); + void install_global_hotkeys(); + void uninstall_global_hotkeys() noexcept; + void on_key_event(int key, int action, int mods); + void dispatch_global_hotkeys(); + void update_render_view_input(); + void request_capture(bool pause_first); + [[nodiscard]] std::filesystem::path make_capture_path() const; + [[nodiscard]] SimulationRuntime& active_runtime(); + [[nodiscard]] const SimulationRuntime& active_runtime() const; [[nodiscard]] EngineAPI make_api(); + + friend void engine_key_callback(GLFWwindow* window, int key, int scancode, int action, int mods); }; } // namespace ndde diff --git a/nurbs_dde/src/engine/EngineAPI.hpp b/nurbs_dde/src/engine/EngineAPI.hpp index 1f0a384e..ac7ce346 100644 --- a/nurbs_dde/src/engine/EngineAPI.hpp +++ b/nurbs_dde/src/engine/EngineAPI.hpp @@ -6,13 +6,11 @@ #include "memory/ArenaSlice.hpp" #include "engine/AppConfig.hpp" #include "math/Scalars.hpp" +#include #include -#include namespace ndde { -class IScene; // forward-declared so EngineAPI can reference it without pulling IScene.hpp - enum class RenderTarget : u8 { Primary3D, Contour2D @@ -23,7 +21,7 @@ enum class RenderTarget : u8 { // No Vulkan types — safe to include from any translation unit. struct DebugStats { - // ── Arena (BufferManager) ───────────────────────────────────────────────── + // ── Frame GPU arena (MemoryService) ─────────────────────────────────────── u64 arena_bytes_used = 0; ///< bytes written this frame u64 arena_bytes_total = 0; ///< capacity of the arena f32 arena_utilisation = 0.f; ///< used / total [0, 1] @@ -42,7 +40,7 @@ struct DebugStats { // ── EngineAPI ───────────────────────────────────────────────────────────────── struct EngineAPI { - // Allocate vertex_count Vertex slots from the per-frame arena. + // Allocate vertex_count Vertex slots from the per-frame GPU arena. std::function acquire; // Submit a populated slice to a render target. @@ -72,10 +70,9 @@ struct EngineAPI { // Populated by Engine just before Scene::on_frame() is called. std::function debug_stats; - // Request a scene switch at the end of the current frame. - // factory receives a fresh EngineAPI and returns the new scene. - // The engine flushes the GPU before destroying the old scene. - std::function(EngineAPI)>)> switch_scene; + // Request a first-class simulation switch by registry index. + // Index 0 is Ctrl+1, index 1 is Ctrl+2, etc. + std::function switch_simulation; }; } // namespace ndde diff --git a/nurbs_dde/src/engine/HotkeyService.hpp b/nurbs_dde/src/engine/HotkeyService.hpp new file mode 100644 index 00000000..fa34ae83 --- /dev/null +++ b/nurbs_dde/src/engine/HotkeyService.hpp @@ -0,0 +1,100 @@ +#pragma once +// engine/HotkeyService.hpp +// Engine-owned hotkey registration and dispatch service. + +#include "engine/ServiceHandle.hpp" +#include "math/Scalars.hpp" +#include "memory/Containers.hpp" +#include "memory/MemoryService.hpp" + +#include +#include +#include +#include + +namespace ndde { + +using HotkeyId = u64; +using HotkeyHandle = ServiceHandle; + +struct KeyChord { + int key = 0; + int mods = 0; + + [[nodiscard]] friend bool operator==(KeyChord a, KeyChord b) noexcept { + return a.key == b.key && a.mods == b.mods; + } +}; + +struct HotkeyDescriptor { + KeyChord chord{}; + std::string label; + std::string category = "Simulation"; + std::function callback; +}; + +class HotkeyService { +public: + void set_memory_service(memory::MemoryService* memory) { + std::pmr::memory_resource* resource = memory ? memory->persistent().resource() + : std::pmr::get_default_resource(); + if (resource == m_hotkeys.get_allocator().resource()) + return; + std::destroy_at(&m_hotkeys); + std::construct_at(&m_hotkeys, resource); + } + + [[nodiscard]] HotkeyHandle register_action(HotkeyDescriptor descriptor) { + const HotkeyId id = m_next_id++; + m_hotkeys.push_back(HotkeyEntry{ + .id = id, + .descriptor = std::move(descriptor), + .active = true + }); + return HotkeyHandle([this, id] { unregister(id); }); + } + + [[nodiscard]] bool dispatch(KeyChord chord) { + bool handled = false; + for (auto& entry : m_hotkeys) { + if (!entry.active || entry.descriptor.chord != chord || !entry.descriptor.callback) continue; + entry.descriptor.callback(); + handled = true; + } + return handled; + } + + [[nodiscard]] std::size_t active_count() const noexcept { + return static_cast(std::count_if(m_hotkeys.begin(), m_hotkeys.end(), + [](const HotkeyEntry& entry) { return entry.active; })); + } + + [[nodiscard]] bool contains(std::string_view label) const { + return std::any_of(m_hotkeys.begin(), m_hotkeys.end(), + [label](const HotkeyEntry& entry) { + return entry.active && entry.descriptor.label == label; + }); + } + +private: + struct HotkeyEntry { + HotkeyId id = 0; + HotkeyDescriptor descriptor; + bool active = false; + }; + + HotkeyId m_next_id = 1; + memory::PersistentVector m_hotkeys; + + void unregister(HotkeyId id) noexcept { + for (auto& entry : m_hotkeys) { + if (entry.id == id) { + entry.active = false; + entry.descriptor.callback = {}; + return; + } + } + } +}; + +} // namespace ndde diff --git a/nurbs_dde/src/engine/IScene.hpp b/nurbs_dde/src/engine/IScene.hpp index 5c0a2506..7b820925 100644 --- a/nurbs_dde/src/engine/IScene.hpp +++ b/nurbs_dde/src/engine/IScene.hpp @@ -20,16 +20,39 @@ // the actual replacement until the current frame has fully ended. All Vulkan // work is flushed before destruction. // -// EngineAPI is passed at construction time (not through the interface) so -// scenes are free to choose their own constructor signature. The factory -// pattern (make_surface_sim_scene, make_analysis_scene, ...) gives Engine -// type-erased construction without coupling it to scene headers. +// Legacy note: this interface is retained while older scene components are +// migrated. New simulation runtime code uses ISimulation. #include "math/Scalars.hpp" // f32 +#include "memory/Containers.hpp" +#include +#include +#include #include namespace ndde { +struct ParticleSnapshot { + std::uint64_t id = 0; + std::string role; + std::string label; + float u = 0.f; + float v = 0.f; + float x = 0.f; + float y = 0.f; + float z = 0.f; +}; + +struct SceneSnapshot { + std::string name; + bool paused = false; + float sim_time = 0.f; + float sim_speed = 0.f; + std::size_t particle_count = 0; + std::string status; + memory::FrameVector particles; +}; + class IScene { public: virtual ~IScene() = default; @@ -47,6 +70,24 @@ class IScene { // Short human-readable identifier used in the scene selector UI. [[nodiscard]] virtual std::string_view name() const = 0; + + [[nodiscard]] virtual SceneSnapshot snapshot() const { + return SceneSnapshot{ + .name = std::string(name()), + .paused = paused(), + .status = "Scene" + }; + } + + // Simulation controls. Scenes that own simulation state override these; + // static/read-only scenes can keep the defaults. + virtual void set_paused(bool /*paused*/) {} + [[nodiscard]] virtual bool paused() const noexcept { return false; } + + // Raw key events forwarded by Engine from GLFW after ImGui receives them. + // Parameters are GLFW-style integer key/action/modifier values without + // requiring this interface to include GLFW headers. + virtual void on_key_event(int /*key*/, int /*action*/, int /*mods*/) {} }; } // namespace ndde diff --git a/nurbs_dde/src/engine/PanelService.hpp b/nurbs_dde/src/engine/PanelService.hpp new file mode 100644 index 00000000..6bb2e1b6 --- /dev/null +++ b/nurbs_dde/src/engine/PanelService.hpp @@ -0,0 +1,106 @@ +#pragma once +// engine/PanelService.hpp +// Engine-owned registration surface for UI panels. + +#include "engine/ServiceHandle.hpp" +#include "memory/Containers.hpp" +#include "memory/MemoryService.hpp" + +#include +#include +#include +#include + +namespace ndde { + +using PanelId = u64; +using PanelHandle = ServiceHandle; + +enum class PanelScope : u8 { + Global, + Simulation +}; + +struct PanelDescriptor { + std::string title; + std::string category = "Simulation"; + PanelScope scope = PanelScope::Simulation; + bool initially_open = true; + std::function draw; +}; + +class PanelService { +public: + void set_memory_service(memory::MemoryService* memory) { + std::pmr::memory_resource* resource = memory ? memory->persistent().resource() + : std::pmr::get_default_resource(); + if (resource == m_panels.get_allocator().resource()) + return; + std::destroy_at(&m_panels); + std::construct_at(&m_panels, resource); + } + + [[nodiscard]] PanelHandle register_panel(PanelDescriptor descriptor) { + const PanelId id = m_next_id++; + m_panels.push_back(PanelEntry{ + .id = id, + .descriptor = std::move(descriptor), + .active = true + }); + return PanelHandle([this, id] { unregister(id); }); + } + + void draw_registered_panels(PanelScope scope) { + for (auto& entry : m_panels) { + if (!entry.active || !entry.descriptor.draw) continue; + if (entry.descriptor.scope != scope) continue; + entry.descriptor.draw(); + } + } + + void draw_registered_panels() { + draw_registered_panels(PanelScope::Global); + draw_registered_panels(PanelScope::Simulation); + } + + [[nodiscard]] std::size_t active_count() const noexcept { + return static_cast(std::count_if(m_panels.begin(), m_panels.end(), + [](const PanelEntry& entry) { return entry.active; })); + } + + [[nodiscard]] std::size_t active_count(PanelScope scope) const noexcept { + return static_cast(std::count_if(m_panels.begin(), m_panels.end(), + [scope](const PanelEntry& entry) { + return entry.active && entry.descriptor.scope == scope; + })); + } + + [[nodiscard]] bool contains(std::string_view title) const { + return std::any_of(m_panels.begin(), m_panels.end(), + [title](const PanelEntry& entry) { + return entry.active && entry.descriptor.title == title; + }); + } + +private: + struct PanelEntry { + PanelId id = 0; + PanelDescriptor descriptor; + bool active = false; + }; + + PanelId m_next_id = 1; + memory::PersistentVector m_panels; + + void unregister(PanelId id) noexcept { + for (auto& entry : m_panels) { + if (entry.id == id) { + entry.active = false; + entry.descriptor.draw = {}; + return; + } + } + } +}; + +} // namespace ndde diff --git a/nurbs_dde/src/math/Axes.cpp b/nurbs_dde/src/math/Axes.cpp index 2d30a93b..c446e906 100644 --- a/nurbs_dde/src/math/Axes.cpp +++ b/nurbs_dde/src/math/Axes.cpp @@ -1,5 +1,6 @@ // math/Axes.cpp #include "math/Axes.hpp" +#include "numeric/ops.hpp" #include #include #include @@ -9,7 +10,7 @@ namespace ndde::math { // ── Helpers ─────────────────────────────────────────────────────────────────── static u32 count_lines(float extent, float step) noexcept { - const auto n = static_cast(std::floor(extent / step)); + const auto n = static_cast(ops::floor(extent / step)); return 2u * n; } @@ -39,7 +40,7 @@ void build_grid(std::span out, const AxesConfig& cfg) { }; auto grid_color = [&](float coord) -> Vec4 { - const float rem = std::fmod(std::abs(coord) + eps * 0.5f, cfg.major_step); + const float rem = ops::fmod(ops::abs(coord) + eps * 0.5f, cfg.major_step); return (rem < eps) ? colors::GRID_MAJOR : colors::GRID_MINOR; }; @@ -99,8 +100,8 @@ u32 grid_vp_max_vertices(float vl, float vr, float vb, float vt, // Count of unique integer multiples of minor_step in each axis range, // excluding 0 (the axis itself). Double for ±, double for both axes, 2 verts each. const float safe_step = std::max(minor_step, 1e-6f); - const u32 nx = static_cast(std::ceil((vr - vl) / safe_step)) + 2u; - const u32 ny = static_cast(std::ceil((vt - vb) / safe_step)) + 2u; + const u32 nx = static_cast(ops::ceil((vr - vl) / safe_step)) + 2u; + const u32 ny = static_cast(ops::ceil((vt - vb) / safe_step)) + 2u; return (nx + ny) * 2u; // 2 vertices per line } @@ -118,17 +119,17 @@ u32 build_grid_viewport(std::span out, u32 idx = 0; // Round down to nearest grid line below/left of view - const float x_start = std::floor(vl / minor_step) * minor_step; - const float y_start = std::floor(vb / minor_step) * minor_step; + const float x_start = ops::floor(vl / minor_step) * minor_step; + const float y_start = ops::floor(vb / minor_step) * minor_step; auto is_major = [&](float coord) -> bool { - const float rem = std::fmod(std::abs(coord) + eps * 0.5f, major_step); + const float rem = ops::fmod(ops::abs(coord) + eps * 0.5f, major_step); return rem < eps; }; // Vertical lines (constant X) for (float x = x_start; x <= vr + eps; x += minor_step) { - if (std::abs(x) < eps) continue; // skip the Y-axis line (drawn separately) + if (ops::abs(x) < eps) continue; // skip the Y-axis line (drawn separately) const Vec4 col = is_major(x) ? colors::GRID_MAJOR : colors::GRID_MINOR; out[idx++] = Vertex{ Vec3{ x, vb, 0.f }, col }; out[idx++] = Vertex{ Vec3{ x, vt, 0.f }, col }; @@ -136,7 +137,7 @@ u32 build_grid_viewport(std::span out, // Horizontal lines (constant Y) for (float y = y_start; y <= vt + eps; y += minor_step) { - if (std::abs(y) < eps) continue; // skip the X-axis line + if (ops::abs(y) < eps) continue; // skip the X-axis line const Vec4 col = is_major(y) ? colors::GRID_MAJOR : colors::GRID_MINOR; out[idx++] = Vertex{ Vec3{ vl, y, 0.f }, col }; out[idx++] = Vertex{ Vec3{ vr, y, 0.f }, col }; diff --git a/nurbs_dde/src/math/Conics.cpp b/nurbs_dde/src/math/Conics.cpp index 4b344965..06852bf3 100644 --- a/nurbs_dde/src/math/Conics.cpp +++ b/nurbs_dde/src/math/Conics.cpp @@ -1,5 +1,6 @@ // math/Conics.cpp #include "math/Conics.hpp" +#include "numeric/ops.hpp" #include #include #include @@ -36,24 +37,24 @@ Vec3 IConic::third_derivative(float t) const { float IConic::curvature(float t) const { const Vec3 d1 = derivative(t); const Vec3 d2 = second_derivative(t); - const float len = glm::length(d1); + const float len = ops::length(d1); if (len < 1e-8f) return 0.f; - return glm::length(glm::cross(d1, d2)) / (len * len * len); + return ops::length(ops::cross(d1, d2)) / (len * len * len); } float IConic::torsion(float t) const { const Vec3 d1 = derivative(t); const Vec3 d2 = second_derivative(t); const Vec3 d3 = third_derivative(t); - const Vec3 cross = glm::cross(d1, d2); - const float denom = glm::dot(cross, cross); + const Vec3 cross = ops::cross(d1, d2); + const float denom = ops::dot(cross, cross); if (denom < 1e-12f) return 0.f; - return glm::dot(cross, d3) / denom; + return ops::dot(cross, d3) / denom; } Vec3 IConic::unit_tangent(float t) const { const Vec3 d = derivative(t); - const float len = glm::length(d); + const float len = ops::length(d); if (len < 1e-8f) return Vec3{1.f, 0.f, 0.f}; return d / len; } @@ -61,18 +62,18 @@ Vec3 IConic::unit_tangent(float t) const { Vec3 IConic::unit_normal(float t) const { const Vec3 T = unit_tangent(t); const Vec3 d2 = second_derivative(t); - const Vec3 proj = d2 - glm::dot(d2, T) * T; - const float len = glm::length(proj); + const Vec3 proj = d2 - ops::dot(d2, T) * T; + const float len = ops::length(proj); if (len < 1e-8f) { - Vec3 perp = glm::cross(T, Vec3{0.f, 0.f, 1.f}); - if (glm::length(perp) < 1e-8f) perp = glm::cross(T, Vec3{0.f, 1.f, 0.f}); - return glm::normalize(perp); + Vec3 perp = ops::cross(T, Vec3{0.f, 0.f, 1.f}); + if (ops::length(perp) < 1e-8f) perp = ops::cross(T, Vec3{0.f, 1.f, 0.f}); + return ops::normalize(perp); } return proj / len; } Vec3 IConic::unit_binormal(float t) const { - return glm::normalize(glm::cross(unit_tangent(t), unit_normal(t))); + return ops::normalize(ops::cross(unit_tangent(t), unit_normal(t))); } void IConic::tessellate(std::span out, u32 n, Vec4 color) const { @@ -100,12 +101,12 @@ Vec3 Parabola::third_derivative(float) const { return Vec3{ 0.f, 0.f, 0.f }; float Parabola::curvature(float t) const { const float dy = 2.f * m_a * t + m_b; - const float d = std::pow(1.f + dy * dy, 1.5f); - return (d < 1e-8f) ? 0.f : std::abs(2.f * m_a) / d; + const float d = ops::pow(1.f + dy * dy, 1.5f); + return (d < 1e-8f) ? 0.f : ops::abs(2.f * m_a) / d; } Vec2 Parabola::vertex() const noexcept { - if (std::abs(m_a) < 1e-8f) return Vec2{0.f, m_c}; + if (ops::abs(m_a) < 1e-8f) return Vec2{0.f, m_c}; const float xv = -m_b / (2.f * m_a); return Vec2{ xv, m_a * xv * xv + m_b * xv + m_c }; } @@ -123,23 +124,23 @@ Hyperbola::Hyperbola(float a, float b, float h, float k, Vec3 Hyperbola::eval_branch(float t, bool positive) const noexcept { const float sign = positive ? 1.f : -1.f; if (m_axis == HyperbolaAxis::Horizontal) - return Vec3{ m_h + sign * m_a * std::cosh(t), m_k + m_b * std::sinh(t), 0.f }; + return Vec3{ m_h + sign * m_a * ops::cosh(t), m_k + m_b * ops::sinh(t), 0.f }; else - return Vec3{ m_h + m_b * std::sinh(t), m_k + sign * m_a * std::cosh(t), 0.f }; + return Vec3{ m_h + m_b * ops::sinh(t), m_k + sign * m_a * ops::cosh(t), 0.f }; } Vec3 Hyperbola::evaluate(float t) const { return eval_branch(t, true); } Vec3 Hyperbola::derivative(float t) const { if (m_axis == HyperbolaAxis::Horizontal) - return Vec3{ m_a*std::sinh(t), m_b*std::cosh(t), 0.f }; + return Vec3{ m_a*ops::sinh(t), m_b*ops::cosh(t), 0.f }; else - return Vec3{ m_b*std::cosh(t), m_a*std::sinh(t), 0.f }; + return Vec3{ m_b*ops::cosh(t), m_a*ops::sinh(t), 0.f }; } Vec3 Hyperbola::second_derivative(float t) const { if (m_axis == HyperbolaAxis::Horizontal) - return Vec3{ m_a*std::cosh(t), m_b*std::sinh(t), 0.f }; + return Vec3{ m_a*ops::cosh(t), m_b*ops::sinh(t), 0.f }; else - return Vec3{ m_b*std::sinh(t), m_a*std::cosh(t), 0.f }; + return Vec3{ m_b*ops::sinh(t), m_a*ops::cosh(t), 0.f }; } Vec3 Hyperbola::third_derivative(float t) const { return derivative(t); } @@ -163,17 +164,17 @@ void Hyperbola::tessellate_two_branch(std::span out, u32 n, Vec4 color) // ── Helix ───────────────────────────────────────────────────────────────────── Helix::Helix(float r, float pitch, float tmin, float tmax) - : m_r(r), m_pitch(pitch), m_b(pitch / (2.f * std::numbers::pi_v)), + : m_r(r), m_pitch(pitch), m_b(pitch / ops::two_pi_v), m_tmin(tmin), m_tmax(tmax) { if (r <= 0.f) throw std::invalid_argument("[Helix] radius must be > 0"); if (tmin >= tmax) throw std::invalid_argument("[Helix] t_min must be < t_max"); } -Vec3 Helix::evaluate(float t) const { return Vec3{ m_r*std::cos(t), m_r*std::sin(t), m_b*t }; } -Vec3 Helix::derivative(float t) const { return Vec3{ -m_r*std::sin(t), m_r*std::cos(t), m_b }; } -Vec3 Helix::second_derivative(float t) const { return Vec3{ -m_r*std::cos(t), -m_r*std::sin(t), 0.f }; } -Vec3 Helix::third_derivative(float t) const { return Vec3{ m_r*std::sin(t), -m_r*std::cos(t), 0.f }; } +Vec3 Helix::evaluate(float t) const { return Vec3{ m_r*ops::cos(t), m_r*ops::sin(t), m_b*t }; } +Vec3 Helix::derivative(float t) const { return Vec3{ -m_r*ops::sin(t), m_r*ops::cos(t), m_b }; } +Vec3 Helix::second_derivative(float t) const { return Vec3{ -m_r*ops::cos(t), -m_r*ops::sin(t), 0.f }; } +Vec3 Helix::third_derivative(float t) const { return Vec3{ m_r*ops::sin(t), -m_r*ops::cos(t), 0.f }; } float Helix::curvature(float /*t*/) const { return m_r / (m_r*m_r + m_b*m_b); } float Helix::torsion(float /*t*/) const { return m_b / (m_r*m_r + m_b*m_b); } @@ -195,7 +196,7 @@ float Helix::torsion(float /*t*/) const { return m_b / (m_r*m_r + m_b*m_b ParaboloidCurve::ParaboloidCurve(float a, float theta, float tmin, float tmax) : m_a(a), m_theta(theta), - m_ct(std::cos(theta)), m_st(std::sin(theta)), + m_ct(ops::cos(theta)), m_st(ops::sin(theta)), m_tmin(tmin), m_tmax(tmax) { if (tmin >= tmax) throw std::invalid_argument("[ParaboloidCurve] t_min must be < t_max"); @@ -233,7 +234,7 @@ float ParaboloidCurve::curvature(float t) const { // // κ = 2|a| / (1 + 4a²t²)^(3/2) const float s = 1.f + 4.f * m_a * m_a * t * t; - return 2.f * std::abs(m_a) / std::pow(s, 1.5f); + return 2.f * ops::abs(m_a) / ops::pow(s, 1.5f); } } // namespace ndde::math diff --git a/nurbs_dde/src/math/Conics.hpp b/nurbs_dde/src/math/Conics.hpp index d839732a..2a39d8ba 100644 --- a/nurbs_dde/src/math/Conics.hpp +++ b/nurbs_dde/src/math/Conics.hpp @@ -4,11 +4,11 @@ // Zero-copy design: tessellate() writes directly into GPU-visible memory. #include "math/Scalars.hpp" +#include "numeric/ops.hpp" #include "math/GeometryTypes.hpp" #include #include #include -#include namespace ndde::math { @@ -128,7 +128,7 @@ class Helix final : public IConic { explicit Helix(float r = 1.f, float pitch = 0.5f, float tmin = 0.f, - float tmax = static_cast(4.f * std::numbers::pi)); + float tmax = 4.f * ops::pi_v); [[nodiscard]] Vec3 evaluate(float t) const override; [[nodiscard]] Vec3 derivative(float t) const override; diff --git a/nurbs_dde/src/math/Conics_eval_branch_public.bak b/nurbs_dde/src/math/Conics_eval_branch_public.bak deleted file mode 100644 index 377320ce..00000000 --- a/nurbs_dde/src/math/Conics_eval_branch_public.bak +++ /dev/null @@ -1,9 +0,0 @@ - /// Evaluate a specific branch directly. - /// positive=true → right branch (same as evaluate()) - /// positive=false → left branch - [[nodiscard]] Vec3 eval_branch(float t, bool positive) const noexcept; - -private: - float m_a, m_b, m_h, m_k; - HyperbolaAxis m_axis; - float m_half_range; diff --git a/nurbs_dde/src/math/ExtremumSurface.hpp b/nurbs_dde/src/math/ExtremumSurface.hpp index 3aef68e5..7236eda1 100644 --- a/nurbs_dde/src/math/ExtremumSurface.hpp +++ b/nurbs_dde/src/math/ExtremumSurface.hpp @@ -14,6 +14,7 @@ // surface ideal for the ExtremumTable grid search. #include "math/Surfaces.hpp" +#include "numeric/ops.hpp" #include #include @@ -68,8 +69,8 @@ class ExtremumSurface final : public ISurface { const float r2sq = du2*du2 + dv2*dv2; const float s1 = 2.f * m_p.peak_sigma * m_p.peak_sigma; const float s2 = 2.f * m_p.pit_sigma * m_p.pit_sigma; - return m_p.peak_amp * std::exp(-r1sq / s1) - - m_p.pit_amp * std::exp(-r2sq / s2); + return m_p.peak_amp * ops::exp(-r1sq / s1) + - m_p.pit_amp * ops::exp(-r2sq / s2); } // Analytic df/du @@ -80,8 +81,8 @@ class ExtremumSurface final : public ISurface { const float dv2 = v - m_p.pit_centre.y; const float s1 = 2.f * m_p.peak_sigma * m_p.peak_sigma; const float s2 = 2.f * m_p.pit_sigma * m_p.pit_sigma; - return (-2.f * du1 / s1) * m_p.peak_amp * std::exp(-(du1*du1+dv1*dv1)/s1) - + ( 2.f * du2 / s2) * m_p.pit_amp * std::exp(-(du2*du2+dv2*dv2)/s2); + return (-2.f * du1 / s1) * m_p.peak_amp * ops::exp(-(du1*du1+dv1*dv1)/s1) + + ( 2.f * du2 / s2) * m_p.pit_amp * ops::exp(-(du2*du2+dv2*dv2)/s2); } // Analytic df/dv @@ -92,8 +93,8 @@ class ExtremumSurface final : public ISurface { const float dv2 = v - m_p.pit_centre.y; const float s1 = 2.f * m_p.peak_sigma * m_p.peak_sigma; const float s2 = 2.f * m_p.pit_sigma * m_p.pit_sigma; - return (-2.f * dv1 / s1) * m_p.peak_amp * std::exp(-(du1*du1+dv1*dv1)/s1) - + ( 2.f * dv2 / s2) * m_p.pit_amp * std::exp(-(du2*du2+dv2*dv2)/s2); + return (-2.f * dv1 / s1) * m_p.peak_amp * ops::exp(-(du1*du1+dv1*dv1)/s1) + + ( 2.f * dv2 / s2) * m_p.pit_amp * ops::exp(-(du2*du2+dv2*dv2)/s2); } }; diff --git a/nurbs_dde/src/math/ExtremumTable.cpp b/nurbs_dde/src/math/ExtremumTable.cpp index 085b6cde..1e266207 100644 --- a/nurbs_dde/src/math/ExtremumTable.cpp +++ b/nurbs_dde/src/math/ExtremumTable.cpp @@ -1,6 +1,8 @@ // math/ExtremumTable.cpp #include "math/ExtremumTable.hpp" +#include "numeric/ops.hpp" #include "math/Surfaces.hpp" +#include "numeric/ops.hpp" #include #include #include @@ -39,8 +41,8 @@ void ExtremumTable::build(const ISurface& surface, float t, u32 grid_n) { for (int k = 0; k < iters; ++k) { const Vec3 du_v = surface.du(p.x, p.y, t); const Vec3 dv_v = surface.dv(p.x, p.y, t); - p.x = std::clamp(p.x + step * du_v.z, u0, u1); - p.y = std::clamp(p.y + step * dv_v.z, v0, v1); + p.x = ops::clamp(p.x + step * du_v.z, u0, u1); + p.y = ops::clamp(p.y + step * dv_v.z, v0, v1); } const float z = surface.evaluate(p.x, p.y, t).z; if (z > max_z) { max_uv = p; max_z = z; } @@ -52,8 +54,8 @@ void ExtremumTable::build(const ISurface& surface, float t, u32 grid_n) { for (int k = 0; k < iters; ++k) { const Vec3 du_v = surface.du(p.x, p.y, t); const Vec3 dv_v = surface.dv(p.x, p.y, t); - p.x = std::clamp(p.x - step * du_v.z, u0, u1); - p.y = std::clamp(p.y - step * dv_v.z, v0, v1); + p.x = ops::clamp(p.x - step * du_v.z, u0, u1); + p.y = ops::clamp(p.y - step * dv_v.z, v0, v1); } const float z = surface.evaluate(p.x, p.y, t).z; if (z < min_z) { min_uv = p; min_z = z; } diff --git a/nurbs_dde/src/math/ExtremumTable.hpp b/nurbs_dde/src/math/ExtremumTable.hpp index eeaa54ff..1e6d0176 100644 --- a/nurbs_dde/src/math/ExtremumTable.hpp +++ b/nurbs_dde/src/math/ExtremumTable.hpp @@ -3,7 +3,7 @@ // ExtremumTable: cached locations of the global max and min of a surface. // // Built by a grid search followed by gradient refinement. -// Owned by SurfaceSimScene as a value member (stable address). +// Cached by simulations that need extremum lookup tables. // Non-owning pointers to it are held by LeaderSeekerEquation and // BiasedBrownianLeader -- safe because the scene outlives all particles. diff --git a/nurbs_dde/src/math/SineRationalSurface.hpp b/nurbs_dde/src/math/SineRationalSurface.hpp index fcca3a3b..e88764a0 100644 --- a/nurbs_dde/src/math/SineRationalSurface.hpp +++ b/nurbs_dde/src/math/SineRationalSurface.hpp @@ -33,6 +33,7 @@ // Both are computed exactly — no finite differences. #include "math/Surfaces.hpp" +#include "numeric/ops.hpp" #include namespace ndde::math { @@ -63,8 +64,19 @@ class SineRationalSurface final : public ISurface { [[nodiscard]] bool is_periodic_u() const override { return false; } [[nodiscard]] bool is_periodic_v() const override { return false; } + [[nodiscard]] SurfaceMetadata metadata(float t = 0.f) const override { + SurfaceMetadata data = ISurface::metadata(t); + data.name = "Sine-Rational Surface"; + data.formula = "z=(3/(1+(x+y+1)^2)) sin(2x) cos(2y)+0.1 sin(5x) sin(5y)"; + data.has_analytic_derivatives = true; + data.parameters = {{ + {.name = "extent", .value = m_ext, .description = "square domain half-width"} + }}; + data.parameter_count = 1u; + return data; + } - // ── Public scalar helpers (used by AnalysisScene for confinement) ───────── + // ── Public scalar helpers used by analysis simulations ─────────────────── [[nodiscard]] float height(float u, float v) const noexcept { return f(u, v); } @@ -85,8 +97,8 @@ class SineRationalSurface final : public ISurface { [[nodiscard]] static float f(float x, float y) noexcept { const float s = x + y + 1.f; const float A = 1.f + s * s; - return (3.f / A) * std::sin(2.f * x) * std::cos(2.f * y) - + 0.1f * std::sin(5.f * x) * std::sin(5.f * y); + return (3.f / A) * ops::sin(2.f * x) * ops::cos(2.f * y) + + 0.1f * ops::sin(5.f * x) * ops::sin(5.f * y); } // ── Analytic ∂f/∂x ─────────────────────────────────────────────────────── @@ -99,12 +111,12 @@ class SineRationalSurface final : public ISurface { const float s = x + y + 1.f; const float A = 1.f + s * s; const float A2 = A * A; - const float s2x = std::sin(2.f * x); - const float c2x = std::cos(2.f * x); - const float c2y = std::cos(2.f * y); + const float s2x = ops::sin(2.f * x); + const float c2x = ops::cos(2.f * x); + const float c2y = ops::cos(2.f * y); return (-6.f * s / A2) * s2x * c2y + ( 6.f / A) * c2x * c2y - + 0.5f * std::cos(5.f * x) * std::sin(5.f * y); + + 0.5f * ops::cos(5.f * x) * ops::sin(5.f * y); } // ── Analytic ∂f/∂y ─────────────────────────────────────────────────────── @@ -117,12 +129,12 @@ class SineRationalSurface final : public ISurface { const float s = x + y + 1.f; const float A = 1.f + s * s; const float A2 = A * A; - const float s2x = std::sin(2.f * x); - const float s2y = std::sin(2.f * y); - const float c2y = std::cos(2.f * y); + const float s2x = ops::sin(2.f * x); + const float s2y = ops::sin(2.f * y); + const float c2y = ops::cos(2.f * y); return (-6.f * s / A2) * s2x * c2y + (-6.f / A) * s2x * s2y - + 0.5f * std::sin(5.f * x) * std::cos(5.f * y); + + 0.5f * ops::sin(5.f * x) * ops::cos(5.f * y); } }; diff --git a/nurbs_dde/src/math/Surfaces.cpp b/nurbs_dde/src/math/Surfaces.cpp index 26207159..34fb44c0 100644 --- a/nurbs_dde/src/math/Surfaces.cpp +++ b/nurbs_dde/src/math/Surfaces.cpp @@ -1,14 +1,30 @@ // math/Surfaces.cpp #include "math/Surfaces.hpp" +#include "numeric/ops.hpp" #include #include #include -#include namespace ndde::math { // ── ISurface base ───────────────────────────────────────────────────────────── +SurfaceMetadata ISurface::metadata(float t) const { + return SurfaceMetadata{ + .name = "Generic Surface", + .formula = "p(u,v)", + .domain = SurfaceDomainInfo{ + .u_min = u_min(t), + .u_max = u_max(t), + .v_min = v_min(t), + .v_max = v_max(t) + }, + .has_analytic_derivatives = false, + .deformable = false, + .time_varying = is_time_varying() + }; +} + Vec3 ISurface::du(float u, float v, float t) const { constexpr float h = 1e-4f; return (evaluate(u + h, v, t) - evaluate(u - h, v, t)) / (2.f * h); @@ -20,17 +36,17 @@ Vec3 ISurface::dv(float u, float v, float t) const { } Vec3 ISurface::unit_normal(float u, float v, float t) const { - const Vec3 cross = glm::cross(du(u, v, t), dv(u, v, t)); - const float len = glm::length(cross); + const Vec3 cross = ops::cross(du(u, v, t), dv(u, v, t)); + const float len = ops::length(cross); return (len > 1e-8f) ? cross / len : Vec3{0.f, 0.f, 1.f}; } float ISurface::gaussian_curvature(float u, float v, float t) const { const Vec3 fu = du(u, v, t); const Vec3 fv = dv(u, v, t); - const float E = glm::dot(fu, fu); - const float F = glm::dot(fu, fv); - const float G = glm::dot(fv, fv); + const float E = ops::dot(fu, fu); + const float F = ops::dot(fu, fv); + const float G = ops::dot(fv, fv); constexpr float h = 1e-3f; const Vec3 fuu = (evaluate(u+h,v,t) - 2.f*evaluate(u,v,t) + evaluate(u-h,v,t)) / (h*h); @@ -39,20 +55,20 @@ float ISurface::gaussian_curvature(float u, float v, float t) const { const Vec3 fvv = (evaluate(u,v+h,t) - 2.f*evaluate(u,v,t) + evaluate(u,v-h,t)) / (h*h); const Vec3 n = unit_normal(u, v, t); - const float L = glm::dot(fuu, n); - const float M = glm::dot(fuv, n); - const float N = glm::dot(fvv, n); + const float L = ops::dot(fuu, n); + const float M = ops::dot(fuv, n); + const float N = ops::dot(fvv, n); const float denom = E*G - F*F; - return (std::abs(denom) < 1e-12f) ? 0.f : (L*N - M*M) / denom; + return (ops::abs(denom) < 1e-12f) ? 0.f : (L*N - M*M) / denom; } float ISurface::mean_curvature(float u, float v, float t) const { const Vec3 fu = du(u, v, t); const Vec3 fv = dv(u, v, t); - const float E = glm::dot(fu, fu); - const float F = glm::dot(fu, fv); - const float G = glm::dot(fv, fv); + const float E = ops::dot(fu, fu); + const float F = ops::dot(fu, fv); + const float G = ops::dot(fv, fv); constexpr float h = 1e-3f; const Vec3 fuu = (evaluate(u+h,v,t) - 2.f*evaluate(u,v,t) + evaluate(u-h,v,t)) / (h*h); @@ -61,12 +77,12 @@ float ISurface::mean_curvature(float u, float v, float t) const { const Vec3 fvv = (evaluate(u,v+h,t) - 2.f*evaluate(u,v,t) + evaluate(u,v-h,t)) / (h*h); const Vec3 n = unit_normal(u, v, t); - const float L = glm::dot(fuu, n); - const float M = glm::dot(fuv, n); - const float N = glm::dot(fvv, n); + const float L = ops::dot(fuu, n); + const float M = ops::dot(fuv, n); + const float N = ops::dot(fvv, n); const float denom = 2.f * (E*G - F*F); - return (std::abs(denom) < 1e-12f) ? 0.f : (E*N + G*L - 2.f*F*M) / denom; + return (ops::abs(denom) < 1e-12f) ? 0.f : (E*N + G*L - 2.f*F*M) / denom; } u32 ISurface::wireframe_vertex_count(u32 u_lines, u32 v_lines) const noexcept { @@ -120,21 +136,21 @@ Paraboloid::Paraboloid(float a, float u_max, float vmin, float vmax) } Vec3 Paraboloid::evaluate(float u, float v, float /*t*/) const { - return Vec3{ u * std::cos(v), u * std::sin(v), m_a * u * u }; + return Vec3{ u * ops::cos(v), u * ops::sin(v), m_a * u * u }; } Vec3 Paraboloid::du(float u, float v, float /*t*/) const { - return Vec3{ std::cos(v), std::sin(v), 2.f * m_a * u }; + return Vec3{ ops::cos(v), ops::sin(v), 2.f * m_a * u }; } Vec3 Paraboloid::dv(float u, float v, float /*t*/) const { - return Vec3{ -u * std::sin(v), u * std::cos(v), 0.f }; + return Vec3{ -u * ops::sin(v), u * ops::cos(v), 0.f }; } Vec3 Paraboloid::unit_normal(float u, float v, float /*t*/) const { - if (std::abs(u) < 1e-7f) return Vec3{ 0.f, 0.f, 1.f }; - const float denom = std::abs(u) * std::sqrt(1.f + 4.f*m_a*m_a*u*u); - return Vec3{ -2.f*m_a*u*std::cos(v), -2.f*m_a*u*std::sin(v), u } / denom; + if (ops::abs(u) < 1e-7f) return Vec3{ 0.f, 0.f, 1.f }; + const float denom = ops::abs(u) * ops::sqrt(1.f + 4.f*m_a*m_a*u*u); + return Vec3{ -2.f*m_a*u*ops::cos(v), -2.f*m_a*u*ops::sin(v), u } / denom; } float Paraboloid::gaussian_curvature(float u, float /*v*/, float /*t*/) const { @@ -144,16 +160,29 @@ float Paraboloid::gaussian_curvature(float u, float /*v*/, float /*t*/) const { float Paraboloid::mean_curvature(float u, float /*v*/, float /*t*/) const { const float s = 1.f + 4.f*m_a*m_a*u*u; - return m_a * (2.f + 4.f*m_a*m_a*u*u) / std::pow(s, 1.5f); + return m_a * (2.f + 4.f*m_a*m_a*u*u) / ops::pow(s, 1.5f); } float Paraboloid::kappa1(float u) const noexcept { const float s = 1.f + 4.f*m_a*m_a*u*u; - return 2.f*m_a / std::pow(s, 1.5f); + return 2.f*m_a / ops::pow(s, 1.5f); } float Paraboloid::kappa2(float u) const noexcept { - return 2.f*m_a / std::sqrt(1.f + 4.f*m_a*m_a*u*u); + return 2.f*m_a / ops::sqrt(1.f + 4.f*m_a*m_a*u*u); +} + +SurfaceMetadata Paraboloid::metadata(float t) const { + SurfaceMetadata data = ISurface::metadata(t); + data.name = "Paraboloid"; + data.formula = "z = a r^2"; + data.has_analytic_derivatives = true; + data.parameters = {{ + {.name = "a", .value = m_a, .description = "curvature scale"}, + {.name = "u_max", .value = m_umax, .description = "radial domain extent"} + }}; + data.parameter_count = 2u; + return data; } // ── Torus ───────────────────────────────────────────────────────────────────── @@ -167,39 +196,52 @@ Torus::Torus(float R, float r) } Vec3 Torus::evaluate(float u, float v, float /*t*/) const { - const float cu = std::cos(u), su = std::sin(u); - const float cv = std::cos(v), sv = std::sin(v); + const float cu = ops::cos(u), su = ops::sin(u); + const float cv = ops::cos(v), sv = ops::sin(v); const float rho = m_R + m_r * cv; return Vec3{ rho * cu, rho * su, m_r * sv }; } Vec3 Torus::du(float u, float v, float /*t*/) const { - const float rho = m_R + m_r * std::cos(v); - return Vec3{ -rho * std::sin(u), rho * std::cos(u), 0.f }; + const float rho = m_R + m_r * ops::cos(v); + return Vec3{ -rho * ops::sin(u), rho * ops::cos(u), 0.f }; } Vec3 Torus::dv(float u, float v, float /*t*/) const { - const float sv = std::sin(v), cv = std::cos(v); - const float cu = std::cos(u), su = std::sin(u); + const float sv = ops::sin(v), cv = ops::cos(v); + const float cu = ops::cos(u), su = ops::sin(u); return Vec3{ -m_r * sv * cu, -m_r * sv * su, m_r * cv }; } Vec3 Torus::unit_normal(float u, float v, float /*t*/) const { - return Vec3{ std::cos(v)*std::cos(u), std::cos(v)*std::sin(u), std::sin(v) }; + return Vec3{ ops::cos(v)*ops::cos(u), ops::cos(v)*ops::sin(u), ops::sin(v) }; } float Torus::gaussian_curvature(float /*u*/, float v, float /*t*/) const { - const float cv = std::cos(v); + const float cv = ops::cos(v); const float rho = m_R + m_r * cv; - if (std::abs(rho) < 1e-9f) return 0.f; + if (ops::abs(rho) < 1e-9f) return 0.f; return cv / (m_r * rho); } float Torus::mean_curvature(float /*u*/, float v, float /*t*/) const { - const float cv = std::cos(v); + const float cv = ops::cos(v); const float rho = m_R + m_r * cv; - if (std::abs(rho) < 1e-9f) return 0.f; + if (ops::abs(rho) < 1e-9f) return 0.f; return (m_R + 2.f * m_r * cv) / (2.f * m_r * rho); } +SurfaceMetadata Torus::metadata(float t) const { + SurfaceMetadata data = ISurface::metadata(t); + data.name = "Torus"; + data.formula = "((R + r cos v) cos u, (R + r cos v) sin u, r sin v)"; + data.has_analytic_derivatives = true; + data.parameters = {{ + {.name = "R", .value = m_R, .description = "major radius"}, + {.name = "r", .value = m_r, .description = "minor radius"} + }}; + data.parameter_count = 2u; + return data; +} + } // namespace ndde::math diff --git a/nurbs_dde/src/math/Surfaces.hpp b/nurbs_dde/src/math/Surfaces.hpp index 7912ce08..b6fcfd44 100644 --- a/nurbs_dde/src/math/Surfaces.hpp +++ b/nurbs_dde/src/math/Surfaces.hpp @@ -17,12 +17,39 @@ #include "math/Scalars.hpp" #include "math/GeometryTypes.hpp" +#include #include #include #include +#include namespace ndde::math { +struct SurfaceDomainInfo { + float u_min = 0.f; + float u_max = 0.f; + float v_min = 0.f; + float v_max = 0.f; +}; + +struct SurfaceParameterInfo { + std::string_view name; + float value = 0.f; + std::string_view units; + std::string_view description; +}; + +struct SurfaceMetadata { + std::string_view name = "Unnamed Surface"; + std::string_view formula; + SurfaceDomainInfo domain{}; + bool has_analytic_derivatives = false; + bool deformable = false; + bool time_varying = false; + std::array parameters{}; + u32 parameter_count = 0; +}; + // ── ISurface ────────────────────────────────────────────────────────────────── // Smooth parametric surface p : D x R -> R^3. // The time parameter t defaults to 0.f; static surfaces ignore it. @@ -49,6 +76,8 @@ class ISurface { // on frames where the surface has not changed. [[nodiscard]] virtual bool is_time_varying() const { return false; } + [[nodiscard]] virtual SurfaceMetadata metadata(float t = 0.f) const; + // First-order partial derivatives (default: central finite difference). // Override with analytic formulas for efficiency and accuracy. [[nodiscard]] virtual Vec3 du(float u, float v, float t = 0.f) const; @@ -103,6 +132,7 @@ class Paraboloid final : public ISurface { [[nodiscard]] float v_max(float = 0.f) const override { return m_vmax; } [[nodiscard]] float a() const noexcept { return m_a; } + [[nodiscard]] SurfaceMetadata metadata(float t = 0.f) const override; // Analytic overrides [[nodiscard]] Vec3 unit_normal(float u, float v, float t = 0.f) const; @@ -147,6 +177,7 @@ class Torus final : public ISurface { [[nodiscard]] float R() const noexcept { return m_R; } [[nodiscard]] float r() const noexcept { return m_r; } + [[nodiscard]] SurfaceMetadata metadata(float t = 0.f) const override; [[nodiscard]] Vec3 unit_normal(float u, float v, float t = 0.f) const; [[nodiscard]] float gaussian_curvature(float u, float v, float t = 0.f) const; @@ -179,6 +210,12 @@ class IDeformableSurface : public ISurface { public: [[nodiscard]] bool is_time_varying() const override { return true; } [[nodiscard]] float time() const noexcept { return m_time; } + [[nodiscard]] SurfaceMetadata metadata(float t = 0.f) const override { + SurfaceMetadata data = ISurface::metadata(t); + data.deformable = true; + data.time_varying = true; + return data; + } // Tick the surface clock. Override for PDE-driven surfaces. virtual void advance(float dt) { m_time += dt; } diff --git a/nurbs_dde/src/numeric/MathTraits.hpp b/nurbs_dde/src/numeric/MathTraits.hpp index 3bb0b7be..95bc6663 100644 --- a/nurbs_dde/src/numeric/MathTraits.hpp +++ b/nurbs_dde/src/numeric/MathTraits.hpp @@ -49,10 +49,43 @@ #include "math/Scalars.hpp" #include "numeric/Constants.hpp" +#include "numeric/math_config.hpp" #include namespace ndde::numeric { +namespace detail { + +template +[[nodiscard]] inline T reduce_to_half_pi(T x) noexcept { + const T pi = Constants::PI; + const T two_pi = Constants::TWO_PI; + x = std::fmod(x, two_pi); + if (x > pi) x -= two_pi; + if (x < -pi) x += two_pi; + const T half_pi = pi / T{2}; + if (x > half_pi) return pi - x; + if (x < -half_pi) return -pi - x; + return x; +} + +template +[[nodiscard]] inline T taylor_sin_19(T x) noexcept { + const T y = reduce_to_half_pi(x); + const T y2 = y * y; + T term = y; + T sum = y; + for (int n = 1; n <= 9; ++n) { + const T a = static_cast(2 * n); + const T b = static_cast(2 * n + 1); + term *= -y2 / (a * b); + sum += term; + } + return sum; +} + +} // namespace detail + template struct MathTraits { // ── Lipschitz constants ─────────────────────────────────────────────────── @@ -69,7 +102,13 @@ struct MathTraits { // ── Trigonometric functions ─────────────────────────────────────────────── [[nodiscard]] static T cos(T x) noexcept { return std::cos(x); } - [[nodiscard]] static T sin(T x) noexcept { return std::sin(x); } + [[nodiscard]] static T sin(T x) noexcept { + if constexpr (!ndde::use_builtin_math && ndde::use_taylor_sin) + return taylor_sin(x); + else + return std::sin(x); + } + [[nodiscard]] static T taylor_sin(T x) noexcept { return detail::taylor_sin_19(x); } [[nodiscard]] static T tan(T x) noexcept { return std::tan(x); } [[nodiscard]] static T acos(T x) noexcept { return std::acos(x); } [[nodiscard]] static T asin(T x) noexcept { return std::asin(x); } diff --git a/nurbs_dde/src/numeric/math_config.hpp b/nurbs_dde/src/numeric/math_config.hpp index 8d742b71..ea57acf9 100644 --- a/nurbs_dde/src/numeric/math_config.hpp +++ b/nurbs_dde/src/numeric/math_config.hpp @@ -27,10 +27,10 @@ // convert it to a constexpr bool. Everywhere else in the codebase, use the // constexpr bool — never the macro directly. // -// ── Current status ──────────────────────────────────────────────────────────── -// Both paths currently delegate to std:: / GLM. The ndde path will diverge -// as MathTraits receives in-house Taylor / CORDIC implementations, which will -// be validated against the std:: oracle by running tests with both flags. +// ── Approximation switches ─────────────────────────────────────────────────── +// NDDE_USE_TAYLOR_SIN=1 routes MathTraits::sin through the in-house Taylor +// implementation when NDDE_USE_BUILTIN_MATH is OFF. The approximation remains +// callable directly either way as MathTraits::taylor_sin(x). namespace ndde { @@ -43,4 +43,12 @@ inline constexpr bool use_builtin_math = false; #endif +/// True → route numeric sine through the in-house Taylor approximation. +inline constexpr bool use_taylor_sin = +#if defined(NDDE_USE_TAYLOR_SIN) && NDDE_USE_TAYLOR_SIN + true; +#else + false; +#endif + } // namespace ndde diff --git a/nurbs_dde/src/numeric/ops.hpp b/nurbs_dde/src/numeric/ops.hpp index aea70c10..87c4862a 100644 --- a/nurbs_dde/src/numeric/ops.hpp +++ b/nurbs_dde/src/numeric/ops.hpp @@ -5,14 +5,14 @@ // // ── Purpose ─────────────────────────────────────────────────────────────────── // This header provides a stable API surface: -// ndde::ops::cos(x) — scalar trig -// ndde::ops::length(v) — vector norm -// ndde::ops::cross(u, v) — exterior product +// ndde::ops::cos(x) - scalar trig +// ndde::ops::length(v) - vector norm +// ndde::ops::cross(u, v) - exterior product // ... etc. // // Under the hood it dispatches to either: // ndde::numeric::MathTraits (NDDE_USE_BUILTIN_MATH = 0, default) -// std:: / GLM (NDDE_USE_BUILTIN_MATH = 1, oracle) +// platform math libraries (NDDE_USE_BUILTIN_MATH = 1, oracle) // // Because both paths ultimately call the same functions right now, the // behavioural difference is zero. The value is the seam: when MathTraits @@ -20,19 +20,23 @@ // caller gets the new implementation automatically. // // ── Migration note ──────────────────────────────────────────────────────────── -// The math/*.cpp files currently call std:: and glm:: directly. To migrate: +// The math/*.cpp files should call this facade directly. To migrate: // 1. Replace #include with #include "numeric/ops.hpp" -// 2. Replace std::cos(x) with ops::cos(x) -// 3. Replace glm::length(v) with ops::length(v) +// 2. Replace raw scalar calls with ops::cos(x) +// 3. Replace raw vector calls with ops::length(v) // ... and so on. // The math/*.hpp files should not change — they use the types from Scalars.hpp. #include "numeric/math_config.hpp" #include "numeric/MathTraits.hpp" #include "numeric/Vec3Ops.hpp" +#include namespace ndde::ops { +template inline constexpr T pi_v = numeric::Constants::PI; +template inline constexpr T two_pi_v = numeric::Constants::TWO_PI; + // ── Scalar ops ──────────────────────────────────────────────────────────────── // All templated on T; specialise for f32 or f64. @@ -41,6 +45,7 @@ template [[nodiscard]] inline T sin(T x) noexcept { return numeric: template [[nodiscard]] inline T tan(T x) noexcept { return numeric::MathTraits::tan(x); } template [[nodiscard]] inline T acos(T x) noexcept { return numeric::MathTraits::acos(x); } template [[nodiscard]] inline T asin(T x) noexcept { return numeric::MathTraits::asin(x); } +template [[nodiscard]] inline T atan(T x) noexcept { return numeric::MathTraits::atan(x); } template [[nodiscard]] inline T atan2(T y, T x) noexcept { return numeric::MathTraits::atan2(y, x); } template [[nodiscard]] inline T cosh(T x) noexcept { return numeric::MathTraits::cosh(x); } template [[nodiscard]] inline T sinh(T x) noexcept { return numeric::MathTraits::sinh(x); } @@ -76,6 +81,10 @@ template [[nodiscard]] inline T length(const numeric::GlmVec3& v) noexcept { return numeric::Vec3Ops::length(v); } +template +[[nodiscard]] inline T length(const glm::vec& v) noexcept +{ return glm::length(v); } + template [[nodiscard]] inline T length_sq(const numeric::GlmVec3& v) noexcept { return numeric::Vec3Ops::length_sq(v); } diff --git a/nurbs_dde/src/renderer/Pipeline.cpp b/nurbs_dde/src/renderer/Pipeline.cpp index 810fe74a..9c9871ec 100644 --- a/nurbs_dde/src/renderer/Pipeline.cpp +++ b/nurbs_dde/src/renderer/Pipeline.cpp @@ -54,7 +54,7 @@ void Pipeline::init(VkDevice device, VkFormat color_format, .polygonMode = VK_POLYGON_MODE_FILL, .cullMode = VK_CULL_MODE_NONE, .frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE, - .lineWidth = 1.5f + .lineWidth = 1.0f }; VkPipelineMultisampleStateCreateInfo msaa{ diff --git a/nurbs_dde/src/renderer/Renderer.cpp b/nurbs_dde/src/renderer/Renderer.cpp index 57dd1173..71d7b004 100644 --- a/nurbs_dde/src/renderer/Renderer.cpp +++ b/nurbs_dde/src/renderer/Renderer.cpp @@ -1,8 +1,10 @@ // renderer/Renderer.cpp #include "renderer/Renderer.hpp" +#include "renderer/PngWriter.hpp" #include #include #include +#include #define GLFW_INCLUDE_NONE #include @@ -18,6 +20,7 @@ void Renderer::init(const platform::VulkanContext& ctx, GLFWwindow* window) { m_device = ctx.device(); + m_physical_device = ctx.physical_device(); m_graphics_queue = ctx.graphics_queue(); m_present_queue = ctx.present_queue(); @@ -51,9 +54,14 @@ void Renderer::destroy() { m_render_fence = VK_NULL_HANDLE; m_cmd_pool = VK_NULL_HANDLE; m_cmd = VK_NULL_HANDLE; + m_physical_device = VK_NULL_HANDLE; m_device = VK_NULL_HANDLE; } +void Renderer::request_png_capture(std::filesystem::path path) { + m_pending_capture = std::move(path); +} + bool Renderer::begin_frame(const Swapchain& swapchain) { vkWaitForFences(m_device, 1, &m_render_fence, VK_TRUE, UINT64_MAX); @@ -152,9 +160,73 @@ bool Renderer::end_frame(const Swapchain& swapchain) { vkCmdEndRendering(m_cmd); - transition_image(swapchain.images()[m_image_index], - VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, - VK_IMAGE_LAYOUT_PRESENT_SRC_KHR); + struct CaptureReadback { + VkBuffer buffer = VK_NULL_HANDLE; + VkDeviceMemory memory = VK_NULL_HANDLE; + std::filesystem::path path; + } capture; + + const bool do_capture = m_pending_capture.has_value(); + if (do_capture) { + const VkExtent2D ext = swapchain.extent(); + const VkDeviceSize bytes = static_cast(ext.width) * ext.height * 4u; + capture.path = std::move(*m_pending_capture); + m_pending_capture.reset(); + + VkBufferCreateInfo bi{ + .sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO, + .size = bytes, + .usage = VK_BUFFER_USAGE_TRANSFER_DST_BIT, + .sharingMode = VK_SHARING_MODE_EXCLUSIVE + }; + if (vkCreateBuffer(m_device, &bi, nullptr, &capture.buffer) != VK_SUCCESS) + throw std::runtime_error("[Renderer] capture vkCreateBuffer failed"); + + VkMemoryRequirements req{}; + vkGetBufferMemoryRequirements(m_device, capture.buffer, &req); + VkMemoryAllocateInfo ai{ + .sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO, + .allocationSize = req.size, + .memoryTypeIndex = find_memory_type(req.memoryTypeBits, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT) + }; + if (vkAllocateMemory(m_device, &ai, nullptr, &capture.memory) != VK_SUCCESS) { + vkDestroyBuffer(m_device, capture.buffer, nullptr); + throw std::runtime_error("[Renderer] capture vkAllocateMemory failed"); + } + if (vkBindBufferMemory(m_device, capture.buffer, capture.memory, 0) != VK_SUCCESS) { + vkFreeMemory(m_device, capture.memory, nullptr); + vkDestroyBuffer(m_device, capture.buffer, nullptr); + throw std::runtime_error("[Renderer] capture vkBindBufferMemory failed"); + } + + transition_image(swapchain.images()[m_image_index], + VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, + VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL); + VkBufferImageCopy region{ + .bufferOffset = 0, + .bufferRowLength = 0, + .bufferImageHeight = 0, + .imageSubresource = { + .aspectMask = VK_IMAGE_ASPECT_COLOR_BIT, + .mipLevel = 0, + .baseArrayLayer = 0, + .layerCount = 1 + }, + .imageOffset = {0, 0, 0}, + .imageExtent = {ext.width, ext.height, 1} + }; + vkCmdCopyImageToBuffer(m_cmd, swapchain.images()[m_image_index], + VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + capture.buffer, 1, ®ion); + transition_image(swapchain.images()[m_image_index], + VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + VK_IMAGE_LAYOUT_PRESENT_SRC_KHR); + } else { + transition_image(swapchain.images()[m_image_index], + VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, + VK_IMAGE_LAYOUT_PRESENT_SRC_KHR); + } if (vkEndCommandBuffer(m_cmd) != VK_SUCCESS) throw std::runtime_error("[Renderer] vkEndCommandBuffer failed"); @@ -175,6 +247,30 @@ bool Renderer::end_frame(const Swapchain& swapchain) { if (vkQueueSubmit(m_graphics_queue, 1, &submit, m_render_fence) != VK_SUCCESS) throw std::runtime_error("[Renderer] vkQueueSubmit failed"); + if (do_capture) { + vkWaitForFences(m_device, 1, &m_render_fence, VK_TRUE, UINT64_MAX); + const VkExtent2D ext = swapchain.extent(); + const std::size_t pixel_count = static_cast(ext.width) * ext.height; + const std::size_t byte_count = pixel_count * 4u; + void* mapped = nullptr; + if (vkMapMemory(m_device, capture.memory, 0, static_cast(byte_count), 0, &mapped) != VK_SUCCESS) + throw std::runtime_error("[Renderer] capture vkMapMemory failed"); + + std::vector rgba(byte_count); + const auto* bgra = static_cast(mapped); + for (std::size_t i = 0; i < pixel_count; ++i) { + rgba[i * 4u + 0u] = bgra[i * 4u + 2u]; + rgba[i * 4u + 1u] = bgra[i * 4u + 1u]; + rgba[i * 4u + 2u] = bgra[i * 4u + 0u]; + rgba[i * 4u + 3u] = bgra[i * 4u + 3u]; + } + vkUnmapMemory(m_device, capture.memory); + write_png_rgba8(capture.path, ext.width, ext.height, rgba); + vkFreeMemory(m_device, capture.memory, nullptr); + vkDestroyBuffer(m_device, capture.buffer, nullptr); + std::cout << "[Renderer] Wrote capture: " << capture.path.string() << "\n"; + } + VkSwapchainKHR sc = swapchain.swapchain(); VkPresentInfoKHR present{ .sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR, @@ -199,7 +295,7 @@ void Renderer::on_swapchain_recreated(const Swapchain& swapchain) { } void Renderer::reset_frame_state() { - // Called after vkDeviceWaitIdle (guaranteed by switch_scene). + // Called after vkDeviceWaitIdle when the active simulation changes. // Destroy and recreate both semaphore vectors so every slot is // cleanly unsignaled before the new scene's first acquire. const VkSemaphoreCreateInfo si{ .sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO }; @@ -310,6 +406,17 @@ void Renderer::transition_image(VkImage image, VkImageLayout from, VkImageLayout 0, 0, nullptr, 0, nullptr, 1, &barrier); } +u32 Renderer::find_memory_type(u32 type_filter, VkMemoryPropertyFlags props) const { + VkPhysicalDeviceMemoryProperties mem_props{}; + vkGetPhysicalDeviceMemoryProperties(m_physical_device, &mem_props); + for (u32 i = 0; i < mem_props.memoryTypeCount; ++i) { + if ((type_filter & (1u << i)) && + (mem_props.memoryTypes[i].propertyFlags & props) == props) + return i; + } + throw std::runtime_error("[Renderer] No suitable capture memory type found"); +} + Pipeline& Renderer::pipeline_for(Topology topo) { switch (topo) { case Topology::LineList: return m_pipeline_line_list; diff --git a/nurbs_dde/src/renderer/Renderer.hpp b/nurbs_dde/src/renderer/Renderer.hpp index a9a32d73..c45fee69 100644 --- a/nurbs_dde/src/renderer/Renderer.hpp +++ b/nurbs_dde/src/renderer/Renderer.hpp @@ -7,10 +7,12 @@ #include "renderer/Pipeline.hpp" #include "renderer/ImGuiLayer.hpp" #include "memory/ArenaSlice.hpp" +#include "memory/Containers.hpp" #include "renderer/GpuTypes.hpp" #include +#include +#include #include -#include struct GLFWwindow; @@ -46,6 +48,7 @@ class Renderer { void imgui_render() { m_imgui.render(m_cmd); } [[nodiscard]] bool end_frame(const Swapchain& swapchain); void on_swapchain_recreated(const Swapchain& swapchain); + void request_png_capture(std::filesystem::path path); // Call after vkDeviceWaitIdle (e.g. during scene switch) to put all // per-frame sync objects back into a clean known state. This destroys @@ -63,6 +66,7 @@ class Renderer { private: VkDevice m_device = VK_NULL_HANDLE; + VkPhysicalDevice m_physical_device = VK_NULL_HANDLE; VkQueue m_graphics_queue = VK_NULL_HANDLE; VkQueue m_present_queue = VK_NULL_HANDLE; VkCommandPool m_cmd_pool = VK_NULL_HANDLE; @@ -70,8 +74,8 @@ class Renderer { VkFence m_render_fence = VK_NULL_HANDLE; // One semaphore per swapchain image prevents reuse-before-consumed races // for both the acquire signal and the render-finished signal. - std::vector m_image_available; ///< signalled by vkAcquireNextImageKHR - std::vector m_render_finished; ///< signalled by vkQueueSubmit, waited by present + memory::PersistentVector m_image_available; ///< signalled by vkAcquireNextImageKHR + memory::PersistentVector m_render_finished; ///< signalled by vkQueueSubmit, waited by present u32 m_image_index = 0; u32 m_sync_index = 0; u32 m_frame_sync = 0; @@ -85,11 +89,13 @@ class Renderer { Pipeline m_pipeline_line_strip; Pipeline m_pipeline_triangle_list; ImGuiLayer m_imgui; + std::optional m_pending_capture; void create_command_objects(u32 graphics_queue_family); void create_sync_objects(u32 image_count); void init_pipelines(VkFormat color_format, const std::string& shader_dir); void transition_image(VkImage image, VkImageLayout from, VkImageLayout to); + [[nodiscard]] u32 find_memory_type(u32 type_filter, VkMemoryPropertyFlags props) const; [[nodiscard]] Pipeline& pipeline_for(Topology topo); }; diff --git a/nurbs_dde/src/renderer/SecondWindow.cpp b/nurbs_dde/src/renderer/SecondWindow.cpp index 576b20b4..ee245cc2 100644 --- a/nurbs_dde/src/renderer/SecondWindow.cpp +++ b/nurbs_dde/src/renderer/SecondWindow.cpp @@ -19,7 +19,8 @@ SecondWindow::~SecondWindow() { destroy(); } void SecondWindow::init(const platform::VulkanContext& ctx, int x, int y, u32 width, u32 height, const std::string& title, - const std::string& shader_dir) + const std::string& shader_dir, + bool vsync) { m_instance = ctx.instance(); m_physical_device = ctx.physical_device(); @@ -27,6 +28,7 @@ void SecondWindow::init(const platform::VulkanContext& ctx, m_gfx_queue = ctx.graphics_queue(); m_present_queue = ctx.present_queue(); m_gfx_family = ctx.queue_families().graphics; + m_vsync = vsync; // GLFW window — glfwInit already called by primary GlfwContext. glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API); @@ -241,7 +243,7 @@ void SecondWindow::build_swapchain(u32 w, u32 h) { vkb::SwapchainBuilder builder{ m_physical_device, m_device, m_surface }; auto sc_ret = builder .set_desired_format({ VK_FORMAT_B8G8R8A8_UNORM, VK_COLOR_SPACE_SRGB_NONLINEAR_KHR }) - .set_desired_present_mode(VK_PRESENT_MODE_FIFO_KHR) + .set_desired_present_mode(m_vsync ? VK_PRESENT_MODE_FIFO_KHR : VK_PRESENT_MODE_MAILBOX_KHR) .set_desired_extent(w, h) .add_image_usage_flags(VK_IMAGE_USAGE_TRANSFER_DST_BIT) .build(); @@ -252,10 +254,13 @@ void SecondWindow::build_swapchain(u32 w, u32 h) { m_sc_raw = vkb_sc.swapchain; m_sc_format = vkb_sc.image_format; m_sc_extent = vkb_sc.extent; - m_sc_images = vkb_sc.get_images().value(); - m_sc_views = vkb_sc.get_image_views().value(); - std::cout << std::format("[SecondWindow] Swapchain {}x{} images:{}\n", - m_sc_extent.width, m_sc_extent.height, m_sc_images.size()); + const auto images = vkb_sc.get_images().value(); + const auto views = vkb_sc.get_image_views().value(); + m_sc_images.assign(images.begin(), images.end()); + m_sc_views.assign(views.begin(), views.end()); + std::cout << std::format("[SecondWindow] Swapchain {}x{} images:{} present:{}\n", + m_sc_extent.width, m_sc_extent.height, m_sc_images.size(), + m_vsync ? "fifo" : "mailbox"); } void SecondWindow::destroy_swapchain() { diff --git a/nurbs_dde/src/renderer/SecondWindow.hpp b/nurbs_dde/src/renderer/SecondWindow.hpp index 00124f3e..e1fd02a8 100644 --- a/nurbs_dde/src/renderer/SecondWindow.hpp +++ b/nurbs_dde/src/renderer/SecondWindow.hpp @@ -3,12 +3,12 @@ // A second OS window with its own Vulkan surface + swapchain, // sharing the VkDevice/queues/pipelines of the primary window. +#include "memory/Containers.hpp" #include "renderer/GpuTypes.hpp" #include "renderer/Pipeline.hpp" #include #include #include -#include struct GLFWwindow; namespace ndde::platform { class VulkanContext; } @@ -29,7 +29,8 @@ class SecondWindow { void init(const platform::VulkanContext& ctx, int x, int y, u32 width, u32 height, const std::string& title, - const std::string& shader_dir); + const std::string& shader_dir, + bool vsync); void destroy(); @@ -59,21 +60,22 @@ class SecondWindow { VkSwapchainKHR m_sc_raw = VK_NULL_HANDLE; VkFormat m_sc_format = VK_FORMAT_UNDEFINED; VkExtent2D m_sc_extent = {}; - std::vector m_sc_images; - std::vector m_sc_views; + memory::PersistentVector m_sc_images; + memory::PersistentVector m_sc_views; VkCommandPool m_cmd_pool = VK_NULL_HANDLE; VkCommandBuffer m_cmd = VK_NULL_HANDLE; VkFence m_render_fence = VK_NULL_HANDLE; // One semaphore per swapchain image — prevents reuse-before-consumed races // for both acquire and render-finished signals. - std::vector m_image_available; - std::vector m_render_finished; + memory::PersistentVector m_image_available; + memory::PersistentVector m_render_finished; u32 m_image_index = 0; u32 m_sync_index = 0; u32 m_frame_sync = 0; bool m_frame_open = false; bool m_initialised = false; + bool m_vsync = true; Pipeline m_pipeline_line_list; Pipeline m_pipeline_line_strip; diff --git a/nurbs_dde/src/renderer/Swapchain.cpp b/nurbs_dde/src/renderer/Swapchain.cpp index 8bcbc34b..3fb133b8 100644 --- a/nurbs_dde/src/renderer/Swapchain.cpp +++ b/nurbs_dde/src/renderer/Swapchain.cpp @@ -8,16 +8,18 @@ namespace ndde::renderer { Swapchain::~Swapchain() { destroy(); } -void Swapchain::init(const platform::VulkanContext& ctx, u32 width, u32 height) { +void Swapchain::init(const platform::VulkanContext& ctx, u32 width, u32 height, bool vsync) { m_device = ctx.device(); vkb::SwapchainBuilder builder{ ctx.physical_device(), ctx.device(), ctx.surface() }; + const VkPresentModeKHR present_mode = + vsync ? VK_PRESENT_MODE_FIFO_KHR : VK_PRESENT_MODE_MAILBOX_KHR; auto sc_ret = builder .set_desired_format({ VK_FORMAT_B8G8R8A8_UNORM, VK_COLOR_SPACE_SRGB_NONLINEAR_KHR }) - .set_desired_present_mode(VK_PRESENT_MODE_FIFO_KHR) + .set_desired_present_mode(present_mode) .set_desired_extent(width, height) - .add_image_usage_flags(VK_IMAGE_USAGE_TRANSFER_DST_BIT) + .add_image_usage_flags(VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT) .build(); if (!sc_ret) @@ -27,16 +29,19 @@ void Swapchain::init(const platform::VulkanContext& ctx, u32 width, u32 height) m_swapchain = vkb_sc.swapchain; m_format = vkb_sc.image_format; m_extent = vkb_sc.extent; - m_images = vkb_sc.get_images().value(); - m_image_views = vkb_sc.get_image_views().value(); - - std::cout << std::format("[Swapchain] {}x{} images:{} format:{}\n", - m_extent.width, m_extent.height, m_images.size(), static_cast(m_format)); + const auto images = vkb_sc.get_images().value(); + const auto image_views = vkb_sc.get_image_views().value(); + m_images.assign(images.begin(), images.end()); + m_image_views.assign(image_views.begin(), image_views.end()); + + std::cout << std::format("[Swapchain] {}x{} images:{} format:{} present:{}\n", + m_extent.width, m_extent.height, m_images.size(), static_cast(m_format), + vsync ? "fifo" : "mailbox"); } -void Swapchain::recreate(const platform::VulkanContext& ctx, u32 width, u32 height) { +void Swapchain::recreate(const platform::VulkanContext& ctx, u32 width, u32 height, bool vsync) { destroy(); - init(ctx, width, height); + init(ctx, width, height, vsync); } void Swapchain::destroy() { diff --git a/nurbs_dde/src/renderer/Swapchain.hpp b/nurbs_dde/src/renderer/Swapchain.hpp index 4e43a308..d3b1b7a2 100644 --- a/nurbs_dde/src/renderer/Swapchain.hpp +++ b/nurbs_dde/src/renderer/Swapchain.hpp @@ -5,9 +5,9 @@ #include #include +#include "memory/Containers.hpp" #include "platform/VulkanContext.hpp" #include "renderer/GpuTypes.hpp" -#include namespace ndde::renderer { @@ -19,15 +19,15 @@ class Swapchain { Swapchain(const Swapchain&) = delete; Swapchain& operator=(const Swapchain&) = delete; - void init(const platform::VulkanContext& ctx, u32 width, u32 height); - void recreate(const platform::VulkanContext& ctx, u32 width, u32 height); + void init(const platform::VulkanContext& ctx, u32 width, u32 height, bool vsync); + void recreate(const platform::VulkanContext& ctx, u32 width, u32 height, bool vsync); void destroy(); [[nodiscard]] VkSwapchainKHR swapchain() const noexcept { return m_swapchain; } [[nodiscard]] VkFormat format() const noexcept { return m_format; } [[nodiscard]] VkExtent2D extent() const noexcept { return m_extent; } - [[nodiscard]] const std::vector& images() const noexcept { return m_images; } - [[nodiscard]] const std::vector& image_views() const noexcept { return m_image_views; } + [[nodiscard]] const memory::PersistentVector& images() const noexcept { return m_images; } + [[nodiscard]] const memory::PersistentVector& image_views() const noexcept { return m_image_views; } [[nodiscard]] u32 image_count() const noexcept { return static_cast(m_images.size()); } private: @@ -35,8 +35,8 @@ class Swapchain { VkSwapchainKHR m_swapchain = VK_NULL_HANDLE; VkFormat m_format = VK_FORMAT_UNDEFINED; VkExtent2D m_extent = {}; - std::vector m_images; - std::vector m_image_views; + memory::PersistentVector m_images; + memory::PersistentVector m_image_views; void destroy_image_views(); }; diff --git a/nurbs_dde/src/sim/BiasedBrownianLeader.hpp b/nurbs_dde/src/sim/BiasedBrownianLeader.hpp index 5b459612..b024e952 100644 --- a/nurbs_dde/src/sim/BiasedBrownianLeader.hpp +++ b/nurbs_dde/src/sim/BiasedBrownianLeader.hpp @@ -27,7 +27,9 @@ // goal-switching state must persist across velocity() calls. #include "sim/IEquation.hpp" +#include "numeric/ops.hpp" #include "math/ExtremumTable.hpp" +#include "numeric/ops.hpp" #include "sim/LeaderSeekerEquation.hpp" // Goal enum #include #include @@ -78,7 +80,7 @@ class BiasedBrownianLeader final : public IEquation { if (delta.y < -span * 0.5f) delta.y += span; } - const float dist = glm::length(delta); + const float dist = ops::length(delta); // Goal flip: arrival neighbourhood if (dist < m_p.arrival_radius) { @@ -89,8 +91,8 @@ class BiasedBrownianLeader final : public IEquation { // Goal flip: gradient flatness const Vec3 du_v = surface.du(state.uv.x, state.uv.y); const Vec3 dv_v = surface.dv(state.uv.x, state.uv.y); - const float grad_mag = std::sqrt(du_v.z*du_v.z + dv_v.z*dv_v.z); - if (std::abs(grad_mag - m_p.target_grad_magnitude) < m_p.epsilon) + const float grad_mag = ops::sqrt(du_v.z*du_v.z + dv_v.z*dv_v.z); + if (ops::abs(grad_mag - m_p.target_grad_magnitude) < m_p.epsilon) m_goal = (m_goal == Goal::SeekMax) ? Goal::SeekMin : Goal::SeekMax; // Goal-directed drift: unit vector toward goal, scaled by drift_strength @@ -98,10 +100,10 @@ class BiasedBrownianLeader final : public IEquation { glm::vec2 mu = (delta / dist) * m_p.drift_strength; // Optional gradient drift (same as BrownianMotion::drift_strength) - if (std::abs(m_p.gradient_drift) > 1e-7f) { + if (ops::abs(m_p.gradient_drift) > 1e-7f) { const float fu = du_v.z; const float fv = dv_v.z; - const float gn = std::sqrt(fu*fu + fv*fv) + 1e-7f; + const float gn = ops::sqrt(fu*fu + fv*fv) + 1e-7f; mu += glm::vec2{ m_p.gradient_drift * fu / gn, m_p.gradient_drift * fv / gn }; } diff --git a/nurbs_dde/src/sim/BrownianMotion.hpp b/nurbs_dde/src/sim/BrownianMotion.hpp index 96295938..2c383c47 100644 --- a/nurbs_dde/src/sim/BrownianMotion.hpp +++ b/nurbs_dde/src/sim/BrownianMotion.hpp @@ -43,6 +43,7 @@ // Set drift_strength = 0 for pure isotropic Brownian motion. #include "sim/IEquation.hpp" +#include "numeric/ops.hpp" #include namespace ndde::sim { @@ -63,7 +64,7 @@ class BrownianMotion final : public IEquation { float t) const override { (void)state; (void)t; - if (std::abs(m_p.drift_strength) < 1e-7f) + if (ops::abs(m_p.drift_strength) < 1e-7f) return {0.f, 0.f}; // Gradient direction in parameter space (z-components of tangent vectors) @@ -71,7 +72,7 @@ class BrownianMotion final : public IEquation { const glm::vec3 dv_vec = surface.dv(state.uv.x, state.uv.y); const float fu = du_vec.z; const float fv = dv_vec.z; - const float gn = std::sqrt(fu*fu + fv*fv) + 1e-7f; + const float gn = ops::sqrt(fu*fu + fv*fv) + 1e-7f; return { m_p.drift_strength * fu / gn, m_p.drift_strength * fv / gn }; } diff --git a/nurbs_dde/src/sim/DelayPursuitEquation.hpp b/nurbs_dde/src/sim/DelayPursuitEquation.hpp index 8fcd9031..17464e45 100644 --- a/nurbs_dde/src/sim/DelayPursuitEquation.hpp +++ b/nurbs_dde/src/sim/DelayPursuitEquation.hpp @@ -52,7 +52,9 @@ // constant sigma, which is the default here). #include "sim/IEquation.hpp" +#include "numeric/ops.hpp" #include "sim/HistoryBuffer.hpp" +#include "numeric/ops.hpp" #include #include @@ -109,7 +111,7 @@ class DelayPursuitEquation final : public IEquation { } } - const float dist = glm::length(delta); + const float dist = ops::length(delta); if (dist < 1e-7f) return {0.f, 0.f}; // Pure pursuit: unit vector toward target, scaled by pursuit speed diff --git a/nurbs_dde/src/sim/DirectPursuitEquation.hpp b/nurbs_dde/src/sim/DirectPursuitEquation.hpp index f8570759..a69a05c9 100644 --- a/nurbs_dde/src/sim/DirectPursuitEquation.hpp +++ b/nurbs_dde/src/sim/DirectPursuitEquation.hpp @@ -11,6 +11,7 @@ // In practice: [this]{ return m_curves[0].head_uv(); } #include "sim/IEquation.hpp" +#include "numeric/ops.hpp" #include #include #include @@ -51,7 +52,7 @@ class DirectPursuitEquation final : public IEquation { if (delta.y < -span * 0.5f) delta.y += span; } - const float dist = glm::length(delta); + const float dist = ops::length(delta); if (dist < 1e-7f) return {0.f, 0.f}; return (delta / dist) * m_p.pursuit_speed; } diff --git a/nurbs_dde/src/sim/DomainConfinement.hpp b/nurbs_dde/src/sim/DomainConfinement.hpp index 656b152d..4409cbe1 100644 --- a/nurbs_dde/src/sim/DomainConfinement.hpp +++ b/nurbs_dde/src/sim/DomainConfinement.hpp @@ -18,8 +18,7 @@ // because those equations ignore state.angle entirely. #include "sim/IConstraint.hpp" -#include -#include +#include "numeric/ops.hpp" #include namespace ndde::sim { @@ -42,11 +41,11 @@ class DomainConfinement final : public IConstraint { constexpr float margin = 0.3f; if (state.uv.x < u0 + margin) { state.uv.x = u0 + margin; - state.angle = std::numbers::pi_v - state.angle; + state.angle = ops::pi_v - state.angle; } if (state.uv.x > u1 - margin) { state.uv.x = u1 - margin; - state.angle = std::numbers::pi_v - state.angle; + state.angle = ops::pi_v - state.angle; } } diff --git a/nurbs_dde/src/sim/EulerIntegrator.hpp b/nurbs_dde/src/sim/EulerIntegrator.hpp index 45e38a69..6c52b99c 100644 --- a/nurbs_dde/src/sim/EulerIntegrator.hpp +++ b/nurbs_dde/src/sim/EulerIntegrator.hpp @@ -25,6 +25,7 @@ // is eliminated in this step. #include "sim/IIntegrator.hpp" +#include "numeric/ops.hpp" #include namespace ndde::sim { @@ -48,7 +49,7 @@ class EulerIntegrator final : public IIntegrator { // Phase accumulates proportional to arc-length speed. // phase_rate() is 0 for equations that don't use a steering phase. - state.phase += equation.phase_rate() * glm::length(vel) * dt; + state.phase += equation.phase_rate() * ops::length(vel) * dt; } [[nodiscard]] std::string name() const override { return "EulerIntegrator"; } diff --git a/nurbs_dde/src/sim/GradientWalker.hpp b/nurbs_dde/src/sim/GradientWalker.hpp index 3863aafb..7f8fc3ac 100644 --- a/nurbs_dde/src/sim/GradientWalker.hpp +++ b/nurbs_dde/src/sim/GradientWalker.hpp @@ -28,8 +28,7 @@ // owns the mutation contract -- it passes mutable state to the equation. #include "sim/IEquation.hpp" -#include -#include +#include "numeric/ops.hpp" #include namespace ndde::sim { @@ -69,24 +68,24 @@ class GradientWalker final : public IEquation { const glm::vec3 dv_vec = surface.dv(state.uv.x, state.uv.y); const float fx = du_vec.z; const float fy = dv_vec.z; - const float gn = std::sqrt(fx*fx + fy*fy) + 1e-5f; + const float gn = ops::sqrt(fx*fx + fy*fy) + 1e-5f; // Perpendicular-to-gradient in the (u,v) plane const float perp_x = -fy / gn; const float perp_y = fx / gn; // Desired heading: gradient-perpendicular + sinusoidal perturbation - const float steer = m_steer_amp * std::sin(state.phase * m_steer_freq); - const float desired = std::atan2(perp_y, perp_x) + steer; + const float steer = m_steer_amp * ops::sin(state.phase * m_steer_freq); + const float desired = ops::atan2(perp_y, perp_x) + steer; // Rate-limited heading update -- mutable, no const_cast (Step 5 fix) float da = desired - state.angle; - while (da > std::numbers::pi_v) da -= 2.f * std::numbers::pi_v; - while (da < -std::numbers::pi_v) da += 2.f * std::numbers::pi_v; - state.angle += std::clamp(da, -m_turn_rate, m_turn_rate); + while (da > ops::pi_v) da -= ops::two_pi_v; + while (da < -ops::pi_v) da += ops::two_pi_v; + state.angle += ops::clamp(da, -m_turn_rate, m_turn_rate); - return { m_walk_speed * std::cos(state.angle), - m_walk_speed * std::sin(state.angle) }; + return { m_walk_speed * ops::cos(state.angle), + m_walk_speed * ops::sin(state.angle) }; } // Phase accumulates at phase_rate() * |vel| * dt per integrator step. diff --git a/nurbs_dde/src/sim/HistoryBuffer.hpp b/nurbs_dde/src/sim/HistoryBuffer.hpp index 9fb7324c..cc798c55 100644 --- a/nurbs_dde/src/sim/HistoryBuffer.hpp +++ b/nurbs_dde/src/sim/HistoryBuffer.hpp @@ -29,10 +29,12 @@ // If t_past < oldest record: return oldest record (clamp left) // If t_past > newest record: return newest record (clamp right) +#include "memory/Containers.hpp" + #include -#include #include #include +#include namespace ndde::sim { @@ -46,8 +48,10 @@ class HistoryBuffer { // capacity: maximum number of records held simultaneously. // dt_min: minimum time between stored records (rate-limiter). // Records more frequent than dt_min are silently dropped. - explicit HistoryBuffer(std::size_t capacity = 4096, float dt_min = 0.f) - : m_capacity(capacity), m_dt_min(dt_min) + explicit HistoryBuffer(std::size_t capacity = 4096, + float dt_min = 0.f, + std::pmr::memory_resource* resource = std::pmr::get_default_resource()) + : m_capacity(capacity), m_dt_min(dt_min), m_records(resource) { m_records.reserve(capacity); } @@ -132,8 +136,8 @@ class HistoryBuffer { // When the buffer has not yet wrapped: a simple copy of m_records. // When wrapped: reorders the two halves around m_head. // Cost: O(n) time and space. Only call for export/debug, not per-frame. - [[nodiscard]] std::vector to_vector() const { - std::vector out; + [[nodiscard]] memory::HistoryVector to_vector() const { + memory::HistoryVector out{m_records.get_allocator().resource()}; const std::size_t n = m_records.size(); out.reserve(n); for (std::size_t i = 0; i < n; ++i) { @@ -148,7 +152,7 @@ class HistoryBuffer { private: std::size_t m_capacity; float m_dt_min; - std::vector m_records; + memory::HistoryVector m_records; std::size_t m_head = 0; // index of oldest record (when full) float m_last_push_t = -1e30f; }; diff --git a/nurbs_dde/src/sim/IConstraint.hpp b/nurbs_dde/src/sim/IConstraint.hpp index 20296798..95627001 100644 --- a/nurbs_dde/src/sim/IConstraint.hpp +++ b/nurbs_dde/src/sim/IConstraint.hpp @@ -17,7 +17,7 @@ // IPairConstraint (pairwise): // Called once per ordered pair (i, j), i < j, after all per-particle // IConstraint::apply() calls. -// Stored in SurfaceSimScene::m_pair_constraints. +// Stored by ParticleSystem as pair constraints. // // Contract for IConstraint::apply(): // - MAY modify state.uv freely. @@ -50,7 +50,7 @@ class IConstraint { // ── IPairConstraint ─────────────────────────────────────────────────────────── // Post-integration correction applied to a pair of particles. // -// Called by SurfaceSimScene::apply_pairwise_constraints() AFTER all per-particle +// Called by ParticleSystem AFTER all per-particle // IConstraint::apply() calls, once per distinct ordered pair (i, j) with i < j. // Both states may be modified. class IPairConstraint { diff --git a/nurbs_dde/src/sim/LeaderSeekerEquation.hpp b/nurbs_dde/src/sim/LeaderSeekerEquation.hpp index bb6ea5ce..6dc92e96 100644 --- a/nurbs_dde/src/sim/LeaderSeekerEquation.hpp +++ b/nurbs_dde/src/sim/LeaderSeekerEquation.hpp @@ -27,7 +27,9 @@ // interface level, but goal-switching state must persist across velocity() calls. #include "sim/IEquation.hpp" +#include "numeric/ops.hpp" #include "math/ExtremumTable.hpp" +#include "numeric/ops.hpp" #include #include #include @@ -76,7 +78,7 @@ class LeaderSeekerEquation final : public IEquation { if (delta.y < -span * 0.5f) delta.y += span; } - const float dist = glm::length(delta); + const float dist = ops::length(delta); // 3. Arrival neighbourhood: flip goal if (dist < m_p.arrival_radius) { @@ -87,8 +89,8 @@ class LeaderSeekerEquation final : public IEquation { // 4-5. Gradient flatness check: flip goal const Vec3 du_v = surface.du(state.uv.x, state.uv.y); const Vec3 dv_v = surface.dv(state.uv.x, state.uv.y); - const float grad_mag = std::sqrt(du_v.z*du_v.z + dv_v.z*dv_v.z); - if (std::abs(grad_mag - m_p.target_grad_magnitude) < m_p.epsilon) + const float grad_mag = ops::sqrt(du_v.z*du_v.z + dv_v.z*dv_v.z); + if (ops::abs(grad_mag - m_p.target_grad_magnitude) < m_p.epsilon) m_goal = (m_goal == Goal::SeekMax) ? Goal::SeekMin : Goal::SeekMax; // 6. Bearing: unit vector toward goal diff --git a/nurbs_dde/src/sim/LevelCurveWalker.hpp b/nurbs_dde/src/sim/LevelCurveWalker.hpp index 6c153370..bb049aa7 100644 --- a/nurbs_dde/src/sim/LevelCurveWalker.hpp +++ b/nurbs_dde/src/sim/LevelCurveWalker.hpp @@ -13,20 +13,24 @@ // but numerical errors and discrete time steps let it drift. To maintain // confinement, we add a corrective term: // -// If z > z₀ + ε : steer downhill (−∇f direction) -// If z < z₀ − ε : steer uphill (+∇f direction) -// Otherwise : pure level-curve tangent +// If z > z₀ + ε : steer mostly downhill (−∇f direction) +// If z < z₀ − ε : steer mostly uphill (+∇f direction) +// Otherwise : mostly level-curve tangent // // This is a proportional controller on the height error: // // e = (z − z₀) / ε ∈ [−1, 1] in band, outside otherwise -// correction = clamp(e, −1, 1) · ∇f / |∇f| +// correction = -clamp(e, −1, 1) · ∇f / |∇f| // // The total velocity direction is: // -// d = (1−|clamp(e)|) · ∇⊥f/|∇f| + clamp(e) · ∇f/|∇f| +// d = max(tangent_floor, 1−|clamp(e)|) · ∇⊥f/|∇f| +// − clamp(e) · ∇f/|∇f| // // normalised and scaled by walk_speed. +// Keeping a tangential floor prevents the walker from becoming a pure +// gradient-descent/ascent particle, which can otherwise trap it in a basin +// around local extrema. // // When |∇f| ≈ 0 (at a critical point), the particle wanders in a fixed // direction (the last valid heading) rather than stopping — this prevents @@ -41,8 +45,7 @@ // smooths direction reversals at gradient discontinuities #include "sim/IEquation.hpp" -#include -#include +#include "numeric/ops.hpp" #include #include @@ -55,6 +58,7 @@ class LevelCurveWalker final : public IEquation { float epsilon = 0.15f; ///< confinement half-band width float walk_speed = 0.7f; ///< forward speed (param-units/s) float turn_rate = 2.5f; ///< max heading change per step (radians) + float tangent_floor = 0.35f; ///< minimum level-curve component }; explicit LevelCurveWalker(Params p = {}) : m_p(p) {} @@ -74,7 +78,7 @@ class LevelCurveWalker final : public IEquation { // Gradient components (z-components of tangent vectors for height field) const float gx = du_v.z; // ∂f/∂u const float gy = dv_v.z; // ∂f/∂v - const float gn = std::sqrt(gx*gx + gy*gy); + const float gn = ops::sqrt(gx*gx + gy*gy); // Level-curve tangent direction: ∇⊥f = (−gy, gx) / |∇f| // Gradient direction: ∇f = (gx, gy) / |∇f| @@ -83,30 +87,30 @@ class LevelCurveWalker final : public IEquation { // Current height and height error const float z = surface.evaluate(state.uv.x, state.uv.y).z; const float err = (z - m_p.z0) / std::max(m_p.epsilon, 1e-6f); - const float ce = std::clamp(err, -1.f, 1.f); - const float te = 1.f - std::abs(ce); // tangent weight + const float ce = ops::clamp(err, -1.f, 1.f); + const float te = std::max(m_p.tangent_floor, 1.f - ops::abs(ce)); // Blend tangent and corrective gradient components - const float raw_x = te * (-gy / gn) + ce * (gx / gn); - const float raw_y = te * ( gx / gn) + ce * (gy / gn); - const float rn = std::sqrt(raw_x*raw_x + raw_y*raw_y); + const float raw_x = te * (-gy / gn) - ce * (gx / gn); + const float raw_y = te * ( gx / gn) - ce * (gy / gn); + const float rn = ops::sqrt(raw_x*raw_x + raw_y*raw_y); tx = (rn > 1e-6f) ? raw_x / rn : -gy / gn; ty = (rn > 1e-6f) ? raw_y / rn : gx / gn; } else { // Critical point — hold last heading - tx = std::cos(state.angle); - ty = std::sin(state.angle); + tx = ops::cos(state.angle); + ty = ops::sin(state.angle); } // Rate-limited heading update (same approach as GradientWalker) - const float desired = std::atan2(ty, tx); + const float desired = ops::atan2(ty, tx); float da = desired - state.angle; - while (da > std::numbers::pi_v) da -= 2.f * std::numbers::pi_v; - while (da < -std::numbers::pi_v) da += 2.f * std::numbers::pi_v; - state.angle += std::clamp(da, -m_p.turn_rate, m_p.turn_rate); + while (da > ops::pi_v) da -= ops::two_pi_v; + while (da < -ops::pi_v) da += ops::two_pi_v; + state.angle += ops::clamp(da, -m_p.turn_rate, m_p.turn_rate); - return { m_p.walk_speed * std::cos(state.angle), - m_p.walk_speed * std::sin(state.angle) }; + return { m_p.walk_speed * ops::cos(state.angle), + m_p.walk_speed * ops::sin(state.angle) }; } [[nodiscard]] float phase_rate() const override { return 0.f; } diff --git a/nurbs_dde/src/sim/MilsteinIntegrator.hpp b/nurbs_dde/src/sim/MilsteinIntegrator.hpp index 3f8bf8e2..63751d6a 100644 --- a/nurbs_dde/src/sim/MilsteinIntegrator.hpp +++ b/nurbs_dde/src/sim/MilsteinIntegrator.hpp @@ -47,9 +47,8 @@ // Z2 = sqrt(-2 ln U1) * sin(2*pi*U2) ~ N(0,1) #include "sim/IIntegrator.hpp" +#include "numeric/ops.hpp" #include -#include -#include #include namespace ndde::sim { @@ -83,7 +82,7 @@ class MilsteinIntegrator final : public IIntegrator { const glm::vec2 sigma = equation.noise_coefficient(state, surface, t); // ── Wiener increment: dW ~ N(0, dt) ────────────────────────────────── - const float sqrt_dt = std::sqrt(dt); + const float sqrt_dt = ops::sqrt(dt); const glm::vec2 dW = sqrt_dt * normal2(); // ── Milstein correction: (1/2)*sigma*(d sigma/dX)*(dW^2 - dt) ──────── @@ -95,7 +94,7 @@ class MilsteinIntegrator final : public IIntegrator { state.uv += mu * dt + sigma * dW + milstein; // Phase: purely deterministic (no stochastic phase accumulation). - state.phase += equation.phase_rate() * glm::length(mu) * dt; + state.phase += equation.phase_rate() * ops::length(mu) * dt; } [[nodiscard]] std::string name() const override { return "MilsteinIntegrator"; } @@ -115,9 +114,9 @@ class MilsteinIntegrator final : public IIntegrator { const float u1 = uniform(rng); const float u2 = uniform(rng); - const float r = std::sqrt(-2.f * std::log(u1)); - const float th = 2.f * std::numbers::pi_v * u2; - return { r * std::cos(th), r * std::sin(th) }; + const float r = ops::sqrt(-2.f * ops::log(u1)); + const float th = ops::two_pi_v * u2; + return { r * ops::cos(th), r * ops::sin(th) }; } inline static uint64_t s_global_seed = 0; diff --git a/nurbs_dde/src/sim/MinDistConstraint.hpp b/nurbs_dde/src/sim/MinDistConstraint.hpp index d53fd426..ef2a63f1 100644 --- a/nurbs_dde/src/sim/MinDistConstraint.hpp +++ b/nurbs_dde/src/sim/MinDistConstraint.hpp @@ -42,7 +42,7 @@ class MinDistConstraint final : public IPairConstraint { if (delta.y < -span * 0.5f) delta.y += span; } - const float dist = glm::length(delta); + const float dist = ops::length(delta); if (dist >= m_min_dist || dist < 1e-7f) return; // Push each particle half the overlap distance along the separation axis diff --git a/nurbs_dde/src/sim/MomentumBearingEquation.hpp b/nurbs_dde/src/sim/MomentumBearingEquation.hpp index 8b236636..179634af 100644 --- a/nurbs_dde/src/sim/MomentumBearingEquation.hpp +++ b/nurbs_dde/src/sim/MomentumBearingEquation.hpp @@ -20,7 +20,9 @@ // This is Strategy C from docs/ctrl_a_leader_seeker.md. #include "sim/IEquation.hpp" +#include "numeric/ops.hpp" #include "sim/HistoryBuffer.hpp" +#include "numeric/ops.hpp" #include #include #include @@ -67,7 +69,7 @@ class MomentumBearingEquation final : public IEquation { if (displacement.y < -span * 0.5f) displacement.y += span; } - const float len = glm::length(displacement); + const float len = ops::length(displacement); if (len < 1e-7f) return {0.f, 0.f}; return (displacement / len) * m_p.pursuit_speed; } diff --git a/nurbs_dde/tests/CMakeLists.txt b/nurbs_dde/tests/CMakeLists.txt index c055a49b..c1c825f0 100644 --- a/nurbs_dde/tests/CMakeLists.txt +++ b/nurbs_dde/tests/CMakeLists.txt @@ -2,9 +2,31 @@ enable_testing() add_executable(ndde_tests + test_all_simulations.cpp + test_alternate_view_builders.cpp test_conics.cpp test_axes.cpp test_numeric.cpp + test_particle_system.cpp + test_surface_mesh_cache.cpp + test_engine_services.cpp + test_simulation_context.cpp + test_simulation_runtime.cpp + test_simulation_surface_gaussian.cpp + test_surface_metadata.cpp + ${CMAKE_SOURCE_DIR}/src/app/GaussianSurface.cpp + ${CMAKE_SOURCE_DIR}/src/app/ParticleSystem.cpp + ${CMAKE_SOURCE_DIR}/src/app/AnalysisSpawner.cpp + ${CMAKE_SOURCE_DIR}/src/app/SceneFactories.cpp + ${CMAKE_SOURCE_DIR}/src/app/MultiWellSpawner.cpp + ${CMAKE_SOURCE_DIR}/src/app/SimulationAnalysis.cpp + ${CMAKE_SOURCE_DIR}/src/app/SimulationMultiWell.cpp + ${CMAKE_SOURCE_DIR}/src/app/SimulationSurfaceGaussian.cpp + ${CMAKE_SOURCE_DIR}/src/app/SimulationWavePredatorPrey.cpp + ${CMAKE_SOURCE_DIR}/src/app/SurfaceMeshCache.cpp + ${CMAKE_SOURCE_DIR}/src/app/SurfaceSimSpawner.cpp + ${CMAKE_SOURCE_DIR}/src/app/WavePredatorPreySpawner.cpp + ${CMAKE_SOURCE_DIR}/src/engine/SimulationRuntime.cpp ) target_include_directories(ndde_tests PRIVATE @@ -12,8 +34,11 @@ target_include_directories(ndde_tests PRIVATE ) target_link_libraries(ndde_tests PRIVATE + ndde_memory ndde_math ndde_numeric + volk + imgui GTest::gtest_main glm::glm ) @@ -38,3 +63,13 @@ endif() include(GoogleTest) gtest_discover_tests(ndde_tests) + +add_test( + NAME allocation_policy + COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/tools/check_allocation_policy.py +) + +add_test( + NAME hot_path_container_policy + COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/tools/check_hot_path_container_policy.py +) diff --git a/nurbs_dde/tests/test_numeric.cpp b/nurbs_dde/tests/test_numeric.cpp index f31b143a..9c0e6a36 100644 --- a/nurbs_dde/tests/test_numeric.cpp +++ b/nurbs_dde/tests/test_numeric.cpp @@ -14,6 +14,7 @@ #include #include "numeric/Constants.hpp" #include "numeric/MathTraits.hpp" +#include "numeric/ops.hpp" #include "numeric/Vec3Ops.hpp" #include "numeric/Differentiator.hpp" #include @@ -77,6 +78,36 @@ TEST(MathTraits, SinF64VsStd) { } } +TEST(MathTraits, TaylorSinF64VsStdOnReducedDomain) { + for (double t : {-Constants::PI, -2.4, -1.7, -1.0, -0.3, + 0.0, 0.3, 1.0, 1.7, 2.4, Constants::PI}) { + EXPECT_NEAR(MathTraits::taylor_sin(t), std::sin(t), 5e-15) << "t=" << t; + } +} + +TEST(MathTraits, TaylorSinF32VsStdAcrossSimulationAngles) { + for (float t : {-50.f, -17.25f, -6.2f, -3.1415926f, -1.0f, + 0.f, 1.0f, 3.1415926f, 6.2f, 17.25f, 50.f}) { + EXPECT_NEAR(MathTraits::taylor_sin(t), std::sin(t), 2e-6f) << "t=" << t; + } +} + +TEST(MathTraits, TaylorSinIsOdd) { + for (double t : {0.1, 0.7, 1.3, 2.2, 8.0}) { + EXPECT_NEAR(MathTraits::taylor_sin(-t), -MathTraits::taylor_sin(t), 1e-14) + << "t=" << t; + } +} + +TEST(MathTraits, OpsSinUsesTaylorWhenConfigured) { + const f64 x = 1.2345; + if constexpr (!ndde::use_builtin_math && ndde::use_taylor_sin) { + EXPECT_EQ(ops::sin(x), MathTraits::taylor_sin(x)); + } else { + EXPECT_NEAR(ops::sin(x), std::sin(x), 1e-14); + } +} + TEST(MathTraits, CoshSinhIdentity) { // cosh²(x) - sinh²(x) = 1 ∀ x for (double x : {0.0, 0.5, 1.0, 2.0, 3.0}) {