From 4fb9ecab1b30fb35889f189a8eb55a50a1e4667d Mon Sep 17 00:00:00 2001 From: Bosheng Li Date: Thu, 4 Jun 2026 09:41:59 -0700 Subject: [PATCH] align LSystem package with dev --- EvoEngine_App/CMakeLists.txt | 1 + EvoEngine_App/resources/LSystemApp.rc | 1 + EvoEngine_App/src/LSystemApp.cpp | 86 + .../LSystem/include/CrossSectionProfile.hpp | 231 ++ .../LSystem/include/DerivationEngine.hpp | 401 +++ .../LSystem/include/DistributionDefaults.hpp | 52 + .../LSystem/include/ElasticaSolver.hpp | 258 ++ .../include/GeneralizedCylinderMesher.hpp | 190 ++ .../LSystem/include/GeometryPass.hpp | 117 + .../LSystem/include/GrowthField.hpp | 170 ++ .../LSystem/include/GrowthFunction.hpp | 158 ++ .../include/ILSystemExplorableDescriptor.hpp | 54 + .../LSystem/include/LSystemComponentBase.hpp | 45 + .../LSystem/include/LSystemDescriptor.hpp | 38 + .../include/LSystemDescriptorDefaults.hpp | 71 + .../LSystem/include/LSystemGraph.hpp | 1017 ++++++++ .../include/LSystemGrowthModelBase.hpp | 324 +++ .../LSystem/include/LSystemLayer.hpp | 62 + .../LSystem/include/LSystemRuleHelpers.hpp | 192 ++ .../LSystem/include/MaterialProfile.hpp | 109 + .../LSystem/include/ModuleTypes.hpp | 133 + .../LSystem/include/OrganCenterline.hpp | 281 +++ .../LSystem/include/OrganMeshChannel.hpp | 242 ++ .../LSystem/include/OrganMeshPly.hpp | 76 + .../LSystem/include/ParamSpaceExplorer.hpp | 108 + .../LSystem/include/PineGrowthModel.hpp | 109 + .../LSystem/include/PlantRenderTarget.hpp | 183 ++ .../LSystem/include/ProductionRule.hpp | 113 + .../LSystem/include/ScotsPine.hpp | 195 ++ .../LSystem/include/ScotsPineDescriptor.hpp | 466 ++++ .../LSystem/include/ScotsPineModules.hpp | 218 ++ .../LSystem/include/ScotsPineRules.hpp | 773 ++++++ .../LSystem/include/SimulationClock.hpp | 168 ++ .../LSystem/include/StressFeedbackPolicy.hpp | 145 ++ .../LSystem/include/StringExport.hpp | 216 ++ .../LSystem/src/DistributionDefaults.cpp | 107 + .../LSystem/src/LSystemDescriptor.cpp | 126 + .../LSystem/src/LSystemDescriptorDefaults.cpp | 31 + .../LSystem/src/LSystemLayer.cpp | 565 +++++ .../LSystem/src/ParamSpaceExplorer.cpp | 674 +++++ .../LSystem/src/PineGrowthModel.cpp | 494 ++++ EvoEngine_Packages/LSystem/src/ScotsPine.cpp | 1819 ++++++++++++++ .../LSystem/src/ScotsPineDescriptor.cpp | 2232 +++++++++++++++++ .../LSystemProject/Assets/New Scene.evescene | 83 + .../Assets/New Scene.evescene.evefilemeta | 4 + Resources/LSystemProject/test.eveproj | 1 + 46 files changed, 13139 insertions(+) create mode 100644 EvoEngine_App/resources/LSystemApp.rc create mode 100644 EvoEngine_App/src/LSystemApp.cpp create mode 100644 EvoEngine_Packages/LSystem/include/CrossSectionProfile.hpp create mode 100644 EvoEngine_Packages/LSystem/include/DerivationEngine.hpp create mode 100644 EvoEngine_Packages/LSystem/include/DistributionDefaults.hpp create mode 100644 EvoEngine_Packages/LSystem/include/ElasticaSolver.hpp create mode 100644 EvoEngine_Packages/LSystem/include/GeneralizedCylinderMesher.hpp create mode 100644 EvoEngine_Packages/LSystem/include/GeometryPass.hpp create mode 100644 EvoEngine_Packages/LSystem/include/GrowthField.hpp create mode 100644 EvoEngine_Packages/LSystem/include/GrowthFunction.hpp create mode 100644 EvoEngine_Packages/LSystem/include/ILSystemExplorableDescriptor.hpp create mode 100644 EvoEngine_Packages/LSystem/include/LSystemComponentBase.hpp create mode 100644 EvoEngine_Packages/LSystem/include/LSystemDescriptor.hpp create mode 100644 EvoEngine_Packages/LSystem/include/LSystemDescriptorDefaults.hpp create mode 100644 EvoEngine_Packages/LSystem/include/LSystemGraph.hpp create mode 100644 EvoEngine_Packages/LSystem/include/LSystemGrowthModelBase.hpp create mode 100644 EvoEngine_Packages/LSystem/include/LSystemLayer.hpp create mode 100644 EvoEngine_Packages/LSystem/include/LSystemRuleHelpers.hpp create mode 100644 EvoEngine_Packages/LSystem/include/MaterialProfile.hpp create mode 100644 EvoEngine_Packages/LSystem/include/ModuleTypes.hpp create mode 100644 EvoEngine_Packages/LSystem/include/OrganCenterline.hpp create mode 100644 EvoEngine_Packages/LSystem/include/OrganMeshChannel.hpp create mode 100644 EvoEngine_Packages/LSystem/include/OrganMeshPly.hpp create mode 100644 EvoEngine_Packages/LSystem/include/ParamSpaceExplorer.hpp create mode 100644 EvoEngine_Packages/LSystem/include/PineGrowthModel.hpp create mode 100644 EvoEngine_Packages/LSystem/include/PlantRenderTarget.hpp create mode 100644 EvoEngine_Packages/LSystem/include/ProductionRule.hpp create mode 100644 EvoEngine_Packages/LSystem/include/ScotsPine.hpp create mode 100644 EvoEngine_Packages/LSystem/include/ScotsPineDescriptor.hpp create mode 100644 EvoEngine_Packages/LSystem/include/ScotsPineModules.hpp create mode 100644 EvoEngine_Packages/LSystem/include/ScotsPineRules.hpp create mode 100644 EvoEngine_Packages/LSystem/include/SimulationClock.hpp create mode 100644 EvoEngine_Packages/LSystem/include/StressFeedbackPolicy.hpp create mode 100644 EvoEngine_Packages/LSystem/include/StringExport.hpp create mode 100644 EvoEngine_Packages/LSystem/src/DistributionDefaults.cpp create mode 100644 EvoEngine_Packages/LSystem/src/LSystemDescriptor.cpp create mode 100644 EvoEngine_Packages/LSystem/src/LSystemDescriptorDefaults.cpp create mode 100644 EvoEngine_Packages/LSystem/src/LSystemLayer.cpp create mode 100644 EvoEngine_Packages/LSystem/src/ParamSpaceExplorer.cpp create mode 100644 EvoEngine_Packages/LSystem/src/PineGrowthModel.cpp create mode 100644 EvoEngine_Packages/LSystem/src/ScotsPine.cpp create mode 100644 EvoEngine_Packages/LSystem/src/ScotsPineDescriptor.cpp create mode 100644 Resources/LSystemProject/Assets/New Scene.evescene create mode 100644 Resources/LSystemProject/Assets/New Scene.evescene.evefilemeta create mode 100644 Resources/LSystemProject/test.eveproj diff --git a/EvoEngine_App/CMakeLists.txt b/EvoEngine_App/CMakeLists.txt index 5d5271a2..a6aba4f2 100644 --- a/EvoEngine_App/CMakeLists.txt +++ b/EvoEngine_App/CMakeLists.txt @@ -127,6 +127,7 @@ register_evoengine_app(DemoApp ON) register_evoengine_app(EcoSysLabApp ON) register_evoengine_app(DigitalAgricultureApp ON) register_evoengine_app(LogGradingApp ON) +register_evoengine_app(LSystemApp ON) register_evoengine_app(TreeDataGeneratorApp ON) register_evoengine_app(SorghumDataGeneratorApp ON) diff --git a/EvoEngine_App/resources/LSystemApp.rc b/EvoEngine_App/resources/LSystemApp.rc new file mode 100644 index 00000000..aa4dc835 --- /dev/null +++ b/EvoEngine_App/resources/LSystemApp.rc @@ -0,0 +1 @@ +IDI_ICON1 ICON DISCARDABLE "EvoEngineApp.ico" diff --git a/EvoEngine_App/src/LSystemApp.cpp b/EvoEngine_App/src/LSystemApp.cpp new file mode 100644 index 00000000..0ab1d566 --- /dev/null +++ b/EvoEngine_App/src/LSystemApp.cpp @@ -0,0 +1,86 @@ +// LSystemApp.cpp - standalone L-system editor shell. +// +// Boots into an LSystem project by default. We resolve several candidate +// locations so local developer layouts (Resources vs Temp) still open into +// LSystem content without manual project selection. +#include + +#include +#include + +#include "ClassRegistry.hpp" +#include "Times.hpp" + +#include "ProjectManager.hpp" + +#include "EditorLayer.hpp" +#include "RenderLayer.hpp" +#include "WindowLayer.hpp" + +using namespace evo_engine; + +namespace { + +std::filesystem::path FindResourceFolder() { + std::filesystem::path resource_folder_path("../../../../../Resources"); + if (!std::filesystem::exists(resource_folder_path)) { + resource_folder_path = "../../../../Resources"; + } + if (!std::filesystem::exists(resource_folder_path)) { + resource_folder_path = "../../../Resources"; + } + if (!std::filesystem::exists(resource_folder_path)) { + resource_folder_path = "../../Resources"; + } + if (!std::filesystem::exists(resource_folder_path)) { + resource_folder_path = "../Resources"; + } + return resource_folder_path; +} + +std::filesystem::path ResolveDefaultLSystemProjectPath(const std::filesystem::path& resource_folder_path) { + const std::array candidates = { + resource_folder_path / "LSystemProject" / "test.eveproj", + resource_folder_path / "LSystemProjectAssets" / "test.eveproj"}; + + for (const auto& candidate : candidates) { + if (std::filesystem::exists(candidate)) { + return std::filesystem::absolute(candidate); + } + } + + // Keep a stable fallback target even when assets are not present yet. + return std::filesystem::absolute(resource_folder_path / "LSystemProject" / "test.eveproj"); +} + +} // namespace + +int main() { + Application application; + const auto resource_folder_path = FindResourceFolder(); + const auto lsystem_project_path = ResolveDefaultLSystemProjectPath(resource_folder_path); + + ApplicationContext::Get().PushLayer("Render Layer"); + ApplicationContext::Get().PushLayer("Window Layer"); + ApplicationContext::Get().PushLayer("Editor Layer"); + + ApplicationInitializationSettings application_configs; + application_configs.application_name = "LSystem"; + application_configs.project_path = lsystem_project_path; + application_configs.enable_runtime_packages = true; + // Keep LSystem first while also loading DigitalAgriculture so copied + // default-scene components deserialize with maximal compatibility. + application_configs.startup_runtime_packages = {"LSystem", "DigitalAgriculture"}; + ApplicationContext::Get().Initialize(application_configs); + + const auto editor_layer = ApplicationContext::Get().GetLayer(); + if (editor_layer) { + editor_layer->velocity = 2.f; + editor_layer->default_scene_camera_position = glm::vec3(0.0f, 1.0f, 5.0f); + } + + ApplicationContext::Get().Start(); + ApplicationContext::Get().Run(); + ApplicationContext::Get().Terminate(); + return 0; +} diff --git a/EvoEngine_Packages/LSystem/include/CrossSectionProfile.hpp b/EvoEngine_Packages/LSystem/include/CrossSectionProfile.hpp new file mode 100644 index 00000000..ccc32b0d --- /dev/null +++ b/EvoEngine_Packages/LSystem/include/CrossSectionProfile.hpp @@ -0,0 +1,231 @@ +#pragma once + +// --------------------------------------------------------------------------- +// CrossSectionProfile +// +// Phase 1 of the biologically-emergent organ growth substrate. Defines the +// shape of the cross-section that is swept along an OrganCenterline by the +// GeneralizedCylinderMesher. Each profile reports two things at every +// requested azimuthal sample u in [0,1): +// +// * `radial_offset` - vector from the centerline (in {normal, binormal}) +// to the surface, in metres at the requested +// `radius` parameter, +// * `outward_normal` - surface outward direction in the same 2D basis. +// +// Profiles are deliberately stateless (per-station radius is passed in by +// the mesher) so the same profile object can be reused across all stations +// of all organs of the same kind. The pine needle now uses an +// `EllipticProfile` whose major/minor vary along arc length; the +// maize/sorghum leaf blade can reuse the same two-axis interface. +// +// Phase 1 invariant: `CircularProfile` with N samples produces a regular +// N-gon identical to the existing GenerateUnitCylinderMesh tessellation +// modulo orientation, so the new mesher is a strict superset of the old +// straight-cylinder behaviour. +// --------------------------------------------------------------------------- + +#include +#include +#include +#include + +#include +#include + +namespace l_system_package { + +/// One sample around the perimeter of the cross-section, expressed in the +/// 2D basis (normal, binormal) anchored to the centerline frame. +struct CrossSectionSample { + glm::vec2 radial_offset = glm::vec2(0.0f); ///< (n, b) coordinates of the surface point. + glm::vec2 outward_normal = glm::vec2(1, 0); ///< Surface outward direction (unit). + float u = 0.0f; ///< Parameter in [0,1) used for V tex coord. + bool seam = false; ///< Profile starts/ends here (open profiles only). +}; + +/// Polymorphic interface so the mesher is profile-agnostic. +class CrossSectionProfile { + public: + virtual ~CrossSectionProfile() = default; + + /// Returns true for closed profiles (full perimeter, last vertex stitches + /// back to first), false for open profiles (e.g. a leaf blade with two + /// distinct edges). + virtual bool IsClosed() const = 0; + + /// Sample the perimeter at `sample_count` evenly spaced parameters and + /// fill `out_samples` (cleared first). `radius` is the per-station radius + /// supplied by the mesher; profile implementations may interpret it + /// freely (e.g. semi-major axis for ellipses). + virtual void Sample(int sample_count, float radius, std::vector& out_samples) const = 0; + + /// Optional two-axis sampling path for anisotropic cross-sections (ellipse + /// major/minor semi-axes). Profiles that are axis-symmetric can ignore the + /// secondary radius and reuse the legacy one-axis implementation. + virtual void SampleWithAxes(int sample_count, float primary_radius, float secondary_radius, + std::vector& out_samples) const { + (void)secondary_radius; + Sample(sample_count, primary_radius, out_samples); + } +}; + +// --------------------------------------------------------------------------- +// CircularProfile - closed, regular n-gon. Default for woody internodes and +// the Phase 1 visual-no-op fallback for needles. +// --------------------------------------------------------------------------- +class CircularProfile final : public CrossSectionProfile { + public: + bool IsClosed() const override { + return true; + } + + void Sample(int sample_count, float radius, std::vector& out_samples) const override { + out_samples.clear(); + const int n = std::max(3, sample_count); + out_samples.reserve(static_cast(n)); + const float two_pi = glm::two_pi(); + for (int i = 0; i < n; ++i) { + const float u = static_cast(i) / static_cast(n); + const float theta = u * two_pi; + const float c = std::cos(theta); + const float s = std::sin(theta); + CrossSectionSample sample; + sample.radial_offset = glm::vec2(c, s) * radius; + sample.outward_normal = glm::vec2(c, s); + sample.u = u; + sample.seam = (i == 0); + out_samples.push_back(sample); + } + } +}; + +// --------------------------------------------------------------------------- +// SemiCircularProfile - closed, flat-adaxial / convex-abaxial. Models the +// actual cross-section of a Scots pine fascicle needle (each needle in a +// 2-needle bundle is approximately a semicircle whose flat face touches its +// sibling along the bundle axis). +// +// Layout in the (n=adaxial, b=binormal) basis: +// +// flat side +------------------+ adaxial face (n = -radius, flat across b) +// | | +// | convex abaxial | +// | side | +// +------------------+ +// arc bulges toward +n +// +// `n_flat_samples` controls how many vertices lie on the flat edge; the +// remaining `sample_count - n_flat_samples` cover the convex half-arc. +// --------------------------------------------------------------------------- +class SemiCircularProfile final : public CrossSectionProfile { + public: + explicit SemiCircularProfile(float adaxial_offset = 0.0f, int n_flat_samples = 2) + : adaxial_offset_(adaxial_offset), n_flat_samples_(std::max(2, n_flat_samples)) { + } + + bool IsClosed() const override { + return true; + } + + void Sample(int sample_count, float radius, std::vector& out_samples) const override { + out_samples.clear(); + const int n_total = std::max(n_flat_samples_ + 3, sample_count); + const int n_arc = n_total - n_flat_samples_; + out_samples.reserve(static_cast(n_total)); + + // Flat adaxial edge: parameterized along -binormal -> +binormal at + // n = -adaxial_offset (flush with the bundle axis when offset = 0). + for (int i = 0; i < n_flat_samples_; ++i) { + const float t = static_cast(i) / static_cast(n_flat_samples_ - 1); + const float b = (-1.0f + 2.0f * t) * radius; + CrossSectionSample s; + s.radial_offset = glm::vec2(-adaxial_offset_, b); + s.outward_normal = glm::vec2(-1.0f, 0.0f); // adaxial side faces -n. + s.u = t * 0.5f; // first half of u. + s.seam = (i == 0); + out_samples.push_back(s); + } + + // Convex abaxial half: half-arc from +binormal back to -binormal through + // +normal. theta runs 0..pi where theta=0 is at (+0, +radius) and + // theta=pi is at (+0, -radius), with peak at (+radius, 0). + for (int i = 1; i < n_arc; ++i) { + const float t = static_cast(i) / static_cast(n_arc); + const float theta = t * glm::pi(); + const float c = std::cos(theta); // 1 -> -1 + const float s = std::sin(theta); // 0 -> 0 (peak at theta=pi/2 = 1) + CrossSectionSample sample; + sample.radial_offset = glm::vec2(s * radius, c * radius); + sample.outward_normal = glm::vec2(s, c); + sample.u = 0.5f + 0.5f * t; + sample.seam = false; + out_samples.push_back(sample); + } + } + + private: + float adaxial_offset_; ///< Pushes the flat face away from the bundle axis (m). + int n_flat_samples_; ///< Vertices laid along the flat adaxial edge. +}; + +// --------------------------------------------------------------------------- +// EllipticProfile - closed ellipse. Stub for the future maize/sorghum leaf +// blade; semi_minor varies along arc length will be done by the mesher +// passing a different `radius` at every station, with a profile-specific +// aspect ratio applied here. Wired up here so the maize port is data-only. +// --------------------------------------------------------------------------- +class EllipticProfile final : public CrossSectionProfile { + public: + EllipticProfile(float aspect_ratio = 1.0f, float twist_radians = 0.0f) + : aspect_(std::max(0.05f, aspect_ratio)), twist_(twist_radians) { + } + + bool IsClosed() const override { + return true; + } + + void Sample(int sample_count, float radius, std::vector& out_samples) const override { + SampleWithAxes(sample_count, radius, radius * aspect_, out_samples); + } + + void SampleWithAxes(int sample_count, float primary_radius, float secondary_radius, + std::vector& out_samples) const override { + out_samples.clear(); + const int n = std::max(3, sample_count); + out_samples.reserve(static_cast(n)); + const float two_pi = glm::two_pi(); + const float ct = std::cos(twist_); + const float st = std::sin(twist_); + const float major_radius = std::max(1.0e-6f, primary_radius); + const float minor_radius = std::max(1.0e-6f, secondary_radius); + for (int i = 0; i < n; ++i) { + const float u = static_cast(i) / static_cast(n); + const float theta = u * two_pi; + const float c = std::cos(theta); + const float s = std::sin(theta); + // Ellipse in (n, b): semi-major along n, semi-minor along b. + glm::vec2 p(c * major_radius, s * minor_radius); + // Outward normal of the ellipse at parameter theta (un-normalized then normalised). + glm::vec2 nrm(c / major_radius, s / minor_radius); + const float l = std::sqrt(nrm.x * nrm.x + nrm.y * nrm.y); + if (l > 1e-20f) + nrm /= l; + // Rotate by twist_ in the (n, b) plane. + const glm::vec2 p_r(p.x * ct - p.y * st, p.x * st + p.y * ct); + const glm::vec2 n_r(nrm.x * ct - nrm.y * st, nrm.x * st + nrm.y * ct); + CrossSectionSample sample; + sample.radial_offset = p_r; + sample.outward_normal = n_r; + sample.u = u; + sample.seam = (i == 0); + out_samples.push_back(sample); + } + } + + private: + float aspect_; + float twist_; +}; + +} // namespace l_system_package \ No newline at end of file diff --git a/EvoEngine_Packages/LSystem/include/DerivationEngine.hpp b/EvoEngine_Packages/LSystem/include/DerivationEngine.hpp new file mode 100644 index 00000000..22f5aa1a --- /dev/null +++ b/EvoEngine_Packages/LSystem/include/DerivationEngine.hpp @@ -0,0 +1,401 @@ +#pragma once + +#include +#include +#include +#include +#include +#include "LSystemGraph.hpp" +#include "ProductionRule.hpp" + +namespace l_system_package { + +/** + * @brief Two-phase derivation engine for L-system graphs. + * + * Phase 1 (topology): applies topology_rules that may change graph structure + * (extend, branch, remove nodes). + * Phase 2 (growth): applies growth_rules that modify node data in-place + * (no topology changes). + * + * @tparam GraphData Whole-graph data type. + * @tparam FlowData Per-flow data type. + * @tparam ModuleData Per-node module data type (typically ModuleVariant<...>). + */ +template +class DerivationEngine { + public: + using GraphType = LSystemGraph; + using RuleType = ProductionRule; + using ContextType = RuleContext; + using ResultType = ProductionResult; + + std::vector topology_rules; ///< Rules that may alter graph topology. + std::vector growth_rules; ///< Rules that only modify node data in-place. + + /** + * @brief Perform one full derivation step (topology + growth). + * @param graph The L-system graph to derive. + * @param rng Random number generator for stochastic rules. + * @return true if topology changed, false if only growth updates occurred. + */ + bool Derive(GraphType& graph, std::mt19937& rng) const; + + /** + * @brief Perform n derivation steps. + * @param n Number of steps. + * @param graph The L-system graph. + * @param rng Random number generator. + * @return Number of steps where topology changed. + */ + int DeriveN(int n, GraphType& graph, std::mt19937& rng) const; + + private: + /** + * @brief Find the highest-priority matching rule for a node. + * @return Pointer to the matching rule, or nullptr if none matches. + */ + const RuleType* FindMatchingRule(const std::vector& rules, const ContextType& ctx) const; + + /** + * @brief Find the highest-priority matching rule from a prefiltered candidate list. + * @return Pointer to the matching rule, or nullptr if none matches. + */ + const RuleType* FindMatchingRule(const std::vector& rules, const ContextType& ctx) const; + + public: + /** + * @brief Apply topology phase: collect all rule matches, then apply extensions/removals. + * @return true if any topology change occurred. + */ + bool ApplyTopologyRules(GraphType& graph, std::mt19937& rng) const; + + /** + * @brief Apply growth phase: in-place data modifications only. + */ + void ApplyGrowthRules(GraphType& graph, std::mt19937& rng) const; +}; + +// ============================================================================= +// Template implementation +// ============================================================================= + +template +bool DerivationEngine::Derive(GraphType& graph, std::mt19937& rng) const { + bool topology_changed = ApplyTopologyRules(graph, rng); + ApplyGrowthRules(graph, rng); + return topology_changed; +} + +template +int DerivationEngine::DeriveN(int n, GraphType& graph, std::mt19937& rng) const { + int topology_changes = 0; + for (int i = 0; i < n; i++) { + if (Derive(graph, rng)) + topology_changes++; + } + return topology_changes; +} + +// Two-stage stochastic dispatch (P&L 1990 / vlab `cpfg` semantics). +// +// Stage 1: filter rules to the highest matching `priority`. +// Stage 2: +// - bucket size 1 -> return it (no RNG) +// - bucket size > 1 AND all probabilities default (1.0f) -> return first +// match (legacy +// "first wins"; +// consumes no RNG +// state) +// - bucket size > 1 AND any rule opts in (probability!=1) -> weighted random +// pick using +// ctx.rng with all +// bucket weights +// (vlab `SelectProd`). +// +// Negative or non-finite probabilities are clamped to 0. + +namespace derivation_engine_detail { + +template +const RuleType* PickFromBucket(const std::vector& bucket, std::mt19937& rng) { + if (bucket.empty()) + return nullptr; + if (bucket.size() == 1) + return bucket.front(); + + bool any_stochastic = false; + float total = 0.0f; + for (const auto* r : bucket) { + const float w = (r && std::isfinite(r->probability) && r->probability > 0.0f) ? r->probability : 0.0f; + if (r && r->probability != 1.0f) + any_stochastic = true; + total += w; + } + + if (!any_stochastic) { + // Legacy deterministic path: return first (preserves prior behavior of + // the original `if (!best || rule.priority > best->priority)` loop, + // which never replaced a same-priority earlier match). + return bucket.front(); + } + + if (total <= 0.0f) + return bucket.front(); + + std::uniform_real_distribution uni(0.0f, 1.0f); + float draw = total * uni(rng); + for (const auto* r : bucket) { + const float w = (r && std::isfinite(r->probability) && r->probability > 0.0f) ? r->probability : 0.0f; + draw -= w; + if (draw <= 0.0f) + return r; + } + return bucket.back(); +} + +} // namespace derivation_engine_detail + +template +const typename DerivationEngine::RuleType* +DerivationEngine::FindMatchingRule(const std::vector& rules, + const ContextType& ctx) const { + // Stage 1: find max priority among matching rules. + int max_prio = std::numeric_limits::min(); + bool any = false; + for (const auto& rule : rules) { + if (rule.predecessor_symbol >= 0 && rule.predecessor_symbol != ctx.self.symbol_id) + continue; + if (rule.condition && !rule.condition(ctx)) + continue; + if (!any || rule.priority > max_prio) { + max_prio = rule.priority; + any = true; + } + } + if (!any) + return nullptr; + + // Stage 2: collect bucket of matches at max_prio (stable order). + std::vector bucket; + bucket.reserve(rules.size()); + for (const auto& rule : rules) { + if (rule.priority != max_prio) + continue; + if (rule.predecessor_symbol >= 0 && rule.predecessor_symbol != ctx.self.symbol_id) + continue; + if (rule.condition && !rule.condition(ctx)) + continue; + bucket.push_back(&rule); + } + return derivation_engine_detail::PickFromBucket(bucket, ctx.rng); +} + +template +const typename DerivationEngine::RuleType* +DerivationEngine::FindMatchingRule(const std::vector& rules, + const ContextType& ctx) const { + // Stage 1: find max priority among matching rules. + int max_prio = std::numeric_limits::min(); + bool any = false; + for (const auto* rule : rules) { + if (!rule) + continue; + if (rule->predecessor_symbol >= 0 && rule->predecessor_symbol != ctx.self.symbol_id) + continue; + if (rule->condition && !rule->condition(ctx)) + continue; + if (!any || rule->priority > max_prio) { + max_prio = rule->priority; + any = true; + } + } + if (!any) + return nullptr; + + // Stage 2: collect bucket of matches at max_prio (stable order). + std::vector bucket; + bucket.reserve(rules.size()); + for (const auto* rule : rules) { + if (!rule || rule->priority != max_prio) + continue; + if (rule->predecessor_symbol >= 0 && rule->predecessor_symbol != ctx.self.symbol_id) + continue; + if (rule->condition && !rule->condition(ctx)) + continue; + bucket.push_back(rule); + } + return derivation_engine_detail::PickFromBucket(bucket, ctx.rng); +} + +template +bool DerivationEngine::ApplyTopologyRules(GraphType& graph, std::mt19937& rng) const { + if (topology_rules.empty()) + return false; + + graph.SortLists(); + const auto& sorted = graph.PeekSortedNodeList(); + + int max_symbol = -1; + std::vector wildcard_rules; + wildcard_rules.reserve(topology_rules.size()); + for (const auto& rule : topology_rules) { + if (rule.predecessor_symbol >= 0) { + max_symbol = std::max(max_symbol, rule.predecessor_symbol); + } else { + wildcard_rules.push_back(&rule); + } + } + + std::vector> candidate_rules_by_symbol; + if (max_symbol >= 0) { + candidate_rules_by_symbol.resize(static_cast(max_symbol) + 1); + for (int symbol = 0; symbol <= max_symbol; symbol++) { + auto& candidates = candidate_rules_by_symbol[static_cast(symbol)]; + candidates.reserve(topology_rules.size()); + for (const auto& rule : topology_rules) { + if (rule.predecessor_symbol < 0 || rule.predecessor_symbol == symbol) { + candidates.push_back(&rule); + } + } + } + } + + // Phase 1a: Collect all matches before modifying topology. + // We store (handle, result) pairs. We iterate in BFS order (root->leaves). + struct PendingAction { + LNodeHandle handle; + ResultType result; + }; + std::vector pending; + + for (const auto& node_handle : sorted) { + const auto& node = graph.PeekNode(node_handle); + const auto* parent_ptr = node.GetParentHandle() >= 0 ? &graph.PeekNode(node.GetParentHandle()) : nullptr; + ContextType ctx{node, parent_ptr, node.PeekChildHandles(), graph, rng, node_handle}; + + const RuleType* rule = nullptr; + if (node.symbol_id >= 0 && node.symbol_id <= max_symbol) { + const auto& candidates = candidate_rules_by_symbol[static_cast(node.symbol_id)]; + rule = FindMatchingRule(candidates, ctx); + } else if (!wildcard_rules.empty()) { + rule = FindMatchingRule(wildcard_rules, ctx); + } + if (!rule) + continue; + + // Need mutable context for produce. + ContextType mut_ctx{node, parent_ptr, node.PeekChildHandles(), graph, rng, node_handle}; + auto result = rule->produce(mut_ctx); + + // Only collect if the result actually changes topology. + if (result.IsDeath() || result.IsExtension()) + pending.push_back({node_handle, std::move(result)}); + else if (!result.successors.empty()) { + // Size 1 = in-place update (topology rule that decided not to change topology this step). + auto& mut_node = graph.RefNode(node_handle); + mut_node.symbol_id = result.successors[0].symbol_id; + mut_node.data = result.successors[0].data; + } + } + + if (pending.empty()) + return false; + + // Phase 1b: Apply topology changes. + // Process deaths and extensions. Extensions create new nodes; deaths remove nodes. + // We process extensions first (they don't invalidate existing handles via swap-pop), + // then deaths (which do invalidate via swap-pop, but we batch them). + + std::vector nodes_to_remove; + + for (auto& [handle, result] : pending) { + if (result.IsDeath()) { + nodes_to_remove.push_back(handle); + } else if (result.IsExtension()) { + // First successor: overwrite the existing node in-place. + auto& existing_node = graph.RefNode(handle); + existing_node.symbol_id = result.successors[0].symbol_id; + existing_node.data = result.successors[0].data; + + // Remaining successors: extend from this node. + for (size_t s = 1; s < result.successors.size(); s++) { + const auto& succ = result.successors[s]; + auto new_handle = graph.Extend(handle, succ.is_branch); + auto& new_node = graph.RefNode(new_handle); + new_node.symbol_id = succ.symbol_id; + new_node.data = succ.data; + } + } + } + + if (!nodes_to_remove.empty()) { + graph.RemoveNodes(nodes_to_remove); + } + + graph.SortLists(); + return true; +} + +template +void DerivationEngine::ApplyGrowthRules(GraphType& graph, std::mt19937& rng) const { + if (growth_rules.empty()) + return; + + graph.SortLists(); + const auto& sorted = graph.PeekSortedNodeList(); + + int max_symbol = -1; + std::vector wildcard_rules; + wildcard_rules.reserve(growth_rules.size()); + for (const auto& rule : growth_rules) { + if (rule.predecessor_symbol >= 0) { + max_symbol = std::max(max_symbol, rule.predecessor_symbol); + } else { + wildcard_rules.push_back(&rule); + } + } + + std::vector> candidate_rules_by_symbol; + if (max_symbol >= 0) { + candidate_rules_by_symbol.resize(static_cast(max_symbol) + 1); + for (int symbol = 0; symbol <= max_symbol; symbol++) { + auto& candidates = candidate_rules_by_symbol[static_cast(symbol)]; + candidates.reserve(growth_rules.size()); + for (const auto& rule : growth_rules) { + if (rule.predecessor_symbol < 0 || rule.predecessor_symbol == symbol) { + candidates.push_back(&rule); + } + } + } + } + + for (const auto& node_handle : sorted) { + const auto& node = graph.PeekNode(node_handle); + const auto* parent_ptr = node.GetParentHandle() >= 0 ? &graph.PeekNode(node.GetParentHandle()) : nullptr; + ContextType ctx{node, parent_ptr, node.PeekChildHandles(), graph, rng, node_handle}; + + const RuleType* rule = nullptr; + if (node.symbol_id >= 0 && node.symbol_id <= max_symbol) { + const auto& candidates = candidate_rules_by_symbol[static_cast(node.symbol_id)]; + rule = FindMatchingRule(candidates, ctx); + } else if (!wildcard_rules.empty()) { + rule = FindMatchingRule(wildcard_rules, ctx); + } + + if (!rule) + continue; + + ContextType mut_ctx{node, parent_ptr, node.PeekChildHandles(), graph, rng, node_handle}; + auto result = rule->produce(mut_ctx); + + // Growth rules can only do in-place modifications (size == 1). + if (!result.successors.empty()) { + auto& mut_node = graph.RefNode(node_handle); + mut_node.symbol_id = result.successors[0].symbol_id; + mut_node.data = result.successors[0].data; + } + } +} + +} // namespace l_system_package \ No newline at end of file diff --git a/EvoEngine_Packages/LSystem/include/DistributionDefaults.hpp b/EvoEngine_Packages/LSystem/include/DistributionDefaults.hpp new file mode 100644 index 00000000..ea71b8b3 --- /dev/null +++ b/EvoEngine_Packages/LSystem/include/DistributionDefaults.hpp @@ -0,0 +1,52 @@ +#pragma once + +#include +#include +#include + +namespace l_system_package { + +class DistributionDefaults { + public: + struct PlottedDistributionUiEntry { + const char* label = ""; + evo_engine::PlottedDistribution* distribution = nullptr; + const char* tip = ""; + }; + + struct SingleDistributionUiPreset { + float speed = 0.01f; + const char* format = "%.3f"; + std::string tip; + }; + + static void SetCurveLinear01(evo_engine::Curve2D& curve, int sample_count = 9); + static void SetCurveFlat(evo_engine::Curve2D& curve, float y_value = 0.0f, int sample_count = 2); + + static void ApplyMeanPlotDefaults(evo_engine::Plot2D& plot); + static void ApplyStdPlotDefaults(evo_engine::Plot2D& plot); + static void ApplyMeanStdPlotDefaults(evo_engine::PlottedDistribution& distribution); + static void ApplyLinearGrowthCurveDefaults(evo_engine::PlottedDistribution& distribution, + float mean_start = 0.0f, float mean_end = 1.0f, int sample_count = 9); + + static void ApplySingleDefaults(evo_engine::SingleDistribution& distribution, float mean = 0.0f, + float deviation = 0.0f); + static evo_engine::SingleDistribution MakeSingleDefaults(float mean = 0.0f, float deviation = 0.0f); + + static evo_engine::PlottedDistributionSettings MakePlottedGuiSettings(const std::string& tip = ""); + static bool InspectPlottedDistributionCategory(const char* category_label, + std::initializer_list entries, + int tree_node_flags = 0); +}; + +template +void ApplyMeanStdPlotDefaultsToAll(TDistributions&... distributions) { + (DistributionDefaults::ApplyMeanStdPlotDefaults(distributions), ...); +} + +template +void ApplyLinearGrowthCurveDefaultsToAll(TDistributions&... distributions) { + (DistributionDefaults::ApplyLinearGrowthCurveDefaults(distributions), ...); +} + +} // namespace l_system_package \ No newline at end of file diff --git a/EvoEngine_Packages/LSystem/include/ElasticaSolver.hpp b/EvoEngine_Packages/LSystem/include/ElasticaSolver.hpp new file mode 100644 index 00000000..73e82623 --- /dev/null +++ b/EvoEngine_Packages/LSystem/include/ElasticaSolver.hpp @@ -0,0 +1,258 @@ +#pragma once + +// --------------------------------------------------------------------------- +// ElasticaSolver +// +// Phase 4: planar quasi-static elastica solver for a single rod-like organ. +// Pure-CPU, decoupled from L-system types so it is unit-testable in isolation. +// +// Problem statement (planar Euler-Bernoulli, large rotation, small strain): +// +// Rod parameterized by arc length s in [0, L]. State variable theta(s) is +// the tangent angle measured from the base tangent (theta(0) = 0). The +// rod has intrinsic curvature kappa_intrinsic(s) (the unloaded shape's +// curvature) and bending stiffness EI(s) > 0. A distributed load +// q_perp(s) (force per unit arc length perpendicular to the rod's +// in-plane bending axis) and an optional tip force F_tip act on the rod. +// +// Equilibrium of moments about a station s: +// +// EI(s) * (dtheta/ds(s) - kappa_intrinsic(s)) = M(s) +// +// where M(s) is the bending moment at s due to all loads distal to s +// (s' > s). For planar gravity loading, M(s) is computed from the +// in-plane lever arms of the distal weight (and tip force) about s. +// +// Algorithm (damped fixed-point iteration; converges in 3-6 iters at the +// load levels typical for needles/young internodes): +// +// 1. Compute station positions (x(s), z(s)) by integrating tangent. +// 2. Compute M(s) by tip-to-base sweep accumulating distal weights' lever arms. +// 3. Compute new dtheta/ds = kappa_intrinsic + M / EI; integrate base->tip. +// 4. Damp: theta = (1-alpha) * theta_old + alpha * theta_new. +// 5. Repeat until max |theta_new - theta_old| < tolerance. +// +// Validation (called from ScotsPineSmokeTest): +// +// Tip-loaded cantilever, kappa_intrinsic = 0, uniform EI, no distributed +// load, tip force W perpendicular to base tangent: small-deflection theory +// gives delta_tip = W * L^3 / (3 * EI). The solver reproduces this to +// well below 1% for W * L^2 / EI < 0.05. +// --------------------------------------------------------------------------- + +#include +#include +#include +#include + +#include + +namespace l_system_package { + +/// Inputs to a planar elastica solve. All quantities are in the rod's local +/// in-plane (x, z) frame, with the base at the origin and the base tangent +/// along +Z. Curvature is positive when the rod bends toward +X. +struct PlanarElasticaInput { + /// Total arc length L (m). Must be > 0. + float length_m = 0.0f; + /// Number of stations N+1 along the arc. Must be >= 3. + int station_count = 17; + /// kappa_intrinsic(s_norm) in 1/m, positive bends toward +X. + std::function intrinsic_curvature_per_m; + /// EI(s_norm) in Pa*m^4. Must be > 0 at every station. + std::function bending_stiffness_Pa_m4; + /// Gravitational acceleration in the rod's local in-plane (x, z) frame + /// (m/s^2). Combined with `mass_per_length_kg_m(s)` to produce the + /// distributed body force. Pass (0, 0) to disable distributed loading. + /// Sign convention: components in the same axes as the rod frame. + glm::vec2 gravity_acceleration_xz_m_s2 = glm::vec2(0.0f, 0.0f); + /// Per-station mass density (kg/m). When null or returning 0, distributed + /// loading is disabled regardless of `gravity_acceleration_xz_m_s2`. + std::function mass_per_length_kg_m; + /// Concentrated tip force (N) applied at s = L, in (x, z) plane. + glm::vec2 tip_force_N_xz = glm::vec2(0.0f, 0.0f); + /// Damping factor for fixed-point iteration in (0, 1]. + float relaxation = 0.6f; + /// Convergence tolerance on max |delta_theta| (radians). + float tolerance_rad = 1e-5f; + /// Maximum iteration count. + int max_iterations = 32; +}; + +/// Output of a planar elastica solve. +struct PlanarElasticaOutput { + /// Per-station tangent angle theta(s_i) in radians, theta(0) = 0. + std::vector theta_rad; + /// Per-station positions (x, z) in metres, position(0) = (0, 0). + std::vector positions_xz; + /// Per-station bending moment M(s_i) in N*m (informational; Phase 5 stress feedback). + std::vector bending_moment_Nm; + /// True iff the iteration converged below `tolerance_rad`. + bool converged = false; + /// Iterations actually performed. + int iterations_performed = 0; + /// Final max |delta_theta| at the last iteration (radians). + float final_max_delta_rad = 0.0f; +}; + +/// Solve the planar quasi-static elastica problem. +inline PlanarElasticaOutput SolvePlanarElastica(const PlanarElasticaInput& in) { + PlanarElasticaOutput out; + const int n = std::max(3, in.station_count); + if (in.length_m <= 0.0f) + return out; + const float L = in.length_m; + const float ds = L / static_cast(n - 1); + + // Pre-sample stiffness, intrinsic curvature, and mass per length so we + // do not pay lambda overhead in every iteration. + std::vector kappa_intr(n, 0.0f); + std::vector EI(n, 1.0f); + std::vector mu(n, 0.0f); + for (int i = 0; i < n; ++i) { + const float s_norm = static_cast(i) / static_cast(n - 1); + if (in.intrinsic_curvature_per_m) + kappa_intr[i] = in.intrinsic_curvature_per_m(s_norm); + if (in.bending_stiffness_Pa_m4) + EI[i] = std::max(1e-20f, in.bending_stiffness_Pa_m4(s_norm)); + if (in.mass_per_length_kg_m) + mu[i] = std::max(0.0f, in.mass_per_length_kg_m(s_norm)); + } + + // Initial guess: integrate intrinsic curvature only (unloaded shape). + out.theta_rad.assign(n, 0.0f); + for (int i = 1; i < n; ++i) { + const float kappa_avg = 0.5f * (kappa_intr[i - 1] + kappa_intr[i]); + out.theta_rad[i] = out.theta_rad[i - 1] + kappa_avg * ds; + } + + out.positions_xz.assign(n, glm::vec2(0.0f)); + out.bending_moment_Nm.assign(n, 0.0f); + + std::vector theta_new(n, 0.0f); + std::vector distal_load(n, glm::vec2(0.0f)); + std::vector distal_moment_arm(n, glm::vec2(0.0f)); + + const glm::vec2 g_xz = in.gravity_acceleration_xz_m_s2; + const glm::vec2 F_tip = in.tip_force_N_xz; + const float alpha = std::clamp(in.relaxation, 0.05f, 1.0f); + + for (int iter = 0; iter < std::max(1, in.max_iterations); ++iter) { + // (1) Integrate tangents -> positions. + out.positions_xz[0] = glm::vec2(0.0f); + for (int i = 1; i < n; ++i) { + const float t_a = out.theta_rad[i - 1]; + const float t_b = out.theta_rad[i]; + const float t_mid = 0.5f * (t_a + t_b); + out.positions_xz[i] = out.positions_xz[i - 1] + ds * glm::vec2(std::sin(t_mid), std::cos(t_mid)); + } + + // (2) Compute bending moment at every station via a tip->base sweep. + // M(s_i) = F_tip x (r_tip - r_i) + sum_{j > i} (mu_j * g_xz * ds) x (r_j - r_i) + // where x is the planar cross-product (returns scalar; sign chosen so + // a positive M increases theta toward +X). + auto planar_cross = [](const glm::vec2& a, const glm::vec2& b) { + // a x b = a.x * b.y - a.y * b.x, but our axes are (x, z); we want the + // scalar moment about the out-of-plane (+y) axis with right-handed + // orientation. With force F = (Fx, Fz) and lever r = (rx, rz), + // M_y = rz * Fx - rx * Fz. + return a.y * b.x - a.x * b.y; + }; + for (int i = 0; i < n; ++i) { + float M = 0.0f; + const glm::vec2 r_i = out.positions_xz[i]; + // Tip force contribution. + if (F_tip.x != 0.0f || F_tip.y != 0.0f) { + const glm::vec2 r_tip = out.positions_xz[n - 1]; + M += planar_cross(r_tip - r_i, F_tip); + } + // Distributed weight contribution. Trapezoidal: each station carries + // mu * g_xz * ds_segment as a point force at its position. + for (int j = i + 1; j < n; ++j) { + const float w_seg = (j == n - 1) ? 0.5f * ds : ds; // tip half-segment + const glm::vec2 force = g_xz * (mu[j] * w_seg); + if (force.x == 0.0f && force.y == 0.0f) + continue; + M += planar_cross(out.positions_xz[j] - r_i, force); + } + out.bending_moment_Nm[i] = M; + } + + // (3) Integrate dtheta/ds = kappa_intrinsic + M / EI base->tip. + theta_new[0] = 0.0f; + for (int i = 1; i < n; ++i) { + const float k_a = kappa_intr[i - 1] + out.bending_moment_Nm[i - 1] / EI[i - 1]; + const float k_b = kappa_intr[i] + out.bending_moment_Nm[i] / EI[i]; + theta_new[i] = theta_new[i - 1] + 0.5f * (k_a + k_b) * ds; + } + + // (4) Damped update + convergence check. + float max_delta = 0.0f; + for (int i = 0; i < n; ++i) { + const float blended = (1.0f - alpha) * out.theta_rad[i] + alpha * theta_new[i]; + max_delta = std::max(max_delta, std::abs(blended - out.theta_rad[i])); + out.theta_rad[i] = blended; + } + out.iterations_performed = iter + 1; + out.final_max_delta_rad = max_delta; + if (max_delta < in.tolerance_rad) { + out.converged = true; + break; + } + } + + // Final position pass with the converged theta (so caller sees a coherent + // (theta, positions) pair). + out.positions_xz[0] = glm::vec2(0.0f); + for (int i = 1; i < n; ++i) { + const float t_mid = 0.5f * (out.theta_rad[i - 1] + out.theta_rad[i]); + out.positions_xz[i] = out.positions_xz[i - 1] + ds * glm::vec2(std::sin(t_mid), std::cos(t_mid)); + } + return out; +} + +/// Analytic-cantilever validation: a uniform straight rod of length L and +/// stiffness EI loaded at its tip with force W perpendicular to the base +/// tangent has small-deflection tip displacement +/// +/// delta_tip = W * L^3 / (3 * EI) +/// +/// Returns (solver_tip_x, analytic_tip_x, relative_error). Use small W*L^2/EI +/// (< 0.05) to stay in the small-deflection regime where the analytic +/// formula is valid. +struct CantileverValidationResult { + float solver_tip_x_m = 0.0f; + float analytic_tip_x_m = 0.0f; + float relative_error = 0.0f; + bool converged = false; + int iterations = 0; +}; + +inline CantileverValidationResult RunCantileverValidation(const float length_m, const float EI_Pa_m4, + const float tip_force_N, const int station_count = 33) { + PlanarElasticaInput in; + in.length_m = length_m; + in.station_count = station_count; + in.bending_stiffness_Pa_m4 = [EI_Pa_m4](float) { + return EI_Pa_m4; + }; + in.intrinsic_curvature_per_m = [](float) { + return 0.0f; + }; + in.tip_force_N_xz = glm::vec2(tip_force_N, 0.0f); + in.relaxation = 0.7f; + in.tolerance_rad = 1e-7f; + in.max_iterations = 64; + const PlanarElasticaOutput o = SolvePlanarElastica(in); + CantileverValidationResult r; + r.solver_tip_x_m = o.positions_xz.empty() ? 0.0f : o.positions_xz.back().x; + r.analytic_tip_x_m = tip_force_N * length_m * length_m * length_m / (3.0f * EI_Pa_m4); + r.relative_error = (std::abs(r.analytic_tip_x_m) > 1e-12f) + ? std::abs(r.solver_tip_x_m - r.analytic_tip_x_m) / std::abs(r.analytic_tip_x_m) + : std::abs(r.solver_tip_x_m); + r.converged = o.converged; + r.iterations = o.iterations_performed; + return r; +} + +} // namespace l_system_package \ No newline at end of file diff --git a/EvoEngine_Packages/LSystem/include/GeneralizedCylinderMesher.hpp b/EvoEngine_Packages/LSystem/include/GeneralizedCylinderMesher.hpp new file mode 100644 index 00000000..a0243e9a --- /dev/null +++ b/EvoEngine_Packages/LSystem/include/GeneralizedCylinderMesher.hpp @@ -0,0 +1,190 @@ +#pragma once + +// --------------------------------------------------------------------------- +// GeneralizedCylinderMesher +// +// Phase 1 of the biologically-emergent organ growth substrate. Sweeps a +// CrossSectionProfile along an OrganCenterline at uniformly spaced arc- +// length stations, emitting an explicit triangle mesh (`evo_engine::Vertex` +// + `glm::uvec3` index list). Produces: +// +// * deterministic vertex ordering (station-major, then perimeter-major) +// so visual diffs and PLY snapshot tests are stable across runs, +// * smooth shading via per-vertex normals derived from the profile's +// `outward_normal` rotated into the centerline frame, +// * one anchor transform applied to every vertex so the same mesher is +// used for needles attached to different internodes / orientations. +// +// The mesher is allocation-aware: caller-supplied `out_vertices` / +// `out_indices` are *appended to*, not cleared, so a single mesh entity can +// aggregate every needle in the tree in one pass with zero per-needle +// allocations. +// +// Phase 1 contract: with a Straight() centerline and a CircularProfile, the +// emitted mesh has the same topology (segments * (stations - 1) quads) as the +// existing GenerateUnitCylinderMesh helper in ScotsPine.cpp, modulo the +// flexibility to vary radius along arc length. +// --------------------------------------------------------------------------- + +#include +#include +#include +#include + +#include +#include + +#include "CrossSectionProfile.hpp" +#include "OrganCenterline.hpp" + +namespace l_system_package { + +struct GeneralizedCylinderMesherConfig { + int station_count = 12; ///< Number of arc-length sampling stations along the centerline (>=2). + int perimeter_count = 8; ///< Number of perimeter samples per station (>=3 for closed profiles). + glm::vec4 vertex_color = glm::vec4(1.0f); + /// Optional station-wise colors over arc-length [0,1]. If non-empty, this + /// overrides `vertex_color` and is linearly interpolated by station index. + std::vector station_color_table; + /// Anchor transform applied to every emitted vertex. Position-only translation + + /// rotation; no scaling. Defaults to identity (organ-local frame == world). + glm::vec3 anchor_position = glm::vec3(0.0f); + glm::quat anchor_rotation = glm::quat(1, 0, 0, 0); + /// Mode for cross-section radius along arc length: + /// * if `radius_table.empty()`, uses constant `base_radius`. + /// * else linearly interpolates `radius_table` over arc length. + float base_radius = 0.05f; + std::vector radius_table; + /// Optional secondary axis radius (for ellipsoid cross-sections). + /// If `secondary_radius_table` is empty, `secondary_base_radius` is used. + float secondary_base_radius = 0.05f; + std::vector secondary_radius_table; + /// Reference adaxial direction at the base of the organ (in organ-local frame). + /// Drives the rotation-minimizing parallel-transport frame. + glm::vec3 base_adaxial = glm::vec3(1, 0, 0); +}; + +namespace mesher_detail { + +inline float ResolveRadius(const GeneralizedCylinderMesherConfig& cfg, float arc_length, float total_length) { + if (cfg.radius_table.empty() || total_length <= 0.0f) + return cfg.base_radius; + if (cfg.radius_table.size() == 1) + return cfg.radius_table.front(); + const float t = std::clamp(arc_length / total_length, 0.0f, 1.0f); + const float pos = t * static_cast(cfg.radius_table.size() - 1); + const int i = std::min(static_cast(cfg.radius_table.size()) - 2, static_cast(std::floor(pos))); + const float u = pos - static_cast(i); + return glm::mix(cfg.radius_table[i], cfg.radius_table[i + 1], u); +} + +inline float ResolveSecondaryRadius(const GeneralizedCylinderMesherConfig& cfg, float arc_length, float total_length) { + if (cfg.secondary_radius_table.empty() || total_length <= 0.0f) { + return cfg.secondary_base_radius; + } + if (cfg.secondary_radius_table.size() == 1) + return cfg.secondary_radius_table.front(); + const float t = std::clamp(arc_length / total_length, 0.0f, 1.0f); + const float pos = t * static_cast(cfg.secondary_radius_table.size() - 1); + const int i = std::min(static_cast(cfg.secondary_radius_table.size()) - 2, static_cast(std::floor(pos))); + const float u = pos - static_cast(i); + return glm::mix(cfg.secondary_radius_table[i], cfg.secondary_radius_table[i + 1], u); +} + +inline glm::vec4 ResolveStationColor(const GeneralizedCylinderMesherConfig& cfg, int station_index, int station_count) { + if (cfg.station_color_table.empty()) + return cfg.vertex_color; + if (cfg.station_color_table.size() == 1) + return cfg.station_color_table.front(); + const float denom = static_cast(std::max(1, station_count - 1)); + const float t = std::clamp(static_cast(station_index) / denom, 0.0f, 1.0f); + const float pos = t * static_cast(cfg.station_color_table.size() - 1); + const int i = std::min(static_cast(cfg.station_color_table.size()) - 2, static_cast(std::floor(pos))); + const float u = pos - static_cast(i); + return glm::mix(cfg.station_color_table[i], cfg.station_color_table[i + 1], u); +} + +} // namespace mesher_detail + +/// Sweep `profile` along `centerline` and append geometry to `out_vertices` / +/// `out_indices`. Returns the number of vertices appended (use as a future +/// re-entry index into `out_vertices`). +template +inline std::size_t SweepGeneralizedCylinder(const OrganCenterline& centerline, const CrossSectionProfile& profile, + const GeneralizedCylinderMesherConfig& cfg, + std::vector& out_vertices, std::vector& out_indices) { + const int n_stations = std::max(2, cfg.station_count); + const int n_perim = std::max(3, cfg.perimeter_count); + const float total_length = centerline.TotalLength(); + if (total_length <= 0.0f) + return 0; + + const std::size_t vertex_offset = out_vertices.size(); + std::vector profile_samples; + + // Cached per-station data so we can emit triangles between station i and i+1 + // without re-sampling. + for (int i = 0; i < n_stations; ++i) { + const float t = static_cast(i) / static_cast(n_stations - 1); + const float s = t * total_length; + const CenterlineSample frame = centerline.Sample(s, cfg.base_adaxial); + const float radius = mesher_detail::ResolveRadius(cfg, s, total_length); + const float secondary_radius = mesher_detail::ResolveSecondaryRadius(cfg, s, total_length); + const glm::vec4 station_color = mesher_detail::ResolveStationColor(cfg, i, n_stations); + profile.SampleWithAxes(n_perim, radius, secondary_radius, profile_samples); + + for (const auto& ps : profile_samples) { + const glm::vec3 local_pos = + frame.position + ps.radial_offset.x * frame.normal + ps.radial_offset.y * frame.binormal; + const glm::vec3 local_nrm = ps.outward_normal.x * frame.normal + ps.outward_normal.y * frame.binormal; + VertexT v{}; + v.position = cfg.anchor_position + cfg.anchor_rotation * local_pos; + v.normal = cfg.anchor_rotation * glm::normalize(local_nrm); + v.tangent = cfg.anchor_rotation * frame.tangent; + v.color = station_color; + v.tex_coord = glm::vec2(ps.u, t); + out_vertices.push_back(v); + } + } + + // Emit quads (two triangles each) between consecutive stations. + const bool closed = profile.IsClosed(); + const std::size_t emitted_per_station = static_cast(n_perim); + for (int station = 0; station < n_stations - 1; ++station) { + const std::size_t row_a = vertex_offset + static_cast(station) * emitted_per_station; + const std::size_t row_b = row_a + emitted_per_station; + const int last_p = closed ? n_perim : n_perim - 1; + for (int p = 0; p < last_p; ++p) { + const std::size_t p0 = row_a + static_cast(p); + const std::size_t p1 = row_a + static_cast((p + 1) % n_perim); + const std::size_t p2 = row_b + static_cast(p); + const std::size_t p3 = row_b + static_cast((p + 1) % n_perim); + // Outward winding (consistent with right-handed (n, b) frame). + out_indices.emplace_back(static_cast(p0), static_cast(p2), + static_cast(p1)); + out_indices.emplace_back(static_cast(p1), static_cast(p2), + static_cast(p3)); + } + } + + // Caps for closed profiles only (open profiles e.g. leaf blade need a + // distinct algorithm). Triangle fan from the first vertex of each end ring. + if (closed && n_perim >= 3) { + // Base cap: fan around station 0, normal = -tangent_at_base. + const std::size_t row_base = vertex_offset; + for (int p = 1; p + 1 < n_perim; ++p) { + out_indices.emplace_back(static_cast(row_base), static_cast(row_base + p + 1), + static_cast(row_base + p)); + } + // Tip cap: fan around last station. + const std::size_t row_tip = vertex_offset + static_cast(n_stations - 1) * emitted_per_station; + for (int p = 1; p + 1 < n_perim; ++p) { + out_indices.emplace_back(static_cast(row_tip), static_cast(row_tip + p), + static_cast(row_tip + p + 1)); + } + } + + return out_vertices.size() - vertex_offset; +} + +} // namespace l_system_package \ No newline at end of file diff --git a/EvoEngine_Packages/LSystem/include/GeometryPass.hpp b/EvoEngine_Packages/LSystem/include/GeometryPass.hpp new file mode 100644 index 00000000..3a6bdf4e --- /dev/null +++ b/EvoEngine_Packages/LSystem/include/GeometryPass.hpp @@ -0,0 +1,117 @@ +#pragma once + +#include +#include "LSystemGraph.hpp" + +namespace l_system_package { + +/** + * @brief Top-down geometry propagation pass for LSystemGraph. + * + * Traverses the graph in BFS (sorted) order, computing each node's + * `global_position` and `global_rotation` from its parent's transform plus + * its own local rotation and length. + * + * This is the graph-primary equivalent of a turtle interpreter. It does NOT + * interpret a linear string - it operates directly on graph node references. + * + * Convention: forward direction is -Z in local space; default root rotation + * maps this to world +Y (upward growth). + */ +struct GeometryPass { + /** + * @brief Execute the geometry propagation pass. + * + * For each node in sorted (BFS root->leaf) order: + * - Root nodes keep their existing global_position and global_rotation. + * - Non-root nodes: + * child.global_position = parent.info.GetGlobalEndPosition() + * child.global_rotation = parent.global_rotation * child_local_rotation + * + * The local rotation is obtained from the user-supplied callback, which + * can derive it from the node's module data (e.g., branching angles stored + * in the module parameters). + * + * @tparam GraphData Graph-wide data type. + * @tparam FlowData Per-flow data type. + * @tparam ModuleData Per-node module data type. + * @param graph The graph to update in-place. + * @param local_rotation_fn Optional callback returning the local rotation for + * a node relative to its parent. If nullptr, identity + * rotation is used (child inherits parent direction). + */ + template + static void Execute(LSystemGraph& graph, + std::function& node, const LGraphNode& parent)> + local_rotation_fn = nullptr); + + /** + * @brief Execute geometry propagation with a fixed root transform. + * + * Sets the first root's position and rotation before propagating. + * + * @param root_position Position of the root node. + * @param root_rotation Global rotation of the root node. + */ + template + static void Execute(LSystemGraph& graph, const glm::vec3& root_position, + const glm::quat& root_rotation, + std::function& node, const LGraphNode& parent)> + local_rotation_fn = nullptr); +}; + +// ============================================================================= +// Template implementations +// ============================================================================= + +template +void GeometryPass::Execute( + LSystemGraph& graph, + std::function& node, const LGraphNode& parent)> + local_rotation_fn) { + const auto& sorted = graph.PeekSortedNodeList(); + for (const auto handle : sorted) { + auto& node = graph.RefNode(handle); + const auto parent_handle = node.GetParentHandle(); + if (parent_handle == -1) { + // Root node: position and rotation stay as-is (set externally or default). + continue; + } + + const auto& parent = graph.PeekNode(parent_handle); + + // Child starts where parent ends. + node.info.global_position = parent.info.GetGlobalEndPosition(); + + // Child rotation = parent rotation composed with local rotation. + if (local_rotation_fn) { + glm::quat local_rot = local_rotation_fn(node, parent); + node.info.global_rotation = glm::normalize(parent.info.global_rotation * local_rot); + } else { + // No local rotation - inherit parent direction. + node.info.global_rotation = glm::normalize(parent.info.global_rotation); + } + } +} + +template +void GeometryPass::Execute( + LSystemGraph& graph, const glm::vec3& root_position, + const glm::quat& root_rotation, + std::function& node, const LGraphNode& parent)> + local_rotation_fn) { + // Set root transform(s). + const auto& sorted = graph.PeekSortedNodeList(); + for (const auto handle : sorted) { + auto& node = graph.RefNode(handle); + if (node.GetParentHandle() == -1) { + node.info.global_position = root_position; + node.info.global_rotation = glm::normalize(root_rotation); + } + } + + // Run standard propagation. + Execute(graph, local_rotation_fn); +} + +} // namespace l_system_package \ No newline at end of file diff --git a/EvoEngine_Packages/LSystem/include/GrowthField.hpp b/EvoEngine_Packages/LSystem/include/GrowthField.hpp new file mode 100644 index 00000000..84448b24 --- /dev/null +++ b/EvoEngine_Packages/LSystem/include/GrowthField.hpp @@ -0,0 +1,170 @@ +#pragma once + +// --------------------------------------------------------------------------- +// GrowthField +// +// Phase 3 of the biologically-emergent organ growth substrate. A growth field +// is the *driver* of intrinsic centerline curvature: it does not store a +// shape, it stores per-side elongation biases from which the equilibrium +// intrinsic curvature emerges. +// +// For a 1D rod (needle, internode), each material point has two opposing +// "sides" (adaxial = stem-facing / inner; abaxial = away / outer). A +// per-side elongation bias `g_side(s, t)` produces local strain differential +// across the cross-section diameter `d(s, t)`, which, integrated along arc +// length, becomes intrinsic curvature: +// +// kappa_eq(s) = (g_abaxial(s) - g_adaxial(s)) / d(s) +// +// This module provides only the *equilibrium* curvature (steady-state shape +// the rod relaxes toward as it matures). The time evolution is handled by +// the caller (typically `ContinuousGrowthState::Multiplier(t - t_init)`), +// so the actual instantaneous intrinsic curvature is the equilibrium value +// scaled by the organ's age multiplier. This keeps biology and timing +// decoupled. +// +// Default-constructed values are *inert* (all biases and gradients zero) so +// any organ that does not opt in produces kappa ~= 0 - preserving Phase 1/Phase 2 +// straight geometry exactly. Phase 5 (stress feedback) will modulate the +// stored biases at runtime; Phase 6 (internodes) reuses this same struct. +// +// 2D extension for maize/sorghum leaves (curl across blade *width* in +// addition to arc length) is deliberately deferred to a separate +// `BilateralGrowthField2D`; the 1D version here keeps the needle math +// compact and the per-needle CPU cost trivial. +// --------------------------------------------------------------------------- + +#include +#include +#include + +#include + +#include "OrganCenterline.hpp" + +namespace l_system_package { + +/// 1D bilateral growth field for a single rod-like organ. +/// +/// All units are dimensionless elongation rates (relative growth per unit +/// physiological time). Values are typically in [-0.2, 0.2]; larger values +/// produce sharper curvature. +struct BilateralGrowthField1D { + /// Elongation bias on the adaxial side (s = base..tip, uniform). + float adaxial_bias = 0.0f; + /// Elongation bias on the abaxial side (s = base..tip, uniform). + float abaxial_bias = 0.0f; + /// Linear gradient added to the (abaxial - adaxial) differential along + /// normalized arc length s_norm in [0, 1]. Positive values bias the tip + /// to bend more than the base (apical-dominant curvature). + float gradient_per_arclen = 0.0f; + /// Effective cross-section diameter used to convert strain differential + /// into curvature. For Pinus sylvestris: ~1.0 mm. Must be > 0 to be active. + float diameter_m = 0.0f; + + /// True iff every term is exactly zero (or diameter is unset). When true, + /// callers should skip integration and produce a straight centerline so + /// behavior is bit-identical to Phase 2. + [[nodiscard]] bool IsInert() const { + return diameter_m <= 0.0f || (adaxial_bias == 0.0f && abaxial_bias == 0.0f && gradient_per_arclen == 0.0f); + } + + /// Equilibrium intrinsic curvature (1/m) at normalized arc length + /// `s_norm` in [0, 1]. Positive values bend the rod toward the abaxial + /// side; negative values toward adaxial. Returns 0 when inert. + [[nodiscard]] float EquilibriumCurvature(const float s_norm) const { + if (IsInert()) + return 0.0f; + const float s = std::clamp(s_norm, 0.0f, 1.0f); + const float strain_diff = (abaxial_bias - adaxial_bias) + gradient_per_arclen * s; + return strain_diff / diameter_m; + } +}; + +/// Build a planar bent centerline by integrating the equilibrium curvature +/// of `field` along arc length `length`, scaled by `maturation_multiplier` +/// (the organ's age-driven growth multiplier in [0, 1]). The bend lies in +/// the local x-z plane: tangent starts along +Z, curvature rotates the +/// tangent toward +X (the adaxial->abaxial direction in the organ frame). +/// +/// Optional sinusoidal waviness is injected as an additional intrinsic +/// curvature term, with amplitude in degrees, frequency in cycles over +/// the full needle length, and a phase offset in radians. The wave uses a +/// tip-emphasized envelope so distal segments undulate more than basal ones. +/// +/// When the field is inert OR `maturation_multiplier <= 0`, this returns a +/// straight centerline equivalent to `OrganCenterline::Straight(length, segments)`. +/// This preserves the Phase 1/Phase 2 visual baseline whenever the descriptor +/// has not opted into curvature. +inline OrganCenterline BuildBentNeedleCenterline(const float length, const int segments, + const BilateralGrowthField1D& field, + const float maturation_multiplier = 1.0f, + const float sinusoidal_amplitude_deg = 0.0f, + const float sinusoidal_frequency_cycles = 0.0f, + const float sinusoidal_phase_rad = 0.0f) { + const int n = std::max(2, segments + 1); + const float clamped_length = std::max(0.0f, length); + constexpr float kPi = 3.14159265358979323846f; + constexpr float kTwoPi = 6.28318530717958647692f; + const float clamped_maturation = std::max(0.0f, maturation_multiplier); + const bool has_growth_field = !field.IsInert(); + const float clamped_wave_amplitude_deg = std::clamp(sinusoidal_amplitude_deg, 0.0f, 45.0f); + const float clamped_wave_frequency = std::clamp(sinusoidal_frequency_cycles, 0.0f, 12.0f); + const bool has_wave = clamped_wave_amplitude_deg > 0.0f && clamped_wave_frequency > 0.0f; + if ((!has_growth_field && !has_wave) || clamped_maturation <= 0.0f || clamped_length <= 0.0f) { + return OrganCenterline::Straight(clamped_length, segments); + } + const float wave_amplitude_rad = (clamped_wave_amplitude_deg * (kPi / 180.0f)) * std::min(clamped_maturation, 1.0f); + + // Sub-step the integration finely so that even short needles with high + // curvature land control points on a smooth arc. We integrate over a dense + // grid then resample to (n) control points for the Catmull-Rom spline. + const int sub_steps = std::max(64, n * 8); + const float ds = clamped_length / static_cast(sub_steps); + std::vector dense_positions; + dense_positions.reserve(static_cast(sub_steps + 1)); + dense_positions.emplace_back(0.0f, 0.0f, 0.0f); + + // Planar Frenet integration in the x-z plane: + // theta(0) = 0 (tangent along +Z) + // dtheta/ds = kappa(s_norm) * maturation_multiplier + // x(s) = integral sin(theta) ds, z(s) = integral cos(theta) ds + // Mid-point rule for second-order accuracy without an extra control flag. + float theta = 0.0f; + glm::vec3 cursor(0.0f); + for (int i = 0; i < sub_steps; ++i) { + const float s_mid = (static_cast(i) + 0.5f) * ds; + const float s_norm = s_mid / clamped_length; + float kappa = has_growth_field ? field.EquilibriumCurvature(s_norm) * clamped_maturation : 0.0f; + if (has_wave) { + // Tip-emphasized envelope: suppress base wobble and keep most wave + // expression toward distal needle segments. + const float envelope = std::clamp(s_norm * s_norm, 0.0f, 1.0f); + const float phase = kTwoPi * clamped_wave_frequency * s_norm + sinusoidal_phase_rad; + const float kappa_wave = + (wave_amplitude_rad * kTwoPi * clamped_wave_frequency * envelope * std::cos(phase)) / clamped_length; + kappa += kappa_wave; + } + const float theta_mid = theta + 0.5f * kappa * ds; + cursor.x += std::sin(theta_mid) * ds; + cursor.z += std::cos(theta_mid) * ds; + theta += kappa * ds; + dense_positions.push_back(cursor); + } + + // Resample to (n) control points by uniform index spacing on the dense + // grid. Control points end up roughly arc-length-uniform because the + // dense grid itself is arc-length-uniform. + OrganCenterline centerline; + auto& cps = centerline.MutableControlPoints(); + cps.resize(static_cast(n)); + for (int i = 0; i < n; ++i) { + const float u = static_cast(i) / static_cast(n - 1); + const int idx = std::clamp(static_cast(std::round(u * static_cast(sub_steps))), 0, sub_steps); + cps[static_cast(i)] = dense_positions[static_cast(idx)]; + } + centerline.Invalidate(); + return centerline; +} + +} // namespace l_system_package \ No newline at end of file diff --git a/EvoEngine_Packages/LSystem/include/GrowthFunction.hpp b/EvoEngine_Packages/LSystem/include/GrowthFunction.hpp new file mode 100644 index 00000000..744b71ba --- /dev/null +++ b/EvoEngine_Packages/LSystem/include/GrowthFunction.hpp @@ -0,0 +1,158 @@ +#pragma once + +// --------------------------------------------------------------------------- +// GrowthFunction +// +// Phase 2 of the biologically-emergent organ growth substrate. Each organ +// follows a determinate growth trajectory: starts slowly, accelerates, +// asymptotes to its mature size. This header provides four sigmoidal / +// polynomial families, all normalized so that: +// +// Value(t <= 0) == 0 +// Value(t == 1) ~= 1 (within tolerance set by `kAsymptoteTolerance`) +// Value(t >= ~) -> 1 +// +// Time `t` is the *normalized* organ age in [0, +inf): 0 at initiation, +// 1 at "nominal maturity". The caller is responsible for the conversion +// `t = (clock.now_years - t_init_years) / maturation_years`. +// +// `Derivative(t)` returns d(Value)/dt in the same normalized time. Useful +// for elastica feedback in Phase 5 (high derivative => active elongation +// region; high stress here both slows growth and conditions stiffness). +// +// All evaluators are pure functions; the GrowthFunction struct itself is a +// small POD that selects the family and its parameters. Safe to pass by +// value into compute shaders later. +// --------------------------------------------------------------------------- + +#include +#include +#include + +namespace l_system_package { + +constexpr float kAsymptoteTolerance = 1e-4f; + +enum class GrowthFunctionKind : std::uint8_t { + /// Symmetric logistic: f(t) = 1 / (1 + exp(-k(t - 0.5))) renormalized to f(0)=0, f(1)~1. + /// Default for tissue elongation under steady auxin/cytokinin balance. + Logistic = 0, + /// Richards (generalized logistic): f(t) = (1 + (nu) * exp(-k(t - tm)))^(-1/nu). + /// `nu > 1` skews growth peak later (toward tip); `nu < 1` earlier (toward base). + /// Used for asymmetric organs whose maximum growth rate occurs off-center. + Richards = 1, + /// Gompertz: f(t) = exp(-b * exp(-c * t)) renormalized. + /// Heavily right-skewed; matches needle elongation in long-day flush conditions. + Gompertz = 2, + /// Cubic Hermite: smooth-step 3t^2 - 2t^3, derivative zero at endpoints. + /// Cheapest; useful as a baseline / unit-test reference. + Cubic = 3, + /// Monotonic half-cosine easing in [0,1]: f(t)=0.5-0.5*cos(pi*t). + /// Provides sinusoidal growth timing without shrink/regrow oscillations. + Sinusoidal = 4, +}; + +struct GrowthFunction { + GrowthFunctionKind kind = GrowthFunctionKind::Logistic; + /// Logistic / Richards steepness (higher = sharper transition near tm). + float k = 8.0f; + /// Richards shape parameter nu > 0. + float nu = 1.0f; + /// Gompertz displacement b (controls onset delay). + float b = 4.0f; + /// Gompertz rate c. + float c = 4.0f; + /// Logistic / Richards midpoint in normalized time. + float tm = 0.5f; + + /// Normalized growth output in [0, ~1]. `t <= 0` returns 0. + float Value(float t) const { + if (!std::isfinite(t)) + return 0.0f; + if (t <= 0.0f) + return 0.0f; + switch (kind) { + case GrowthFunctionKind::Logistic: { + const float raw = 1.0f / (1.0f + std::exp(-k * (t - tm))); + const float r0 = 1.0f / (1.0f + std::exp(-k * (0.0f - tm))); + const float r1 = 1.0f / (1.0f + std::exp(-k * (1.0f - tm))); + return std::clamp((raw - r0) / std::max(kAsymptoteTolerance, (r1 - r0)), 0.0f, 1.0f); + } + case GrowthFunctionKind::Richards: { + const float nu_safe = std::max(0.05f, nu); + auto eval = [&](float x) { + return std::pow(1.0f + nu_safe * std::exp(-k * (x - tm)), -1.0f / nu_safe); + }; + const float r0 = eval(0.0f); + const float r1 = eval(1.0f); + return std::clamp((eval(t) - r0) / std::max(kAsymptoteTolerance, (r1 - r0)), 0.0f, 1.0f); + } + case GrowthFunctionKind::Gompertz: { + auto eval = [&](float x) { + return std::exp(-b * std::exp(-c * x)); + }; + const float r0 = eval(0.0f); + const float r1 = eval(1.0f); + return std::clamp((eval(t) - r0) / std::max(kAsymptoteTolerance, (r1 - r0)), 0.0f, 1.0f); + } + case GrowthFunctionKind::Cubic: { + if (t >= 1.0f) + return 1.0f; + return t * t * (3.0f - 2.0f * t); + } + case GrowthFunctionKind::Sinusoidal: { + if (t >= 1.0f) + return 1.0f; + const float x = std::clamp(t, 0.0f, 1.0f); + constexpr float kPi = 3.14159265358979323846f; + return 0.5f - 0.5f * std::cos(kPi * x); + } + } + return 0.0f; + } + + /// Derivative d(Value)/dt at normalized time t. Approximate (analytic for + /// Cubic and unnormalized logistic / Gompertz; central-difference for the + /// renormalized variants - accuracy not critical for Phase 5 feedback). + float Derivative(float t) const { + if (!std::isfinite(t)) + return 0.0f; + if (t <= 0.0f || t >= 1.0f + 1e-3f) + return 0.0f; + if (kind == GrowthFunctionKind::Cubic) { + return 6.0f * t * (1.0f - t); + } + if (kind == GrowthFunctionKind::Sinusoidal) { + const float x = std::clamp(t, 0.0f, 1.0f); + constexpr float kPi = 3.14159265358979323846f; + return 0.5f * kPi * std::sin(kPi * x); + } + constexpr float h = 1e-3f; + return (Value(t + h) - Value(t - h)) * (1.0f / (2.0f * h)); + } +}; + +/// Per-organ continuous-growth state. Stored on every module that has a +/// determinate trajectory (PineNeedleCluster, PineInternode after Phase 6, +/// future maize/sorghum leaf). Owns the data needed to evaluate +/// `GrowthFunction.Value((clock.now() - t_init_years) / maturation_years)`. +struct ContinuousGrowthState { + float t_init_years = 0.0f; ///< Physiological time at module initiation. + float maturation_years = 1.0f; ///< Time to reach "nominal mature size" (Value(1)). + GrowthFunction function{}; ///< Family + parameters. + + /// Normalized age in [0, +inf). Negative ages clamp to 0. + float NormalizedAge(float clock_now_years) const { + if (maturation_years <= 0.0f) + return 1.0f; + const float dt = clock_now_years - t_init_years; + return std::max(0.0f, dt) / maturation_years; + } + + /// Convenience: the unnormalized growth multiplier for the organ's target size. + float Multiplier(float clock_now_years) const { + return function.Value(NormalizedAge(clock_now_years)); + } +}; + +} // namespace l_system_package \ No newline at end of file diff --git a/EvoEngine_Packages/LSystem/include/ILSystemExplorableDescriptor.hpp b/EvoEngine_Packages/LSystem/include/ILSystemExplorableDescriptor.hpp new file mode 100644 index 00000000..8e5088f5 --- /dev/null +++ b/EvoEngine_Packages/LSystem/include/ILSystemExplorableDescriptor.hpp @@ -0,0 +1,54 @@ +#pragma once + +#include + +// ILSystemExplorableDescriptor +// ---------------------------- +// Lightweight abstract interface that any L-System asset descriptor implements +// to opt-in to the generic ParamSpaceExplorer UI panel (random walk, Lissajous, +// linear interp, parallel coords, snapshots). +// +// C++ has no reflection; rather than hard-coding the explorer to a single +// descriptor type, descriptors enumerate their tunable distributions/curves +// once via RegisterExplorableAxes by calling explorer.AddSingle/AddPlotted/ +// AddCurve/AddAxis. ParamSpaceExplorer itself stays descriptor-agnostic. +// +// To add a new L-System asset descriptor with full explorer support: +// 1. inherit publicly from ILSystemExplorableDescriptor; +// 2. override RegisterExplorableAxes to list every field you want exposed; +// 3. own a `ParamSpaceExplorer explorer_` member (or share one externally); +// 4. in OnInspect call: +// if (!explorer_.IsBound()) explorer_.Bind(*this); +// explorer_.OnInspect(); + +namespace l_system_package { + +class ParamSpaceExplorer; + +class ILSystemExplorableDescriptor { + public: + virtual ~ILSystemExplorableDescriptor() = default; + + /// Populate the explorer's axis list. Called by ParamSpaceExplorer::Bind / + /// RebuildAxes after the explorer has cleared its axes_ container. The + /// implementation should call explorer.AddSingle/AddPlotted/AddCurve/AddAxis + /// for every parameter that should be tweakable from the panel. + virtual void RegisterExplorableAxes(ParamSpaceExplorer& explorer) = 0; + + /// Optional hook fired whenever the explorer mutates a parameter. Default + /// no-op; descriptors needing live re-instantiation can override. + virtual void OnExplorerParameterChanged() { + } + + /// Cheap fingerprint of the descriptor's *structural* shape (e.g. dynamic + /// array sizes such as tropism count). Used by ParamSpaceExplorer::OnInspect + /// to detect when axes_ became stale and an automatic RebuildAxes is needed. + /// Implementations must change this value whenever RegisterExplorableAxes + /// would emit a different number/order of axes. Default = 0 disables + /// auto-detection (the user-facing "Rebuild Axis Schema" button still works). + virtual uint64_t ExplorableSchemaFingerprint() const { + return 0; + } +}; + +} // namespace l_system_package \ No newline at end of file diff --git a/EvoEngine_Packages/LSystem/include/LSystemComponentBase.hpp b/EvoEngine_Packages/LSystem/include/LSystemComponentBase.hpp new file mode 100644 index 00000000..c69f7218 --- /dev/null +++ b/EvoEngine_Packages/LSystem/include/LSystemComponentBase.hpp @@ -0,0 +1,45 @@ +#pragma once + +#include +#include "IPrivateComponent.hpp" + +namespace l_system_package { +using namespace evo_engine; + +/** + * @brief Base class for L-System components, providing shared state and infancy reset. + */ +template +class LSystemComponentBase : public IPrivateComponent { + public: + /// Reference to the genotype descriptor asset. + AssetRef descriptor_ref; + + /// Seed for deterministic generation. + unsigned int seed = 42; + + /// Target GDD used by GrowToTargetGDD. + float target_gdd = 0.0f; + + /** + * @brief Resets the component to its infancy stage and clears geometry. + * + * Re-seeds if a new seed is provided, assigns the infancy GDD bound, + * and clears the geometry until explicitly generated. + */ + void ResetToInfancy(unsigned int optional_new_seed) { + seed = optional_new_seed; + target_gdd = static_cast(this)->GetInfancyTargetGDD(); + static_cast(this)->ClearGeometryEntities(); + } + + /** + * @brief Resets the component to its infancy stage without altering the seed and clears geometry. + */ + void ResetToInfancy() { + target_gdd = static_cast(this)->GetInfancyTargetGDD(); + static_cast(this)->ClearGeometryEntities(); + } +}; + +} // namespace l_system_package \ No newline at end of file diff --git a/EvoEngine_Packages/LSystem/include/LSystemDescriptor.hpp b/EvoEngine_Packages/LSystem/include/LSystemDescriptor.hpp new file mode 100644 index 00000000..17934e24 --- /dev/null +++ b/EvoEngine_Packages/LSystem/include/LSystemDescriptor.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include +#include +#include +#include "LSystemGraph.hpp" + +namespace l_system_package { + +/** + * @brief Serializable asset holding L-system derivation parameters. + * + * Stores the type-independent configuration for an L-system instance: + * derivation step count, RNG seed, root transform, and display options. + * + * Since production rules and module types are defined in C++ templates + * (Phase 1), this asset does not store the rules themselves - only the + * parameters that control derivation and geometry. User code instantiates + * the typed DerivationEngine and feeds it the parameters from this asset. + */ +class LSystemDescriptor : public evo_engine::IAsset { + public: + LSystemDescriptor(); + + int derivation_steps = 5; ///< Number of derivation iterations. + unsigned int seed = 42; ///< RNG seed for stochastic rules. + glm::vec3 root_position = glm::vec3(0.0f); ///< World-space root position. + glm::quat root_rotation = kDefaultRootRotation; ///< World-space root rotation (default: upward). + float default_length = 1.0f; ///< Default internode length. + float default_thickness = 0.1f; ///< Default internode thickness. + bool auto_derive_on_change = true; ///< Re-derive when parameters change in editor. + + bool OnInspect(const std::shared_ptr& editor_layer) override; + void Serialize(YAML::Emitter& out) const override; + void Deserialize(const YAML::Node& in) override; +}; + +} // namespace l_system_package \ No newline at end of file diff --git a/EvoEngine_Packages/LSystem/include/LSystemDescriptorDefaults.hpp b/EvoEngine_Packages/LSystem/include/LSystemDescriptorDefaults.hpp new file mode 100644 index 00000000..be384da8 --- /dev/null +++ b/EvoEngine_Packages/LSystem/include/LSystemDescriptorDefaults.hpp @@ -0,0 +1,71 @@ +#pragma once + +#include +#include +#include +#include + +namespace l_system_package::descriptor_defaults { + +template +std::filesystem::path ResolveFirstExistingAbsolutePath(const TCandidates& candidates) { + for (const auto& relative_candidate : candidates) { + const auto absolute_candidate = std::filesystem::absolute(relative_candidate); + if (std::filesystem::exists(absolute_candidate)) { + return absolute_candidate; + } + } + return {}; +} + +template +std::filesystem::path ResolveFirstExistingProjectAssetPath(const TCandidates& project_asset_candidates) { + const auto assets_folder = evo_engine::ProjectManager::GetAssetsFolderPath(); + if (assets_folder.empty()) { + return {}; + } + + for (const auto& relative_candidate : project_asset_candidates) { + const auto absolute_candidate = assets_folder / relative_candidate; + if (std::filesystem::exists(absolute_candidate)) { + return absolute_candidate; + } + } + return {}; +} + +template +std::filesystem::path ResolveExistingDefaultsPath(const TResourceCandidates& resource_candidates, + const TProjectAssetCandidates& project_asset_candidates) { + if (const auto existing_resource_path = ResolveFirstExistingAbsolutePath(resource_candidates); + !existing_resource_path.empty()) { + return existing_resource_path; + } + return ResolveFirstExistingProjectAssetPath(project_asset_candidates); +} + +template +std::filesystem::path ResolveWritableDefaultsPath(const TResourceCandidates& resource_candidates, + const TProjectAssetCandidates& project_asset_candidates, + const TWritableTemplateCandidates& writable_template_candidates, + const std::filesystem::path& fallback_relative_path) { + if (const auto existing = ResolveExistingDefaultsPath(resource_candidates, project_asset_candidates); + !existing.empty()) { + return existing; + } + + for (const auto& candidate : writable_template_candidates) { + const auto absolute_candidate = std::filesystem::absolute(candidate); + const auto parent = absolute_candidate.parent_path(); + if (parent.empty() || std::filesystem::exists(parent)) { + return absolute_candidate; + } + } + + return fallback_relative_path.empty() ? std::filesystem::path{} : std::filesystem::absolute(fallback_relative_path); +} + +bool LoadDefaultsYamlMap(const std::filesystem::path& file_path, YAML::Node& out_defaults, + const std::string& descriptor_name_for_logging); + +} // namespace l_system_package::descriptor_defaults \ No newline at end of file diff --git a/EvoEngine_Packages/LSystem/include/LSystemGraph.hpp b/EvoEngine_Packages/LSystem/include/LSystemGraph.hpp new file mode 100644 index 00000000..6d241633 --- /dev/null +++ b/EvoEngine_Packages/LSystem/include/LSystemGraph.hpp @@ -0,0 +1,1017 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace l_system_package { + +/// Default root rotation: maps the internal -Z forward to world +Y (upward). +/// Equivalent to a 90-degree rotation around the X axis. +inline const glm::quat kDefaultRootRotation(0.7071068f, 0.7071068f, 0.0f, 0.0f); + +typedef int LNodeHandle; +typedef int LFlowHandle; + +#pragma region Structural Info + +struct LNodeInfo { + struct TemporalState { + bool initialized = false; + float birth_thermal_gdd = 0.0f; + float age_thermal_gdd = 0.0f; + float birth_absolute_years = 0.0f; + float age_absolute_years = 0.0f; + }; + + bool locked = false; + + glm::vec3 global_position = glm::vec3(0.0f); + glm::quat global_rotation = glm::vec3(0.0f); + + float length = 0.0f; + float thickness = 0.1f; + float root_distance = 0.0f; + float end_distance = 0.0f; + int chain_index = 0; + glm::quat regulated_global_rotation = glm::vec3(0.0f); + + glm::vec4 color = glm::vec4(1.0f); + + TemporalState temporal{}; + + float volume = 0; + float descendant_total_volume = 0; + int order = 0; + int level = 0; + bool max_child = false; + + [[nodiscard]] glm::vec3 GetGlobalEndPosition() const; + [[nodiscard]] glm::vec3 GetGlobalCenterPosition() const; + [[nodiscard]] glm::vec3 GetGlobalDirection() const; +}; + +inline glm::vec3 LNodeInfo::GetGlobalEndPosition() const { + return global_position + glm::normalize(global_rotation * glm::vec3(0, 0, -1)) * length; +} + +inline glm::vec3 LNodeInfo::GetGlobalCenterPosition() const { + return global_position + glm::normalize(global_rotation * glm::vec3(0, 0, -1)) * length * 0.5f; +} + +inline glm::vec3 LNodeInfo::GetGlobalDirection() const { + return glm::normalize(global_rotation * glm::vec3(0, 0, -1)); +} + +struct LFlowInfo { + glm::vec3 global_start_position = glm::vec3(0.0f); + glm::quat global_start_rotation = glm::vec3(0.0f); + float start_thickness = 0.0f; + + glm::vec3 global_end_position = glm::vec3(0.0f); + glm::quat global_end_rotation = glm::vec3(0.0f); + float end_thickness = 0.0f; + + float flow_length = 0.0f; + int order = 0; +}; +#pragma endregion + +/** + * @brief A node in the L-system graph, representing a single module instance. + * @tparam ModuleData The typed data stored per-module (a std::variant of module types). + */ +template +class LGraphNode { + template + friend class LGraphFlow; + + template + friend class LSystemGraph; + + bool end_node_ = true; + LNodeHandle handle_ = -1; + LFlowHandle flow_handle_ = -1; + LNodeHandle parent_handle_ = -1; + std::vector child_handles_; + bool apical_ = true; + int index_ = -1; + + public: + int symbol_id = -1; ///< Which module type this node represents. + ModuleData data; ///< User-defined per-module data (variant or struct). + LNodeInfo info; ///< Structural geometry info. + + [[nodiscard]] bool IsEndNode() const; + [[nodiscard]] bool IsApical() const; + [[nodiscard]] LNodeHandle GetHandle() const; + [[nodiscard]] LNodeHandle GetParentHandle() const; + [[nodiscard]] LFlowHandle GetFlowHandle() const; + [[nodiscard]] const std::vector& PeekChildHandles() const; + [[nodiscard]] std::vector& UnsafeRefChildHandles(); + + LGraphNode() = default; + explicit LGraphNode(LNodeHandle handle); + [[nodiscard]] int GetIndex() const; +}; + +/** + * @brief A flow (linear chain of nodes) in the L-system graph. + * @tparam FlowData The type of data stored per-flow. + */ +template +class LGraphFlow { + template + friend class LSystemGraph; + + LFlowHandle handle_ = -1; + std::vector nodes_; + LFlowHandle parent_handle_ = -1; + std::vector child_handles_; + bool apical_ = false; + int index_ = -1; + + public: + FlowData data; + LFlowInfo info; + + [[nodiscard]] bool IsApical() const; + [[nodiscard]] LFlowHandle GetHandle() const; + [[nodiscard]] LFlowHandle GetParentHandle() const; + [[nodiscard]] const std::vector& PeekChildHandles() const; + [[nodiscard]] const std::vector& PeekNodeHandles() const; + + LGraphFlow() = default; + explicit LGraphFlow(LFlowHandle handle); + [[nodiscard]] int GetIndex() const; +}; + +struct BaseLGraphData {}; +struct BaseLFlowData {}; +struct BaseLModuleData {}; + +/** + * @brief The primary data structure for the L-system plugin. + * + * Forked from eco_sys_lab_plugin::Skeleton<> with L-system-specific additions. + * Operates as a graph of typed modules with flows representing linear chains. + * + * @tparam GraphData Whole-graph data (e.g., cumulative GDD, global state). + * @tparam FlowData Per-flow data. + * @tparam ModuleData Per-node module data (typically a std::variant of module types). + */ +template +class LSystemGraph { + template + friend class LSystemGraph; + + std::vector> flows_; + std::vector> nodes_; + + int new_version_ = 0; + int version_ = -1; + + std::vector sorted_node_list_; + std::vector sorted_flow_list_; + + LNodeHandle AllocateNode(); + LFlowHandle AllocateFlow(); + + void SetParentFlow(LFlowHandle target_handle, LFlowHandle parent_handle); + void DetachChildFlow(LFlowHandle target_handle, LFlowHandle child_handle); + void SetParentNode(LNodeHandle target_handle, LNodeHandle parent_handle); + void DetachChildNode(LNodeHandle target_handle, LNodeHandle child_handle); + + int max_node_index_ = -1; + int max_flow_index_ = -1; + + std::vector base_node_list_; + void RefreshBaseNodeList(); + + int max_level_ = 0; + int max_order_ = 0; + + public: + [[nodiscard]] int GetMaxLevel() const; + [[nodiscard]] int GetMaxOrder() const; + [[nodiscard]] int GetMaxNodeIndex() const; + [[nodiscard]] int GetMaxFlowIndex() const; + + GraphData data; ///< Whole-graph data. + + void CalculateDistanceVolumeLevel(); + void CalculateRegulatedGlobalRotation(); + void CalculateMinMax(); + + void RemoveNodes(const std::vector& node_handles); + + [[nodiscard]] LNodeHandle Extend(LNodeHandle target_handle, bool branching); + void ReparentNode(LNodeHandle target_handle, LNodeHandle new_parent_handle); + + [[nodiscard]] const std::vector& PeekBaseNodeList(); + [[nodiscard]] const std::vector& PeekSortedNodeList() const; + [[nodiscard]] std::vector GetSubTree(LNodeHandle base_node_handle) const; + [[nodiscard]] std::vector GetChainToRoot(LNodeHandle end_node_handle) const; + [[nodiscard]] const std::vector& PeekSortedFlowList() const; + + [[nodiscard]] std::vector>& RefRawFlows(); + [[nodiscard]] std::vector>& RefRawNodes(); + [[nodiscard]] const std::vector>& PeekRawFlows() const; + [[nodiscard]] const std::vector>& PeekRawNodes() const; + + void SortLists(); + + explicit LSystemGraph(unsigned initial_node_count = 1); + + [[nodiscard]] int GetVersion() const; + + void CalculateFlows(); + + LGraphNode& RefNode(LNodeHandle handle); + LGraphFlow& RefFlow(LFlowHandle handle); + [[nodiscard]] const LGraphNode& PeekNode(LNodeHandle handle) const; + [[nodiscard]] const LGraphFlow& PeekFlow(LFlowHandle handle) const; + + glm::vec3 min = glm::vec3(0.0f); + glm::vec3 max = glm::vec3(0.0f); +}; + +typedef LSystemGraph BaseLSystemGraph; + +// ============================================================================= +// Template implementations +// ============================================================================= + +#pragma region LGraphNode + +template +LGraphNode::LGraphNode(const LNodeHandle handle) { + handle_ = handle; + end_node_ = true; + symbol_id = -1; + data = {}; + info = {}; + index_ = -1; +} + +template +bool LGraphNode::IsEndNode() const { + return end_node_; +} + +template +bool LGraphNode::IsApical() const { + return apical_; +} + +template +LNodeHandle LGraphNode::GetHandle() const { + return handle_; +} + +template +LNodeHandle LGraphNode::GetParentHandle() const { + return parent_handle_; +} + +template +LFlowHandle LGraphNode::GetFlowHandle() const { + return flow_handle_; +} + +template +const std::vector& LGraphNode::PeekChildHandles() const { + return child_handles_; +} + +template +std::vector& LGraphNode::UnsafeRefChildHandles() { + return child_handles_; +} + +template +int LGraphNode::GetIndex() const { + return index_; +} + +#pragma endregion + +#pragma region LGraphFlow + +template +LGraphFlow::LGraphFlow(const LFlowHandle handle) { + handle_ = handle; + data = {}; + info = {}; + apical_ = false; + index_ = -1; +} + +template +int LGraphFlow::GetIndex() const { + return index_; +} + +template +const std::vector& LGraphFlow::PeekNodeHandles() const { + return nodes_; +} + +template +LFlowHandle LGraphFlow::GetParentHandle() const { + return parent_handle_; +} + +template +const std::vector& LGraphFlow::PeekChildHandles() const { + return child_handles_; +} + +template +LFlowHandle LGraphFlow::GetHandle() const { + return handle_; +} + +template +bool LGraphFlow::IsApical() const { + return apical_; +} + +#pragma endregion + +#pragma region LSystemGraph + +template +LSystemGraph::LSystemGraph(const unsigned initial_node_count) { + max_node_index_ = -1; + max_flow_index_ = -1; + for (unsigned i = 0; i < initial_node_count; i++) { + auto flow_handle = AllocateFlow(); + auto node_handle = AllocateNode(); + auto& root_flow = flows_[flow_handle]; + auto& root_node = nodes_[node_handle]; + root_node.flow_handle_ = flow_handle; + root_flow.nodes_.emplace_back(node_handle); + base_node_list_.emplace_back(node_handle); + } +} + +// --- Accessors --- + +template +LGraphFlow& LSystemGraph::RefFlow(LFlowHandle handle) { + assert(handle >= 0 && handle < static_cast(flows_.size())); + return flows_[handle]; +} + +template +const LGraphFlow& LSystemGraph::PeekFlow(LFlowHandle handle) const { + assert(handle >= 0 && handle < static_cast(flows_.size())); + return flows_[handle]; +} + +template +LGraphNode& LSystemGraph::RefNode(LNodeHandle handle) { + assert(handle >= 0 && handle < static_cast(nodes_.size())); + return nodes_[handle]; +} + +template +const LGraphNode& LSystemGraph::PeekNode(LNodeHandle handle) const { + assert(handle >= 0 && handle < static_cast(nodes_.size())); + return nodes_[handle]; +} + +template +int LSystemGraph::GetVersion() const { + return version_; +} + +template +int LSystemGraph::GetMaxNodeIndex() const { + return max_node_index_; +} + +template +int LSystemGraph::GetMaxFlowIndex() const { + return max_flow_index_; +} + +template +int LSystemGraph::GetMaxLevel() const { + return max_level_; +} + +template +int LSystemGraph::GetMaxOrder() const { + return max_order_; +} + +template +std::vector>& LSystemGraph::RefRawFlows() { + return flows_; +} + +template +std::vector>& LSystemGraph::RefRawNodes() { + return nodes_; +} + +template +const std::vector>& LSystemGraph::PeekRawFlows() const { + return flows_; +} + +template +const std::vector>& LSystemGraph::PeekRawNodes() const { + return nodes_; +} + +// --- Topology --- + +template +void LSystemGraph::SortLists() { + if (version_ == new_version_) + return; + version_ = new_version_; + sorted_flow_list_.clear(); + sorted_node_list_.clear(); + if (nodes_.empty()) { + base_node_list_.clear(); + return; + } + RefreshBaseNodeList(); + std::queue flow_wait_list; + std::queue node_wait_list; + + for (const auto& base_node_handle : base_node_list_) { + node_wait_list.push(base_node_handle); + flow_wait_list.push(nodes_[base_node_handle].flow_handle_); + } + + while (!flow_wait_list.empty()) { + sorted_flow_list_.emplace_back(flow_wait_list.front()); + flow_wait_list.pop(); + for (const auto& i : flows_[sorted_flow_list_.back()].child_handles_) { + flow_wait_list.push(i); + } + } + + while (!node_wait_list.empty()) { + sorted_node_list_.emplace_back(node_wait_list.front()); + node_wait_list.pop(); + for (const auto& i : nodes_[sorted_node_list_.back()].child_handles_) { + node_wait_list.push(i); + } + } +} + +template +const std::vector& LSystemGraph::PeekSortedFlowList() const { + return sorted_flow_list_; +} + +template +const std::vector& LSystemGraph::PeekSortedNodeList() const { + return sorted_node_list_; +} + +template +std::vector LSystemGraph::GetSubTree( + const LNodeHandle base_node_handle) const { + std::vector ret_val{}; + std::queue node_handles; + node_handles.push(base_node_handle); + while (!node_handles.empty()) { + auto next_node_handle = node_handles.front(); + ret_val.emplace_back(node_handles.front()); + node_handles.pop(); + for (const auto& child_handle : nodes_[next_node_handle].child_handles_) { + node_handles.push(child_handle); + } + } + return ret_val; +} + +template +std::vector LSystemGraph::GetChainToRoot( + const LNodeHandle end_node_handle) const { + std::vector ret_val{}; + LNodeHandle walker = end_node_handle; + while (walker != -1) { + ret_val.emplace_back(walker); + walker = nodes_[walker].parent_handle_; + } + return ret_val; +} + +template +LNodeHandle LSystemGraph::Extend(LNodeHandle target_handle, const bool branching) { + assert(target_handle < static_cast(nodes_.size())); + auto& target_node = nodes_[target_handle]; + assert(target_node.flow_handle_ < static_cast(flows_.size())); + auto new_node_handle = AllocateNode(); + SetParentNode(new_node_handle, target_handle); + auto& original_node = nodes_[target_handle]; + auto& new_node = nodes_[new_node_handle]; + original_node.end_node_ = false; + if (branching) { + auto new_flow_handle = AllocateFlow(); + auto& new_flow = flows_[new_flow_handle]; + + new_node.flow_handle_ = new_flow_handle; + new_node.apical_ = false; + new_flow.nodes_.emplace_back(new_node_handle); + new_flow.apical_ = false; + if (target_handle != flows_[original_node.flow_handle_].nodes_.back()) { + auto extended_flow_handle = AllocateFlow(); + auto& extended_flow = flows_[extended_flow_handle]; + extended_flow.apical_ = true; + auto& original_flow = flows_[original_node.flow_handle_]; + for (auto r = original_flow.nodes_.begin(); r != original_flow.nodes_.end(); ++r) { + if (*r == target_handle) { + extended_flow.nodes_.insert(extended_flow.nodes_.end(), r + 1, original_flow.nodes_.end()); + original_flow.nodes_.erase(r + 1, original_flow.nodes_.end()); + break; + } + } + for (const auto& extracted_node_handle : extended_flow.nodes_) { + auto& extracted_node = nodes_[extracted_node_handle]; + extracted_node.flow_handle_ = extended_flow_handle; + } + extended_flow.child_handles_ = original_flow.child_handles_; + original_flow.child_handles_.clear(); + for (const auto& child_flow_handle : extended_flow.child_handles_) { + flows_[child_flow_handle].parent_handle_ = extended_flow_handle; + } + SetParentFlow(extended_flow_handle, original_node.flow_handle_); + } + SetParentFlow(new_flow_handle, original_node.flow_handle_); + } else { + auto& flow = flows_[original_node.flow_handle_]; + flow.nodes_.emplace_back(new_node_handle); + new_node.flow_handle_ = original_node.flow_handle_; + new_node.apical_ = true; + } + new_version_++; + return new_node_handle; +} + +template +void LSystemGraph::ReparentNode(const LNodeHandle target_handle, + const LNodeHandle new_parent_handle) { + assert(target_handle >= 0 && new_parent_handle >= 0 && target_handle < static_cast(nodes_.size()) && + new_parent_handle < static_cast(nodes_.size())); + + if (target_handle == new_parent_handle) { + return; + } + + auto& target_node = nodes_[target_handle]; + if (target_node.parent_handle_ == new_parent_handle) { + return; + } + + // Guard against introducing a cycle. + for (LNodeHandle walker = new_parent_handle; walker != -1; walker = nodes_[walker].parent_handle_) { + if (walker == target_handle) { + return; + } + } + + if (target_node.parent_handle_ != -1) { + DetachChildNode(target_node.parent_handle_, target_handle); + } + + nodes_[new_parent_handle].end_node_ = false; + SetParentNode(target_handle, new_parent_handle); + new_version_++; +} + +template +const std::vector& LSystemGraph::PeekBaseNodeList() { + RefreshBaseNodeList(); + return base_node_list_; +} + +template +void LSystemGraph::RemoveNodes(const std::vector& node_handles) { + if (node_handles.empty() || nodes_.empty()) { + return; + } + + SortLists(); + std::unordered_map sorted_node_indices; + std::unordered_map sorted_flow_indices; + + for (uint32_t i = 0; i < sorted_node_list_.size(); i++) { + sorted_node_indices[sorted_node_list_[i]] = i; + } + for (uint32_t i = 0; i < sorted_flow_list_.size(); i++) { + sorted_flow_indices[sorted_flow_list_[i]] = i; + } + std::map collected_node_handle_set{}; + std::map collected_flow_handle_set{}; + + std::queue processing_node_handles; + std::vector visited(nodes_.size(), 0); + for (const auto& i : node_handles) { + if (i >= 0 && i < static_cast(nodes_.size())) { + processing_node_handles.emplace(i); + } + } + while (!processing_node_handles.empty()) { + auto node_handle = processing_node_handles.front(); + processing_node_handles.pop(); + if (node_handle < 0 || node_handle >= static_cast(nodes_.size())) { + continue; + } + if (visited[node_handle]) { + continue; + } + visited[node_handle] = 1; + + const auto node_sorted_it = sorted_node_indices.find(node_handle); + if (node_sorted_it == sorted_node_indices.end()) { + continue; + } + collected_node_handle_set[node_sorted_it->second] = node_handle; + + const auto& node = nodes_[node_handle]; + if (node.flow_handle_ >= 0 && node.flow_handle_ < static_cast(flows_.size())) { + const auto& flow = flows_[node.flow_handle_]; + if (!flow.nodes_.empty() && flow.nodes_.front() == node_handle) { + const auto flow_sorted_it = sorted_flow_indices.find(node.flow_handle_); + if (flow_sorted_it != sorted_flow_indices.end()) { + collected_flow_handle_set[flow_sorted_it->second] = node.flow_handle_; + } + } + } + for (const auto& child_node_handle : node.child_handles_) { + if (child_node_handle >= 0 && child_node_handle < static_cast(nodes_.size())) { + processing_node_handles.push(child_node_handle); + } + } + } + + std::vector sorted_node_handle_removal_list; + std::vector sorted_flow_handle_removal_list; + for (auto i = collected_node_handle_set.rbegin(); i != collected_node_handle_set.rend(); ++i) { + sorted_node_handle_removal_list.emplace_back(i->second); + } + for (auto i = collected_flow_handle_set.rbegin(); i != collected_flow_handle_set.rend(); ++i) { + sorted_flow_handle_removal_list.emplace_back(i->second); + } + + // Remove nodes via swap-and-pop. + for (uint32_t i = 0; i < sorted_node_handle_removal_list.size(); i++) { + const auto removal_node_handle = sorted_node_handle_removal_list[i]; + if (removal_node_handle < 0 || removal_node_handle >= static_cast(nodes_.size())) { + continue; + } + auto& node = nodes_[removal_node_handle]; + if (node.parent_handle_ != -1) { + auto& parent_node = nodes_[node.parent_handle_]; + for (int32_t ci = parent_node.child_handles_.size() - 1; ci >= 0; --ci) { + if (parent_node.child_handles_[ci] == removal_node_handle) { + parent_node.child_handles_.erase(parent_node.child_handles_.begin() + ci); + break; + } + } + } + if (node.flow_handle_ >= 0 && node.flow_handle_ < static_cast(flows_.size())) { + auto& flow = flows_[node.flow_handle_]; + for (int32_t fi = flow.nodes_.size() - 1; fi >= 0; --fi) { + if (flow.nodes_[fi] == removal_node_handle) { + flow.nodes_.erase(flow.nodes_.begin() + fi); + break; + } + } + } + if (removal_node_handle != static_cast(nodes_.size()) - 1) { + auto& repair_node = nodes_[removal_node_handle]; + repair_node = std::move(nodes_.back()); + const auto repair_node_handle = static_cast(nodes_.size()) - 1; + repair_node.handle_ = removal_node_handle; + for (auto& handle : sorted_node_handle_removal_list) { + if (handle == repair_node_handle) { + handle = removal_node_handle; + } + } + if (repair_node.parent_handle_ != -1) { + auto& parent_node = nodes_[repair_node.parent_handle_]; + for (auto ci = parent_node.child_handles_.rbegin(); ci != parent_node.child_handles_.rend(); ++ci) { + if (*ci == repair_node_handle) { + *ci = removal_node_handle; + break; + } + } + } + for (const auto& child_handle : repair_node.child_handles_) { + if (child_handle >= 0 && child_handle < static_cast(nodes_.size())) { + nodes_[child_handle].parent_handle_ = removal_node_handle; + } + } + if (repair_node.flow_handle_ >= 0 && repair_node.flow_handle_ < static_cast(flows_.size())) { + auto& repair_flow = flows_[repair_node.flow_handle_]; + for (int32_t fi = repair_flow.nodes_.size() - 1; fi >= 0; --fi) { + if (repair_flow.nodes_[fi] == repair_node_handle) { + repair_flow.nodes_[fi] = removal_node_handle; + break; + } + } + } + } + nodes_.pop_back(); + } + + // Remove flows via swap-and-pop. + for (uint32_t i = 0; i < sorted_flow_handle_removal_list.size(); i++) { + const auto removal_flow_handle = sorted_flow_handle_removal_list[i]; + if (removal_flow_handle < 0 || removal_flow_handle >= static_cast(flows_.size())) { + continue; + } + auto& flow = flows_[removal_flow_handle]; + if (flow.parent_handle_ != -1 && flow.parent_handle_ < static_cast(flows_.size())) { + auto& parent_flow = flows_[flow.parent_handle_]; + for (int32_t ci = parent_flow.child_handles_.size() - 1; ci >= 0; --ci) { + if (parent_flow.child_handles_[ci] == removal_flow_handle) { + parent_flow.child_handles_.erase(parent_flow.child_handles_.begin() + ci); + break; + } + } + } + assert(flow.nodes_.empty()); + if (removal_flow_handle != static_cast(flows_.size()) - 1) { + auto& repair_flow = flows_[removal_flow_handle]; + repair_flow = flows_.back(); + const auto repair_flow_handle = static_cast(flows_.size()) - 1; + repair_flow.handle_ = removal_flow_handle; + for (auto& handle : sorted_flow_handle_removal_list) { + if (handle == repair_flow_handle) { + handle = removal_flow_handle; + } + } + if (repair_flow.parent_handle_ != -1) { + auto& parent_flow = flows_[repair_flow.parent_handle_]; + for (auto ci = parent_flow.child_handles_.rbegin(); ci != parent_flow.child_handles_.rend(); ++ci) { + if (*ci == repair_flow_handle) { + *ci = removal_flow_handle; + break; + } + } + } + for (const auto& child_handle : repair_flow.child_handles_) { + flows_[child_handle].parent_handle_ = removal_flow_handle; + } + for (const auto& node_handle : repair_flow.nodes_) { + nodes_[node_handle].flow_handle_ = removal_flow_handle; + } + } + flows_.pop_back(); + } + new_version_++; + SortLists(); +} + +// --- Calculation passes --- + +template +void LSystemGraph::CalculateDistanceVolumeLevel() { + for (const auto& node_handle : sorted_node_list_) { + auto& node = nodes_[node_handle]; + auto& node_info = node.info; + node_info.volume = node_info.thickness * node_info.thickness * node_info.length; + if (node.GetParentHandle() == -1) { + node_info.root_distance = node_info.length; + node_info.chain_index = 0; + } else { + const auto& parent_node = nodes_[node.GetParentHandle()]; + node_info.root_distance = parent_node.info.root_distance + node_info.length; + if (node.IsApical()) { + node.info.chain_index = parent_node.info.chain_index + 1; + } else { + node.info.chain_index = 0; + } + } + } + for (auto it = sorted_node_list_.rbegin(); it != sorted_node_list_.rend(); ++it) { + auto& node = nodes_[*it]; + float max_distance_to_any_branch_end = 0; + node.info.end_distance = 0; + node.info.descendant_total_volume = 0; + for (const auto& i : node.PeekChildHandles()) { + const auto& child_node = nodes_[i]; + const float child_max_dist = child_node.info.end_distance + child_node.info.length; + max_distance_to_any_branch_end = glm::max(max_distance_to_any_branch_end, child_max_dist); + node.info.descendant_total_volume += child_node.info.volume + child_node.info.descendant_total_volume; + } + node.info.end_distance = max_distance_to_any_branch_end; + } + max_level_ = 0; + max_order_ = 0; + + for (const auto& flow_handle : sorted_flow_list_) { + auto& flow = flows_[flow_handle]; + if (flow.GetParentHandle() == -1) { + flow.info.order = 0; + } else { + const auto& parent_flow = flows_[flow.GetParentHandle()]; + flow.info.order = flow.IsApical() ? parent_flow.info.order : parent_flow.info.order + 1; + } + max_order_ = glm::max(max_order_, flow.info.order); + } + + for (const auto& node_handle : sorted_node_list_) { + auto& node = nodes_[node_handle]; + auto& node_info = node.info; + node_info.order = flows_[node.flow_handle_].info.order; + if (node.GetParentHandle() == -1) { + node_info.level = 0; + } else { + float max_score = 0.0f; + LNodeHandle max_child = -1; + for (const auto& child_handle : node.PeekChildHandles()) { + auto& child_info = nodes_[child_handle].info; + if (const auto score = child_info.descendant_total_volume + child_info.volume; score > max_score) { + max_score = score; + max_child = child_handle; + } + } + for (const auto& child_handle : node.PeekChildHandles()) { + auto& child_info = nodes_[child_handle].info; + if (child_handle == max_child) { + child_info.level = node_info.level; + child_info.max_child = true; + } else { + child_info.level = node_info.level + 1; + child_info.max_child = false; + } + } + } + max_level_ = glm::max(max_level_, node_info.level); + } +} + +template +void LSystemGraph::CalculateRegulatedGlobalRotation() { + min = glm::vec3(FLT_MAX); + max = glm::vec3(-FLT_MAX); + for (const auto& node_handle : sorted_node_list_) { + auto& node = nodes_[node_handle]; + auto& node_info = node.info; + min = glm::min(min, node_info.global_position); + min = glm::min(min, node_info.GetGlobalEndPosition()); + max = glm::max(max, node_info.global_position); + max = glm::max(max, node_info.GetGlobalEndPosition()); + if (node.parent_handle_ != -1) { + auto& parent_info = nodes_[node.parent_handle_].info; + auto front = node_info.global_rotation * glm::vec3(0, 0, -1); + auto parent_regulated_up = parent_info.regulated_global_rotation * glm::vec3(0, 1, 0); + auto regulated_up = glm::normalize(glm::cross(glm::cross(front, parent_regulated_up), front)); + node_info.regulated_global_rotation = glm::quatLookAt(front, regulated_up); + } else { + node_info.regulated_global_rotation = node_info.global_rotation; + } + } +} + +template +void LSystemGraph::CalculateFlows() { + for (const auto& flow_handle : sorted_flow_list_) { + auto& flow = flows_[flow_handle]; + auto& first_node = nodes_[flow.nodes_.front()]; + auto& last_node = nodes_[flow.nodes_.back()]; + flow.info.start_thickness = first_node.info.thickness; + flow.info.global_start_position = first_node.info.global_position; + flow.info.global_start_rotation = first_node.info.global_rotation; + flow.info.end_thickness = last_node.info.thickness; + flow.info.global_end_position = + last_node.info.global_position + last_node.info.length * (last_node.info.global_rotation * glm::vec3(0, 0, -1)); + flow.info.global_end_rotation = last_node.info.global_rotation; + flow.info.flow_length = 0.0f; + for (const auto& node_handle : flow.nodes_) { + flow.info.flow_length += nodes_[node_handle].info.length; + } + } +} + +template +void LSystemGraph::CalculateMinMax() { + if (nodes_.empty()) { + min = glm::vec3(0.f); + max = glm::vec3(0.f); + return; + } + min = glm::vec3(FLT_MAX); + max = glm::vec3(-FLT_MAX); + for (const auto& node : nodes_) { + min = glm::min(min, node.info.global_position); + min = glm::min(min, node.info.GetGlobalEndPosition()); + max = glm::max(max, node.info.global_position); + max = glm::max(max, node.info.GetGlobalEndPosition()); + } +} + +// --- Internal helpers --- + +template +void LSystemGraph::RefreshBaseNodeList() { + base_node_list_.clear(); + base_node_list_.reserve(nodes_.size()); + for (LNodeHandle i = 0; i < static_cast(nodes_.size()); i++) { + if (nodes_[i].parent_handle_ == -1) { + base_node_list_.emplace_back(i); + } + } +} + +template +void LSystemGraph::SetParentNode(LNodeHandle target_handle, + LNodeHandle parent_handle) { + assert(target_handle >= 0 && parent_handle >= 0 && target_handle < static_cast(nodes_.size()) && + parent_handle < static_cast(nodes_.size())); + auto& target_node = nodes_[target_handle]; + auto& parent_node = nodes_[parent_handle]; + target_node.parent_handle_ = parent_handle; + parent_node.child_handles_.emplace_back(target_handle); +} + +template +void LSystemGraph::DetachChildNode(LNodeHandle target_handle, + LNodeHandle child_handle) { + assert(target_handle >= 0 && child_handle >= 0 && target_handle < static_cast(nodes_.size()) && + child_handle < static_cast(nodes_.size())); + auto& target_node = nodes_[target_handle]; + auto& child_node = nodes_[child_handle]; + auto& children = target_node.child_handles_; + for (size_t i = 0; i < children.size(); i++) { + if (children[i] == child_handle) { + children[i] = children.back(); + children.pop_back(); + child_node.parent_handle_ = -1; + if (children.empty()) + target_node.end_node_ = true; + return; + } + } +} + +template +void LSystemGraph::SetParentFlow(LFlowHandle target_handle, + LFlowHandle parent_handle) { + assert(target_handle >= 0 && parent_handle >= 0 && target_handle < static_cast(flows_.size()) && + parent_handle < static_cast(flows_.size())); + auto& target_flow = flows_[target_handle]; + auto& parent_flow = flows_[parent_handle]; + target_flow.parent_handle_ = parent_handle; + parent_flow.child_handles_.emplace_back(target_handle); +} + +template +void LSystemGraph::DetachChildFlow(LFlowHandle target_handle, + LFlowHandle child_handle) { + assert(target_handle >= 0 && child_handle >= 0 && target_handle < static_cast(flows_.size()) && + child_handle < static_cast(flows_.size())); + auto& target_flow = flows_[target_handle]; + auto& child_flow = flows_[child_handle]; + if (!child_flow.nodes_.empty()) { + auto first_node_handle = child_flow.nodes_[0]; + if (auto& first_node = nodes_[first_node_handle]; first_node.parent_handle_ != -1) + DetachChildNode(first_node.parent_handle_, first_node_handle); + } + auto& children = target_flow.child_handles_; + for (size_t i = 0; i < children.size(); i++) { + if (children[i] == child_handle) { + children[i] = children.back(); + children.pop_back(); + child_flow.parent_handle_ = -1; + return; + } + } +} + +template +LFlowHandle LSystemGraph::AllocateFlow() { + max_flow_index_++; + flows_.emplace_back(static_cast(flows_.size())); + flows_.back().index_ = max_flow_index_; + return flows_.back().handle_; +} + +template +LNodeHandle LSystemGraph::AllocateNode() { + max_node_index_++; + nodes_.emplace_back(static_cast(nodes_.size())); + nodes_.back().index_ = max_node_index_; + return nodes_.back().handle_; +} + +#pragma endregion + +} // namespace l_system_package \ No newline at end of file diff --git a/EvoEngine_Packages/LSystem/include/LSystemGrowthModelBase.hpp b/EvoEngine_Packages/LSystem/include/LSystemGrowthModelBase.hpp new file mode 100644 index 00000000..6d238cb8 --- /dev/null +++ b/EvoEngine_Packages/LSystem/include/LSystemGrowthModelBase.hpp @@ -0,0 +1,324 @@ +#pragma once + +// Generic Curiously Recurring Template Pattern (CRTP) base for L-system growth models. Owns the algorithm skeleton +// (DeriveTopology, GrowStep, GrowToGDD, PropagateGeometry, profile aggregation, +// reset of base state) so per-species growth models only implement the +// plant-specific hooks listed below. +// +// Phase 2.2 of the L-system consolidation work; see docs/seam_inventory.md +// for the broader plan. +// +// CRTP usage: +// +// class TasselGrowthModel +// : public LSystemGrowthModelBase { +// public: +// // Plant-specific axiom + sampled-state setup. Calls ResetBase() then +// // populates `graph`, `sampled`, and any plant-only fields. Drives the +// // descriptor.Sample(rng_) and root-apex creation steps. +// void Initialize(const MaizeTasselDescriptor& descriptor, ...); +// +// // Hooks invoked by the base via CRTP: +// void UpdateNodeInfoImpl(LGraphNode& node); +// bool IsDevelopmentalSymbolImpl(const LGraphNode& node) const; +// glm::quat ComputeChildLocalRotationImpl( +// const LGraphNode& node, const LGraphNode& parent) const; +// void ResetExtensionImpl(); // reset plant-specific fields +// }; +// +// Bit-exact-parity contract: the call order in DeriveTopology / GrowStep / +// GrowToGDD / PropagateGeometry below MUST match the original +// TasselGrowthModel::* bodies character-for-character (RNG draws, sort +// invocations, profile timestamps). + +#include +#include +#include +#include +#include +#include +#include +#include "GeometryPass.hpp" + +namespace l_system_package { + +inline double ProfileNowSeconds() { + using Clock = std::chrono::steady_clock; + static const auto kStart = Clock::now(); + return std::chrono::duration(Clock::now() - kStart).count(); +} + +template +class LSystemGrowthModelBase { + public: + struct GrowthStepProfile { + double total_seconds = 0.0; + double apply_growth_rules_seconds = 0.0; + double apply_topology_rules_seconds = 0.0; + double sort_lists_seconds = 0.0; + double update_node_info_seconds = 0.0; + double propagate_geometry_seconds = 0.0; + double topology_scan_seconds = 0.0; + }; + + // -- Public state -- + float accumulated_gdd = 0.0f; + float gdd_per_growth_step = 1.0f; + uint32_t last_growth_steps = 0; + GrowthStepProfile last_growth_step_profile{}; + GrowthStepProfile last_grow_to_gdd_profile{}; + TGraph graph{1}; + + [[nodiscard]] bool IsInitialized() const { + return initialized_; + } + [[nodiscard]] bool IsTopologyComplete() const { + return topology_complete_; + } + + /// Apply topology rules until no structural change occurs. Generic. + void DeriveTopology(); + + /// Apply one thermal growth step (age updates + topology + geometry). Generic. + void GrowStep(); + + /// Grow until the accumulated GDD reaches target_gdd. Generic loop with + /// per-step profile aggregation. + void GrowToGDD(float target_gdd, uint32_t max_growth_steps = 0); + + /// Run age-only updates using chronological time without applying topology + /// or growth rules. Returns true if any node changed visible age state. + bool AgeOnlyStep(); + + void SetChronologicalCoupledToThermal(const bool coupled) { + chronological_coupled_to_thermal_ = coupled; + } + + [[nodiscard]] bool IsChronologicalCoupledToThermal() const { + return chronological_coupled_to_thermal_; + } + + [[nodiscard]] float GetChronologicalYears() const { + return chronological_years_; + } + + void SetChronologicalYears(const float years) { + if (!std::isfinite(years)) + return; + chronological_years_ = std::max(0.0f, years); + } + + void AdvanceChronologicalYears(const float dt_years) { + if (!std::isfinite(dt_years) || dt_years <= 0.0f) + return; + chronological_years_ += dt_years; + } + + /// Propagate geometry transforms top-down using the Derived hook for the + /// per-node local rotation. + void PropagateGeometry(); + + protected: + TEngine engine_{}; + std::mt19937 rng_; + bool initialized_ = false; + bool topology_complete_ = false; + int max_topology_steps_ = 0; + glm::vec3 root_position_{0.0f}; + glm::quat root_rotation_{1, 0, 0, 0}; + + /// Reset the base-class state. Derived classes call this from their own + /// Reset() and then reset their plant-specific fields. + void ResetBase() { + graph = TGraph(1); + engine_ = TEngine(); + accumulated_gdd = 0.0f; + initialized_ = false; + topology_complete_ = false; + max_topology_steps_ = 0; + chronological_years_ = 0.0f; + chronological_coupled_to_thermal_ = true; + } + + private: + template + void RefreshNodeTemporalState(TNode& node) { + auto& temporal = node.info.temporal; + if (!temporal.initialized) { + temporal.initialized = true; + temporal.birth_thermal_gdd = accumulated_gdd; + temporal.birth_absolute_years = chronological_years_; + } + temporal.age_thermal_gdd = std::max(0.0f, accumulated_gdd - temporal.birth_thermal_gdd); + temporal.age_absolute_years = std::max(0.0f, chronological_years_ - temporal.birth_absolute_years); + } + + Derived* AsDerived() { + return static_cast(this); + } + const Derived* AsDerived() const { + return static_cast(this); + } + + float chronological_years_ = 0.0f; + bool chronological_coupled_to_thermal_ = true; +}; + +// --------------------------------------------------------------------------- +// DeriveTopology +// --------------------------------------------------------------------------- + +template +void LSystemGrowthModelBase::DeriveTopology() { + if (!initialized_) + return; + + // Keep deriving until no more topology changes occur. + for (int i = 0; i < max_topology_steps_; i++) { + bool changed = engine_.ApplyTopologyRules(graph, rng_); + if (!changed) + break; + } + + // Assign lengths/thicknesses from module data before geometry pass. + graph.SortLists(); + for (const auto handle : graph.PeekSortedNodeList()) { + auto& node = graph.RefNode(handle); + RefreshNodeTemporalState(node); + AsDerived()->UpdateNodeInfoImpl(node); + } + + PropagateGeometry(); + topology_complete_ = true; +} + +// --------------------------------------------------------------------------- +// GrowStep +// --------------------------------------------------------------------------- + +template +void LSystemGrowthModelBase::GrowStep() { + if (!initialized_) + return; + + const double step_start = ProfileNowSeconds(); + last_growth_step_profile = {}; + bool topology_phase_ran = false; + bool topology_changed = false; + + // Age and interpolate existing modules first. + double phase_start = ProfileNowSeconds(); + engine_.ApplyGrowthRules(graph, rng_); + last_growth_step_profile.apply_growth_rules_seconds = ProfileNowSeconds() - phase_start; + + // Once topology is complete, growth rules can no longer create topology symbols. + if (!topology_complete_) { + topology_phase_ran = true; + phase_start = ProfileNowSeconds(); + topology_changed = engine_.ApplyTopologyRules(graph, rng_); + last_growth_step_profile.apply_topology_rules_seconds = ProfileNowSeconds() - phase_start; + + phase_start = ProfileNowSeconds(); + if (topology_changed) { + graph.SortLists(); + } + last_growth_step_profile.sort_lists_seconds += ProfileNowSeconds() - phase_start; + } + + accumulated_gdd += gdd_per_growth_step; + if (chronological_coupled_to_thermal_) { + const float gdd_per_year = std::max(1.0f, AsDerived()->ChronologicalGddPerYearImpl()); + AdvanceChronologicalYears(gdd_per_growth_step / gdd_per_year); + } + + const auto& sorted_nodes = graph.PeekSortedNodeList(); + phase_start = ProfileNowSeconds(); + for (const auto handle : sorted_nodes) { + auto& node = graph.RefNode(handle); + RefreshNodeTemporalState(node); + AsDerived()->UpdateNodeInfoImpl(node); + } + last_growth_step_profile.update_node_info_seconds = ProfileNowSeconds() - phase_start; + + phase_start = ProfileNowSeconds(); + PropagateGeometry(); + last_growth_step_profile.propagate_geometry_seconds = ProfileNowSeconds() - phase_start; + + if (topology_phase_ran) { + // Topology is complete when no pending developmental symbols remain. + phase_start = ProfileNowSeconds(); + topology_complete_ = true; + for (const auto handle : sorted_nodes) { + const auto& node = graph.PeekNode(handle); + if (AsDerived()->IsDevelopmentalSymbolImpl(node)) { + topology_complete_ = false; + break; + } + } + last_growth_step_profile.topology_scan_seconds = ProfileNowSeconds() - phase_start; + } + + last_growth_step_profile.total_seconds = ProfileNowSeconds() - step_start; +} + +// --------------------------------------------------------------------------- +// GrowToGDD +// --------------------------------------------------------------------------- + +template +void LSystemGrowthModelBase::GrowToGDD(const float target_gdd, + const uint32_t max_growth_steps) { + last_growth_steps = 0; + last_grow_to_gdd_profile = {}; + while (accumulated_gdd < target_gdd && (max_growth_steps == 0 || last_growth_steps < max_growth_steps)) { + GrowStep(); + last_growth_steps++; + + last_grow_to_gdd_profile.total_seconds += last_growth_step_profile.total_seconds; + last_grow_to_gdd_profile.apply_growth_rules_seconds += last_growth_step_profile.apply_growth_rules_seconds; + last_grow_to_gdd_profile.apply_topology_rules_seconds += last_growth_step_profile.apply_topology_rules_seconds; + last_grow_to_gdd_profile.sort_lists_seconds += last_growth_step_profile.sort_lists_seconds; + last_grow_to_gdd_profile.update_node_info_seconds += last_growth_step_profile.update_node_info_seconds; + last_grow_to_gdd_profile.propagate_geometry_seconds += last_growth_step_profile.propagate_geometry_seconds; + last_grow_to_gdd_profile.topology_scan_seconds += last_growth_step_profile.topology_scan_seconds; + } + if (last_growth_steps == 0) { + last_growth_step_profile = {}; + last_grow_to_gdd_profile = {}; + } +} + +// --------------------------------------------------------------------------- +// AgeOnlyStep +// --------------------------------------------------------------------------- + +template +bool LSystemGrowthModelBase::AgeOnlyStep() { + if (!initialized_) + return false; + + graph.SortLists(); + bool changed = false; + for (const auto handle : graph.PeekSortedNodeList()) { + auto& node = graph.RefNode(handle); + RefreshNodeTemporalState(node); + changed = AsDerived()->UpdateNodeAgingOnlyImpl(node) || changed; + } + return changed; +} + +// --------------------------------------------------------------------------- +// PropagateGeometry +// --------------------------------------------------------------------------- + +template +void LSystemGrowthModelBase::PropagateGeometry() { + using NodeT = typename std::decay::type; + GeometryPass::Execute( + graph, root_position_, root_rotation_, + std::function([this](const NodeT& node, const NodeT& parent) -> glm::quat { + return AsDerived()->ComputeChildLocalRotationImpl(node, parent); + })); +} + +} // namespace l_system_package \ No newline at end of file diff --git a/EvoEngine_Packages/LSystem/include/LSystemLayer.hpp b/EvoEngine_Packages/LSystem/include/LSystemLayer.hpp new file mode 100644 index 00000000..8cd99519 --- /dev/null +++ b/EvoEngine_Packages/LSystem/include/LSystemLayer.hpp @@ -0,0 +1,62 @@ +#pragma once + +#include "ILayer.hpp" +#include "Input.hpp" + +#include +#include +#include + +namespace l_system_package { + +class LSystemLayer : public evo_engine::ILayer { + public: + struct ProfileFrame { + double update_ms = 0.0; + double grow_ms = 0.0; + double rebuild_ms = 0.0; + uint32_t pine_count = 0; + uint32_t growth_steps = 0; + uint32_t node_count = 0; + uint32_t internode_count = 0; + uint32_t needle_count = 0; + uint32_t invalid_instance_count = 0; + }; + + bool auto_grow = false; + bool reseed_on_reset = false; + + bool seasonality_enabled = false; + int season_start_day = 60; + int season_end_day = 334; + float chronological_days_per_second = 30.0f; + float simulation_day_of_year = 60.0f; + + int tassel_color_mode = 0; + bool scene_plant_view_tint_enabled = true; + bool pine_stem_only_mode = false; + + bool profiling_enabled = false; + int profiling_history_size = 240; + std::string profiling_export_path = "lsystem_profile.csv"; + + ProfileFrame last_profile_frame{}; + std::vector profiling_history{}; + + void OnCreate() override; + void OnDestroy() override; + void Update() override; + void OnInspect(const std::shared_ptr& editor_layer) override; + void Serialize(YAML::Emitter& out) const; + void Deserialize(const YAML::Node& in); + + private: + static constexpr float kAutoGrowFailsafeMinFps = 1.0f; + bool fps_failsafe_tripped_ = false; + float last_failsafe_fps_ = 0.0f; + + void PushProfileFrame(const ProfileFrame& frame); + void ExportProfileCsv(const std::string& path) const; +}; + +} // namespace l_system_package diff --git a/EvoEngine_Packages/LSystem/include/LSystemRuleHelpers.hpp b/EvoEngine_Packages/LSystem/include/LSystemRuleHelpers.hpp new file mode 100644 index 00000000..f1cfad91 --- /dev/null +++ b/EvoEngine_Packages/LSystem/include/LSystemRuleHelpers.hpp @@ -0,0 +1,192 @@ +#pragma once + +// Generic L-system rule helpers and tropism types shared by all species +// (maize tassel, Scots pine, etc.). +// +// Bit-exact-parity contract: function bodies, RNG distribution construction, +// and integer constants below MUST remain byte-identical to the originals. + +#include +#include +#include +#include +#include +#include +#include + +namespace l_system_package { + +// --------------------------------------------------------------------------- +// Helper: deterministic sampling from a SingleDistribution using a seeded RNG. +// --------------------------------------------------------------------------- + +template +T SampleDistribution(const evo_engine::SingleDistribution& dist, std::mt19937& rng) { + if (dist.deviation <= 0.0f) + return dist.mean; + std::normal_distribution normal(0.0f, 1.0f); + return dist.mean + T(dist.deviation * normal(rng)); +} + +// --------------------------------------------------------------------------- +// Helper: deterministic sampling from a PlottedDistribution using a seeded RNG. +// --------------------------------------------------------------------------- + +inline float SamplePlotted(const evo_engine::PlottedDistribution& pd, float t, std::mt19937& rng) { + const float mean_val = pd.mean.GetValue(t); + const float dev_val = pd.deviation.GetValue(t); + if (dev_val <= 0.0f) + return mean_val; + std::normal_distribution dist(mean_val, dev_val); + return dist(rng); +} + +inline uint32_t HashNodeSeed(const float node_random, const uint32_t salt) { + const float clamped = std::clamp(node_random, 0.0f, 1.0f); + uint32_t x = static_cast(clamped * 4294967295.0f) ^ (salt + 0x9e3779b9u); + x ^= x >> 16; + x *= 0x7feb352du; + x ^= x >> 15; + x *= 0x846ca68bu; + x ^= x >> 16; + return x; +} + +inline std::mt19937 MakeNodeRng(const float node_random, const uint32_t salt) { + return std::mt19937(HashNodeSeed(node_random, salt)); +} + +inline float EvaluatePlottedDeterministic(const evo_engine::PlottedDistribution& pd, const float t, + const float node_random, const uint32_t salt, + const float lo = -std::numeric_limits::infinity(), + const float hi = std::numeric_limits::infinity()) { + const float mean_val = pd.mean.GetValue(t); + const float sigma_val = std::max(0.0f, pd.deviation.GetValue(t)); + if (!(sigma_val > 0.0f)) { + return std::clamp(mean_val, lo, hi); + } + + auto rng = MakeNodeRng(node_random, salt); + std::normal_distribution unit_normal(0.0f, 1.0f); + const float z = unit_normal(rng); + return std::clamp(mean_val + sigma_val * z, lo, hi); +} + +inline float SampleUnit01(std::mt19937& rng) { + std::uniform_real_distribution dist(0.0f, 1.0f); + return dist(rng); +} + +// --------------------------------------------------------------------------- +// Per-node stochastic samplers. +// +// These complement the per-plant `SampleDistribution` / `SamplePlotted` helpers +// above. They are intended for use *inside* production-rule producers, where +// each emitted module draws fresh noise from a per-node RNG (see +// `MakeNodeRng`). Driving every draw from a per-node RNG keeps the L-system +// deterministic for a fixed plant seed: tree(seed=N) is reproducible bit-for- +// bit regardless of derivation order. +// +// All samplers are no-ops when their dispersion parameter is <= 0, so a +// descriptor with default (zero) per-node noise produces the same trajectory +// as the legacy deterministic baseline. +// --------------------------------------------------------------------------- + +/// Gaussian sample clamped to [lo, hi]. Returns `mean` unchanged when +/// `sigma <= 0`. Up to 8 redraws to land inside [lo, hi]; if all fail, the +/// returned value is hard-clamped (avoids infinite loops on extreme bounds). +inline float SampleGaussianClamped(const float mean, const float sigma, const float lo, const float hi, + std::mt19937& rng) { + if (!(sigma > 0.0f)) + return std::clamp(mean, lo, hi); + std::normal_distribution dist(mean, sigma); + for (int attempt = 0; attempt < 8; ++attempt) { + const float v = dist(rng); + if (v >= lo && v <= hi) + return v; + } + return std::clamp(dist(rng), lo, hi); +} + +/// Poisson sample clipped to >= 0. Returns `static_cast(std::round(lambda))` +/// when `lambda <= 0` (degenerate distribution). std::poisson_distribution +/// requires positive `mean`, so we guard with `lambda > 0`. +inline int SamplePoissonNonneg(const float lambda, std::mt19937& rng) { + if (!(lambda > 0.0f)) + return std::max(0, static_cast(std::round(lambda))); + std::poisson_distribution dist(static_cast(lambda)); + return std::max(0, dist(rng)); +} + +/// Discrete count sampler used for "branches per whorl"-style fields. +/// If `poisson_lambda > 0`, sample Poisson(poisson_lambda); else return +/// `default_count`. Result clamped to [min_count, max_count]. +inline int SampleCountPoissonOrFixed(const int default_count, const float poisson_lambda, const int min_count, + const int max_count, std::mt19937& rng) { + const int raw = (poisson_lambda > 0.0f) ? SamplePoissonNonneg(poisson_lambda, rng) : default_count; + return std::clamp(raw, min_count, max_count); +} + +// --------------------------------------------------------------------------- +// JitteredScalar - POD bundle for a per-node noise-augmented scalar. +// +// Used by per-node sampling sites that don't need a full PlottedDistribution. +// `mean` : central value (already drawn per-plant from a SingleDistribution). +// `sigma` : per-node Gaussian noise (additive when `relative` is false, +// fractional/CV when `relative` is true). +// `relative` : if true, sigma is interpreted as coefficient of variation +// (final value = mean * (1 + sigma*N(0,1))). +// `lo` / `hi` : clamp bounds. Default range is the full float line. +// +// Construction is cheap and the type is trivially copyable; callers can stash +// it inside `SampledPineParams` without inflating the per-plant footprint. +// --------------------------------------------------------------------------- + +struct JitteredScalar { + float mean = 0.0f; + float sigma = 0.0f; + float lo = -std::numeric_limits::infinity(); + float hi = std::numeric_limits::infinity(); + bool relative = false; + + /// Sample once from a per-node RNG; returns `mean` unchanged when sigma==0. + float Sample(std::mt19937& rng) const { + if (!(sigma > 0.0f)) + return std::clamp(mean, lo, hi); + if (relative) { + std::normal_distribution dist(0.0f, sigma); + const float v = mean * (1.0f + dist(rng)); + return std::clamp(v, lo, hi); + } + return SampleGaussianClamped(mean, sigma, lo, hi, rng); + } +}; + +// --------------------------------------------------------------------------- +// TropismEntry - user-facing tropism descriptor (one per dynamic list entry). +// Generic across species: a tropism is a directional bias modulated by +// branching order, with a per-plant activation roll. +// --------------------------------------------------------------------------- + +struct TropismEntry { + evo_engine::SingleDistribution direction_x{0.0f}; + evo_engine::SingleDistribution direction_y{-1.0f}; + evo_engine::SingleDistribution direction_z{0.0f}; + evo_engine::SingleDistribution strength{0.0f}; + float usage_chance_percent = 100.0f; ///< Per-plant activation chance in [0, 100]. + + /// Curve: x = normalized branching order (0=rachis..1=max order), y = response multiplier. + evo_engine::PlottedDistribution order_response; +}; + +// --------------------------------------------------------------------------- +// SampledTropism - concrete sampled tropism values for one instance. +// --------------------------------------------------------------------------- + +struct SampledTropism { + glm::vec3 direction{0.0f, -1.0f, 0.0f}; + float strength = 0.0f; + evo_engine::PlottedDistribution order_response; +}; + +} // namespace l_system_package \ No newline at end of file diff --git a/EvoEngine_Packages/LSystem/include/MaterialProfile.hpp b/EvoEngine_Packages/LSystem/include/MaterialProfile.hpp new file mode 100644 index 00000000..8509cd63 --- /dev/null +++ b/EvoEngine_Packages/LSystem/include/MaterialProfile.hpp @@ -0,0 +1,109 @@ +#pragma once + +// --------------------------------------------------------------------------- +// MaterialProfile1D +// +// Phase 4 of the biologically-emergent organ growth substrate. Pure-POD +// description of a rod's mechanical properties along its arc length and over +// physiological time. Provides: +// +// * Young's modulus E(t) following a maturation/lignification sigmoid. +// * Cross-section second moment of area I(s_norm) for a circular profile +// parameterized by base/tip radius (linear taper). Other profiles can be +// wired in by callers via `bending_stiffness_override_Pa_m4` if needed. +// * Tissue density (kg/m^3) used to derive distributed weight per arc length. +// +// Default-constructed values are *inert*: `young_modulus_baseline_Pa = 0` +// short-circuits `IsActive()` to false, in which case the elastica solver is +// skipped entirely and the centerline retains its intrinsic-curvature shape. +// This preserves Phase 1/Phase 2/Phase 3 behavior bit-exactly when the +// descriptor has not opted into mechanics. +// --------------------------------------------------------------------------- + +#include +#include + +#include "GrowthFunction.hpp" +#include "SimulationClock.hpp" + +namespace l_system_package { + +/// Mechanical properties of a single rod-like organ (needle, internode). +/// Quantities are SI: Pa (= N/m^2) for moduli, m for radii, kg/m^3 for +/// density. The solver treats the rod as Euler-Bernoulli (small-strain, +/// large-rotation), which is appropriate for the slender geometries here +/// (length/diameter > ~30 for both Scots pine needles and most internodes). +struct MaterialProfile1D { + /// Asymptotic Young's modulus (Pa) at maturity. Typical values: + /// - young needle parenchyma: 1e7 .. 1e8 + /// - mature needle (lignified midrib): 5e8 .. 5e9 + /// - mature softwood internode: 5e9 .. 1.5e10 + /// Setting this to 0 marks the profile inert (solver is skipped). + float young_modulus_baseline_Pa = 0.0f; + + /// Per-organ lignification trajectory. `Multiplier(t)` ramps from a small + /// floor (default 0) up to 1.0 over `maturation_years`. Defaults to inert + /// (maturation_years = 0 -> Multiplier returns 1.0 after t_init). + ContinuousGrowthState lignification{}; + + /// Cross-section radius at the base (s_norm = 0). Combined with `tip_radius_m` + /// for a linear taper, gives `r(s_norm) = mix(base_radius_m, tip_radius_m, s_norm)`. + float base_radius_m = 0.0f; + float tip_radius_m = 0.0f; + + /// Tissue density (kg/m^3). Used to derive distributed weight per unit arc + /// length. Typical values: + /// - fresh needle tissue: ~700 .. 900 (mostly water) + /// - dry softwood: ~400 .. 550 + /// 0 -> no distributed weight (solver still runs if a tip force is supplied). + float density_kg_m3 = 0.0f; + + /// Optional override on EI (Pa*m^4) - when > 0, supersedes the + /// E(t) * I(s_norm) computation. Useful for analytic cantilever tests. + float bending_stiffness_override_Pa_m4 = 0.0f; + + /// True iff the profile has any mechanical effect. When false, the solver + /// is skipped and the centerline retains its intrinsic-curvature shape. + [[nodiscard]] bool IsActive() const { + return young_modulus_baseline_Pa > 0.0f || bending_stiffness_override_Pa_m4 > 0.0f; + } + + /// Time-varying Young's modulus. Returns the baseline scaled by the + /// lignification multiplier evaluated at the supplied physiological time. + [[nodiscard]] float YoungModulus_Pa(const float t_now_years) const { + if (lignification.maturation_years > 0.0f) { + return young_modulus_baseline_Pa * lignification.Multiplier(t_now_years); + } + return young_modulus_baseline_Pa; + } + + /// Linear taper radius along normalized arc length s_norm in [0, 1]. + [[nodiscard]] float Radius_m(const float s_norm) const { + const float s = std::clamp(s_norm, 0.0f, 1.0f); + return base_radius_m * (1.0f - s) + tip_radius_m * s; + } + + /// Second moment of area for a solid circular cross-section: I = pi * r^4 / 4. + [[nodiscard]] float SecondMomentOfArea_m4(const float s_norm) const { + const float r = Radius_m(s_norm); + return 0.25f * 3.14159265358979323846f * r * r * r * r; + } + + /// Bending stiffness EI(s_norm, t). Honors the override when set. + [[nodiscard]] float BendingStiffness_Pa_m4(const float s_norm, const float t_now_years) const { + if (bending_stiffness_override_Pa_m4 > 0.0f) { + return bending_stiffness_override_Pa_m4; + } + return YoungModulus_Pa(t_now_years) * SecondMomentOfArea_m4(s_norm); + } + + /// Mass per unit arc length (kg/m) for distributed weight. Returns 0 when + /// density or radius is unset. + [[nodiscard]] float MassPerLength_kg_m(const float s_norm) const { + const float r = Radius_m(s_norm); + const float area = 3.14159265358979323846f * r * r; + return area * density_kg_m3; + } +}; + +} // namespace l_system_package \ No newline at end of file diff --git a/EvoEngine_Packages/LSystem/include/ModuleTypes.hpp b/EvoEngine_Packages/LSystem/include/ModuleTypes.hpp new file mode 100644 index 00000000..c306b60c --- /dev/null +++ b/EvoEngine_Packages/LSystem/include/ModuleTypes.hpp @@ -0,0 +1,133 @@ +#pragma once + +#include +#include +#include +#include + +namespace l_system_package { + +/** + * @brief Typed container for module data in the L-system graph. + * + * Wraps a std::variant of user-defined module structs. Each module type + * corresponds to a symbol in the L-system grammar (e.g., Apex, Internode, Leaf). + * + * Usage: + * @code + * struct Apex { float vigor; int order; }; + * struct Inter { float length; float thickness; }; + * struct Leaf { float area; float angle; }; + * + * using MyModules = ModuleVariant; + * // symbol_id 0 = Apex, 1 = Inter, 2 = Leaf (matches variant index) + * + * MyModules m; + * m.Set({1.0f, 0}); // Sets data and symbol_id automatically + * auto& apex = m.Get(); + * @endcode + * + * @tparam ModuleTypes All module struct types used in this L-system. + */ +template +struct ModuleVariant { + std::variant data; + + ModuleVariant() = default; + + /** + * @brief Get the symbol_id (variant index) of the currently held module type. + */ + [[nodiscard]] int SymbolId() const { + return static_cast(data.index()); + } + + /** + * @brief Set the module data to a specific type. Symbol ID is inferred from the variant index. + */ + template + void Set(const T& value) { + data = value; + } + + /** + * @brief Set the module data to a specific type via move. + */ + template + void Set(T&& value) { + data = std::move(value); + } + + /** + * @brief Get a mutable reference to the module data, assuming it holds type T. + * UB if the variant doesn't hold T. + */ + template + [[nodiscard]] T& Get() { + return std::get(data); + } + + /** + * @brief Get a const reference to the module data, assuming it holds type T. + */ + template + [[nodiscard]] const T& Get() const { + return std::get(data); + } + + /** + * @brief Check if the variant currently holds type T. + */ + template + [[nodiscard]] bool Is() const { + return std::holds_alternative(data); + } + + /** + * @brief Visit the variant with a callable (visitor pattern). + */ + template + decltype(auto) Visit(Visitor&& visitor) { + return std::visit(std::forward(visitor), data); + } + + template + decltype(auto) Visit(Visitor&& visitor) const { + return std::visit(std::forward(visitor), data); + } +}; + +/** + * @brief Compile-time helper to get the index of type T within a parameter pack. + * + * Usage: constexpr int id = ModuleIndex::value; // 0 + */ +template +struct ModuleIndex; + +// Specialization: T matches the first type in the pack -> index is 0. +template +struct ModuleIndex { + static constexpr int value = 0; +}; + +// Specialization: T does not match First -> recurse into Rest. +template +struct ModuleIndex { + static constexpr int value = 1 + ModuleIndex::value; +}; + +template +struct ModuleIndex { + static_assert(sizeof(T) == 0, "Type not found in module type list"); +}; + +/** + * @brief Compile-time count of module types. + */ +template +struct ModuleCount { + static constexpr int value = sizeof...(ModuleTypes); +}; + +} // namespace l_system_package \ No newline at end of file diff --git a/EvoEngine_Packages/LSystem/include/OrganCenterline.hpp b/EvoEngine_Packages/LSystem/include/OrganCenterline.hpp new file mode 100644 index 00000000..cca782aa --- /dev/null +++ b/EvoEngine_Packages/LSystem/include/OrganCenterline.hpp @@ -0,0 +1,281 @@ +#pragma once + +// --------------------------------------------------------------------------- +// OrganCenterline +// +// Phase 1 of the biologically-emergent organ growth substrate. A centerline +// is a sequence of control points expressed in a local "organ frame" (origin +// at the attachment point, +Z forward = principal growth axis, +X = adaxial +// reference direction). The centerline carries: +// +// * a Catmull-Rom interpolant over its control points (centripetal +// parameterization for stability against unequal control-point spacing), +// * a cached arc-length table so per-station sampling at uniform `s` is +// O(log N) per query without re-walking the spline, +// * a rotation-minimizing parallel-transport frame at every sample so the +// downstream cross-section sweep does not introduce visible twist. +// +// The class is deliberately decoupled from any L-system module type so the +// same primitive serves PineNeedleCluster, PineInternode (Phase 6) and the +// future maize/sorghum leaf blade (post-Phase 6). +// +// Phase 1 invariant: a freshly constructed centerline with two control +// points and no curvature is geometrically identical to a straight cylinder +// of the requested length, modulo tessellation. +// --------------------------------------------------------------------------- + +#include +#include +#include +#include + +#include +#include + +namespace l_system_package { + +/// Per-station sample produced by `OrganCenterline::Sample(s)`. +struct CenterlineSample { + glm::vec3 position = glm::vec3(0.0f); ///< Position in the organ-local frame. + glm::vec3 tangent = glm::vec3(0, 0, 1); ///< Unit tangent (forward). + glm::vec3 normal = glm::vec3(1, 0, 0); ///< Unit normal (adaxial reference). + glm::vec3 binormal = glm::vec3(0, 1, 0); ///< cross(tangent, normal); right-handed frame. + float arc_length = 0.0f; ///< Arc length from the base. +}; + +class OrganCenterline { + public: + OrganCenterline() = default; + + /// Construct a straight centerline of `length` along +Z with `segments + 1` + /// uniformly spaced control points (segments >= 1). This is the Phase 1 + /// default state for every organ before the differential growth field + /// (Phase 3) and elastica solver (Phase 4) deflect it. + static OrganCenterline Straight(float length, int segments = 4) { + OrganCenterline c; + const int n = std::max(2, segments + 1); + c.control_points_.resize(static_cast(n)); + for (int i = 0; i < n; ++i) { + const float t = static_cast(i) / static_cast(n - 1); + c.control_points_[i] = glm::vec3(0.0f, 0.0f, t * length); + } + c.Invalidate(); + return c; + } + + /// Direct accessor - Phase 3 differential growth writes to this directly. + std::vector& MutableControlPoints() { + arc_length_dirty_ = true; + return control_points_; + } + const std::vector& ControlPoints() const { + return control_points_; + } + + /// Total arc length along the spline (cached; trigger rebuild if dirty). + float TotalLength() const { + EnsureArcLengthTable(); + return arc_length_table_.empty() ? 0.0f : arc_length_table_.back(); + } + + /// Number of resampling stations used for the arc-length table. Higher = + /// more accurate `Sample()` at the cost of construction time. Must be + /// called before any `Sample()` if changed at runtime. + void SetResampleCount(int count) { + resample_count_ = std::max(8, count); + arc_length_dirty_ = true; + } + + /// Sample the centerline at arc length `s` (clamped to [0, TotalLength()]). + /// Returns a rotation-minimizing frame computed by parallel transport from + /// the base, with the base normal anchored to `base_normal`. + CenterlineSample Sample(float s, const glm::vec3& base_normal = glm::vec3(1, 0, 0)) const { + EnsureArcLengthTable(); + EnsureFrameTable(base_normal); + const float total = TotalLength(); + if (total <= 0.0f) { + CenterlineSample sample; + sample.position = control_points_.empty() ? glm::vec3(0.0f) : control_points_.front(); + sample.tangent = glm::vec3(0, 0, 1); + sample.normal = SafeNormalize(base_normal, glm::vec3(1, 0, 0)); + sample.binormal = glm::cross(sample.tangent, sample.normal); + sample.arc_length = 0.0f; + return sample; + } + s = std::clamp(s, 0.0f, total); + // Locate the resample interval [i, i+1] containing arc length s. + const int last = static_cast(arc_length_table_.size()) - 1; + int lo = 0; + int hi = last; + while (lo + 1 < hi) { + const int mid = (lo + hi) / 2; + if (arc_length_table_[mid] <= s) + lo = mid; + else + hi = mid; + } + const float s_lo = arc_length_table_[lo]; + const float s_hi = arc_length_table_[hi]; + const float u = (s_hi > s_lo) ? (s - s_lo) / (s_hi - s_lo) : 0.0f; + CenterlineSample a = sampled_frames_[lo]; + CenterlineSample b = sampled_frames_[hi]; + CenterlineSample out; + out.position = glm::mix(a.position, b.position, u); + out.tangent = SafeNormalize(glm::mix(a.tangent, b.tangent, u), a.tangent); + out.normal = SafeNormalize( + glm::mix(a.normal, b.normal, u) - out.tangent * glm::dot(glm::mix(a.normal, b.normal, u), out.tangent), + a.normal); + out.binormal = SafeNormalize(glm::cross(out.tangent, out.normal), glm::cross(a.tangent, a.normal)); + out.arc_length = s; + return out; + } + + /// Mark the arc-length and frame tables stale (call after any external + /// mutation of the control points besides MutableControlPoints()). + void Invalidate() { + arc_length_dirty_ = true; + } + + private: + std::vector control_points_; + int resample_count_ = 32; + + mutable bool arc_length_dirty_ = true; + mutable std::vector arc_length_table_; ///< Arc length per resample station. + mutable std::vector sampled_positions_; + mutable std::vector sampled_frames_; + mutable glm::vec3 cached_base_normal_ = glm::vec3(1, 0, 0); + mutable bool frames_dirty_ = true; + + static glm::vec3 SafeNormalize(const glm::vec3& v, const glm::vec3& fallback) { + const float l2 = glm::dot(v, v); + if (l2 > 1e-20f) + return v * (1.0f / std::sqrt(l2)); + return fallback; + } + + // Centripetal Catmull-Rom evaluation between p1 and p2 with neighbors p0, p3. + // u in [0,1]. Falls back to linear interpolation at the endpoints. + static glm::vec3 CatmullRom(const glm::vec3& p0, const glm::vec3& p1, const glm::vec3& p2, const glm::vec3& p3, + float u) { + auto knot = [](float prev_t, const glm::vec3& a, const glm::vec3& b) { + const float d = std::sqrt(glm::length(b - a)); // centripetal exponent = 0.5. + return prev_t + std::max(d, 1e-6f); + }; + const float t0 = 0.0f; + const float t1 = knot(t0, p0, p1); + const float t2 = knot(t1, p1, p2); + const float t3 = knot(t2, p2, p3); + const float t = glm::mix(t1, t2, u); + const glm::vec3 a1 = (t1 - t) / (t1 - t0) * p0 + (t - t0) / (t1 - t0) * p1; + const glm::vec3 a2 = (t2 - t) / (t2 - t1) * p1 + (t - t1) / (t2 - t1) * p2; + const glm::vec3 a3 = (t3 - t) / (t3 - t2) * p2 + (t - t2) / (t3 - t2) * p3; + const glm::vec3 b1 = (t2 - t) / (t2 - t0) * a1 + (t - t0) / (t2 - t0) * a2; + const glm::vec3 b2 = (t3 - t) / (t3 - t1) * a2 + (t - t1) / (t3 - t1) * a3; + return (t2 - t) / (t2 - t1) * b1 + (t - t1) / (t2 - t1) * b2; + } + + // Evaluate at normalized parameter t in [0,1] across the control polyline. + glm::vec3 EvaluatePolyline(float t) const { + if (control_points_.size() < 2) { + return control_points_.empty() ? glm::vec3(0.0f) : control_points_.front(); + } + if (control_points_.size() == 2) { + return glm::mix(control_points_[0], control_points_[1], t); + } + const int segs = static_cast(control_points_.size()) - 1; + const float ts = t * static_cast(segs); + int i = std::min(segs - 1, std::max(0, static_cast(std::floor(ts)))); + const float u = ts - static_cast(i); + const int i0 = std::max(0, i - 1); + const int i1 = i; + const int i2 = i + 1; + const int i3 = std::min(segs, i + 2); + return CatmullRom(control_points_[i0], control_points_[i1], control_points_[i2], control_points_[i3], u); + } + + void EnsureArcLengthTable() const { + if (!arc_length_dirty_) + return; + sampled_positions_.clear(); + sampled_positions_.reserve(static_cast(resample_count_)); + arc_length_table_.assign(static_cast(resample_count_), 0.0f); + if (control_points_.empty()) { + arc_length_dirty_ = false; + frames_dirty_ = true; + return; + } + for (int i = 0; i < resample_count_; ++i) { + const float t = static_cast(i) / static_cast(resample_count_ - 1); + sampled_positions_.push_back(EvaluatePolyline(t)); + } + arc_length_table_[0] = 0.0f; + for (int i = 1; i < resample_count_; ++i) { + arc_length_table_[i] = arc_length_table_[i - 1] + glm::length(sampled_positions_[i] - sampled_positions_[i - 1]); + } + arc_length_dirty_ = false; + frames_dirty_ = true; + } + + void EnsureFrameTable(const glm::vec3& base_normal) const { + if (!frames_dirty_ && glm::length(base_normal - cached_base_normal_) < 1e-6f) + return; + cached_base_normal_ = base_normal; + sampled_frames_.assign(sampled_positions_.size(), CenterlineSample{}); + if (sampled_positions_.empty()) { + frames_dirty_ = false; + return; + } + // Initial tangent: forward difference; if degenerate fall back to +Z. + glm::vec3 prev_tangent(0, 0, 1); + if (sampled_positions_.size() >= 2) { + const glm::vec3 d = sampled_positions_[1] - sampled_positions_[0]; + prev_tangent = SafeNormalize(d, glm::vec3(0, 0, 1)); + } + glm::vec3 prev_normal = base_normal - prev_tangent * glm::dot(base_normal, prev_tangent); + prev_normal = SafeNormalize(prev_normal, glm::vec3(1, 0, 0)); + + for (size_t i = 0; i < sampled_positions_.size(); ++i) { + glm::vec3 tangent; + if (i + 1 < sampled_positions_.size()) { + const glm::vec3 d = sampled_positions_[i + 1] - sampled_positions_[i]; + tangent = SafeNormalize(d, prev_tangent); + } else { + const glm::vec3 d = sampled_positions_[i] - sampled_positions_[i - 1]; + tangent = SafeNormalize(d, prev_tangent); + } + // Rotation-minimizing transport (double-reflection method, Wang et al. 2008). + glm::vec3 normal = prev_normal; + const glm::vec3 v1 = sampled_positions_[i] - (i == 0 ? sampled_positions_[i] : sampled_positions_[i - 1]); + const float c1 = glm::dot(v1, v1); + if (c1 > 1e-20f) { + const glm::vec3 r_l = prev_normal - (2.0f / c1) * glm::dot(v1, prev_normal) * v1; + const glm::vec3 t_l = prev_tangent - (2.0f / c1) * glm::dot(v1, prev_tangent) * v1; + const glm::vec3 v2 = tangent - t_l; + const float c2 = glm::dot(v2, v2); + if (c2 > 1e-20f) { + normal = r_l - (2.0f / c2) * glm::dot(v2, r_l) * v2; + } else { + normal = r_l; + } + normal -= tangent * glm::dot(normal, tangent); + normal = SafeNormalize(normal, prev_normal); + } else { + normal = prev_normal - tangent * glm::dot(prev_normal, tangent); + normal = SafeNormalize(normal, glm::vec3(1, 0, 0)); + } + CenterlineSample s; + s.position = sampled_positions_[i]; + s.tangent = tangent; + s.normal = normal; + s.binormal = SafeNormalize(glm::cross(tangent, normal), glm::vec3(0, 1, 0)); + s.arc_length = arc_length_table_[i]; + sampled_frames_[i] = s; + prev_tangent = tangent; + prev_normal = normal; + } + frames_dirty_ = false; + } +}; + +} // namespace l_system_package \ No newline at end of file diff --git a/EvoEngine_Packages/LSystem/include/OrganMeshChannel.hpp b/EvoEngine_Packages/LSystem/include/OrganMeshChannel.hpp new file mode 100644 index 00000000..fd32d5f5 --- /dev/null +++ b/EvoEngine_Packages/LSystem/include/OrganMeshChannel.hpp @@ -0,0 +1,242 @@ +#pragma once + +#include "LSystem_PCH.hpp" +#include "RenderPublishPolicy.hpp" + +#include "AssetManager.hpp" +#include "Material.hpp" +#include "Mesh.hpp" +#include "MeshRenderer.hpp" +#include "Scene.hpp" + +#include +#include +#include +#include +#include + +namespace l_system_package { +using namespace evo_engine; + +/** + * @brief Plant-agnostic mesh render channel. + * + * Wraps a child Entity carrying a MeshRenderer plus its private Mesh and + * Material assets, and exposes a Stage / Flush interface. Producer code (e.g. + * an L-system organ mesher running on a worker thread) calls Stage with a POD + * vertex/index payload; the owning PlantRenderTarget calls Flush from the main + * thread, which is the only point at which Mesh::SetVertices is invoked. + * + * Design constraints: + * - Zero botany. Vertices and indices are passed through unchanged. + * - No SDK edits. The channel is layered strictly on top of public APIs. + * - No compute shaders. All staging happens in CPU std::vector storage. + * - The channel owns exactly one child Entity. Lifetime is tied to the + * channel; the destructor deletes the Entity (when the Scene is still + * alive). This avoids any global registry. + * + * Stability notes: + * - The high-water vertex / index counts are tracked for diagnostics; they + * are also reported by GetStats so downstream packages can react (e.g. + * skip detail levels) when individual organs blow past expectations. + * - Identical successive payloads are coalesced via a 64-bit hash, which + * avoids the most common source of redundant GeometryStorage churn. + */ +class OrganMeshChannel { + public: + /// Aggregate of runtime statistics for diagnostics. + struct Stats { + std::size_t high_water_vertex_count = 0; + std::size_t high_water_index_count = 0; + std::uint64_t flush_count = 0; + std::uint64_t skipped_by_dedup = 0; + std::uint64_t skipped_by_rate_limit = 0; + }; + + /** + * @brief Constructs the channel: creates a child Entity, attaches a + * MeshRenderer with a fresh temporary Mesh and Material, and parents + * the Entity beneath @p parent. + * + * Must be called from the main thread because Scene mutation is not + * thread-safe. + */ + OrganMeshChannel(const std::shared_ptr& scene, const Entity& parent, const std::string& name) + : scene_(scene), name_(name) { + entity_ = scene->CreateEntity(name); + scene->SetParent(entity_, parent); + mesh_ = AssetManager::CreateTemporaryAsset(); + material_ = AssetManager::CreateTemporaryAsset(); + const auto renderer = scene->GetOrSetPrivateComponent(entity_).lock(); + renderer->mesh = mesh_; + renderer->material = material_; + } + + OrganMeshChannel(const OrganMeshChannel&) = delete; + OrganMeshChannel& operator=(const OrganMeshChannel&) = delete; + OrganMeshChannel(OrganMeshChannel&&) = delete; + OrganMeshChannel& operator=(OrganMeshChannel&&) = delete; + + ~OrganMeshChannel() { + if (const auto scene = scene_.lock()) { + if (scene->IsEntityValid(entity_)) { + scene->DeleteEntity(entity_); + } + } + } + + /** + * @brief Records a payload for later publication. Safe to call from any + * thread; the most recent staged payload always wins. + * + * If @c RenderPublishPolicy::defer_to_main_thread is false, this performs + * the SDK upload immediately and the caller must be on the main thread. + */ + void Stage(VertexAttributes attributes, std::vector vertices, std::vector triangles) { + const std::uint64_t payload_hash = ComputePayloadHash(vertices, triangles); + { + std::lock_guard guard(pending_mutex_); + pending_.attributes = attributes; + pending_.vertices = std::move(vertices); + pending_.triangles = std::move(triangles); + pending_.hash = payload_hash; + pending_.present = true; + } + if (!policy.defer_to_main_thread) { + Flush(); + } + } + + /** + * @brief Convenience wrapper that always uploads immediately. Must be + * called on the main thread. + */ + void Publish(VertexAttributes attributes, std::vector vertices, std::vector triangles) { + Stage(std::move(attributes), std::move(vertices), std::move(triangles)); + Flush(); + } + + /** + * @brief Stages an empty payload, which will cause the next Flush to clear + * the on-GPU geometry. + */ + void Clear() { + Stage({}, {}, {}); + } + + /** + * @brief Drains the staged payload to the SDK. Must run on the main thread. + * Returns true if Mesh::SetVertices was invoked. + */ + bool Flush() { + PendingPayload payload; + { + std::lock_guard guard(pending_mutex_); + if (!pending_.present) { + return false; + } + payload = std::move(pending_); + pending_ = PendingPayload{}; + } + + if (policy.deduplicate_identical_payloads && payload.hash == last_published_hash_ && + payload.vertices.size() == last_published_vertex_count_ && + payload.triangles.size() == last_published_triangle_count_) { + ++stats_.skipped_by_dedup; + return false; + } + + if (policy.min_republish_interval_seconds > 0.0f) { + const double now = NowSeconds(); + if (now - last_publish_time_seconds_ < static_cast(policy.min_republish_interval_seconds)) { + // Re-stage so the next Flush attempt will still see the payload. + std::lock_guard guard(pending_mutex_); + if (!pending_.present) { + pending_ = std::move(payload); + } + ++stats_.skipped_by_rate_limit; + return false; + } + last_publish_time_seconds_ = now; + } + + if (!mesh_) { + return false; + } + mesh_->SetVertices(payload.attributes, payload.vertices, payload.triangles); + + last_published_hash_ = payload.hash; + last_published_vertex_count_ = payload.vertices.size(); + last_published_triangle_count_ = payload.triangles.size(); + stats_.high_water_vertex_count = std::max(stats_.high_water_vertex_count, payload.vertices.size()); + stats_.high_water_index_count = std::max(stats_.high_water_index_count, payload.triangles.size() * std::size_t{3}); + ++stats_.flush_count; + return true; + } + + /// True iff a payload is waiting to be drained. + [[nodiscard]] bool HasPending() const { + std::lock_guard guard(pending_mutex_); + return pending_.present; + } + + [[nodiscard]] Entity GetEntity() const noexcept { + return entity_; + } + [[nodiscard]] const std::shared_ptr& GetMesh() const noexcept { + return mesh_; + } + [[nodiscard]] const std::shared_ptr& GetMaterial() const noexcept { + return material_; + } + [[nodiscard]] const std::string& GetName() const noexcept { + return name_; + } + [[nodiscard]] Stats GetStats() const noexcept { + return stats_; + } + + /// Tunable knobs. Mutate freely; values are read on the next Flush. + RenderPublishPolicy policy{}; + + private: + struct PendingPayload { + VertexAttributes attributes{}; + std::vector vertices; + std::vector triangles; + std::uint64_t hash = 0; + bool present = false; + }; + + static std::uint64_t ComputePayloadHash(const std::vector& vertices, + const std::vector& triangles) { + std::uint64_t hash = HashBytes(vertices.data(), vertices.size() * sizeof(Vertex)); + hash ^= HashBytes(triangles.data(), triangles.size() * sizeof(glm::uvec3)) + 0x9e3779b97f4a7c15ULL + (hash << 6) + + (hash >> 2); + return hash; + } + + static double NowSeconds() { + using clock = std::chrono::steady_clock; + const auto now = clock::now().time_since_epoch(); + return std::chrono::duration(now).count(); + } + + std::weak_ptr scene_; + std::string name_; + Entity entity_{}; + std::shared_ptr mesh_; + std::shared_ptr material_; + + mutable std::mutex pending_mutex_; + PendingPayload pending_; + + std::uint64_t last_published_hash_ = 0; + std::size_t last_published_vertex_count_ = 0; + std::size_t last_published_triangle_count_ = 0; + double last_publish_time_seconds_ = -1.0e18; + + Stats stats_{}; +}; + +} // namespace l_system_package diff --git a/EvoEngine_Packages/LSystem/include/OrganMeshPly.hpp b/EvoEngine_Packages/LSystem/include/OrganMeshPly.hpp new file mode 100644 index 00000000..4ed0bf1e --- /dev/null +++ b/EvoEngine_Packages/LSystem/include/OrganMeshPly.hpp @@ -0,0 +1,76 @@ +#pragma once + +// --------------------------------------------------------------------------- +// OrganMeshPly +// +// Phase 1 QA helper. Writes an explicit triangle mesh to a binary +// little-endian PLY file (vertex { x y z nx ny nz red green blue alpha }, +// face { uchar list uint }) so a Blender artist can open the output of any +// pipeline phase by drag-and-drop. Used to capture the per-phase fascicle +// snapshot called out in the workspace plan. +// +// No dependency on EvoEngine asset machinery; works on a raw vertex/index +// pair so the same writer can be called from CLI tools, unit tests, or +// inside the live editor session. +// --------------------------------------------------------------------------- + +#include +#include +#include +#include +#include + +#include + +#include // evo_engine::Vertex + +namespace l_system_package { + +inline bool WriteOrganMeshToPly(const std::filesystem::path& path, const std::vector& vertices, + const std::vector& triangles) { + std::FILE* fp = std::fopen(path.string().c_str(), "wb"); + if (!fp) + return false; + + std::fprintf(fp, "ply\n"); + std::fprintf(fp, "format binary_little_endian 1.0\n"); + std::fprintf(fp, "comment Generated by EvoEngine LSystem Plugin (OrganMeshPly)\n"); + std::fprintf(fp, "element vertex %zu\n", vertices.size()); + std::fprintf(fp, "property float x\n"); + std::fprintf(fp, "property float y\n"); + std::fprintf(fp, "property float z\n"); + std::fprintf(fp, "property float nx\n"); + std::fprintf(fp, "property float ny\n"); + std::fprintf(fp, "property float nz\n"); + std::fprintf(fp, "property uchar red\n"); + std::fprintf(fp, "property uchar green\n"); + std::fprintf(fp, "property uchar blue\n"); + std::fprintf(fp, "property uchar alpha\n"); + std::fprintf(fp, "element face %zu\n", triangles.size()); + std::fprintf(fp, "property list uchar uint vertex_indices\n"); + std::fprintf(fp, "end_header\n"); + + for (const auto& v : vertices) { + const float pos[3] = {v.position.x, v.position.y, v.position.z}; + const float nrm[3] = {v.normal.x, v.normal.y, v.normal.z}; + auto to_byte = [](float c) -> std::uint8_t { + const float clamped = c < 0.0f ? 0.0f : (c > 1.0f ? 1.0f : c); + return static_cast(clamped * 255.0f + 0.5f); + }; + const std::uint8_t rgba[4] = {to_byte(v.color.r), to_byte(v.color.g), to_byte(v.color.b), to_byte(v.color.a)}; + std::fwrite(pos, sizeof(float), 3, fp); + std::fwrite(nrm, sizeof(float), 3, fp); + std::fwrite(rgba, sizeof(std::uint8_t), 4, fp); + } + for (const auto& t : triangles) { + const std::uint8_t k = 3; + const std::uint32_t idx[3] = {t.x, t.y, t.z}; + std::fwrite(&k, sizeof(std::uint8_t), 1, fp); + std::fwrite(idx, sizeof(std::uint32_t), 3, fp); + } + + std::fclose(fp); + return true; +} + +} // namespace l_system_package \ No newline at end of file diff --git a/EvoEngine_Packages/LSystem/include/ParamSpaceExplorer.hpp b/EvoEngine_Packages/LSystem/include/ParamSpaceExplorer.hpp new file mode 100644 index 00000000..05771637 --- /dev/null +++ b/EvoEngine_Packages/LSystem/include/ParamSpaceExplorer.hpp @@ -0,0 +1,108 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include "ILSystemExplorableDescriptor.hpp" + +namespace l_system_package { + +enum class ParamMotionMode : int { + Stopped = 0, + Lissajous = 1, + RandomWalk = 2, + LinearInterp = 3, +}; + +struct ParamSpaceAxis { + std::string label; // Full label for sliders. + std::string short_label; // Compact axis label for parallel coords. + float min_val = 0.0f; + float max_val = 1.0f; + std::function getter; + std::function setter; +}; + +class ParamSpaceExplorer { + public: + static constexpr int kTrailCapacity = 200; + + ParamMotionMode mode = ParamMotionMode::Stopped; + float speed = 1.0f; + bool playing = false; + + /// Bind any descriptor that implements ILSystemExplorableDescriptor. + /// Triggers RebuildAxes() which delegates axis registration back to the + /// descriptor via RegisterExplorableAxes. + void Bind(ILSystemExplorableDescriptor& desc); + bool IsBound() const { + return bound_desc_ != nullptr; + } + + /// Draw controls + parallel-coords canvas. Returns true if any parameter changed. + bool OnInspect(); + + void Reset(); + void SaveSnapshotA(); + void SaveSnapshotB(); + + // -- Axis registration helpers (called from ILSystemExplorableDescriptor + // ::RegisterExplorableAxes implementations). -- + + void AddAxis(const std::string& label, const std::string& short_label, float min_val, float max_val, + std::function getter, std::function setter); + + void AddSingle(const std::string& label_prefix, const std::string& short_prefix, + evo_engine::SingleDistribution& dist, float mean_min, float mean_max, float deviation_max); + + void AddPlotted(const std::string& label_prefix, const std::string& short_prefix, + evo_engine::PlottedDistribution& dist); + + void AddCurve(const std::string& label_prefix, const std::string& short_prefix, evo_engine::Curve2D& curve); + + private: + ILSystemExplorableDescriptor* bound_desc_ = nullptr; + std::vector axes_; + uint64_t schema_signature_ = 0; + + // Motion state. + double time_ = 0.0; + std::mt19937 rng_{42}; + + // Trail ring buffer (normalized [0,1] per axis, all axes). + std::vector> trail_; + int trail_head_ = 0; + int trail_count_ = 0; + + // Snapshots for LinearInterp (normalized). + std::vector snapshot_a_; + std::vector snapshot_b_; + bool has_snapshot_a_ = false; + bool has_snapshot_b_ = false; + float interp_t_ = 0.0f; + + // UI state. + int visible_axis_offset_ = 0; + int visible_axis_count_ = 24; + bool sliders_window_only_ = true; + std::array axis_search_{}; + + // Helpers. + void RebuildAxes(); + uint64_t ComputeSchemaSignature() const; + + float GetNormalized(size_t i) const; + void SetNormalized(size_t i, float t01); + void GetNormalizedPoint(std::vector& out) const; + void SetNormalizedPoint(const std::vector& pt); + void PushTrailPoint(); + bool Tick(float dt); + std::vector GetVisibleAxisIndices() const; + void DrawParallelCoords(float width, float height, const std::vector& axis_indices); +}; + +} // namespace l_system_package \ No newline at end of file diff --git a/EvoEngine_Packages/LSystem/include/PineGrowthModel.hpp b/EvoEngine_Packages/LSystem/include/PineGrowthModel.hpp new file mode 100644 index 00000000..ffbd0ba2 --- /dev/null +++ b/EvoEngine_Packages/LSystem/include/PineGrowthModel.hpp @@ -0,0 +1,109 @@ +#pragma once + +#include +#include +#include "LSystemGrowthModelBase.hpp" +#include "ScotsPineModules.hpp" +#include "ScotsPineRules.hpp" + +namespace l_system_package { + +// kPineGddPerYear is defined in ScotsPineDescriptor.hpp (included transitively +// via ScotsPineRules.hpp). Both Rules and GrowthModel need it. + +class ScotsPineDescriptor; + +/** + * @brief Annual-cycle growth model for a single Scots pine instance. + * + * Time-stepped version: topology and continuous organ growth advance together + * in GrowStep()/GrowToGDD(). Leader/lateral child emission can be gated by + * parent internode maturation so internodes visibly elongate before emitting + * descendants. Inherits the generic algorithm skeleton from + * LSystemGrowthModelBase via CRTP. + * + * Owned by ScotsPine (or by a smoke-test driver in tools/). + */ +class PineGrowthModel : public LSystemGrowthModelBase { + public: + using Base = LSystemGrowthModelBase; + + /** + * @brief Initialize the model from a genotype and seed. + * Samples all distributions, sets up the leader axiom, creates rules. + */ + void Initialize(const ScotsPineDescriptor& descriptor, unsigned int seed, + const glm::vec3& root_position = glm::vec3(0), const glm::quat& root_rotation = kDefaultRootRotation, + const ScotsPineDescriptor* post_repot_descriptor = nullptr, float repot_switch_gdd = -1.0f, + bool enable_needle_topology = true); + + /** + * @brief Grow toward target GDD with optional one-time pre->post profile switch. + */ + void GrowToGDDWithProfileSwitch(float target_gdd, uint32_t max_growth_steps = 0); + + [[nodiscard]] bool IsPostRepotProfileActive() const { + return post_repot_profile_active_; + } + + [[nodiscard]] bool HasPostRepotProfile() const { + return has_post_repot_profile_; + } + + [[nodiscard]] float GetRepotSwitchGdd() const { + return repot_switch_gdd_; + } + + /// Enable or disable needle-cluster topology emission in Scots pine rules. + /// When false, topology emits internodes only (stem-only mode). + void SetNeedleTopologyEnabled(bool enabled); + + [[nodiscard]] bool IsNeedleTopologyEnabled() const { + return needle_topology_enabled_; + } + + /** + * @brief Reset the model to uninitialized state. + */ + void Reset(); + + // ===== CRTP hooks invoked by LSystemGrowthModelBase ===== + + /// Sync per-node info (length, thickness) from module data. Only PineInternode + /// contributes; everything else collapses to zero so geometry pass skips them. + void UpdateNodeInfoImpl(LGraphNode& node); + + /// True iff the module is a still-developing symbol (apex). Used by the base + /// to decide when topology has converged. + [[nodiscard]] bool IsDevelopmentalSymbolImpl(const LGraphNode& node) const; + + /// Per-node local rotation for the GeometryPass walk. + [[nodiscard]] glm::quat ComputeChildLocalRotationImpl(const LGraphNode& node, + const LGraphNode& parent) const; + + [[nodiscard]] float ChronologicalGddPerYearImpl() const; + + bool UpdateNodeAgingOnlyImpl(LGraphNode& node); + + // -- Plant-specific public state -- + SampledPineParams sampled; + + private: + void ApplyActiveSampledProfile(const SampledPineParams& profile_sampled); + void RefreshEngineRulesForActiveProfile(); + void ActivatePostRepotProfile(); + + void RebuildStemLoadCacheIfNeeded(); + + SampledPineParams pre_repot_sampled_; + SampledPineParams post_repot_sampled_; + bool has_post_repot_profile_ = false; + bool post_repot_profile_active_ = false; + float repot_switch_gdd_ = -1.0f; + bool needle_topology_enabled_ = true; + + int stem_load_cache_graph_version_ = -1; + std::vector stem_load_cache_; +}; + +} // namespace l_system_package diff --git a/EvoEngine_Packages/LSystem/include/PlantRenderTarget.hpp b/EvoEngine_Packages/LSystem/include/PlantRenderTarget.hpp new file mode 100644 index 00000000..57b8e9ca --- /dev/null +++ b/EvoEngine_Packages/LSystem/include/PlantRenderTarget.hpp @@ -0,0 +1,183 @@ +#pragma once + +#include "LSystem_PCH.hpp" +#include "OrganInstanceChannel.hpp" +#include "OrganMeshChannel.hpp" +#include "OrganStrandsChannel.hpp" + +#include "Scene.hpp" + +#include +#include +#include + +namespace l_system_package { +using namespace evo_engine; + +/** + * @brief One render-side facade per plant. + * + * A PlantRenderTarget owns the channels that publish geometry for a single + * plant instance. The owning L-system component (typically derived from + * @c LSystemComponentBase) creates one of these in its OnCreate path and + * destroys it in its OnDestroy / ClearGeometryEntities path. The target keeps + * channels keyed by an integer @c channel_id chosen by the using package, so + * the LSystem core makes no assumption about which organs exist or how many + * channels a single plant uses. + * + * Channel ownership pattern: + * - Channels are owned by std::unique_ptr inside the target. + * - Their child Entities are parented under @c root_entity_. + * - On destruction the channels detach their entities individually, then the + * target leaves @c root_entity_ alone (typically the L-system component's + * owner entity, whose lifetime is managed by the caller). + * + * Threading: + * - GetOrCreate* must run on the main thread (Scene mutation). + * - The channels' Stage methods are safe to call from worker threads. + * - FlushPending must run on the main thread, between simulation step and + * the next render submission. + */ +class PlantRenderTarget { + public: + PlantRenderTarget(const std::shared_ptr& scene, const Entity& root_entity) + : scene_(scene), root_entity_(root_entity) { + } + + PlantRenderTarget(const PlantRenderTarget&) = delete; + PlantRenderTarget& operator=(const PlantRenderTarget&) = delete; + PlantRenderTarget(PlantRenderTarget&&) = delete; + PlantRenderTarget& operator=(PlantRenderTarget&&) = delete; + + ~PlantRenderTarget() = default; + + /// Returns the existing mesh channel for @p channel_id, or creates one + /// parented under the root entity. The channel name is used purely for + /// diagnostics in the scene hierarchy. + OrganMeshChannel* GetOrCreateMeshChannel(int channel_id, const std::string& name) { + if (const auto it = mesh_channels_.find(channel_id); it != mesh_channels_.end()) { + return it->second.get(); + } + const auto scene = scene_.lock(); + if (!scene) { + return nullptr; + } + auto channel = std::make_unique(scene, root_entity_, name); + auto* raw = channel.get(); + mesh_channels_.emplace(channel_id, std::move(channel)); + return raw; + } + + OrganInstanceChannel* GetOrCreateInstanceChannel(int channel_id, const std::string& name, + const std::shared_ptr& instance_mesh, + const std::shared_ptr& instance_material) { + if (const auto it = instance_channels_.find(channel_id); it != instance_channels_.end()) { + return it->second.get(); + } + const auto scene = scene_.lock(); + if (!scene) { + return nullptr; + } + auto channel = std::make_unique(scene, root_entity_, name, instance_mesh, instance_material); + auto* raw = channel.get(); + instance_channels_.emplace(channel_id, std::move(channel)); + return raw; + } + + OrganStrandsChannel* GetOrCreateStrandsChannel(int channel_id, const std::string& name) { + if (const auto it = strands_channels_.find(channel_id); it != strands_channels_.end()) { + return it->second.get(); + } + const auto scene = scene_.lock(); + if (!scene) { + return nullptr; + } + auto channel = std::make_unique(scene, root_entity_, name); + auto* raw = channel.get(); + strands_channels_.emplace(channel_id, std::move(channel)); + return raw; + } + + /// Drops a single channel (and its child Entity). Useful when an organ + /// modality is removed mid-simulation. + void RemoveMeshChannel(int channel_id) { + mesh_channels_.erase(channel_id); + } + void RemoveInstanceChannel(int channel_id) { + instance_channels_.erase(channel_id); + } + void RemoveStrandsChannel(int channel_id) { + strands_channels_.erase(channel_id); + } + + /// Drops every channel; called by L-system components on + /// ClearGeometryEntities. + void ClearAllChannels() { + mesh_channels_.clear(); + instance_channels_.clear(); + strands_channels_.clear(); + } + + /// Drains pending payloads from every owned channel. Returns the number of + /// channels that actually uploaded. Must run on the main thread. + std::size_t FlushPending() { + std::size_t flushed = 0; + for (auto& [id, ch] : mesh_channels_) { + if (ch->Flush()) + ++flushed; + } + for (auto& [id, ch] : instance_channels_) { + if (ch->Flush()) + ++flushed; + } + for (auto& [id, ch] : strands_channels_) { + if (ch->Flush()) + ++flushed; + } + return flushed; + } + + /// Counts pending publishes across every channel (without draining). + [[nodiscard]] std::size_t CountPending() const { + std::size_t pending = 0; + for (const auto& [id, ch] : mesh_channels_) { + if (ch->HasPending()) + ++pending; + } + for (const auto& [id, ch] : instance_channels_) { + if (ch->HasPending()) + ++pending; + } + for (const auto& [id, ch] : strands_channels_) { + if (ch->HasPending()) + ++pending; + } + return pending; + } + + [[nodiscard]] Entity GetRootEntity() const noexcept { + return root_entity_; + } + + [[nodiscard]] const std::unordered_map>& GetMeshChannels() const noexcept { + return mesh_channels_; + } + [[nodiscard]] const std::unordered_map>& GetInstanceChannels() + const noexcept { + return instance_channels_; + } + [[nodiscard]] const std::unordered_map>& GetStrandsChannels() + const noexcept { + return strands_channels_; + } + + private: + std::weak_ptr scene_; + Entity root_entity_{}; + + std::unordered_map> mesh_channels_; + std::unordered_map> instance_channels_; + std::unordered_map> strands_channels_; +}; + +} // namespace l_system_package diff --git a/EvoEngine_Packages/LSystem/include/ProductionRule.hpp b/EvoEngine_Packages/LSystem/include/ProductionRule.hpp new file mode 100644 index 00000000..a1497529 --- /dev/null +++ b/EvoEngine_Packages/LSystem/include/ProductionRule.hpp @@ -0,0 +1,113 @@ +#pragma once + +#include +#include +#include +#include "LSystemGraph.hpp" + +namespace l_system_package { + +/** + * @brief Read-only context passed to production rule conditions and producers. + * + * Provides access to the current node, its parent, children, the whole graph, + * and a per-derivation RNG. + * + * @tparam GraphType The fully instantiated LSystemGraph type. + */ +template +struct RuleContext { + using NodeType = typename std::remove_reference_t().RefNode(0))>; + using FlowType = typename std::remove_reference_t().RefFlow(0))>; + + const NodeType& self; ///< Current node being evaluated. + const NodeType* parent; ///< Parent node (nullptr if root). + const std::vector& child_handles; ///< Handles of child nodes. + const GraphType& graph; ///< Whole graph (read access). + std::mt19937& rng; ///< Per-derivation random number generator. + LNodeHandle self_handle; ///< Handle to self for topology operations. +}; + +/** + * @brief A single successor module produced by a production rule. + * + * @tparam ModuleData The module data type (typically ModuleVariant<...>). + */ +template +struct Successor { + int symbol_id = -1; ///< Symbol ID of the new module. + ModuleData data = {}; ///< Data for the new module. + bool is_branch = false; ///< false = prolong (Extend(false)), true = branch (Extend(true)). +}; + +/** + * @brief Result of applying a production rule to a node. + * + * - Empty successors -> death (node will be removed) + * - 1 successor -> in-place modification (data overwritten, no topology change) + * - 2+ successors -> extension (first = prolong same flow, rest = branches) + * + * @tparam ModuleData The module data type. + */ +template +struct ProductionResult { + std::vector> successors; + + [[nodiscard]] bool IsDeath() const { + return successors.empty(); + } + [[nodiscard]] bool IsIdentity() const { + return successors.size() == 1 && !successors[0].is_branch; + } + [[nodiscard]] bool IsExtension() const { + return successors.size() > 1; + } +}; + +/** + * @brief A single production rule in the L-system. + * + * Matches nodes by symbol_id, evaluates a condition, and produces successors. + * Rules are separated into topology rules (may change graph structure) and + * growth rules (in-place parameter updates only). + * + * @tparam GraphType The fully instantiated LSystemGraph type. + * @tparam ModuleData The module data type stored in nodes. + */ +template +struct ProductionRule { + int predecessor_symbol = -1; ///< Which symbol_id this rule matches (-1 = match any). + + /** + * @brief Condition function: returns true if this rule should fire for this node. + * If nullptr, the rule always fires when the symbol matches. + */ + std::function&)> condition; + + /** + * @brief Producer function: generates the successor modules. + */ + std::function(RuleContext&)> produce; + + int priority = 0; ///< Higher priority rules are checked first. + + /** + * @brief Stochastic-selection weight (P&L 1990 / vlab `cpfg` style). + * + * When two or more rules share the highest matching priority for a given + * node, the engine performs a weighted random pick using these weights. + * + * Dispatch contract: weighted selection only fires when at least one rule in + * the matching same-priority bucket has a `probability` that differs from + * the default `1.0f`. If every rule in the bucket keeps the default, the + * engine falls back to the legacy "first match wins" path and consumes no + * RNG state, keeping existing single-rule-per-priority L-systems unchanged. + * + * To opt in, register multiple rules at the same priority and set their + * `probability` fields (any positive scalar; weights are normalized at + * dispatch time). + */ + float probability = 1.0f; +}; + +} // namespace l_system_package \ No newline at end of file diff --git a/EvoEngine_Packages/LSystem/include/ScotsPine.hpp b/EvoEngine_Packages/LSystem/include/ScotsPine.hpp new file mode 100644 index 00000000..21095067 --- /dev/null +++ b/EvoEngine_Packages/LSystem/include/ScotsPine.hpp @@ -0,0 +1,195 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include "LSystemComponentBase.hpp" +#include "PineGrowthModel.hpp" +#include "PlantRenderTarget.hpp" + +namespace l_system_package { +using namespace evo_engine; + +class ScotsPineDescriptor; + +/** + * @brief Per-instance Scots pine private component. + * + * Architectural mirror of MaizeTassel (single source of generated geometry per + * scene entity), specialized for Pinus sylvestris: + * - Annual scheduling driven by `target_gdd` (growing degree-days), matching MaizeTassel. + * - Owns a PineGrowthModel that runs single-shot topology derivation + * (Phase 3 grammar - no per-frame growth animation yet). + * - Hybrid render path: internodes via `Particles`, aggregate needles via `MeshRenderer`. + * + * Geometry is emitted as two child entities: + * - "Pine Internodes" - instanced unit cylinders (one per PineInternode). + * - "Pine Needles" / "Pine Needles Geometry" - legacy markers or aggregate swept needle mesh. + */ +class ScotsPine final : public LSystemComponentBase { + public: + ScotsPine() = default; + ScotsPine(const ScotsPine& other) + : LSystemComponentBase(other), + post_repot_descriptor_ref(other.post_repot_descriptor_ref), + enable_repot_profile_switch(other.enable_repot_profile_switch), + repot_switch_gdd(other.repot_switch_gdd), + growth_model(other.growth_model), + last_grow_seconds(other.last_grow_seconds), + last_rebuild_seconds(other.last_rebuild_seconds), + last_node_count(other.last_node_count), + last_internode_count(other.last_internode_count), + last_needle_count(other.last_needle_count), + last_invalid_instance_count(other.last_invalid_instance_count), + last_applied_internode_visual_radius_multiplier(other.last_applied_internode_visual_radius_multiplier), + last_applied_render_needles_enabled(other.last_applied_render_needles_enabled), + last_applied_leader_debug_color(other.last_applied_leader_debug_color), + last_applied_color_mode(other.last_applied_color_mode), + last_needle_skeleton_lines(other.last_needle_skeleton_lines), + render_target_(nullptr) { + } + ScotsPine& operator=(const ScotsPine& other) { + if (this == &other) { + return *this; + } + LSystemComponentBase::operator=(other); + post_repot_descriptor_ref = other.post_repot_descriptor_ref; + enable_repot_profile_switch = other.enable_repot_profile_switch; + repot_switch_gdd = other.repot_switch_gdd; + growth_model = other.growth_model; + last_grow_seconds = other.last_grow_seconds; + last_rebuild_seconds = other.last_rebuild_seconds; + last_node_count = other.last_node_count; + last_internode_count = other.last_internode_count; + last_needle_count = other.last_needle_count; + last_invalid_instance_count = other.last_invalid_instance_count; + last_applied_internode_visual_radius_multiplier = other.last_applied_internode_visual_radius_multiplier; + last_applied_render_needles_enabled = other.last_applied_render_needles_enabled; + last_applied_leader_debug_color = other.last_applied_leader_debug_color; + last_applied_color_mode = other.last_applied_color_mode; + last_needle_skeleton_lines = other.last_needle_skeleton_lines; + render_target_.reset(); + return *this; + } + ScotsPine(ScotsPine&&) noexcept = default; + ScotsPine& operator=(ScotsPine&&) noexcept = default; + + static constexpr int kChannelInternodes = 0; + static constexpr int kChannelNeedles = 1; + + struct NeedleSkeletonLine { + int cluster_node_handle = -1; + int parent_node_handle = -1; + int needle_index = -1; + std::vector points_world; + }; + + enum class ColorMode : int { + Shaded = 0, + ByType = 1, + ByInstance = 2, + ByNode = 3, + NeedleLignification = 4, + NeedleStripeProxy = 5, + NeedleSheath = 6, + }; + + static void SetGlobalColorMode(ColorMode mode); + [[nodiscard]] static ColorMode GetGlobalColorMode(); + + /// Scanner-compatibility toggle. CPU-only path is the default for Pine, + /// so this currently has no effect; kept for API parity with MaizeTassel + /// to ease future GPU-pipeline integration. + static void SetForceCpuParticlesPath(bool force); + [[nodiscard]] static bool IsForceCpuParticlesPath(); + + /// Visualization-only multiplier applied to internode rendered cylinder + /// half-thickness. Does NOT modify `node.info.thickness`, so exported + /// flow-graph YAML / OBJ ground-truth radii are preserved. Default 1.0. + /// Intended to make sub-pixel-thin trunks resolvable on rendered images + /// without violating the phenotype-fidelity constraint of the project. + static void SetInternodeVisualRadiusMultiplier(float multiplier); + [[nodiscard]] static float GetInternodeVisualRadiusMultiplier(); + + /// When false, skip needle entity construction during RebuildGeometry and + /// tear down any previously created needle entity. Useful for trunk-only + /// diagnostic renders. Default true (preserves existing behaviour). + static void SetRenderNeedlesEnabled(bool enabled); + [[nodiscard]] static bool IsRenderNeedlesEnabled(); + + /// Topology-generation knob. When false, Scots pine rules do not emit + /// PineNeedleCluster modules, so both CPU and GPU geometry paths are + /// stem-only by construction. Default true. + static void SetGenerateNeedleTopologyEnabled(bool enabled); + [[nodiscard]] static bool IsGenerateNeedleTopologyEnabled(); + + /// Optional debug colour override for leader-axis (order==0) internodes. + /// If alpha > 0, the override replaces the normal ColorMode tint on every + /// leader internode instance, regardless of color mode. Default {0,0,0,0} + /// (disabled). Visualization-only; does not affect exported assets. + static void SetLeaderInternodeDebugColor(const glm::vec4& color); + [[nodiscard]] static glm::vec4 GetLeaderInternodeDebugColor(); + + /// Optional post-repot descriptor used after the switch trigger is reached. + AssetRef post_repot_descriptor_ref; + + /// Enables one-time pre->post descriptor switching in the growth model. + bool enable_repot_profile_switch = false; + + /// Trigger GDD where post-repot profile becomes active. + float repot_switch_gdd = 6000.0f; + + /// Returns infancy GDD for reset. + [[nodiscard]] float GetInfancyTargetGDD() const; + + /// The growth model (non-serialized, rebuilt on Generate). + PineGrowthModel growth_model; + + // Runtime profiling/debug counters (not serialized). + double last_grow_seconds = 0.0; + double last_rebuild_seconds = 0.0; + uint32_t last_node_count = 0; + uint32_t last_internode_count = 0; + uint32_t last_needle_count = 0; + uint32_t last_invalid_instance_count = 0; + + // Cache of visualization-only knobs that require geometry refresh even when + // growth_model.last_growth_steps == 0 in loaded-scene workflows. + float last_applied_internode_visual_radius_multiplier = std::numeric_limits::quiet_NaN(); + bool last_applied_render_needles_enabled = true; + glm::vec4 last_applied_leader_debug_color = glm::vec4(std::numeric_limits::quiet_NaN()); + int last_applied_color_mode = -1; + + void GenerateGeometryEntities(bool uncapped_growth = false); + void GeneratePreviewGeometryEntities(float preview_target_gdd, uint32_t preview_max_growth_steps); + void GrowToTargetGDD(bool uncapped_growth = false); + void SetSeasonalChronologicalMode(bool enable_independent_chronological_clock); + bool AdvanceChronologicalAging(float delta_years); + void RebuildGeometry(); + void ClearGeometryEntities() const; + void ExportObj(const std::filesystem::path& path) const; + void ExportFlowGraph(YAML::Emitter& out); + void ExportFlowGraph(const std::filesystem::path& path); + void ExportNodeGraph(YAML::Emitter& out); + void ExportNodeGraph(const std::filesystem::path& path); + void ExportNeedleSkeleton(YAML::Emitter& out); + void ExportNeedleSkeleton(const std::filesystem::path& path); + + // Last generated needle centerlines in world space (runtime only). + mutable std::vector last_needle_skeleton_lines; + + // Runtime render target and channels (not serialized). + mutable std::unique_ptr render_target_ = nullptr; + + void OnDestroy() override; + bool OnInspect(const std::shared_ptr& editor_layer) override; + void Serialize(YAML::Emitter& out) const override; + void Deserialize(const YAML::Node& in) override; + void CollectAssetRef(std::vector& list) override; +}; + +} // namespace l_system_package diff --git a/EvoEngine_Packages/LSystem/include/ScotsPineDescriptor.hpp b/EvoEngine_Packages/LSystem/include/ScotsPineDescriptor.hpp new file mode 100644 index 00000000..ef05d836 --- /dev/null +++ b/EvoEngine_Packages/LSystem/include/ScotsPineDescriptor.hpp @@ -0,0 +1,466 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include "ILSystemExplorableDescriptor.hpp" +#include "LSystemRuleHelpers.hpp" +#include "ParamSpaceExplorer.hpp" + +namespace l_system_package { + +/// 1 simulated year ~= 1500 GDD (base 5C, mid-latitude). Used as the unit +/// conversion between physiological-time fields (GDD on disk) and the +/// year-based clock used in production rules and growth state. +constexpr float kPineGddPerYear = 1500.0f; + +/** + * @brief Per-emission sampling distributions copied from the descriptor. + * + * Carried inside `SampledPineParams` so that production rules can resample + * each parameter at every consumption site (per phytomer, per whorl bud, + * per needle cluster) using a per-node RNG, instead of being locked to the + * single plant-wide draw stored in the scalar fields below. + * + * Field semantics: each member is a copy of the descriptor's matching + * SingleDistribution. Rules call `SampleDistribution(params.distributions.X, node_rng)` + * inside production lambdas. Time-domain fields stay in GDD (callers convert + * to years via `kPineGddPerYear`) and integer-valued fields are sampled as + * floats and rounded by the rule (matching `Sample()`'s clamp/round contract). + */ +struct PineSamplingDistributions { + // Phytomer scheduling. + evo_engine::SingleDistribution max_branching_order; + evo_engine::SingleDistribution plastochron_gdd; + evo_engine::SingleDistribution max_phytomers_per_seasonal_growth; + // Whorl architecture. + evo_engine::SingleDistribution branches_per_whorl; + // Overwintering dormancy is a chronological/photoperiod process, not a + // heat-sum process, so this is in years (not GDD). See FSPM Rule of Ontogeny. + evo_engine::SingleDistribution whorl_dormancy_years; + evo_engine::SingleDistribution branch_insertion_angle_deg; + evo_engine::SingleDistribution branch_roll_phyllotaxis_deg; + // Phytomer dimensions. + evo_engine::SingleDistribution internode_length_m; + evo_engine::SingleDistribution leader_internode_thickness_m; + evo_engine::SingleDistribution lateral_length_ratio; + evo_engine::SingleDistribution lateral_thickness_ratio; + // Maturity-driven organ shape multipliers (x=normalized maturity age, y=[0,1]). + evo_engine::PlottedDistribution internode_length_maturity_curve; + evo_engine::PlottedDistribution internode_width_maturity_curve; + // Needles. + evo_engine::SingleDistribution bare_zone_fraction; + evo_engine::SingleDistribution needle_count_per_cluster; + evo_engine::SingleDistribution needle_length_m; + evo_engine::PlottedDistribution needle_length_maturity_curve; + evo_engine::SingleDistribution needle_cross_section_width_max_m; + evo_engine::SingleDistribution needle_cross_section_thickness_max_m; + evo_engine::PlottedDistribution needle_cross_section_width_profile; + evo_engine::PlottedDistribution needle_cross_section_thickness_profile; + evo_engine::PlottedDistribution needle_cross_section_temporal_maturity_curve; + // Needle senescence is post-maturity chronological aging, not heat-sum + // driven. Stored in years (see FSPM Rule of Ontogeny). + evo_engine::SingleDistribution needle_lifespan_years; + evo_engine::SingleDistribution needle_browning_years; + evo_engine::SingleDistribution needle_flush_delay_gdd; + evo_engine::SingleDistribution internode_maturation_gdd; + evo_engine::SingleDistribution needle_maturation_gdd; + evo_engine::SingleDistribution needle_branching_angle_deg; + evo_engine::SingleDistribution needle_branching_relax_gdd; + // Needle curvature. + evo_engine::SingleDistribution needle_curvature_adaxial_bias; + evo_engine::SingleDistribution needle_curvature_abaxial_bias; + evo_engine::SingleDistribution needle_curvature_gradient_per_arclen; + evo_engine::SingleDistribution needle_diameter_for_curvature_m; + evo_engine::SingleDistribution needle_sinusoidal_amplitude_deg; + evo_engine::SingleDistribution needle_sinusoidal_frequency_cycles; + evo_engine::SingleDistribution needle_sinusoidal_phase_randomness_deg; + // Needle mechanics. + evo_engine::SingleDistribution needle_young_modulus_baseline_Pa; + evo_engine::SingleDistribution needle_lignification_maturation_years; + evo_engine::SingleDistribution needle_density_kg_m3; + // Per-needle CV. + evo_engine::SingleDistribution needle_per_needle_length_cv; + evo_engine::SingleDistribution needle_per_needle_curvature_cv; + evo_engine::SingleDistribution needle_per_needle_radius_cv; + evo_engine::SingleDistribution needle_per_needle_modulus_cv; + evo_engine::SingleDistribution needle_per_needle_density_cv; + evo_engine::SingleDistribution needle_per_needle_wave_amplitude_cv; + evo_engine::SingleDistribution needle_per_needle_wave_frequency_cv; + evo_engine::SingleDistribution needle_per_needle_wave_phase_cv; + // Tropism. + evo_engine::SingleDistribution gravitropism_first_order; + // Per-shoot CV. + evo_engine::SingleDistribution internode_length_per_node_cv; + evo_engine::SingleDistribution internode_thickness_per_node_cv; + evo_engine::SingleDistribution branch_angle_per_node_sigma_deg; + evo_engine::SingleDistribution roll_phyllotaxis_per_node_sigma_deg; +}; + +/** + * @brief Sampled, deterministic Scots pine parameter set produced by + * ScotsPineDescriptor::Sample at the start of a derivation run. + * + * Phytomer model: an immortal PineApex emits one phytomer per plastochron + * during its active growth season, capped at `max_phytomers_per_seasonal_growth` + * phytomers per year. The first `bare_zone_fraction` of each year's seasonal + * growth produces internode-only phytomers (no needle cluster); subsequent + * phytomers each carry one needle cluster of `needle_count_per_cluster` + * needles. + */ +struct SampledPineParams { + // -- Phytomer scheduling -- + int max_branching_order = 1; ///< 0 = leader only, 1 = primary laterals, ... + float plastochron_years = 1.0f; ///< Physiological time between consecutive phytomer events. + int max_phytomers_per_seasonal_growth = 12; ///< Phytomers emitted per active season before apex pauses. + + // -- Whorl architecture -- + int branches_per_whorl = 6; + float whorl_dormancy_years = 1.0f; ///< Overwintering = 1 year for Pinus sylvestris. + float branch_insertion_angle_deg = 70.0f; + float branch_roll_phyllotaxis_deg = 137.5f; ///< Golden angle (also drives needle phyllotaxis). + + // -- Phytomer dimensions (SI metres) -- + float internode_length_m = 0.012f; ///< Length of one phytomer's internode. + float leader_internode_thickness_m = 0.0015f; ///< Main stem thickness (diameter), metres. + float lateral_length_ratio = 0.7f; ///< lateral length = leader * ratio^order. + float lateral_thickness_ratio = 0.6f; + + // -- Needles -- + float bare_zone_fraction = + 0.08f; ///< Temporal fraction at the start of each year that emits internode-only phytomers [0, 0.95). + int needle_count_per_cluster = 2; ///< Pinus sylvestris fascicle. + int needle_segment_count = 11; ///< Longitudinal segments per needle (mesh stations = segments + 1). + float needle_length_m = 0.025f; + int needle_lifespan_years = 4; + float needle_browning_years = 0.8f; ///< Years from senescence onset to abscission. + float needle_flush_delay_years = 0.08f; ///< Delay from phytomer emergence to needle flush start. + float internode_maturation_years = 0.053f; ///< Time from emergence to mature internode length. + float needle_maturation_years = 0.093f; ///< Time from flush to mature needle length. + float needle_cross_section_width_max_m = 0.0018f; ///< Major-axis full width (diameter) at the broadest position. + float needle_cross_section_thickness_max_m = + 0.0011f; ///< Minor-axis full thickness (diameter) at the broadest position. + float needle_order_length_attenuation = 0.12f; ///< Linear reduction per branch order. + float needle_order_radius_attenuation = 0.10f; ///< Linear thickness reduction per branch order. + float needle_order_min_length_scale = 0.55f; ///< Floor after order attenuation. + float needle_order_min_radius_scale = 0.60f; ///< Thickness floor after order attenuation. + // Intra-year initiation-time capacity mapping (per phytomer index in season). + float needle_intra_year_base_ratio = 0.35f; ///< Early-season lower bound in [0,1]. + float needle_intra_year_sigmoid_steepness = 8.0f; ///< Steepness k for logistic ramp. + float needle_intra_year_sigmoid_midpoint_fraction = 0.45f; ///< Midpoint m in normalized index [0,1]. + float needle_intra_year_late_decay_start_fraction = 1.0f; ///< >=1 disables late-season decay. + float needle_intra_year_late_decay_end_scale = 1.0f; ///< End-of-season multiplicative scale. + // Inter-year capacity mapping (primary -> fascicular transition). + int needle_fascicular_start_year = 1; ///< Year index where fascicular multipliers activate. + float needle_year2plus_length_multiplier = 1.0f; ///< Length multiplier for year >= start_year. + float needle_year2plus_width_multiplier = 1.0f; ///< Width multiplier for year >= start_year. + float needle_year2plus_thickness_multiplier = 1.0f; ///< Thickness multiplier for year >= start_year. + float needle_lignification_factor_year1 = 0.75f; ///< Year-1 lignification response multiplier. + float needle_lignification_factor_year2plus = 1.15f; ///< Year-2+ lignification response multiplier. + float needle_stomatal_strip_density_year1 = 0.35f; ///< Procedural strip density proxy for year-1 cohorts [0,1]. + float needle_stomatal_strip_density_year2plus = 0.65f; ///< Procedural strip density proxy for year-2+ cohorts [0,1]. + float needle_basal_taper_ratio_year1 = 0.82f; ///< Radius multiplier at needle base for year-1 cohorts. + float needle_basal_taper_ratio_year2plus = 0.92f; ///< Radius multiplier at needle base for year-2+ cohorts. + float needle_fascicle_sheath_budget_years = 0.28f; ///< Characteristic years for sheath visual maturation near base. + float needle_specularity_plasticity_year1 = + 0.25f; ///< How much micro-variation tracks maturity in year-1 cohorts [0,1]. + float needle_specularity_plasticity_year2plus = + 0.65f; ///< How much micro-variation tracks maturity in year-2+ cohorts [0,1]. + // Bud-storage proxy coupling from previous-year completion to current-year potential. + float needle_bud_storage_vigor_strength = 0.0f; ///< 0 = disabled, 1 = fully applied. + float needle_bud_storage_completion_floor = 0.60f; ///< Lower clamp for completion-derived vigor. + /// [deprecated] Legacy cap-ratio field kept for descriptor compatibility. + /// Needle width/thickness are now uncapped and this value is ignored. + float needle_radius_to_stem_thickness_max_ratio = 0.45f; + + // -- Needle bilateral curvature (default near-inert; diameter > 0 activates) -- + float needle_curvature_adaxial_bias = 0.003f; + float needle_curvature_abaxial_bias = 0.010f; + float needle_curvature_gradient_per_arclen = 0.0015f; + float needle_diameter_for_curvature_m = 0.001f; + float needle_sinusoidal_amplitude_deg = 0.0f; + float needle_sinusoidal_frequency_cycles = 0.0f; + float needle_sinusoidal_phase_randomness_deg = 0.0f; + + // -- Needle mechanics (defaults inert -> elastica skipped) -- + float needle_young_modulus_baseline_Pa = 0.0f; + float needle_lignification_maturation_years = 0.0f; + float needle_density_kg_m3 = 0.0f; + float gravity_m_s2 = 0.0f; + + // -- Per-needle variability (CV-style multipliers within each fascicle) -- + float needle_per_needle_length_cv = 0.0f; + float needle_per_needle_curvature_cv = 0.0f; + float needle_per_needle_radius_cv = 0.0f; + float needle_per_needle_modulus_cv = 0.0f; + float needle_per_needle_density_cv = 0.0f; + float needle_per_needle_wave_amplitude_cv = 0.0f; + float needle_per_needle_wave_frequency_cv = 0.0f; + float needle_per_needle_wave_phase_cv = 0.0f; + + // -- Tropism -- + float gravitropism_first_order = 0.0001f; ///< deg/GDD applied to leader internodes (order 0) only. + + // -- Per-shoot stochastic noise (CV / sigma; 0 = deterministic) -- + float internode_length_per_node_cv = 0.0f; + float internode_thickness_per_node_cv = 0.0f; + float branch_angle_per_node_sigma_deg = 0.0f; + float roll_phyllotaxis_per_node_sigma_deg = 0.0f; + + // -- Plant-wide -- + float plant_random = 0.5f; + float initial_orientation_yaw_deg = 0.0f; ///< Sampled once per plant; applied as root yaw around +Y. + + // -- Sampled tropism array (used by future growth-step tropism integration) -- + std::vector tropisms; + + /// Copies of the descriptor's distributions, used by production rules to + /// resample each parameter at every consumption site (per phytomer, per + /// whorl bud, per needle cluster). The scalar fields above remain the + /// "plant identity" baseline used by PineGrowthModel for plant-wide + /// initialization (root apex stamping, gravity, etc.); the rules read + /// from `distributions` for per-emission stochasticity. + PineSamplingDistributions distributions; +}; + +/** + * @brief Genotype asset for Scots pine (Pinus sylvestris) L-system generation. + * + * Annual-shoot architecture. Sampled per-instance to produce + * SampledPineParams for the derivation engine. + * + * File extension: .spine + */ +class ScotsPineDescriptor : public evo_engine::IAsset, public ILSystemExplorableDescriptor { + public: + ScotsPineDescriptor(); + + // ============================================================================ + // Field declarations are organized into 14 functional groups below, plus a + // trailing [deprecated] block for fields with no current runtime consumer. + // Time-domain fields stay in GDD on disk and are converted to years inside + // Sample(). YAML key strings, RNG draw order in Sample(), and the explorer + // axis count are intentionally unchanged by this reorganization to preserve + // on-disk asset compatibility, seed reproducibility, and explorer cache + // fingerprints. + // ============================================================================ + + // ===== 1. Plant identity & global time ===== + evo_engine::SingleDistribution initial_orientation_yaw_deg{ + 0.0f}; ///< Sampled once per plant; rotation around +Y. + evo_engine::SingleDistribution target_gdd{3000.0f}; ///< Per-instance target growth GDD. + // GDD/day couples with LSystemLayer chronological day speed: + // delta_gdd = sampled_gdd_per_day * delta_days + // where delta_days = chronological_days_per_second * dt. + // Consumed by LSystemLayer::SamplePineTemporalParameters() and applied + // in the per-pine update path. Mean of season-day fields is clamped to + // [0, 365]; deviation is treated as integer days inside OnInspect. + evo_engine::SingleDistribution gdd_per_day{2.0f}; ///< Per-pine thermal rate (GDD/day). + evo_engine::SingleDistribution growing_season_start_day{60.0f}; ///< Per-pine active season start (DOY 0-365). + evo_engine::SingleDistribution growing_season_end_day{334.0f}; ///< Per-pine active season end (DOY 0-365). + + // ===== 2. Phytomer scheduling ===== + evo_engine::SingleDistribution max_branching_order{0.0f, 0.0f}; + evo_engine::SingleDistribution plastochron_gdd{13.0f, 3.0f}; + evo_engine::SingleDistribution max_phytomers_per_seasonal_growth{40.0f, 10.0f}; + + // ===== 3. Whorl architecture ===== + evo_engine::SingleDistribution branches_per_whorl{6.0f, 0.0f}; + /// Overwintering dormancy for whorl buds. Chronological, NOT GDD: bud + /// release is driven by chilling + photoperiod, not heat sums. Default + /// 1 yr = annual Scots pine whorl cycle. + evo_engine::SingleDistribution whorl_dormancy_years{1.0f, 0.0f}; + evo_engine::SingleDistribution branch_insertion_angle_deg{70.0f, 0.0f}; + evo_engine::SingleDistribution branch_roll_phyllotaxis_deg{137.5f, 1.0f}; + + // ===== 4. Stem geometry (SI metres) ===== + // Internode size, lateral attenuation, stem maturation, and the maturity + // curves that drive stem length/width over age. (Previously scattered: the + // `internode_length_maturity_curve` and `internode_width_maturity_curve` + // fields were declared inside the Needles block, which was misleading.) + evo_engine::SingleDistribution internode_length_m{0.00066f, 0.0005f}; + evo_engine::SingleDistribution leader_internode_thickness_m{ + 0.0003f, 2.5e-05f}; ///< Main stem width (diameter), metres. + evo_engine::SingleDistribution lateral_length_ratio{0.7f, 0.0f}; + evo_engine::SingleDistribution lateral_thickness_ratio{0.6f, 0.0f}; + evo_engine::SingleDistribution internode_maturation_gdd{250.0f, 25.0f}; + evo_engine::PlottedDistribution internode_length_maturity_curve; ///< Stem-axis curve (was in Needles). + evo_engine::PlottedDistribution internode_width_maturity_curve; ///< Stem-axis curve (was in Needles). + float internode_age_exponent = 4.0f; ///< >1 delays visible stem aging toward max age. + + // ===== 5. Stem stochastic noise (per-shoot CV / sigma; 0 = deterministic) ===== + evo_engine::SingleDistribution internode_length_per_node_cv{0.15f}; + evo_engine::SingleDistribution internode_thickness_per_node_cv{0.0f}; + evo_engine::SingleDistribution branch_angle_per_node_sigma_deg{0.0f}; + evo_engine::SingleDistribution roll_phyllotaxis_per_node_sigma_deg{0.0f}; + + // ===== 6. Stem tropism ===== + evo_engine::SingleDistribution gravitropism_first_order{0.0001f}; ///< Main-stem-only curvature (deg/GDD). + + // ===== 7. Needle layout ===== + evo_engine::SingleDistribution bare_zone_fraction{0.2f, 0.05f}; + evo_engine::SingleDistribution needle_count_per_cluster{2.0f, 0.0f}; + int needle_segment_count = 20; ///< Longitudinal segments per needle (mesh stations = segments + 1). + evo_engine::SingleDistribution needle_branching_angle_deg{72.0f, 14.0f}; + /// GDD-equivalent duration converted to years at node creation. + /// Runtime applies this against chronological age so relaxation continues + /// even when thermal accumulation is paused outside the active season. + evo_engine::SingleDistribution needle_branching_relax_gdd{2000.0f, 500.0f}; + /// [deprecated] Legacy cap-ratio field kept for schema compatibility. + /// Needle width/thickness are now uncapped and this value is ignored. + float needle_radius_to_stem_thickness_max_ratio = 0.5f; + + // ===== 8. Needle lifecycle ===== + // Lifespan and browning are post-maturity chronological aging (FSPM Rule + // of Ontogeny: once the organ has reached its final size, time -- not + // heat -- governs its remaining biology). Flush delay and maturation are + // active-expansion processes, so those stay in GDD. + evo_engine::SingleDistribution needle_lifespan_years{4.0f, 0.6666667f}; + evo_engine::SingleDistribution needle_browning_years{2.0f, + 0.0f}; ///< Years from senescence onset to abscission. + evo_engine::SingleDistribution needle_flush_delay_gdd{100.0f, 3.0f}; + evo_engine::SingleDistribution needle_maturation_gdd{3333.0f, 30.0f}; + + // ===== 9. Needle dimensions & cross-section ===== + evo_engine::SingleDistribution needle_length_m{0.01f, 0.001f}; + evo_engine::PlottedDistribution needle_length_maturity_curve; + evo_engine::SingleDistribution needle_cross_section_width_max_m{0.0f, 0.0f}; + evo_engine::SingleDistribution needle_cross_section_thickness_max_m{0.001f, 0.0f}; + evo_engine::PlottedDistribution needle_cross_section_width_profile; + evo_engine::PlottedDistribution needle_cross_section_thickness_profile; + evo_engine::PlottedDistribution needle_cross_section_temporal_maturity_curve; + + // ===== 10. Initiation capacity (order attenuation, intra-year, inter-year, bud storage) ===== + // Order attenuation (linear reduction per branch order). + float needle_order_length_attenuation = 0.0f; + float needle_order_radius_attenuation = 0.0f; + float needle_order_min_length_scale = 0.1f; + float needle_order_min_radius_scale = 0.1f; + // Intra-year initiation-time capacity mapping. + float needle_intra_year_base_ratio = 0.35f; + float needle_intra_year_sigmoid_steepness = 8.0f; + float needle_intra_year_sigmoid_midpoint_fraction = 0.45f; + float needle_intra_year_late_decay_start_fraction = 1.0f; + float needle_intra_year_late_decay_end_scale = 1.0f; + // Inter-year primary/fascicular transition (year1 vs year2+). + int needle_fascicular_start_year = 1; + float needle_year2plus_length_multiplier = 4.0f; + float needle_year2plus_width_multiplier = 1.0f; + float needle_year2plus_thickness_multiplier = 1.0f; + float needle_lignification_factor_year1 = 0.75f; + float needle_lignification_factor_year2plus = 1.15f; + float needle_stomatal_strip_density_year1 = 0.35f; + float needle_stomatal_strip_density_year2plus = 0.65f; + float needle_basal_taper_ratio_year1 = 1.0f; + float needle_basal_taper_ratio_year2plus = 1.0f; + float needle_fascicle_sheath_budget_gdd = 420.0f; + float needle_specularity_plasticity_year1 = 0.25f; + float needle_specularity_plasticity_year2plus = 0.65f; + // Bud-storage proxy controls. + float needle_bud_storage_vigor_strength = 0.0f; + float needle_bud_storage_completion_floor = 0.60f; + + // ===== 11. Needle curvature & waviness (near-inert defaults; activate via diameter > 0) ===== + evo_engine::SingleDistribution needle_curvature_adaxial_bias{0.0f, 0.037f}; + evo_engine::SingleDistribution needle_curvature_abaxial_bias{0.0f, 0.024f}; + evo_engine::SingleDistribution needle_curvature_gradient_per_arclen{0.068f, 0.0f}; + evo_engine::SingleDistribution needle_diameter_for_curvature_m{0.0033f, 0.0f}; + evo_engine::SingleDistribution needle_sinusoidal_amplitude_deg{19.4f, 0.0f}; + evo_engine::SingleDistribution needle_sinusoidal_frequency_cycles{2.0f, 0.0f}; + evo_engine::SingleDistribution needle_sinusoidal_phase_randomness_deg{5.0f, 0.0f}; + + // ===== 12. Needle mechanics (elastica; defaults inert) ===== + evo_engine::SingleDistribution needle_young_modulus_baseline_Pa{0.0f}; + evo_engine::SingleDistribution needle_lignification_maturation_years{0.0f}; + evo_engine::SingleDistribution needle_density_kg_m3{110.0f, 0.0f}; + evo_engine::SingleDistribution gravity_m_s2{9.3f, 0.0f}; + + // ===== 13. Per-needle variability (CV-style multipliers within each fascicle) ===== + evo_engine::SingleDistribution needle_per_needle_length_cv{0.0f}; + evo_engine::SingleDistribution needle_per_needle_curvature_cv{0.0f}; + evo_engine::SingleDistribution needle_per_needle_radius_cv{0.0f}; + evo_engine::SingleDistribution needle_per_needle_modulus_cv{0.0f}; + evo_engine::SingleDistribution needle_per_needle_density_cv{0.0f}; + evo_engine::SingleDistribution needle_per_needle_wave_amplitude_cv{0.0f}; + evo_engine::SingleDistribution needle_per_needle_wave_frequency_cv{0.0f}; + evo_engine::SingleDistribution needle_per_needle_wave_phase_cv{0.0f}; + + // ===== 14. Visualization (color tints, axial age gradient) ===== + // Read by the geometry/material build path (Shaded and ByType color + // modes) and by the needle young->old color blend. Plain runtime values + // (not distributions); serialized to YAML and round-tripped through the + // inspector. + glm::vec4 main_stem_color_rgba{0.7861458f, 0.9165798f, 0.47310424f, 1.0f}; ///< Young main-stem bark tint. + glm::vec4 main_stem_old_color_rgba{0.7009804f, 0.45447198f, 0.18555366f, 1.0f}; ///< Aged main-stem bark tint. + glm::vec4 needle_color_rgba{0.0f, 0.7455683f, 0.051418442f, 1.0f}; ///< Young needle tint. + glm::vec4 needle_old_color_rgba{0.77059436f, 0.5399785f, 0.0f, 1.0f}; ///< Aged/senescent needle tint. + float needle_axial_age_span = 0.34f; ///< [-1,1] Positive makes tip older than base. + float needle_axial_age_exponent = 1.0f; ///< >1 concentrates axial aging near tip/base. + + // ===== 15. [deprecated] No runtime consumer ===== + // Kept declared per workspace policy ("never remove unused code unless + // explicitly told to - comment with [deprecated]"). + /// [deprecated] Pine-side dynamic tropism array. Sampled into + /// `SampledPineParams::tropisms` but unused by the pine growth path. The + /// active stem tropism is the scalar `gravitropism_first_order` above. + /// The Maize tassel side does consume an analogous vector, so the type + /// stays available; only the Scots pine wiring is dormant. + std::vector tropisms; + + /// Sample all distributions deterministically; lock RNG draw order. + SampledPineParams Sample(std::mt19937& rng) const; + + [[nodiscard]] evo_engine::Entity Instantiate() const; + + // ===== IAsset ===== + bool OnInspect(const std::shared_ptr& editor_layer) override; + [[nodiscard]] bool SupportsDefaultsOverwrite() const { + return true; + } + [[nodiscard]] std::filesystem::path ResolveWritableDefaultsPath() const; + void Serialize(YAML::Emitter& out) const override; + void Deserialize(const YAML::Node& in) override; + + // ===== ILSystemExplorableDescriptor ===== + void RegisterExplorableAxes(ParamSpaceExplorer& explorer) override; + uint64_t ExplorableSchemaFingerprint() const override { + // Bump when the explorable axis schema changes shape (added/removed + // axes). The dynamic tropism count is folded in to keep the existing + // contract that adding tropism entries also invalidates cached layouts. + // Constant 0x4E45454458534537 spells "NEEDXSE7". + // change. + return 0x4E45454458534537ull ^ static_cast(tropisms.size()); + } + + // ===== Editor preferences (serialized) ===== + bool live_preview = false; + float live_preview_rate_hz = 12.0f; + bool live_preview_representative_only = true; + bool live_preview_cap_target_gdd = true; + float live_preview_max_gdd = 6000.0f; + int live_preview_max_growth_steps = 64; + int grid_rows = 5; + int grid_cols = 5; + float grid_spacing = 3.0f; + float triangle_side_length = 3.0f; + + ParamSpaceExplorer explorer_; + + private: + // Live-preview scheduling state (not serialized). + bool live_preview_dirty_ = false; + double live_preview_last_apply_seconds_ = -1.0; + bool live_preview_was_dragging_ = false; + bool live_preview_needs_full_apply_ = false; + + uint32_t live_preview_request_count_ = 0; + uint32_t live_preview_apply_count_ = 0; + uint32_t live_preview_coalesced_count_ = 0; + double live_preview_last_apply_ms_ = 0.0; + double live_preview_total_apply_ms_ = 0.0; +}; + +} // namespace l_system_package diff --git a/EvoEngine_Packages/LSystem/include/ScotsPineModules.hpp b/EvoEngine_Packages/LSystem/include/ScotsPineModules.hpp new file mode 100644 index 00000000..4976131f --- /dev/null +++ b/EvoEngine_Packages/LSystem/include/ScotsPineModules.hpp @@ -0,0 +1,218 @@ +#pragma once + +#include +#include +#include "GrowthField.hpp" +#include "GrowthFunction.hpp" +#include "LSystemGraph.hpp" +#include "MaterialProfile.hpp" +#include "ModuleTypes.hpp" +#include "SimulationClock.hpp" + +namespace l_system_package { + +// --------------------------------------------------------------------------- +// Pinus sylvestris module set (simple monopodial phytomer model). +// +// Topology: +// - The PineApex (immortal) emits ONE phytomer per plastochron during its +// active growth season. A phytomer is exactly: +// PineInternode + (optionally) one PineNeedleCluster. +// - During the proximal `bare_zone_fraction` of each year's seasonal +// growth (temporal fraction of the active season), the apex emits an +// internode-only phytomer (no needle cluster). After that, every +// phytomer carries one needle cluster. +// - Once the apex has emitted `max_phytomers_per_seasonal_growth` +// phytomers in the current year it stops until the season rolls over +// to the next year (LSystemLayer detects the dormant->active edge and +// bumps the SimulationClock's year index). +// - PineWhorlBud / lateral spawning rules remain in place but are inert +// while the descriptor's max_branching_order is 0. +// +// Symbols: +// 0 PineApex - immortal terminal meristem; emits phytomers. +// 1 PineInternode - one phytomer's internode segment + thickness. +// 2 PineWhorlBud - overwintering bud; activates -> N lateral apices +// (currently disabled by default). +// 3 PineNeedleCluster - fascicle anchored at s_along_parent_norm on its +// parent internode; ages and senesces over years. +// --------------------------------------------------------------------------- + +enum class PhenologyState { Dormant, Flush, Elongate }; + +struct PineApex { + int order = 0; ///< 0 = leader, 1+ = laterals. + float phyllotaxis_phase = 0.0f; ///< Azimuth carried across phytomers (deg). + float node_random = 0.5f; ///< Per-node random scalar in [0,1]. + PhenologyState phenology_state = PhenologyState::Dormant; + + /// Year index this apex last advanced into. R0 (year-rollover) compares + /// `clock.YearIndex()` against this and resets `phytomers_this_year` when + /// they differ, so the apex starts a fresh seasonal-growth budget. + int year_index = 0; + /// Phytomers emitted so far in the current year. R1 stops emitting once + /// this reaches `max_phytomers_per_seasonal_growth`. + int phytomers_this_year = 0; + /// Physiological time (years) of the last phytomer emission. R1 enforces + /// `plastochron_years` spacing using this anchor. + float t_last_emission_years = 0.0f; + + /// Per-apex stamped sample of the descriptor's `plastochron_gdd` distribution + /// (converted to years). Stamped once at apex creation so the R1 condition + /// predicate and the R1 production lambda observe the same value across + /// derivation passes (avoids gate/produce drift when deviation > 0). Also + /// used by R0 to seed `t_last_emission_years` on year rollover. + float sampled_plastochron_years = 1.0f; + /// Per-apex stamped sample of `max_phytomers_per_seasonal_growth`. Same + /// stability rationale: R1's "stop after this year's cap" check must read + /// a value that is stable across condition/produce calls for one apex. + int sampled_max_phytomers_per_seasonal_growth = 12; + /// Per-apex stamped count of how many phytomers at the start of the + /// current year are emitted *without* a needle cluster. Stamped at year + /// rollover (R0) by drawing the descriptor's `bare_zone_fraction` + /// distribution exactly once and rounding to the nearest integer + /// `bare_zone_fraction * sampled_max_phytomers_per_seasonal_growth`. + /// Replaces the previous time-based gate (which compared + /// `temporal_progress_in_active_season` to `bare_zone_fraction`); a + /// count-based gate is robust to year-to-year differences in active-season + /// length and to plastochron resampling. + int bare_phytomers_this_year = 0; + /// Completion ratio from the immediately preceding year + /// `phytomers_this_year / sampled_max_phytomers_per_seasonal_growth`. + /// Computed in R0 and carried into the next year's emissions. + float previous_season_completion_ratio = 1.0f; + /// Clamped completion-derived vigor proxy carried over at year rollover. + /// Used by initiation-time needle capacity mapping. + float previous_season_vigor = 1.0f; +}; + +struct PineInternode { + float length = 0.0f; ///< Current shoot length (m). + float thickness = 0.0f; ///< Current shoot thickness/diameter (m). + float target_length = 0.0f; ///< Final mature length (m). + float target_thickness = 0.0f; ///< Final mature thickness (m). + float branch_angle = 0.0f; ///< Deflection from parent axis (deg). + float roll_angle = 0.0f; ///< Roll about parent axis (deg). + glm::vec3 bend_axis_local = glm::vec3(1.0f, 0.0f, 0.0f); + float curvature = 0.0f; ///< Local bending from gravitropism (deg). + float growth_progress = 0.0f; ///< 0 = just emerged, 1 = mature. + int year_produced = 0; ///< Year cohort. + int age_years = 0; ///< Module-local age in whole years. + int order = 0; ///< Branching order of this axis. + float node_random = 0.5f; + ContinuousGrowthState continuous_growth{}; +}; + +struct PineWhorlBud { + float insertion_angle = 60.0f; ///< Lateral departure angle (deg). + int lateral_count = 5; ///< Apices spawned at activation. + int dormancy_years_remaining = 0; ///< 0 = activate this step. + int initial_dormancy_years = 0; ///< Creation-time dormancy budget used for chronological countdown. + int order = 1; ///< Order of laterals to spawn. + float phyllotaxis_phase = 0.0f; ///< First lateral azimuth (deg). + float node_random = 0.5f; +}; + +struct PineNeedleInstanceProfile { + float length_scale = 1.0f; ///< Per-needle multiplier on cluster length. + float radius_scale = 1.0f; ///< Per-needle multiplier on both ellipsoid semi-axes. + float branching_relax_years = 0.0f; ///< Years required for this needle to reach full branching angle. + float sinusoidal_amplitude_deg = 0.0f; ///< Per-needle intrinsic waviness amplitude. + float sinusoidal_frequency_cycles = 0.0f; ///< Number of wave cycles along full needle length. + float sinusoidal_phase_rad = 0.0f; ///< Phase offset for deterministic within-fascicle diversity. + BilateralGrowthField1D growth_field{}; + MaterialProfile1D material_profile{}; +}; + +struct PineNeedleCluster { + int count = 2; ///< Pinus sylvestris fascicle = 2. + float length = 0.0f; ///< Current needle length (m). + float target_length = 0.025f; ///< Mature needle length (m). + int age_years = 0; ///< Years since cluster initiation. + int lifespan_years = 4; ///< Years before browning starts. + float browning_years = 0.8f; ///< Years from senescence onset to abscission. + bool alive = true; ///< False = abscised; mesher skips. + bool maturity_reached = false; ///< True once thermal maturation reaches 1.0. + float chronological_age_at_maturity_years = 0.0f; + + // Anchor on the parent internode's centerline. + float s_along_parent_norm = 0.5f; ///< Fractional position [0,1] along parent shoot. + float roll_offset_deg = 0.0f; ///< Azimuthal offset around parent axis (deg). + + // Visible browning progress: 0 = fully green, 1 = fully brown. + // Driven by PineGrowthModel::UpdateNodeInfoImpl from chronological age + // elapsed after thermal maturity. + float senescence_phase = 0.0f; + + // Creation-time vigor scaling for geometric needle thickness. + float render_radius_scale = 1.0f; + + // Initiation-time capacity diagnostics. + int initiation_year_index = 0; + int initiation_phytomer_index = 0; + float intra_year_capacity_weight = 1.0f; + float inter_year_capacity_weight = 1.0f; + float bud_storage_vigor_weight = 1.0f; + + // Ellipsoid cross-section maxima (semi-axis radii, metres) before applying + // per-position profile multipliers in the mesher. + float cross_section_width_radius_m = 0.0009f; + float cross_section_thickness_radius_m = 0.00055f; + + // Fascicle opening behavior: starts apical (0 deg) and relaxes toward this + // branching angle as chronological age approaches per-needle branching_relax_years. + float branching_angle_deg = 72.0f; + // Keep the default equivalent to 220 GDD at 1500 GDD/year without + // depending on descriptor constants in this low-level module header. + float branching_relax_years = 220.0f / 1500.0f; + + float node_random = 0.5f; + ContinuousGrowthState continuous_growth{}; + float sinusoidal_amplitude_deg = 0.0f; + float sinusoidal_frequency_cycles = 0.0f; + float sinusoidal_phase_rad = 0.0f; + BilateralGrowthField1D growth_field{}; + MaterialProfile1D material_profile{}; + std::vector per_needle_profiles{}; +}; + +// --------------------------------------------------------------------------- +// Type aliases +// --------------------------------------------------------------------------- + +using PineModuleData = ModuleVariant; + +struct PineSymbol { + static constexpr int Apex = + ModuleIndex::value; // 0 + static constexpr int Internode = + ModuleIndex::value; // 1 + static constexpr int WhorlBud = + ModuleIndex::value; // 2 + static constexpr int NeedleCluster = + ModuleIndex::value; // 3 +}; + +// --------------------------------------------------------------------------- +// Graph / flow data +// --------------------------------------------------------------------------- + +struct PineGraphData { + int current_year = 0; + int total_derivation_steps = 0; + SimulationClock clock{}; + /// World-frame gravity magnitude (m/s^2). 0 = no body force on needles. + float gravity_m_s2 = 0.0f; + /// [deprecated] Legacy cap-ratio field carried through graph data for + /// backward compatibility. Needle width/thickness are uncapped and this + /// value is ignored by runtime geometry generation. + float needle_radius_to_stem_thickness_max_ratio = 0.45f; +}; + +struct PineFlowData {}; + +using PineGraph = LSystemGraph; +using PineNode = LGraphNode; +using PineFlow = LGraphFlow; + +} // namespace l_system_package diff --git a/EvoEngine_Packages/LSystem/include/ScotsPineRules.hpp b/EvoEngine_Packages/LSystem/include/ScotsPineRules.hpp new file mode 100644 index 00000000..02eca1c2 --- /dev/null +++ b/EvoEngine_Packages/LSystem/include/ScotsPineRules.hpp @@ -0,0 +1,773 @@ +#pragma once + +#include +#include +#include +#include +#include +#include "DerivationEngine.hpp" +#include "LSystemRuleHelpers.hpp" +#include "ProductionRule.hpp" +#include "ScotsPineDescriptor.hpp" +#include "ScotsPineModules.hpp" + +namespace l_system_package { + +using PineRule = ProductionRule; +using PineEngine = DerivationEngine; + +namespace pine_detail { + +inline float NormalizeDegrees(float degrees) { + float w = std::fmod(degrees, 360.0f); + if (w < 0.0f) + w += 360.0f; + return w; +} + +inline float NormalizeRadians(float radians) { + constexpr float kTwoPi = 6.28318530717958647692f; + float w = std::fmod(radians, kTwoPi); + if (w < 0.0f) + w += kTwoPi; + return w; +} + +inline uint32_t MixBits(const uint32_t value) { + uint32_t x = value; + x ^= x >> 16; + x *= 0x7feb352du; + x ^= x >> 15; + x *= 0x846ca68bu; + x ^= x >> 16; + return x; +} + +inline float HashToUnit01(const uint32_t value) { + return static_cast(value & 0x00ffffffu) / 16777215.0f; +} + +inline float DeterministicSignedJitter(const float cluster_random, const int needle_index, const uint32_t channel_tag) { + const float clamped = std::clamp(cluster_random, 0.0f, 1.0f); + const uint32_t base = static_cast(std::round(clamped * 16777215.0f)); + const uint32_t key = base ^ (static_cast(needle_index + 1) * 0x9e3779b9u) ^ channel_tag; + return HashToUnit01(MixBits(key)) * 2.0f - 1.0f; +} + +inline float DeterministicDistributionSample(const evo_engine::SingleDistribution& distribution, + const float cluster_random, const uint32_t sample_index, + const uint32_t channel_tag) { + const float clamped = std::clamp(cluster_random, 0.0f, 1.0f); + const uint32_t base = static_cast(std::round(clamped * 16777215.0f)); + const uint32_t key = base ^ ((sample_index + 1u) * 0x9e3779b9u) ^ channel_tag; + std::mt19937 local_rng(MixBits(key)); + return SampleDistribution(distribution, local_rng); +} + +inline float ComputeOrderVigorScale(int order, float attenuation, float min_scale) { + const int safe_order = std::max(0, order); + const float safe_attn = std::clamp(attenuation, 0.0f, 1.0f); + const float safe_min = std::clamp(min_scale, 0.10f, 1.0f); + const float raw = 1.0f - safe_attn * static_cast(safe_order); + return std::clamp(raw, safe_min, 1.0f); +} + +inline float ComputeNormalizedPhytomerProgress(int phytomer_index_this_year, int phytomers_per_season) { + if (phytomers_per_season <= 1) { + return 1.0f; + } + const float numerator = static_cast(std::max(0, phytomer_index_this_year)); + const float denominator = static_cast(std::max(1, phytomers_per_season - 1)); + return std::clamp(numerator / denominator, 0.0f, 1.0f); +} + +inline float ComputeIntraYearCapacityWeight(float normalized_index, float base_ratio, float sigmoid_steepness, + float sigmoid_midpoint, float late_decay_start, + float late_decay_end_scale) { + const float x = std::clamp(normalized_index, 0.0f, 1.0f); + const float base = std::clamp(base_ratio, 0.0f, 1.0f); + const float k = std::max(0.01f, sigmoid_steepness); + const float m = std::clamp(sigmoid_midpoint, 0.0f, 1.0f); + + const float logistic = 1.0f / (1.0f + std::exp(-k * (x - m))); + float weight = base + (1.0f - base) * logistic; + + const float decay_start = std::clamp(late_decay_start, 0.0f, 1.0f); + if (decay_start < 1.0f && x > decay_start) { + const float t = (x - decay_start) / std::max(1.0e-6f, 1.0f - decay_start); + const float end_scale = std::clamp(late_decay_end_scale, 0.0f, 2.0f); + weight *= glm::mix(1.0f, end_scale, std::clamp(t, 0.0f, 1.0f)); + } + + return std::max(0.0f, weight); +} + +inline float ComputeBudStorageVigorWeight(float previous_season_vigor, float vigor_strength, float vigor_floor) { + const float floor = std::clamp(vigor_floor, 0.0f, 1.0f); + const float proxy = std::clamp(previous_season_vigor, floor, 1.0f); + const float strength = std::clamp(vigor_strength, 0.0f, 1.0f); + return glm::mix(1.0f, proxy, strength); +} + +/// Build the single phytomer internode for `apex`. +/// `is_first_on_axis = true` carries the lateral insertion angle + initial +/// phyllotactic roll set when the bud spawned the apex; subsequent phytomers +/// on the same axis grow straight ahead (branch_angle = 0, no extra roll). +/// +/// Per-emission sampling: every parameter that was previously `p.X` (a +/// plant-wide drawn scalar) is now drawn fresh from +/// `p.distributions.X` using the per-emission `node_rng`. Clamps mirror the +/// bounds previously enforced inside `ScotsPineDescriptor::Sample`. +inline PineInternode MakePhytomerInternode(const SampledPineParams& p, const PineApex& apex, bool is_first_on_axis, + std::mt19937& node_rng, float t_init_years) { + // ---- Per-emission draws (see SampledPineParams::distributions) ---------- + const float internode_length_m = + std::clamp(SampleDistribution(p.distributions.internode_length_m, node_rng), 0.0001f, 0.50f); + const float leader_thickness_raw = + std::clamp(SampleDistribution(p.distributions.leader_internode_thickness_m, node_rng), 0.0002f, 0.05f); + const float max_sane_thickness = std::max(0.0002f, 0.40f * internode_length_m * 8.0f); + const float leader_internode_thickness_m = std::min(leader_thickness_raw, max_sane_thickness); + const float lateral_length_ratio = + std::clamp(SampleDistribution(p.distributions.lateral_length_ratio, node_rng), 0.05f, 1.0f); + const float lateral_thickness_ratio = + std::clamp(SampleDistribution(p.distributions.lateral_thickness_ratio, node_rng), 0.05f, 1.5f); + const float internode_length_per_node_cv = + std::max(0.0f, SampleDistribution(p.distributions.internode_length_per_node_cv, node_rng)); + const float internode_thickness_per_node_cv = + std::max(0.0f, SampleDistribution(p.distributions.internode_thickness_per_node_cv, node_rng)); + const float branch_insertion_angle_deg = + std::clamp(SampleDistribution(p.distributions.branch_insertion_angle_deg, node_rng), -85.0f, 85.0f); + const float branch_angle_per_node_sigma_deg = + std::max(0.0f, SampleDistribution(p.distributions.branch_angle_per_node_sigma_deg, node_rng)); + const float roll_phyllotaxis_per_node_sigma_deg = + std::max(0.0f, SampleDistribution(p.distributions.roll_phyllotaxis_per_node_sigma_deg, node_rng)); + const float internode_maturation_years = + std::clamp(SampleDistribution(p.distributions.internode_maturation_gdd, node_rng) / kPineGddPerYear, 0.0f, 4.0f); + const float main_stem_tropism_deg_per_gdd = SampleDistribution(p.distributions.gravitropism_first_order, node_rng); + + const int order = std::max(0, apex.order); + const float length_scale = std::pow(std::max(0.05f, lateral_length_ratio), static_cast(order)); + const float thickness_scale = std::pow(std::max(0.05f, lateral_thickness_ratio), static_cast(order)); + float base_length = std::max(0.0001f, internode_length_m * length_scale); + float base_thickness = std::max(0.00005f, leader_internode_thickness_m * thickness_scale); + + // Per-internode Gaussian noise on length and thickness (CV interpretation). + if (internode_length_per_node_cv > 0.0f) { + std::normal_distribution n(0.0f, internode_length_per_node_cv); + base_length = std::max(0.0001f, base_length * (1.0f + n(node_rng))); + } + if (internode_thickness_per_node_cv > 0.0f) { + std::normal_distribution n(0.0f, internode_thickness_per_node_cv); + base_thickness = std::max(0.00005f, base_thickness * (1.0f + n(node_rng))); + } + + PineInternode internode; + internode.target_length = base_length; + internode.target_thickness = base_thickness; + // Continuous growth substrate: zero on creation, animated by UpdateNodeInfoImpl. + const bool continuous = internode_maturation_years > 0.0f; + internode.length = continuous ? 0.0f : base_length; + internode.thickness = continuous ? 0.0f : base_thickness; + + // Insertion + roll. Only the first internode on a lateral carries the + // bud's branch angle and phyllotactic phase; subsequent phytomers on the + // same axis grow straight ahead. + float branch_angle = 0.0f; + float roll_angle = 0.0f; + if (is_first_on_axis && order >= 1) { + branch_angle = branch_insertion_angle_deg; + roll_angle = apex.phyllotaxis_phase; + if (branch_angle_per_node_sigma_deg > 0.0f) { + std::normal_distribution nb(0.0f, branch_angle_per_node_sigma_deg); + branch_angle = std::clamp(branch_angle + nb(node_rng), -85.0f, 85.0f); + } + if (roll_phyllotaxis_per_node_sigma_deg > 0.0f) { + std::normal_distribution nr(0.0f, roll_phyllotaxis_per_node_sigma_deg); + roll_angle = NormalizeDegrees(roll_angle + nr(node_rng)); + } + } + internode.branch_angle = branch_angle; + internode.roll_angle = roll_angle; + internode.bend_axis_local = glm::vec3(1.0f, 0.0f, 0.0f); + // Main-stem tropism: per-GDD curvature is integrated in growth-rule space; + // only the leader (order 0) receives this bending term. + internode.curvature = (order == 0) ? -main_stem_tropism_deg_per_gdd * kPineGddPerYear : 0.0f; + internode.growth_progress = continuous ? 0.0f : 1.0f; + internode.year_produced = apex.year_index; + internode.age_years = 0; + internode.order = order; + internode.node_random = SampleUnit01(node_rng); + internode.continuous_growth.t_init_years = t_init_years; + internode.continuous_growth.maturation_years = std::max(0.0f, internode_maturation_years); + internode.continuous_growth.function.kind = GrowthFunctionKind::Sinusoidal; + return internode; +} + +/// Build a needle cluster anchored on the parent phytomer's internode +/// with azimuthal `roll_offset_deg`. Birth time is offset by the species +/// flush delay so needles emerge AFTER the internode has begun elongating. +/// +/// Per-emission sampling: every parameter is drawn fresh per cluster from +/// `p.distributions.X` using `node_rng`. Plain (non-distribution) descriptor +/// scalars (`needle_order_*`, `needle_axial_*`, `internode_age_exponent`) +/// remain plant-wide and read from the SampledPineParams scalar fields. +inline PineNeedleCluster MakeNeedleCluster(const SampledPineParams& p, float s_along_norm, float roll_offset_deg, + std::mt19937& node_rng, float internode_t_init_years, int parent_axis_order, + int phytomer_index_this_year, int phytomers_per_season, + int initiation_year_index, float previous_season_vigor) { + // ---- Per-cluster draws -------------------------------------------------- + const int needle_count_per_cluster = std::clamp( + static_cast(std::round(SampleDistribution(p.distributions.needle_count_per_cluster, node_rng))), 1, 6); + const float needle_length_m = + std::clamp(SampleDistribution(p.distributions.needle_length_m, node_rng), 0.005f, 0.30f); + // Chronological lifespan and browning (years on disk per FSPM Rule of + // Ontogeny). No GDD->years conversion needed. + const int needle_lifespan_years = + std::max(1, static_cast(std::round(SampleDistribution(p.distributions.needle_lifespan_years, node_rng)))); + const float needle_browning_years = + std::max(0.0f, SampleDistribution(p.distributions.needle_browning_years, node_rng)); + const float needle_flush_delay_years = + std::max(0.0f, SampleDistribution(p.distributions.needle_flush_delay_gdd, node_rng) / kPineGddPerYear); + const float needle_maturation_years = + std::clamp(SampleDistribution(p.distributions.needle_maturation_gdd, node_rng) / kPineGddPerYear, 0.0f, 1.0f); + const float needle_curvature_adaxial_bias = + SampleDistribution(p.distributions.needle_curvature_adaxial_bias, node_rng); + const float needle_curvature_abaxial_bias = + SampleDistribution(p.distributions.needle_curvature_abaxial_bias, node_rng); + const float needle_curvature_gradient_per_arclen = + SampleDistribution(p.distributions.needle_curvature_gradient_per_arclen, node_rng); + const float needle_diameter_for_curvature_m = + std::clamp(SampleDistribution(p.distributions.needle_diameter_for_curvature_m, node_rng), 0.0f, 0.005f); + const float needle_sinusoidal_amplitude_deg = + std::max(0.0f, SampleDistribution(p.distributions.needle_sinusoidal_amplitude_deg, node_rng)); + const float needle_sinusoidal_frequency_cycles = + std::max(0.0f, SampleDistribution(p.distributions.needle_sinusoidal_frequency_cycles, node_rng)); + const float needle_sinusoidal_phase_randomness_deg = + std::max(0.0f, SampleDistribution(p.distributions.needle_sinusoidal_phase_randomness_deg, node_rng)); + const float needle_young_modulus_baseline_Pa = + std::max(0.0f, SampleDistribution(p.distributions.needle_young_modulus_baseline_Pa, node_rng)); + const float needle_lignification_maturation_years = + std::max(0.0f, SampleDistribution(p.distributions.needle_lignification_maturation_years, node_rng)); + // Draw maximum ellipsoid cross-section axes (full diameters in metres) + // and convert to semi-axis radii for rendering/mechanics. Keep width and + // thickness uncapped and independent. + const float sampled_needle_cross_section_width_max_m = + std::max(0.0f, SampleDistribution(p.distributions.needle_cross_section_width_max_m, node_rng)); + const float sampled_needle_cross_section_thickness_max_m = + std::max(0.0f, SampleDistribution(p.distributions.needle_cross_section_thickness_max_m, node_rng)); + const float sampled_needle_cross_section_width_radius_m = sampled_needle_cross_section_width_max_m * 0.5f; + const float sampled_needle_cross_section_thickness_radius_m = sampled_needle_cross_section_thickness_max_m * 0.5f; + + const float normalized_phytomer_progress = + ComputeNormalizedPhytomerProgress(phytomer_index_this_year, phytomers_per_season); + const float intra_year_capacity_weight = ComputeIntraYearCapacityWeight( + normalized_phytomer_progress, p.needle_intra_year_base_ratio, p.needle_intra_year_sigmoid_steepness, + p.needle_intra_year_sigmoid_midpoint_fraction, p.needle_intra_year_late_decay_start_fraction, + p.needle_intra_year_late_decay_end_scale); + + const bool is_fascicular_year = initiation_year_index >= std::max(0, p.needle_fascicular_start_year); + const float year_length_multiplier = is_fascicular_year ? std::max(0.0f, p.needle_year2plus_length_multiplier) : 1.0f; + const float year_width_multiplier = is_fascicular_year ? std::max(0.0f, p.needle_year2plus_width_multiplier) : 1.0f; + const float year_thickness_multiplier = + is_fascicular_year ? std::max(0.0f, p.needle_year2plus_thickness_multiplier) : 1.0f; + const float bud_storage_vigor_weight = ComputeBudStorageVigorWeight( + previous_season_vigor, p.needle_bud_storage_vigor_strength, p.needle_bud_storage_completion_floor); + + const float inter_year_length_capacity_weight = year_length_multiplier * bud_storage_vigor_weight; + const float inter_year_width_capacity_weight = year_width_multiplier * bud_storage_vigor_weight; + const float inter_year_thickness_capacity_weight = year_thickness_multiplier * bud_storage_vigor_weight; + + const float adjusted_needle_length_m = + std::max(0.0f, needle_length_m * intra_year_capacity_weight * inter_year_length_capacity_weight); + const float adjusted_width_radius_m = + std::max(0.0f, sampled_needle_cross_section_width_radius_m * intra_year_capacity_weight * + inter_year_width_capacity_weight); + const float adjusted_thickness_radius_m = + std::max(0.0f, sampled_needle_cross_section_thickness_radius_m * intra_year_capacity_weight * + inter_year_thickness_capacity_weight); + + const float needle_cross_section_width_radius_m = adjusted_width_radius_m; + const float needle_cross_section_thickness_radius_m = adjusted_thickness_radius_m; + const float needle_density_kg_m3 = std::max(0.0f, SampleDistribution(p.distributions.needle_density_kg_m3, node_rng)); + const float needle_per_needle_length_cv = + std::max(0.0f, SampleDistribution(p.distributions.needle_per_needle_length_cv, node_rng)); + const float needle_per_needle_curvature_cv = + std::max(0.0f, SampleDistribution(p.distributions.needle_per_needle_curvature_cv, node_rng)); + const float needle_per_needle_radius_cv = + std::max(0.0f, SampleDistribution(p.distributions.needle_per_needle_radius_cv, node_rng)); + const float needle_per_needle_modulus_cv = + std::max(0.0f, SampleDistribution(p.distributions.needle_per_needle_modulus_cv, node_rng)); + const float needle_per_needle_density_cv = + std::max(0.0f, SampleDistribution(p.distributions.needle_per_needle_density_cv, node_rng)); + const float needle_per_needle_wave_amplitude_cv = + std::max(0.0f, SampleDistribution(p.distributions.needle_per_needle_wave_amplitude_cv, node_rng)); + const float needle_per_needle_wave_frequency_cv = + std::max(0.0f, SampleDistribution(p.distributions.needle_per_needle_wave_frequency_cv, node_rng)); + const float needle_per_needle_wave_phase_cv = + std::max(0.0f, SampleDistribution(p.distributions.needle_per_needle_wave_phase_cv, node_rng)); + + PineNeedleCluster c; + c.count = std::max(1, needle_count_per_cluster); + + const float order_length_scale = + ComputeOrderVigorScale(parent_axis_order, p.needle_order_length_attenuation, p.needle_order_min_length_scale); + const float order_radius_scale = + ComputeOrderVigorScale(parent_axis_order, p.needle_order_radius_attenuation, p.needle_order_min_radius_scale); + + c.target_length = std::max(0.001f, adjusted_needle_length_m * order_length_scale); + c.render_radius_scale = std::clamp(order_radius_scale, 0.10f, 2.0f); + c.cross_section_width_radius_m = needle_cross_section_width_radius_m; + c.cross_section_thickness_radius_m = needle_cross_section_thickness_radius_m; + c.initiation_year_index = std::max(0, initiation_year_index); + c.initiation_phytomer_index = std::max(0, phytomer_index_this_year); + c.intra_year_capacity_weight = intra_year_capacity_weight; + c.inter_year_capacity_weight = inter_year_length_capacity_weight; + c.bud_storage_vigor_weight = bud_storage_vigor_weight; + c.age_years = 0; + c.lifespan_years = std::max(1, needle_lifespan_years); + c.browning_years = needle_browning_years; + c.alive = true; + c.s_along_parent_norm = std::clamp(s_along_norm, 0.0f, 1.0f); + c.roll_offset_deg = NormalizeDegrees(roll_offset_deg); + c.senescence_phase = 0.0f; + c.node_random = SampleUnit01(node_rng); + + // Keep this deterministic and isolated from node_rng so adding angle + // relaxation does not perturb topology stochasticity in downstream rules. + c.branching_angle_deg = std::clamp( + DeterministicDistributionSample(p.distributions.needle_branching_angle_deg, c.node_random, 0u, 0x2d3e4f50u), 0.0f, + 89.5f); + const float sampled_branching_relax_gdd = std::max( + 0.0f, + DeterministicDistributionSample(p.distributions.needle_branching_relax_gdd, c.node_random, 0u, 0x17a3c8d2u)); + c.branching_relax_years = sampled_branching_relax_gdd / kPineGddPerYear; + + // Continuous-growth: needle flush is delayed relative to shoot emergence. + const float t_flush = internode_t_init_years + std::max(0.0f, needle_flush_delay_years); + c.continuous_growth.t_init_years = t_flush; + c.continuous_growth.maturation_years = std::max(0.0f, needle_maturation_years); + c.continuous_growth.function.kind = GrowthFunctionKind::Sinusoidal; + c.length = (c.continuous_growth.maturation_years > 0.0f) ? 0.0f : c.target_length; + + // Bilateral differential growth field (defaults are inert -> straight needle). + // Use node_random to slightly modulate magnitude so siblings differ. + const float curvature_scale = 0.80f + 0.40f * c.node_random; + c.growth_field.adaxial_bias = needle_curvature_adaxial_bias * curvature_scale; + c.growth_field.abaxial_bias = needle_curvature_abaxial_bias * curvature_scale; + c.growth_field.gradient_per_arclen = needle_curvature_gradient_per_arclen * curvature_scale; + c.growth_field.diameter_m = needle_diameter_for_curvature_m; + c.sinusoidal_amplitude_deg = std::clamp(needle_sinusoidal_amplitude_deg, 0.0f, 45.0f); + c.sinusoidal_frequency_cycles = std::clamp(needle_sinusoidal_frequency_cycles, 0.0f, 12.0f); + c.sinusoidal_phase_rad = 0.0f; + + // Mechanical material profile (defaults are inert -> elastica skipped). + c.material_profile.young_modulus_baseline_Pa = needle_young_modulus_baseline_Pa; + c.material_profile.base_radius_m = needle_cross_section_width_radius_m; + c.material_profile.tip_radius_m = needle_cross_section_width_radius_m; + c.material_profile.density_kg_m3 = needle_density_kg_m3; + c.material_profile.lignification.t_init_years = t_flush; + c.material_profile.lignification.maturation_years = needle_lignification_maturation_years; + c.material_profile.lignification.function.kind = GrowthFunctionKind::Logistic; + + // Explicit per-needle profiles within a fascicle. Keep this deterministic + // and local to the cluster so topology and upstream RNG sequencing remain stable. + c.per_needle_profiles.clear(); + c.per_needle_profiles.reserve(static_cast(std::max(1, c.count))); + const float length_cv = std::max(0.0f, needle_per_needle_length_cv); + const float curvature_cv = std::max(0.0f, needle_per_needle_curvature_cv); + const float radius_cv = std::max(0.0f, needle_per_needle_radius_cv); + const float modulus_cv = std::max(0.0f, needle_per_needle_modulus_cv); + const float density_cv = std::max(0.0f, needle_per_needle_density_cv); + const float wave_amplitude_cv = std::max(0.0f, needle_per_needle_wave_amplitude_cv); + const float wave_frequency_cv = std::max(0.0f, needle_per_needle_wave_frequency_cv); + const float wave_phase_cv = std::max(0.0f, needle_per_needle_wave_phase_cv); + const int needle_count = std::max(1, c.count); + for (int i = 0; i < needle_count; ++i) { + PineNeedleInstanceProfile profile; + profile.growth_field = c.growth_field; + profile.material_profile = c.material_profile; + const float profile_branching_relax_gdd = + std::max(0.0f, DeterministicDistributionSample(p.distributions.needle_branching_relax_gdd, c.node_random, + static_cast(i), 0x4ab14377u)); + profile.branching_relax_years = profile_branching_relax_gdd / kPineGddPerYear; + profile.sinusoidal_amplitude_deg = c.sinusoidal_amplitude_deg; + profile.sinusoidal_frequency_cycles = c.sinusoidal_frequency_cycles; + profile.sinusoidal_phase_rad = c.sinusoidal_phase_rad; + + const float length_jitter = DeterministicSignedJitter(c.node_random, i, 0x13579bdfu); + const float curvature_jitter = DeterministicSignedJitter(c.node_random, i, 0x2468ace0u); + const float radius_jitter = DeterministicSignedJitter(c.node_random, i, 0x0f1e2d3cu); + const float modulus_jitter = DeterministicSignedJitter(c.node_random, i, 0x55aa55aau); + const float density_jitter = DeterministicSignedJitter(c.node_random, i, 0xa55aa55au); + const float wave_amplitude_jitter = DeterministicSignedJitter(c.node_random, i, 0x5e7711a9u); + const float wave_frequency_jitter = DeterministicSignedJitter(c.node_random, i, 0x1f6c3d2bu); + const float wave_phase_base = DeterministicSignedJitter(c.node_random, i, 0x8bc4ef01u); + const float wave_phase_jitter = DeterministicSignedJitter(c.node_random, i, 0x6395ad72u); + + profile.length_scale = std::clamp(1.0f + length_cv * length_jitter, 0.05f, 3.0f); + profile.radius_scale = std::clamp(1.0f + radius_cv * radius_jitter, 0.05f, 3.0f); + + const float curvature_scale_per_needle = std::clamp(1.0f + curvature_cv * curvature_jitter, 0.0f, 3.0f); + profile.growth_field.adaxial_bias *= curvature_scale_per_needle; + profile.growth_field.abaxial_bias *= curvature_scale_per_needle; + profile.growth_field.gradient_per_arclen *= curvature_scale_per_needle; + + const float modulus_scale = std::clamp(1.0f + modulus_cv * modulus_jitter, 0.0f, 3.0f); + const float density_scale = std::clamp(1.0f + density_cv * density_jitter, 0.0f, 3.0f); + profile.material_profile.young_modulus_baseline_Pa *= modulus_scale; + profile.material_profile.base_radius_m *= profile.radius_scale; + profile.material_profile.tip_radius_m *= profile.radius_scale; + profile.material_profile.density_kg_m3 *= density_scale; + + constexpr float kPi = 3.14159265358979323846f; + constexpr float kTwoPi = 6.28318530717958647692f; + const float wave_amplitude_scale = std::clamp(1.0f + wave_amplitude_cv * wave_amplitude_jitter, 0.0f, 3.0f); + const float wave_frequency_scale = std::clamp(1.0f + wave_frequency_cv * wave_frequency_jitter, 0.0f, 3.0f); + profile.sinusoidal_amplitude_deg = std::clamp(profile.sinusoidal_amplitude_deg * wave_amplitude_scale, 0.0f, 45.0f); + profile.sinusoidal_frequency_cycles = + std::clamp(profile.sinusoidal_frequency_cycles * wave_frequency_scale, 0.0f, 12.0f); + const float base_phase_rad = (0.5f * (wave_phase_base + 1.0f)) * kTwoPi; + const float phase_randomness_deg = std::clamp( + needle_sinusoidal_phase_randomness_deg * std::clamp(1.0f + wave_phase_cv * wave_phase_jitter, 0.0f, 3.0f), 0.0f, + 180.0f); + const float phase_jitter_rad = phase_randomness_deg * wave_phase_jitter * (kPi / 180.0f); + profile.sinusoidal_phase_rad = NormalizeRadians(base_phase_rad + phase_jitter_rad); + + c.per_needle_profiles.emplace_back(profile); + } + return c; +} + +/// Draw fresh plastochron (years) for stamping on a (root or lateral) apex. +/// Clamp matches the bound previously enforced inside `Sample()`. +inline float SamplePlastochronYears(const SampledPineParams& p, std::mt19937& node_rng) { + return std::clamp(SampleDistribution(p.distributions.plastochron_gdd, node_rng) / kPineGddPerYear, 0.05f, 4.0f); +} + +/// Draw fresh max_phytomers_per_seasonal_growth for stamping on an apex at +/// year boundaries. Clamp matches `Sample()`. +inline int SampleMaxPhytomersPerSeasonalGrowth(const SampledPineParams& p, std::mt19937& node_rng) { + return std::clamp( + static_cast(std::round(SampleDistribution(p.distributions.max_phytomers_per_seasonal_growth, node_rng))), 1, + 200); +} + +/// Draw fresh per-year bare-phytomer count from the descriptor's +/// `bare_zone_fraction` distribution. Stamped on the apex at year rollover +/// (R0) and at apex creation, so R1's needle gate is a stable integer +/// comparison `phytomers_this_year < bare_phytomers_this_year` for the +/// entire year. `season_phytomers` is the per-year cap stamped on the same +/// apex so the bare count cannot exceed the year's emissions. +inline int SampleBarePhytomersThisYear(const SampledPineParams& p, int season_phytomers, std::mt19937& node_rng) { + const float fraction = std::clamp(SampleDistribution(p.distributions.bare_zone_fraction, node_rng), 0.0f, 0.95f); + const int cap = std::max(0, season_phytomers); + const int bare = static_cast(std::round(fraction * static_cast(cap))); + return std::clamp(bare, 0, cap); +} + +/// Lateral apex factory. Draws fresh per-apex stamped values for +/// plastochron and per-season cap so each lateral evolves with its own +/// (resampled) seasonal cadence. +inline PineApex MakeLateralApex(const SampledPineParams& params, int order, float phyllotaxis_phase_deg, + float t_init_years, int year_index, std::mt19937& node_rng) { + PineApex apex; + apex.order = order; + apex.phyllotaxis_phase = phyllotaxis_phase_deg; + apex.node_random = SampleUnit01(node_rng); + apex.year_index = year_index; + apex.phytomers_this_year = 0; + // Lateral apices start their plastochron clock from the moment the whorl + // bud activates, so the first phytomer waits a full plastochron after bud + // break before extending. + apex.t_last_emission_years = t_init_years; + apex.sampled_plastochron_years = SamplePlastochronYears(params, node_rng); + apex.sampled_max_phytomers_per_seasonal_growth = SampleMaxPhytomersPerSeasonalGrowth(params, node_rng); + apex.bare_phytomers_this_year = + SampleBarePhytomersThisYear(params, apex.sampled_max_phytomers_per_seasonal_growth, node_rng); + apex.previous_season_completion_ratio = 1.0f; + apex.previous_season_vigor = 1.0f; + return apex; +} + +} // namespace pine_detail + +// --------------------------------------------------------------------------- +// Topology rules (phytomer grammar). +// +// R0. Apex(year_index < clock.YearIndex()) +// -> Apex(year_index = clock.YearIndex(), +// phytomers_this_year = 0, +// t_last_emission_years = now - plastochron, +// sampled_max_phytomers_per_seasonal_growth = , +// sampled_plastochron_years = , +// bare_phytomers_this_year = round( +// * +// sampled_max_phytomers_per_seasonal_growth)) +// (year rollover; immortal) +// +// R1. Apex(in_active_season && +// phytomers_this_year < sampled_max_phytomers_per_seasonal_growth && +// now - t_last_emission >= sampled_plastochron_years) +// -> Internode(phytomer) +// + (NeedleCluster IF phytomers_this_year >= bare_phytomers_this_year) +// + WhorlBud(if max_branching_order >= order+1 && branches_per_whorl > 0) +// + Apex(phytomers_this_year + 1, t_last_emission = now) +// +// R3. WhorlBud(dormancy > 0) -> WhorlBud (dormant hold) +// +// R4. WhorlBud(dormancy <= 0) -> N * Apex(branch, order+1) (activation) +// --------------------------------------------------------------------------- + +inline std::vector CreatePineTopologyRules(const SampledPineParams& params, + const bool enable_needle_topology = true) { + std::vector rules; + + // R0: year rollover. Highest priority so a stale apex always refreshes its + // seasonal-growth budget before R1 considers emitting a new phytomer. + { + PineRule rule; + rule.predecessor_symbol = PineSymbol::Apex; + rule.priority = 30; + rule.condition = [](const RuleContext& ctx) -> bool { + const auto& apex = ctx.self.data.Get(); + return ctx.graph.data.clock.YearIndex() > apex.year_index; + }; + rule.produce = [params](RuleContext& ctx) -> ProductionResult { + const auto& apex = ctx.self.data.Get(); + const auto& clock = ctx.graph.data.clock; + const float now_years = clock.NowYears(); + const float previous_cap = static_cast(std::max(1, apex.sampled_max_phytomers_per_seasonal_growth)); + const float previous_completion_ratio = + std::clamp(static_cast(apex.phytomers_this_year) / previous_cap, 0.0f, 1.0f); + const float previous_vigor = std::clamp(previous_completion_ratio, + std::clamp(params.needle_bud_storage_completion_floor, 0.0f, 1.0f), 1.0f); + // Year rollover: draw fresh stamped plastochron + per-season cap so + // each year evolves with its own (resampled) cadence. + auto node_rng = MakeNodeRng(apex.node_random, 0xA1F00D00u); + ProductionResult result; + Successor s; + s.is_branch = false; + s.symbol_id = PineSymbol::Apex; + PineApex next = apex; + next.year_index = clock.YearIndex(); + next.phytomers_this_year = 0; + next.sampled_plastochron_years = pine_detail::SamplePlastochronYears(params, node_rng); + next.sampled_max_phytomers_per_seasonal_growth = + pine_detail::SampleMaxPhytomersPerSeasonalGrowth(params, node_rng); + next.bare_phytomers_this_year = + pine_detail::SampleBarePhytomersThisYear(params, next.sampled_max_phytomers_per_seasonal_growth, node_rng); + next.previous_season_completion_ratio = previous_completion_ratio; + next.previous_season_vigor = previous_vigor; + next.node_random = SampleUnit01(node_rng); + // Allow the first phytomer of the new year to fire immediately. + next.t_last_emission_years = now_years - std::max(0.0f, next.sampled_plastochron_years); + s.data.Set(next); + result.successors.push_back(std::move(s)); + return result; + }; + rules.push_back(std::move(rule)); + } + + // R1: phytomer extension. + { + PineRule rule; + rule.predecessor_symbol = PineSymbol::Apex; + rule.priority = 10; + rule.condition = [](const RuleContext& ctx) -> bool { + const auto& apex = ctx.self.data.Get(); + const auto& clock = ctx.graph.data.clock; + // Only emit during the active growth season. + if (!clock.InActiveSeason()) + return false; + // Stop after this year's phytomer cap is reached; R0 will reset on + // the next dormant->active edge. Cap is stamped per-year in R0 so + // condition + produce see the same value. + if (apex.phytomers_this_year >= apex.sampled_max_phytomers_per_seasonal_growth) { + return false; + } + // Plastochron gate. Stamped per-emission on the apex by R0 / R1. + const float now_years = clock.NowYears(); + const float elapsed = now_years - apex.t_last_emission_years; + return elapsed >= std::max(0.0f, apex.sampled_plastochron_years); + }; + rule.produce = [params, enable_needle_topology](RuleContext& ctx) -> ProductionResult { + const auto& apex = ctx.self.data.Get(); + const auto& clock = ctx.graph.data.clock; + auto node_rng = MakeNodeRng(apex.node_random, 0xA1F00D01u); + const float now_years = clock.NowYears(); + const bool is_first_on_axis = (apex.phytomers_this_year == 0 && apex.year_index == 0); + + // Per-emission resamples for fields used in this production. Note that + // `bare_zone_fraction` is intentionally NOT resampled here - it was + // stamped on the apex at year rollover (R0) as an integer + // `bare_phytomers_this_year` count, so the needle gate below is a + // stable per-year decision. + const int max_branching_order = std::clamp( + static_cast(std::round(SampleDistribution(params.distributions.max_branching_order, node_rng))), 0, 8); + // Roll advance for the continuation apex (the next phytomer's phyllotactic phase). + const float roll_phyllotaxis_deg = SampleDistribution(params.distributions.branch_roll_phyllotaxis_deg, node_rng); + + ProductionResult result; + + // 1) The phytomer's internode. + { + Successor s; + s.is_branch = false; + s.symbol_id = PineSymbol::Internode; + auto internode = pine_detail::MakePhytomerInternode(params, apex, is_first_on_axis, node_rng, now_years); + s.data.Set(internode); + result.successors.push_back(std::move(s)); + } + + // 2) Optional needle cluster: count-based bare-zone gate. The first + // `bare_phytomers_this_year` phytomers of every year emit an + // internode without a needle cluster; every subsequent phytomer + // in the same year carries one cluster. This replaces the + // previous `(now - YearStart) / SeasonLength >= bare_zone` + // temporal gate, which could deterministically drop ALL needles + // in years where (a) the cap was small enough that no phytomer + // crossed the bare fraction before the season ended, or (b) the + // per-emission bare_zone draw landed above the achievable + // temporal_progress. The new gate is independent of the + // GDD<->calendar coupling and stable across plastochron resamples. + if (enable_needle_topology && apex.phytomers_this_year >= apex.bare_phytomers_this_year) { + Successor s; + s.is_branch = true; + s.symbol_id = PineSymbol::NeedleCluster; + // Anchor the single cluster at the distal end of the phytomer; with + // one phytomer per cluster this is geometrically a point on the new + // internode rather than a fractional position along an annual shoot. + s.data.Set( + pine_detail::MakeNeedleCluster(params, + /*s_along_norm=*/1.0f, + /*roll_offset_deg=*/apex.phyllotaxis_phase, node_rng, + /*internode_t_init_years=*/now_years, + /*parent_axis_order=*/apex.order, + /*phytomer_index_this_year=*/apex.phytomers_this_year, + /*phytomers_per_season=*/apex.sampled_max_phytomers_per_seasonal_growth, + /*initiation_year_index=*/apex.year_index, + /*previous_season_vigor=*/apex.previous_season_vigor)); + result.successors.push_back(std::move(s)); + } + + // 3) Overwintering whorl bud (if the parent can carry laterals). + // Per-whorl resamples: branches_per_whorl, branch_insertion_angle, + // whorl_dormancy_years (chronological, not GDD). max_branching_order + // is also per-emission. + const int branches_per_whorl = std::clamp( + static_cast(std::round(SampleDistribution(params.distributions.branches_per_whorl, node_rng))), 0, 32); + const int next_order = apex.order + 1; + if (max_branching_order >= next_order && branches_per_whorl > 0) { + const float branch_insertion_angle_deg = + std::clamp(SampleDistribution(params.distributions.branch_insertion_angle_deg, node_rng), -85.0f, 85.0f); + // Chronological: bud release is chilling/photoperiod-driven, not heat-driven. + const float whorl_dormancy_years = + std::max(0.0f, SampleDistribution(params.distributions.whorl_dormancy_years, node_rng)); + Successor s; + s.is_branch = true; + s.symbol_id = PineSymbol::WhorlBud; + PineWhorlBud bud; + bud.insertion_angle = branch_insertion_angle_deg; + bud.lateral_count = branches_per_whorl; + bud.dormancy_years_remaining = std::max(0, static_cast(std::round(whorl_dormancy_years))); + bud.initial_dormancy_years = bud.dormancy_years_remaining; + bud.order = next_order; + bud.phyllotaxis_phase = apex.phyllotaxis_phase; + bud.node_random = SampleUnit01(node_rng); + s.data.Set(bud); + result.successors.push_back(std::move(s)); + } + + // 4) Continuation apex. Re-stamp plastochron (per-emission cadence) + // while preserving the per-season cap stamped at year boundaries. + { + Successor s; + s.is_branch = false; + s.symbol_id = PineSymbol::Apex; + PineApex next = apex; + next.phytomers_this_year = apex.phytomers_this_year + 1; + next.phyllotaxis_phase = pine_detail::NormalizeDegrees(apex.phyllotaxis_phase + roll_phyllotaxis_deg); + next.node_random = SampleUnit01(node_rng); + next.t_last_emission_years = now_years; + next.sampled_plastochron_years = pine_detail::SamplePlastochronYears(params, node_rng); + // Per-season cap inherits unchanged from `apex` via the copy above. + s.data.Set(next); + result.successors.push_back(std::move(s)); + } + return result; + }; + rules.push_back(std::move(rule)); + } + + // R3: whorl bud dormant hold. + // Chronological dormancy countdown is maintained in PineGrowthModel. + { + PineRule rule; + rule.predecessor_symbol = PineSymbol::WhorlBud; + rule.priority = 10; + rule.condition = [](const RuleContext& ctx) -> bool { + return ctx.self.data.Get().dormancy_years_remaining > 0; + }; + rule.produce = [](RuleContext& ctx) -> ProductionResult { + const auto bud = ctx.self.data.Get(); + ProductionResult result; + Successor s; + s.is_branch = false; + s.symbol_id = PineSymbol::WhorlBud; + s.data.Set(bud); + result.successors.push_back(std::move(s)); + return result; + }; + rules.push_back(std::move(rule)); + } + + // R4: whorl bud activation -> N lateral apices. + { + PineRule rule; + rule.predecessor_symbol = PineSymbol::WhorlBud; + rule.priority = 5; + rule.condition = [](const RuleContext& ctx) -> bool { + return ctx.self.data.Get().dormancy_years_remaining <= 0; + }; + rule.produce = [params](RuleContext& ctx) -> ProductionResult { + const auto& bud = ctx.self.data.Get(); + auto node_rng = MakeNodeRng(bud.node_random, 0xA1F00D04u); + const auto& clock = ctx.graph.data.clock; + const float now_years = clock.NowYears(); + const int year_index = clock.YearIndex(); + ProductionResult result; + const int n = std::max(0, bud.lateral_count); + for (int i = 0; i < n; ++i) { + // Per-lateral resample of phyllotaxis spread. + const float roll_phyllotaxis_deg = + SampleDistribution(params.distributions.branch_roll_phyllotaxis_deg, node_rng); + Successor s; + s.is_branch = true; + s.symbol_id = PineSymbol::Apex; + const float phase = + pine_detail::NormalizeDegrees(bud.phyllotaxis_phase + static_cast(i) * roll_phyllotaxis_deg); + s.data.Set(pine_detail::MakeLateralApex(params, bud.order, phase, now_years, year_index, node_rng)); + result.successors.push_back(std::move(s)); + } + return result; + }; + rules.push_back(std::move(rule)); + } + + return rules; +} + +// --------------------------------------------------------------------------- +// Growth rules. +// +// Senescence and abscission are driven continuously in +// PineGrowthModel::UpdateNodeInfoImpl from `cluster.continuous_growth.t_init`, +// `lifespan_years`, and `needle_browning_years`. No discrete growth rules +// are required. +// --------------------------------------------------------------------------- + +inline std::vector CreatePineGrowthRules(const SampledPineParams& /*params*/) { + return {}; +} + +} // namespace l_system_package diff --git a/EvoEngine_Packages/LSystem/include/SimulationClock.hpp b/EvoEngine_Packages/LSystem/include/SimulationClock.hpp new file mode 100644 index 00000000..e78101ec --- /dev/null +++ b/EvoEngine_Packages/LSystem/include/SimulationClock.hpp @@ -0,0 +1,168 @@ +#pragma once + +// --------------------------------------------------------------------------- +// SimulationClock +// +// Phase 2 of the biologically-emergent organ growth substrate. A monotonic +// physiological-time accumulator decoupled from rendering frames, GDD ticks, +// or wall-clock. Every plant carries one in its `GraphData`; every module +// born during a derivation step records `t_init_years = clock.NowYears()`, +// so the per-organ growth function can later evaluate +// `Value((NowYears() - t_init_years) / maturation_years)` regardless of who +// or what is driving the clock. +// +// Three drivers are supported: +// +// * `AdvanceYears(dt)` - explicit; for headless dataset generation, +// unit tests, and direct UI scrubbing. +// * `AdvanceFromGDD(dgdd, gdd_per_year)` - wraps GDD increments into +// fractional years; used by the existing +// LSystemGrowthModelBase::GrowStep pipeline. +// * `SetYears(t)` - non-monotonic; for backward-scrubbing scenarios +// (the editor allows dragging target_gdd backward). +// Skips the monotonic guard so callers can rebuild +// state from scratch. +// +// The clock is intentionally free of distribution machinery: no RNG, no +// per-organ state. It is a single float (plus a derived-step counter for +// telemetry). Every other piece of biology consults it through the graph. +// --------------------------------------------------------------------------- + +#include +#include + +namespace l_system_package { + +class SimulationClock { + public: + SimulationClock() = default; + + /// Physiological time in years since clock zero. Monotonic when only + /// `AdvanceYears` / `AdvanceFromGDD` are used. + float NowYears() const { + return now_years_; + } + + /// Number of `Advance*` calls since construction or the last `Reset()`. + /// Used by Phase 2 unit tests to verify deterministic replay. + std::uint64_t StepCount() const { + return step_count_; + } + + /// Advance the clock by `dt_years`. Negative `dt_years` is silently dropped + /// (use `SetYears` for backward scrubbing). + void AdvanceYears(float dt_years) { + if (!std::isfinite(dt_years) || dt_years <= 0.0f) + return; + now_years_ += dt_years; + ++step_count_; + } + + /// Convert a GDD increment to years using the supplied conversion and + /// advance. `gdd_per_year` must be > 0. + void AdvanceFromGDD(float dgdd, float gdd_per_year) { + if (gdd_per_year <= 0.0f || !std::isfinite(dgdd) || dgdd <= 0.0f) + return; + AdvanceYears(dgdd / gdd_per_year); + } + + /// Force the clock to an absolute physiological time. Bypasses the + /// monotonic guard so callers can rewind for re-derivation. Increments + /// `StepCount` to mark the discontinuity. + void SetYears(float t_years) { + if (!std::isfinite(t_years)) + return; + now_years_ = t_years < 0.0f ? 0.0f : t_years; + ++step_count_; + } + + /// Zero the clock and step counter. + void Reset() { + now_years_ = 0.0f; + step_count_ = 0; + year_index_ = 0; + t_year_start_years_ = 0.0f; + active_season_length_years_ = 1.0f; + in_active_season_ = true; + } + + // ------------------------------------------------------------------------- + // Seasonal year tracking (Scots-pine phytomer model). + // + // The phytomer growth rules need three pieces of state that are naturally + // owned by the clock: + // * `YearIndex()` - integer year counter, incremented on each + // dormant->active edge by the LSystem layer. + // * `YearStartYears()` - physiological time at which the current + // active season began. Rules use this to compute + // `temporal_year_progress` for the bare-zone gate. + // * `ActiveSeasonLengthYears()` - duration of the current active season + // (in physiological years). Defaults to + // 1.0 when no calendar gating is active. + // * `InActiveSeason()` - whether the apex is currently allowed to emit. + // ------------------------------------------------------------------------- + int YearIndex() const { + return year_index_; + } + float YearStartYears() const { + return t_year_start_years_; + } + float ActiveSeasonLengthYears() const { + return active_season_length_years_; + } + bool InActiveSeason() const { + return in_active_season_; + } + + /// Increment the year counter and stamp the new active-season start time. + /// Called by the LSystem layer when it detects a dormant->active edge. + void BumpYear(float active_season_length_years) { + ++year_index_; + t_year_start_years_ = now_years_; + active_season_length_years_ = (active_season_length_years > 1.0e-4f) ? active_season_length_years : 1.0f; + in_active_season_ = true; + } + + /// Toggle whether the apex is currently in its active growth season. + /// Driven by the LSystem layer's day-of-year gate. + void SetInActiveSeason(bool in_season) { + in_active_season_ = in_season; + } + + /// Drive seasonal state from the LSystem layer once per frame. Detects the + /// dormant -> active transition and bumps the year counter. When + /// `seasonality_enabled` is false the active-season flag is held true and + /// year rollover happens whenever `now_years_` crosses the next integer + /// (so the phytomer model still gets a rhythmic year clock without a + /// calendar simulation). + void SyncSeasonalState(bool seasonality_enabled, bool layer_in_active_season, float active_season_length_years) { + if (seasonality_enabled) { + const bool was_active = in_active_season_; + in_active_season_ = layer_in_active_season; + if (in_active_season_ && !was_active) { + BumpYear(active_season_length_years); + } + } else { + in_active_season_ = true; + // Year-from-time fallback: bump whenever we cross into a new whole year. + const int candidate_year = static_cast(std::floor(now_years_)); + if (candidate_year > year_index_) { + // Stamp year_start at the integer boundary so temporal_progress + // computed in rules stays in [0, 1). + year_index_ = candidate_year; + t_year_start_years_ = static_cast(candidate_year); + active_season_length_years_ = 1.0f; + } + } + } + + private: + float now_years_ = 0.0f; + std::uint64_t step_count_ = 0; + int year_index_ = 0; + float t_year_start_years_ = 0.0f; + float active_season_length_years_ = 1.0f; + bool in_active_season_ = true; +}; + +} // namespace l_system_package \ No newline at end of file diff --git a/EvoEngine_Packages/LSystem/include/StressFeedbackPolicy.hpp b/EvoEngine_Packages/LSystem/include/StressFeedbackPolicy.hpp new file mode 100644 index 00000000..43cd8089 --- /dev/null +++ b/EvoEngine_Packages/LSystem/include/StressFeedbackPolicy.hpp @@ -0,0 +1,145 @@ +#pragma once + +// --------------------------------------------------------------------------- +// StressFeedbackPolicy +// +// Closes the cell-mechanics loop the brief calls for: bending stress extracted from the +// elastica solver feeds back into both the growth field +// (`g(s, side, t) <- g_baseline * feedback_growth(sigma)`) and the material +// stiffness (`E(t) <- E_baseline * feedback_stiffness(sigma_history)`). +// +// Default policies: +// - feedback_growth: small *negative* feedback. Highly stressed regions +// elongate slower (suppression of growth in over-loaded tissue). +// - feedback_stiffness: *positive* feedback. Sustained stress raises local +// stiffness ("mechanical conditioning" / accelerated lignification). +// +// Numerical hygiene per the plan: +// - feedback multipliers are clamped (configurable floors/ceilings). +// - sigma history is exponentially-moving-averaged in time (alpha = 0 disables +// all updates -> policy never engages -> preserves elastica solver results bit- +// exactly). +// - per-station sigma samples may be smoothed in arc length via a simple +// box average (window = 0 disables smoothing). +// +// Default-constructed values are *inert*: zero gains and zero EMA alpha +// short-circuit IsActive() -> caller skips the feedback path entirely. +// --------------------------------------------------------------------------- + +#include +#include +#include +#include + +namespace l_system_package { + +struct StressFeedbackPolicy { + /// Magnitude of growth suppression per unit of normalized stress. Setting + /// this to 0 disables growth feedback. Typical values: 0.1 .. 0.5 - beyond + /// 1.0 the suppression saturates at `max_suppression_clamp` very quickly. + float growth_suppression_gain = 0.0f; + + /// Magnitude of stiffness hardening per unit of normalized stress history. + /// Setting this to 0 disables stiffness feedback. Typical values: 0.5 .. 2. + float stiffness_hardening_gain = 0.0f; + + /// Normalization stress (Pa). sigma_norm = sigma / sigma_reference_Pa. Order of + /// magnitude for slender needle bending: 1e5 .. 1e6 Pa. Must be > 0 for + /// the policy to be active. + float sigma_reference_Pa = 1.0e6f; + + /// Exponential-moving-average rate per *rebuild tick*: + /// sigma_ema <- (1 - alpha) * sigma_ema + alpha * |sigma|. + /// alpha = 0 -> EMA is never updated (policy effectively inert across rebuilds). + /// alpha = 1 -> no smoothing (instantaneous sigma replaces history every tick). + float time_ema_alpha = 0.0f; + + /// Arc-length smoothing window in *normalized* arc length [0,1] applied to + /// the per-station sigma samples *before* EMA blending. A simple box average + /// over neighbours within +/- window/2 of each station. 0 disables smoothing. + float arc_length_smoothing_window = 0.0f; + + /// Floor on the growth multiplier (i.e. maximum suppression). Clamps + /// `feedback_growth` from below; default 0.5 means growth never drops + /// below 50% of baseline regardless of stress. + float max_suppression_clamp = 0.5f; + + /// Ceiling on the stiffness multiplier. Default 4.0 means EI can rise to + /// at most 4x baseline regardless of stress history. + float max_hardening_clamp = 4.0f; + + /// True iff the policy has any effect across rebuilds. Requires non-zero + /// reference stress, non-zero EMA alpha, and at least one non-zero gain. + /// When false, callers MUST short-circuit and use baseline EI / kappa. + [[nodiscard]] bool IsActive() const { + if (sigma_reference_Pa <= 0.0f) + return false; + if (time_ema_alpha <= 0.0f) + return false; + return growth_suppression_gain != 0.0f || stiffness_hardening_gain != 0.0f; + } + + /// Growth multiplier in (clamp, 1] for the supplied stress sample. + /// `exp(-gain * sigma_norm)` saturates smoothly to 0; the explicit clamp + /// keeps geometry sane under unphysical sigma spikes. + [[nodiscard]] float feedback_growth(const float sigma_Pa) const { + if (growth_suppression_gain == 0.0f) + return 1.0f; + const float sigma_norm = sigma_Pa / sigma_reference_Pa; + const float raw = std::exp(-growth_suppression_gain * std::abs(sigma_norm)); + return std::clamp(raw, max_suppression_clamp, 1.0f); + } + + /// Stiffness multiplier in [1, ceiling] for the supplied stress-history + /// sample. `1 + gain * sigma_ema_norm` rises linearly; the explicit clamp + /// caps unbounded hardening. + [[nodiscard]] float feedback_stiffness(const float sigma_ema_Pa) const { + if (stiffness_hardening_gain == 0.0f) + return 1.0f; + const float sigma_norm = sigma_ema_Pa / sigma_reference_Pa; + const float raw = 1.0f + stiffness_hardening_gain * std::abs(sigma_norm); + return std::clamp(raw, 1.0f, max_hardening_clamp); + } + + /// Update an EMA buffer in place from a fresh per-station |sigma| sample + /// vector. Resizes the EMA buffer if empty (first-touch initialization + /// from the raw sample, no blending). Optionally box-smooths the raw + /// samples in arc length first. + void UpdateEma(const std::vector& sigma_samples_Pa, std::vector& ema_buffer_Pa) const { + const std::size_t N = sigma_samples_Pa.size(); + if (N == 0) + return; + + // Optional arc-length smoothing (box filter over normalized window). + std::vector smoothed; + const std::vector* src = &sigma_samples_Pa; + if (arc_length_smoothing_window > 0.0f && N > 2) { + smoothed.assign(N, 0.0f); + const float half_window = 0.5f * std::clamp(arc_length_smoothing_window, 0.0f, 1.0f); + const int half_stations = std::max(1, static_cast(std::round(half_window * static_cast(N - 1)))); + for (std::size_t i = 0; i < N; ++i) { + const int lo = std::max(0, static_cast(i) - half_stations); + const int hi = std::min(static_cast(N) - 1, static_cast(i) + half_stations); + float acc = 0.0f; + int cnt = 0; + for (int j = lo; j <= hi; ++j) { + acc += sigma_samples_Pa[static_cast(j)]; + ++cnt; + } + smoothed[i] = acc / static_cast(cnt); + } + src = &smoothed; + } + + if (ema_buffer_Pa.size() != N) { + ema_buffer_Pa = *src; // first-touch: seed EMA from raw sample. + return; + } + const float a = std::clamp(time_ema_alpha, 0.0f, 1.0f); + for (std::size_t i = 0; i < N; ++i) { + ema_buffer_Pa[i] = (1.0f - a) * ema_buffer_Pa[i] + a * (*src)[i]; + } + } +}; + +} // namespace l_system_package \ No newline at end of file diff --git a/EvoEngine_Packages/LSystem/include/StringExport.hpp b/EvoEngine_Packages/LSystem/include/StringExport.hpp new file mode 100644 index 00000000..a9d2e85e --- /dev/null +++ b/EvoEngine_Packages/LSystem/include/StringExport.hpp @@ -0,0 +1,216 @@ +#pragma once + +#include +#include +#include +#include +#include +#include "LSystemGraph.hpp" + +namespace l_system_package { + +/** + * @brief A single module in an exported L-system string. + */ +struct ExportedModule { + int symbol_id = -1; ///< Module symbol ID. + std::vector params; ///< Flattened float parameters. + bool is_branch_start = false; ///< True if this is a '[' bracket. + bool is_branch_end = false; ///< True if this is a ']' bracket. +}; + +/** + * @brief A linearised L-system string exported from an LSystemGraph. + * + * Produced by DFS traversal of the graph, inserting '[' and ']' bracket + * modules at branch points. This is a read-only export - modifying it + * does not affect the source graph. + */ +struct ExportedString { + std::vector modules; + + /** + * @brief Convert to human-readable text representation. + * @param symbol_names Optional function mapping symbol_id -> name string. + * If nullptr, uses the numeric ID. + * @return String like "F(1.0) [ +(30.0) F(0.5) ] F(0.8)" + */ + [[nodiscard]] std::string ToText(std::function symbol_names = nullptr) const; + + /** + * @brief Convert to a flat token sequence for ML consumption. + * + * Format: each module becomes [symbol_id, param_count, param0, param1, ...]. + * Branch start = special token (-1), branch end = special token (-2). + * + * @return Vector of integer tokens (float params quantised to int via + * multiplier). + */ + [[nodiscard]] std::vector ToTokens(float quantisation_scale = 100.0f) const; + + /** + * @brief Total number of non-bracket modules. + */ + [[nodiscard]] int ModuleCount() const; +}; + +/** + * @brief Export an LSystemGraph to a linear ExportedString via DFS traversal. + * + * Traverses the graph depth-first, emitting modules in order with '[' and ']' + * bracket modules at branch points. + * + * @tparam GraphData Graph-wide data type. + * @tparam FlowData Per-flow data type. + * @tparam ModuleData Per-node module data type. + * @param graph The graph to export. + * @param param_extractor Function that extracts float parameters from a node's + * ModuleData. If nullptr, no parameters are exported. + * @return The exported string. + */ +template +ExportedString ExportString( + const LSystemGraph& graph, + std::function(int symbol_id, const ModuleData& data)> param_extractor = nullptr) { + ExportedString result; + + const auto& base_nodes = graph.PeekRawNodes(); + if (base_nodes.empty()) + return result; + + // DFS traversal with bracket insertion. + // We traverse from each base (root) node. + struct StackEntry { + LNodeHandle handle; + int child_index; // Which child we're about to visit next. + bool emitted; // Whether we've emitted this node's module. + }; + + // Find root nodes (nodes with no parent). + std::vector roots; + for (const auto& node : base_nodes) { + if (node.GetParentHandle() == -1) + roots.push_back(node.GetHandle()); + } + + for (const auto& root_handle : roots) { + std::stack stack; + stack.push({root_handle, 0, false}); + + while (!stack.empty()) { + auto& top = stack.top(); + const auto& node = graph.PeekNode(top.handle); + + if (!top.emitted) { + // Emit this module. + ExportedModule mod; + mod.symbol_id = node.symbol_id; + if (param_extractor) { + mod.params = param_extractor(node.symbol_id, node.data); + } + result.modules.push_back(std::move(mod)); + top.emitted = true; + } + + const auto& children = node.PeekChildHandles(); + if (top.child_index < static_cast(children.size())) { + auto child_handle = children[top.child_index]; + top.child_index++; + + // If this node has multiple children, non-first children are branches. + if (children.size() > 1 && top.child_index > 1) { + // Emit branch start. + ExportedModule sb; + sb.is_branch_start = true; + sb.symbol_id = -1; + result.modules.push_back(sb); + } + + stack.push({child_handle, 0, false}); + } else { + // All children visited. If we opened branches, close them. + stack.pop(); + + // If this node was a branch child (not the first child), emit branch end. + if (!stack.empty()) { + const auto& parent_entry = stack.top(); + const auto& parent_node = graph.PeekNode(parent_entry.handle); + const auto& parent_children = parent_node.PeekChildHandles(); + // We just finished visiting a child. If parent has multiple children + // and this wasn't the first child (apical continuation), close bracket. + if (parent_children.size() > 1 && parent_entry.child_index > 1) { + ExportedModule eb; + eb.is_branch_end = true; + eb.symbol_id = -2; + result.modules.push_back(eb); + } + } + } + } + } + + return result; +} + +// ============================================================================= +// Inline implementations for ExportedString +// ============================================================================= + +inline std::string ExportedString::ToText(std::function symbol_names) const { + std::ostringstream oss; + for (size_t i = 0; i < modules.size(); i++) { + const auto& mod = modules[i]; + if (i > 0) + oss << " "; + if (mod.is_branch_start) { + oss << "["; + } else if (mod.is_branch_end) { + oss << "]"; + } else { + if (symbol_names) { + oss << symbol_names(mod.symbol_id); + } else { + oss << "M" << mod.symbol_id; + } + if (!mod.params.empty()) { + oss << "("; + for (size_t p = 0; p < mod.params.size(); p++) { + if (p > 0) + oss << ","; + oss << mod.params[p]; + } + oss << ")"; + } + } + } + return oss.str(); +} + +inline std::vector ExportedString::ToTokens(float quantisation_scale) const { + std::vector tokens; + for (const auto& mod : modules) { + if (mod.is_branch_start) { + tokens.push_back(-1); + } else if (mod.is_branch_end) { + tokens.push_back(-2); + } else { + tokens.push_back(mod.symbol_id); + tokens.push_back(static_cast(mod.params.size())); + for (float p : mod.params) { + tokens.push_back(static_cast(p * quantisation_scale)); + } + } + } + return tokens; +} + +inline int ExportedString::ModuleCount() const { + int count = 0; + for (const auto& mod : modules) { + if (!mod.is_branch_start && !mod.is_branch_end) + count++; + } + return count; +} + +} // namespace l_system_package \ No newline at end of file diff --git a/EvoEngine_Packages/LSystem/src/DistributionDefaults.cpp b/EvoEngine_Packages/LSystem/src/DistributionDefaults.cpp new file mode 100644 index 00000000..686f189e --- /dev/null +++ b/EvoEngine_Packages/LSystem/src/DistributionDefaults.cpp @@ -0,0 +1,107 @@ +#include "DistributionDefaults.hpp" + +#include + +#include + +using namespace l_system_package; +using namespace evo_engine; + +namespace { + +void SetCurveLinearRange(Curve2D& curve, const float y_start, const float y_end, const int sample_count) { + curve.SetTangent(false); + auto& values = curve.UnsafeGetValues(); + values.clear(); + + const int safe_samples = std::max(2, sample_count); + const float clamped_start = std::clamp(y_start, 0.0f, 1.0f); + const float clamped_end = std::clamp(y_end, 0.0f, 1.0f); + + for (int i = 0; i < safe_samples; ++i) { + const float x = static_cast(i) / static_cast(safe_samples - 1); + const float y = clamped_start + (clamped_end - clamped_start) * x; + values.emplace_back(x, y); + } +} + +} // namespace + +void DistributionDefaults::SetCurveLinear01(Curve2D& curve, const int sample_count) { + SetCurveLinearRange(curve, 0.0f, 1.0f, sample_count); +} + +void DistributionDefaults::SetCurveFlat(Curve2D& curve, const float y_value, const int sample_count) { + SetCurveLinearRange(curve, y_value, y_value, sample_count); +} + +void DistributionDefaults::ApplyMeanPlotDefaults(Plot2D& plot) { + plot.min_value = 0.0f; + plot.max_value = 1.0f; + SetCurveLinear01(plot.curve); +} + +void DistributionDefaults::ApplyStdPlotDefaults(Plot2D& plot) { + plot.min_value = 0.0f; + plot.max_value = 0.0f; + SetCurveFlat(plot.curve, 0.0f); +} + +void DistributionDefaults::ApplyMeanStdPlotDefaults(PlottedDistribution& distribution) { + ApplyMeanPlotDefaults(distribution.mean); + ApplyStdPlotDefaults(distribution.deviation); +} + +void DistributionDefaults::ApplyLinearGrowthCurveDefaults(PlottedDistribution& distribution, + const float mean_start, const float mean_end, + const int sample_count) { + distribution.mean.min_value = 0.0f; + distribution.mean.max_value = 1.0f; + SetCurveLinearRange(distribution.mean.curve, mean_start, mean_end, sample_count); + + distribution.deviation.min_value = 0.0f; + distribution.deviation.max_value = 0.0f; + SetCurveFlat(distribution.deviation.curve, 0.0f); +} + +void DistributionDefaults::ApplySingleDefaults(SingleDistribution& distribution, const float mean, + const float deviation) { + distribution.mean = mean; + distribution.deviation = std::max(0.0f, deviation); +} + +SingleDistribution DistributionDefaults::MakeSingleDefaults(const float mean, const float deviation) { + SingleDistribution distribution{}; + ApplySingleDefaults(distribution, mean, deviation); + return distribution; +} + +evo_engine::PlottedDistributionSettings DistributionDefaults::MakePlottedGuiSettings(const std::string& tip) { + evo_engine::PlottedDistributionSettings settings; + settings.tip = tip; + settings.mean_settings.m_tip = "Mean response curve. x is normalized axis [0, 1]."; + settings.dev_settings.m_tip = "Standard deviation (sigma) curve. Zero keeps deterministic behavior."; + return settings; +} + +bool DistributionDefaults::InspectPlottedDistributionCategory( + const char* category_label, const std::initializer_list entries, + const int tree_node_flags) { + if (!category_label || category_label[0] == '\0') { + return false; + } + + bool changed = false; + if (ImGui::TreeNodeEx(category_label, static_cast(tree_node_flags))) { + for (const auto& entry : entries) { + if (!entry.distribution || !entry.label || entry.label[0] == '\0') { + continue; + } + const std::string tip = entry.tip ? entry.tip : ""; + changed |= entry.distribution->OnInspect(entry.label, MakePlottedGuiSettings(tip)); + } + ImGui::TreePop(); + } + + return changed; +} \ No newline at end of file diff --git a/EvoEngine_Packages/LSystem/src/LSystemDescriptor.cpp b/EvoEngine_Packages/LSystem/src/LSystemDescriptor.cpp new file mode 100644 index 00000000..79b7887f --- /dev/null +++ b/EvoEngine_Packages/LSystem/src/LSystemDescriptor.cpp @@ -0,0 +1,126 @@ +#include "LSystemDescriptor.hpp" +#include +#include +#include +#include "LSystemDescriptorDefaults.hpp" + +using namespace l_system_package; +using namespace evo_engine; + +namespace { + +constexpr char kLSystemDescriptorName[] = "LSystemDescriptor"; + +// LSystem-only candidates. DigitalAgricultureProject paths intentionally +// removed so this package has no implicit data-path dependency on the +// DigitalAgriculture/EcoSysLab tree. +const std::array kLSystemResourceCandidates = { + std::filesystem::path("./LSystemResources/Defaults/LSystemDescriptor_Default.lsys"), + std::filesystem::path("./EvoEngine_Packages/LSystem/Internals/LSystemResources/Defaults/") / + "LSystemDescriptor_Default.lsys", + std::filesystem::path("./EvoEngine_Plugins/LSystem/Internals/LSystemResources/Defaults/") / + "LSystemDescriptor_Default.lsys", + std::filesystem::path("./04_EvoEngine/EvoEngine_Packages/LSystem/Internals/") / + "LSystemResources/Defaults/LSystemDescriptor_Default.lsys"}; + +const std::array kLSystemProjectAssetCandidates = { + std::filesystem::path("LSystem") / "New LSystemDescriptor.lsys", "New LSystemDescriptor.lsys"}; + +const std::array kLSystemWritableTemplateCandidates = { + std::filesystem::path("./EvoEngine_Packages/LSystem/Internals/LSystemResources/Defaults/") / + "LSystemDescriptor_Default.lsys"}; + +const std::filesystem::path kLSystemFallbackDefaultsPath = + std::filesystem::path("./LSystemResources/Defaults/LSystemDescriptor_Default.lsys"); + +std::filesystem::path ResolveDefaultLSystemDescriptorPath() { + return descriptor_defaults::ResolveExistingDefaultsPath(kLSystemResourceCandidates, kLSystemProjectAssetCandidates); +} + +std::filesystem::path ResolveWritableLSystemDescriptorDefaultsPath() { + return descriptor_defaults::ResolveWritableDefaultsPath(kLSystemResourceCandidates, kLSystemProjectAssetCandidates, + kLSystemWritableTemplateCandidates, + kLSystemFallbackDefaultsPath); +} + +bool LoadLSystemDescriptorDefaultsFromFile(LSystemDescriptor& descriptor, const std::filesystem::path& file_path) { + YAML::Node defaults; + if (!descriptor_defaults::LoadDefaultsYamlMap(file_path, defaults, kLSystemDescriptorName)) { + return false; + } + descriptor.Deserialize(defaults); + return true; +} + +} // namespace + +LSystemDescriptor::LSystemDescriptor() { + const auto defaults_path = ResolveDefaultLSystemDescriptorPath(); + if (!LoadLSystemDescriptorDefaultsFromFile(*this, defaults_path)) { + static bool warned_once = false; + if (!warned_once) { + warned_once = true; + EVOENGINE_WARNING("LSystemDescriptor defaults file not found or invalid. Using inline member defaults."); + } + } +} + +bool LSystemDescriptor::OnInspect(const std::shared_ptr& editor_layer) { + bool changed = false; + + if (ImGui::DragInt("Derivation Steps", &derivation_steps, 1, 0, 100)) + changed = true; + + int seed_int = static_cast(seed); + if (ImGui::DragInt("Seed", &seed_int, 1, 0, 999999)) { + seed = static_cast(seed_int); + changed = true; + } + + if (ImGui::DragFloat3("Root Position", &root_position.x, 0.1f)) + changed = true; + + glm::vec3 euler = glm::degrees(glm::eulerAngles(root_rotation)); + if (ImGui::DragFloat3("Root Rotation (deg)", &euler.x, 1.0f)) { + root_rotation = glm::quat(glm::radians(euler)); + changed = true; + } + + if (ImGui::DragFloat("Default Length", &default_length, 0.01f, 0.001f, 100.0f)) + changed = true; + + if (ImGui::DragFloat("Default Thickness", &default_thickness, 0.001f, 0.001f, 10.0f)) + changed = true; + + if (ImGui::Checkbox("Auto Derive on Change", &auto_derive_on_change)) + changed = true; + + return changed; +} + +void LSystemDescriptor::Serialize(YAML::Emitter& out) const { + out << YAML::Key << "derivation_steps" << YAML::Value << derivation_steps; + out << YAML::Key << "seed" << YAML::Value << seed; + out << YAML::Key << "root_position" << YAML::Value << root_position; + out << YAML::Key << "root_rotation" << YAML::Value << root_rotation; + out << YAML::Key << "default_length" << YAML::Value << default_length; + out << YAML::Key << "default_thickness" << YAML::Value << default_thickness; + out << YAML::Key << "auto_derive_on_change" << YAML::Value << auto_derive_on_change; +} + +void LSystemDescriptor::Deserialize(const YAML::Node& in) { + if (in["derivation_steps"]) + derivation_steps = in["derivation_steps"].as(); + if (in["seed"]) + seed = in["seed"].as(); + if (in["root_position"]) + root_position = in["root_position"].as(); + if (in["root_rotation"]) + root_rotation = in["root_rotation"].as(); + if (in["default_length"]) + default_length = in["default_length"].as(); + if (in["default_thickness"]) + default_thickness = in["default_thickness"].as(); + if (in["auto_derive_on_change"]) + auto_derive_on_change = in["auto_derive_on_change"].as(); +} \ No newline at end of file diff --git a/EvoEngine_Packages/LSystem/src/LSystemDescriptorDefaults.cpp b/EvoEngine_Packages/LSystem/src/LSystemDescriptorDefaults.cpp new file mode 100644 index 00000000..83bae0c3 --- /dev/null +++ b/EvoEngine_Packages/LSystem/src/LSystemDescriptorDefaults.cpp @@ -0,0 +1,31 @@ +#include "LSystemDescriptorDefaults.hpp" + +#include + +#include +#include + +using namespace l_system_package; + +bool descriptor_defaults::LoadDefaultsYamlMap(const std::filesystem::path& file_path, YAML::Node& out_defaults, + const std::string& descriptor_name_for_logging) { + if (file_path.empty() || !std::filesystem::exists(file_path)) { + return false; + } + + try { + const std::ifstream stream(file_path.string()); + std::stringstream string_stream; + string_stream << stream.rdbuf(); + const YAML::Node defaults = YAML::Load(string_stream.str()); + if (!defaults || !defaults.IsMap()) { + return false; + } + out_defaults = defaults; + return true; + } catch (const std::exception& e) { + EVOENGINE_WARNING("Failed to load " + descriptor_name_for_logging + " defaults from " + file_path.string() + ": " + + std::string(e.what())); + return false; + } +} \ No newline at end of file diff --git a/EvoEngine_Packages/LSystem/src/LSystemLayer.cpp b/EvoEngine_Packages/LSystem/src/LSystemLayer.cpp new file mode 100644 index 00000000..bd14723b --- /dev/null +++ b/EvoEngine_Packages/LSystem/src/LSystemLayer.cpp @@ -0,0 +1,565 @@ +#include "LSystemLayer.hpp" + +#include "Application.hpp" +#include "EditorLayer.hpp" +#include "Scene.hpp" +#include "ScotsPine.hpp" +#include "ScotsPineDescriptor.hpp" +#include "Times.hpp" + +#include +#include +#include +#include +#include +#include +#include + +using namespace l_system_package; +using namespace evo_engine; + +namespace { + +template +double AverageProfileMetric(const std::vector& frames, Selector selector) { + if (frames.empty()) { + return 0.0; + } + double sum = 0.0; + for (const auto& frame : frames) { + sum += selector(frame); + } + return sum / static_cast(frames.size()); +} + +template +double MaxProfileMetric(const std::vector& frames, Selector selector) { + double max_value = 0.0; + for (const auto& frame : frames) { + max_value = std::max(max_value, selector(frame)); + } + return max_value; +} + +float SampleDescriptorTargetGddForPine(ScotsPine& pine) { + auto descriptor = pine.descriptor_ref.Get(); + if (pine.enable_repot_profile_switch && pine.growth_model.IsPostRepotProfileActive()) { + if (const auto post_descriptor = pine.post_repot_descriptor_ref.Get()) { + descriptor = post_descriptor; + } + } + if (!descriptor) { + return -1.0f; + } + + std::mt19937 rng(pine.seed); + return std::max(0.0f, SampleDistribution(descriptor->target_gdd, rng)); +} + +float NormalizeDayOfYear(float day) { + if (!std::isfinite(day)) { + return 0.0f; + } + day = std::fmod(day, 365.0f); + if (day < 0.0f) { + day += 365.0f; + } + return day; +} + +bool IsInActiveSeason(const float simulation_day_of_year, const int season_start_day, const int season_end_day) { + const int day = static_cast(std::floor(NormalizeDayOfYear(simulation_day_of_year))); + const int start = std::clamp(season_start_day, 0, 364); + const int end = std::clamp(season_end_day, 0, 364); + if (start <= end) { + return day >= start && day <= end; + } + return day >= start || day <= end; +} + +int ClampColorModeIndex(const int mode) { + return std::clamp(mode, 0, 6); +} + +int ResolveEffectiveColorMode(const int selected_mode, const bool scene_plant_view_tint_enabled) { + return scene_plant_view_tint_enabled ? ClampColorModeIndex(selected_mode) : 0; +} + +void ApplyGlobalPlantColorMode(const int selected_mode, const bool scene_plant_view_tint_enabled) { + const int effective_mode = ResolveEffectiveColorMode(selected_mode, scene_plant_view_tint_enabled); + ScotsPine::SetGlobalColorMode(static_cast(effective_mode)); +} + +void ApplyPineStemOnlyMode(const std::shared_ptr& scene, const bool stem_only_mode, + const bool regenerate_existing_pines) { + ScotsPine::SetGenerateNeedleTopologyEnabled(!stem_only_mode); + + if (!regenerate_existing_pines || !scene) { + return; + } + + if (const auto* pine_entities_ptr = scene->UnsafeGetPrivateComponentOwnersList()) { + const std::vector pine_entities = *pine_entities_ptr; + for (const auto& entity : pine_entities) { + if (!scene->IsEntityValid(entity)) { + continue; + } + if (const auto pine = scene->GetOrSetPrivateComponent(entity).lock()) { + pine->GrowToTargetGDD(); + } + } + } +} + +void RebuildAllPlantGeometry(const std::shared_ptr& scene) { + if (!scene) { + return; + } + + if (const auto* pine_entities_ptr = scene->UnsafeGetPrivateComponentOwnersList()) { + const std::vector pine_entities = *pine_entities_ptr; + for (const auto& entity : pine_entities) { + if (!scene->IsEntityValid(entity)) { + continue; + } + if (const auto pine = scene->GetOrSetPrivateComponent(entity).lock()) { + pine->RebuildGeometry(); + } + } + } +} + +struct PineTemporalSample { + float gdd_per_day = 2.0f; + int season_start_day = 60; + int season_end_day = 334; +}; + +PineTemporalSample SamplePineTemporalParameters(ScotsPine& pine) { + PineTemporalSample sample; + auto descriptor = pine.descriptor_ref.Get(); + if (pine.enable_repot_profile_switch && pine.growth_model.IsPostRepotProfileActive()) { + if (const auto post_descriptor = pine.post_repot_descriptor_ref.Get()) { + descriptor = post_descriptor; + } + } + if (!descriptor) { + return sample; + } + + std::mt19937 rng(static_cast(pine.seed) ^ 0x5f3759dfu); + sample.gdd_per_day = std::max(0.0f, SampleDistribution(descriptor->gdd_per_day, rng)); + + const auto sample_day = [&](const SingleDistribution& distribution) { + const float sampled_day = std::clamp(SampleDistribution(distribution, rng), 0.0f, 365.0f); + return static_cast(std::floor(NormalizeDayOfYear(std::round(sampled_day)))); + }; + + sample.season_start_day = sample_day(descriptor->growing_season_start_day); + sample.season_end_day = sample_day(descriptor->growing_season_end_day); + return sample; +} + +} // namespace + +void LSystemLayer::OnCreate() { + simulation_day_of_year = NormalizeDayOfYear(static_cast(std::clamp(season_start_day, 0, 364))); + ApplyGlobalPlantColorMode(tassel_color_mode, scene_plant_view_tint_enabled); + ApplyPineStemOnlyMode(GetScene(), pine_stem_only_mode, true); +} + +void LSystemLayer::OnDestroy() { +} + +void LSystemLayer::PushProfileFrame(const ProfileFrame& frame) { + if (profiling_history_size <= 0) { + return; + } + if (profiling_history.size() >= static_cast(profiling_history_size)) { + profiling_history.erase(profiling_history.begin()); + } + profiling_history.push_back(frame); +} + +void LSystemLayer::ExportProfileCsv(const std::string& path) const { + std::ofstream out(path, std::ios::trunc); + if (!out.is_open()) { + return; + } + + out << "frame,update_ms,grow_ms,rebuild_ms,pines,growth_steps,nodes,internodes,needles,invalid_instances\n"; + for (size_t i = 0; i < profiling_history.size(); i++) { + const auto& f = profiling_history[i]; + out << i << "," << std::fixed << std::setprecision(4) << f.update_ms << "," << f.grow_ms << "," << f.rebuild_ms + << "," << f.pine_count << "," << f.growth_steps << "," << f.node_count << "," << f.internode_count << "," + << f.needle_count << "," << f.invalid_instance_count << "\n"; + } +} + +void LSystemLayer::Update() { + auto& times = GetApplication().GetTimes(); + const double update_start = times.Now(); + ProfileFrame frame{}; + + if (!auto_grow) { + return; + } + + const auto scene = GetScene(); + if (!scene) { + return; + } + + const float dt = static_cast(times.DeltaTime()); + if (dt > 0.0f) { + const float fps = 1.0f / dt; + if (fps < kAutoGrowFailsafeMinFps) { + auto_grow = false; + fps_failsafe_tripped_ = true; + last_failsafe_fps_ = fps; + return; + } + } + + const float delta_days = std::max(0.0f, chronological_days_per_second) * std::max(0.0f, dt); + if (seasonality_enabled && delta_days > 0.0f) { + simulation_day_of_year = NormalizeDayOfYear(simulation_day_of_year + delta_days); + } + const float delta_years = seasonality_enabled ? (delta_days / 365.0f) : 0.0f; + + if (const auto* pine_entities_ptr = scene->UnsafeGetPrivateComponentOwnersList()) { + const std::vector pine_entities = *pine_entities_ptr; + for (const auto& entity : pine_entities) { + if (!scene->IsEntityValid(entity)) { + continue; + } + + auto pine = scene->GetOrSetPrivateComponent(entity).lock(); + if (!pine) { + continue; + } + frame.pine_count++; + + const PineTemporalSample pine_temporal = SamplePineTemporalParameters(*pine); + const bool pine_in_active_season = + !seasonality_enabled || + IsInActiveSeason(simulation_day_of_year, pine_temporal.season_start_day, pine_temporal.season_end_day); + const float pine_delta_gdd = + pine_in_active_season ? std::max(0.0f, pine_temporal.gdd_per_day) * delta_days : 0.0f; + + const float pine_season_days = + (pine_temporal.season_end_day >= pine_temporal.season_start_day) + ? static_cast(pine_temporal.season_end_day - pine_temporal.season_start_day + 1) + : static_cast(365 - pine_temporal.season_start_day + pine_temporal.season_end_day + 1); + const float pine_season_length_years = std::max(1e-3f, pine_season_days / 365.0f); + + pine->SetSeasonalChronologicalMode(seasonality_enabled); + + if (seasonality_enabled && delta_years > 0.0f) { + if (pine_in_active_season) { + if (!pine->growth_model.IsInitialized()) { + if (const auto descriptor = pine->descriptor_ref.Get()) { + std::shared_ptr post_descriptor = nullptr; + float repot_switch_gdd = -1.0f; + if (pine->enable_repot_profile_switch) { + post_descriptor = pine->post_repot_descriptor_ref.Get(); + if (post_descriptor) { + repot_switch_gdd = std::max(0.0f, pine->repot_switch_gdd); + } + } + + pine->growth_model.Initialize(*descriptor, pine->seed, glm::vec3(0), kDefaultRootRotation, + post_descriptor.get(), repot_switch_gdd, + ScotsPine::IsGenerateNeedleTopologyEnabled()); + } + } + pine->growth_model.AdvanceChronologicalYears(delta_years); + } else { + pine->AdvanceChronologicalAging(delta_years); + } + } + + if (!pine_in_active_season) { + if (pine->growth_model.IsInitialized()) { + pine->growth_model.graph.data.clock.SyncSeasonalState(seasonality_enabled, pine_in_active_season, + pine_season_length_years); + } + continue; + } + + if (pine->growth_model.IsInitialized()) { + pine->growth_model.graph.data.clock.SyncSeasonalState(seasonality_enabled, pine_in_active_season, + pine_season_length_years); + } + + const float descriptor_target_gdd = SampleDescriptorTargetGddForPine(*pine); + const float next_target_gdd = std::max(0.0f, pine->target_gdd + pine_delta_gdd); + pine->target_gdd = + descriptor_target_gdd >= 0.0f ? std::min(next_target_gdd, descriptor_target_gdd) : next_target_gdd; + + pine->GrowToTargetGDD(); + + if (profiling_enabled) { + frame.grow_ms += pine->last_grow_seconds * 1000.0; + frame.rebuild_ms += pine->last_rebuild_seconds * 1000.0; + frame.growth_steps += pine->growth_model.last_growth_steps; + frame.node_count += pine->last_node_count; + frame.internode_count += pine->last_internode_count; + frame.needle_count += pine->last_needle_count; + frame.invalid_instance_count += pine->last_invalid_instance_count; + } + } + } + + if (profiling_enabled) { + frame.update_ms = (times.Now() - update_start) * 1000.0; + last_profile_frame = frame; + PushProfileFrame(frame); + } +} + +void LSystemLayer::OnInspect(const std::shared_ptr& editor_layer) { + auto reset_all_lsystems = [this]() { + const auto scene = GetScene(); + if (!scene) { + return; + } + + unsigned int base_seed = 0u; + if (reseed_on_reset) { + base_seed = static_cast(std::chrono::steady_clock::now().time_since_epoch().count() & 0xffffffffu); + } + unsigned int seed_offset = 0u; + + if (const auto* pine_entities_ptr = scene->UnsafeGetPrivateComponentOwnersList()) { + const std::vector pine_entities = *pine_entities_ptr; + for (const auto& entity : pine_entities) { + if (!scene->IsEntityValid(entity)) { + continue; + } + auto pine = scene->GetOrSetPrivateComponent(entity).lock(); + if (!pine) { + continue; + } + + if (reseed_on_reset) { + pine->seed = base_seed + seed_offset++; + } + // Hard reset for Ctrl+W: clear all generated geometry and leave the + // plant at zero thermal target (no warm-start regrowth). + pine->target_gdd = 0.0f; + pine->growth_model.Reset(); + pine->ClearGeometryEntities(); + } + } + + simulation_day_of_year = NormalizeDayOfYear(static_cast(std::clamp(season_start_day, 0, 364))); + }; + + const auto left_ctrl_state = EditorLayer::GetKey(GLFW_KEY_LEFT_CONTROL); + const auto right_ctrl_state = EditorLayer::GetKey(GLFW_KEY_RIGHT_CONTROL); + const bool ctrl_down = + left_ctrl_state == Input::KeyActionType::Hold || left_ctrl_state == Input::KeyActionType::Press || + right_ctrl_state == Input::KeyActionType::Hold || right_ctrl_state == Input::KeyActionType::Press; + + if (ctrl_down) { + if (EditorLayer::GetKey(GLFW_KEY_F) == Input::KeyActionType::Press) { + auto_grow = !auto_grow; + if (auto_grow) { + fps_failsafe_tripped_ = false; + last_failsafe_fps_ = 0.0f; + } + } + if (EditorLayer::GetKey(GLFW_KEY_W) == Input::KeyActionType::Press) { + auto& app = GetApplication(); + if (app.IsPlaying()) { + app.Stop(); + } + auto_grow = false; + reset_all_lsystems(); + } + } + + if (ImGui::Checkbox("Auto-Grow (Ctrl+F)", &auto_grow) && auto_grow) { + fps_failsafe_tripped_ = false; + last_failsafe_fps_ = 0.0f; + } + ImGui::TextDisabled("Thermal rates are descriptor-owned and sampled per pine."); + + ImGui::SeparatorText("Seasonality"); + ImGui::Checkbox("Enable Calendar Seasonality", &seasonality_enabled); + if (ImGui::DragInt("Season Start Day", &season_start_day, 1.0f, 0, 364)) { + season_start_day = std::clamp(season_start_day, 0, 364); + simulation_day_of_year = NormalizeDayOfYear(static_cast(season_start_day)); + } + ImGui::DragInt("Season End Day", &season_end_day, 1.0f, 0, 364); + ImGui::DragFloat("Calendar Days/sec (Chronology)", &chronological_days_per_second, 0.25f, 0.0f, 365.0f, "%.2f"); + if (ImGui::DragFloat("Simulation Day Of Year", &simulation_day_of_year, 0.25f, 0.0f, 364.999f, "%.2f")) { + simulation_day_of_year = NormalizeDayOfYear(simulation_day_of_year); + } + const bool inspector_active_season = IsInActiveSeason(simulation_day_of_year, season_start_day, season_end_day); + ImGui::Text("Season State: %s", inspector_active_season ? "Active" : "Dormant"); + + ImGui::Checkbox("Reseed on Reset (Ctrl+W when stopped)", &reseed_on_reset); + if (fps_failsafe_tripped_) { + ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.2f, 1.0f), "Auto-grow stopped by 1 FPS failsafe (last: %.2f FPS).", + last_failsafe_fps_); + ImGui::TextDisabled("Re-enable Auto-Grow to resume growth."); + } + + if (ImGui::Checkbox("Scots Pine Stem-Only Mode", &pine_stem_only_mode)) { + ApplyPineStemOnlyMode(GetScene(), pine_stem_only_mode, true); + } + + if (ImGui::Checkbox("Enable Scene/Plant View Tint", &scene_plant_view_tint_enabled)) { + ApplyGlobalPlantColorMode(tassel_color_mode, scene_plant_view_tint_enabled); + RebuildAllPlantGeometry(GetScene()); + } + if (!scene_plant_view_tint_enabled) { + ImGui::TextDisabled("Tint disabled: effective mode forced to Shaded."); + } + + { + const char* color_mode_items[] = { + "Shaded", "By Type", "By Instance", "By Node", "Needle Lignification", "Needle Stripe Proxy", "Needle Sheath"}; + if (ImGui::Combo("Plant Color Mode", &tassel_color_mode, color_mode_items, IM_ARRAYSIZE(color_mode_items))) { + tassel_color_mode = ClampColorModeIndex(tassel_color_mode); + ApplyGlobalPlantColorMode(tassel_color_mode, scene_plant_view_tint_enabled); + RebuildAllPlantGeometry(GetScene()); + } + } + + if (ImGui::Button("Reset Plant Color View (Shaded)")) { + tassel_color_mode = 0; + scene_plant_view_tint_enabled = false; + ApplyGlobalPlantColorMode(tassel_color_mode, scene_plant_view_tint_enabled); + RebuildAllPlantGeometry(GetScene()); + } + + if (ImGui::Button("Reset All LSystems (Ctrl+W)")) { + reset_all_lsystems(); + } + + ImGui::Separator(); + ImGui::Checkbox("Enable LSystem Profiling", &profiling_enabled); + ImGui::DragInt("Profile History Size", &profiling_history_size, 1.0f, 30, 4000); + + static char export_path_buffer[260] = "lsystem_profile.csv"; + static std::string last_loaded_export_path; + if (last_loaded_export_path != profiling_export_path && profiling_export_path.size() < sizeof(export_path_buffer)) { + std::snprintf(export_path_buffer, sizeof(export_path_buffer), "%s", profiling_export_path.c_str()); + last_loaded_export_path = profiling_export_path; + } + if (ImGui::InputText("Profile CSV Path", export_path_buffer, sizeof(export_path_buffer))) { + profiling_export_path = export_path_buffer; + last_loaded_export_path = profiling_export_path; + } + + if (ImGui::Button("Export Profile CSV")) { + ExportProfileCsv(profiling_export_path); + } + if (ImGui::Button("Clear Profile History")) { + profiling_history.clear(); + } + + if (profiling_enabled) { + const double avg_update_ms = AverageProfileMetric(profiling_history, [](const ProfileFrame& f) { + return f.update_ms; + }); + const double avg_grow_ms = AverageProfileMetric(profiling_history, [](const ProfileFrame& f) { + return f.grow_ms; + }); + const double avg_rebuild_ms = AverageProfileMetric(profiling_history, [](const ProfileFrame& f) { + return f.rebuild_ms; + }); + + const double max_update_ms = MaxProfileMetric(profiling_history, [](const ProfileFrame& f) { + return f.update_ms; + }); + const double max_grow_ms = MaxProfileMetric(profiling_history, [](const ProfileFrame& f) { + return f.grow_ms; + }); + const double max_rebuild_ms = MaxProfileMetric(profiling_history, [](const ProfileFrame& f) { + return f.rebuild_ms; + }); + + ImGui::SeparatorText("LSystem Profiling (Rolling)"); + ImGui::Text("History Frames: %d", static_cast(profiling_history.size())); + ImGui::Text("Last Update: %.3f ms", last_profile_frame.update_ms); + ImGui::Text("Last Grow: %.3f ms", last_profile_frame.grow_ms); + ImGui::Text("Last Rebuild: %.3f ms", last_profile_frame.rebuild_ms); + ImGui::Text("Last Pines: %u", last_profile_frame.pine_count); + ImGui::Text("Last Growth Steps: %u", last_profile_frame.growth_steps); + ImGui::Text("Last Nodes/Internodes/Needles: %u / %u / %u", last_profile_frame.node_count, + last_profile_frame.internode_count, last_profile_frame.needle_count); + ImGui::Text("Last Invalid Instances: %u", last_profile_frame.invalid_instance_count); + + ImGui::SeparatorText("Averages / Maxima"); + ImGui::Text("Update ms avg/max: %.3f / %.3f", avg_update_ms, max_update_ms); + ImGui::Text("Grow ms avg/max: %.3f / %.3f", avg_grow_ms, max_grow_ms); + ImGui::Text("Rebuild ms avg/max: %.3f / %.3f", avg_rebuild_ms, max_rebuild_ms); + } +} + +void LSystemLayer::Serialize(YAML::Emitter& out) const { + out << YAML::Key << "auto_grow" << YAML::Value << auto_grow; + out << YAML::Key << "seasonality_enabled" << YAML::Value << seasonality_enabled; + out << YAML::Key << "season_start_day" << YAML::Value << season_start_day; + out << YAML::Key << "season_end_day" << YAML::Value << season_end_day; + out << YAML::Key << "chronological_days_per_second" << YAML::Value << chronological_days_per_second; + out << YAML::Key << "simulation_day_of_year" << YAML::Value << NormalizeDayOfYear(simulation_day_of_year); + out << YAML::Key << "reseed_on_reset" << YAML::Value << reseed_on_reset; + out << YAML::Key << "tassel_color_mode" << YAML::Value << tassel_color_mode; + out << YAML::Key << "scene_plant_view_tint_enabled" << YAML::Value << scene_plant_view_tint_enabled; + out << YAML::Key << "pine_stem_only_mode" << YAML::Value << pine_stem_only_mode; + out << YAML::Key << "profiling_enabled" << YAML::Value << profiling_enabled; + out << YAML::Key << "profiling_history_size" << YAML::Value << profiling_history_size; + out << YAML::Key << "profiling_export_path" << YAML::Value << profiling_export_path; +} + +void LSystemLayer::Deserialize(const YAML::Node& in) { + if (in["auto_grow"]) { + auto_grow = in["auto_grow"].as(); + } + if (in["seasonality_enabled"]) { + seasonality_enabled = in["seasonality_enabled"].as(); + } + if (in["season_start_day"]) { + season_start_day = std::clamp(in["season_start_day"].as(), 0, 364); + } + if (in["season_end_day"]) { + season_end_day = std::clamp(in["season_end_day"].as(), 0, 364); + } + if (in["chronological_days_per_second"]) { + chronological_days_per_second = std::max(0.0f, in["chronological_days_per_second"].as()); + } + simulation_day_of_year = NormalizeDayOfYear(static_cast(season_start_day)); + if (in["simulation_day_of_year"]) { + simulation_day_of_year = NormalizeDayOfYear(in["simulation_day_of_year"].as()); + } + if (in["reseed_on_reset"]) { + reseed_on_reset = in["reseed_on_reset"].as(); + } + if (in["tassel_color_mode"]) { + tassel_color_mode = ClampColorModeIndex(in["tassel_color_mode"].as()); + } + if (in["scene_plant_view_tint_enabled"]) { + scene_plant_view_tint_enabled = in["scene_plant_view_tint_enabled"].as(); + } + if (in["pine_stem_only_mode"]) { + pine_stem_only_mode = in["pine_stem_only_mode"].as(); + } + if (in["profiling_enabled"]) { + profiling_enabled = in["profiling_enabled"].as(); + } + if (in["profiling_history_size"]) { + profiling_history_size = std::max(30, in["profiling_history_size"].as()); + } + if (in["profiling_export_path"]) { + profiling_export_path = in["profiling_export_path"].as(); + } + + ApplyGlobalPlantColorMode(tassel_color_mode, scene_plant_view_tint_enabled); + ApplyPineStemOnlyMode(GetScene(), pine_stem_only_mode, true); + RebuildAllPlantGeometry(GetScene()); +} diff --git a/EvoEngine_Packages/LSystem/src/ParamSpaceExplorer.cpp b/EvoEngine_Packages/LSystem/src/ParamSpaceExplorer.cpp new file mode 100644 index 00000000..5c2b03bd --- /dev/null +++ b/EvoEngine_Packages/LSystem/src/ParamSpaceExplorer.cpp @@ -0,0 +1,674 @@ +#include "ParamSpaceExplorer.hpp" +#include +#include +#include +#include +#include +#include +#include +#include "ILSystemExplorableDescriptor.hpp" + +using namespace l_system_package; + +namespace { +constexpr const char* kModeNames[] = {"Stopped", "Lissajous", "Random Walk", "Linear Interp"}; +constexpr float kPi = 3.14159265358979f; +constexpr float kGoldenAngle = 2.39996323f; // radians + +std::string MakeCompactLabel(const std::string& s) { + std::string compact; + compact.reserve(4); + for (const char c : s) { + if (std::isalnum(static_cast(c))) { + compact.push_back(static_cast(std::toupper(static_cast(c)))); + if (compact.size() >= 4) { + break; + } + } + } + if (compact.empty()) { + compact = "AXIS"; + } + return compact; +} + +bool ContainsCaseInsensitive(const char* haystack, const char* needle) { + if (!haystack || !needle) { + return false; + } + if (*needle == '\0') { + return true; + } + + const std::string h(haystack); + const std::string n(needle); + auto to_lower = [](unsigned char c) { + return static_cast(std::tolower(c)); + }; + + std::string hl; + std::string nl; + hl.resize(h.size()); + nl.resize(n.size()); + std::transform(h.begin(), h.end(), hl.begin(), to_lower); + std::transform(n.begin(), n.end(), nl.begin(), to_lower); + return hl.find(nl) != std::string::npos; +} + +uint64_t HashCombine(uint64_t seed, uint64_t value) { + return seed ^ (value + 0x9e3779b97f4a7c15ULL + (seed << 6U) + (seed >> 2U)); +} + +void ExpandRange(float value, float& min_v, float& max_v) { + min_v = std::min(min_v, value); + max_v = std::max(max_v, value); +} + +void SymmetricRange(float min_source, float max_source, float& min_out, float& max_out, float min_span) { + float lo = std::min(min_source, max_source); + float hi = std::max(min_source, max_source); + const float span = std::max(min_span, hi - lo); + min_out = lo - span; + max_out = hi + span; +} +} // namespace + +// --------------------------------------------------------------------------- +// Bind +// --------------------------------------------------------------------------- + +void ParamSpaceExplorer::Bind(ILSystemExplorableDescriptor& d) { + bound_desc_ = &d; + RebuildAxes(); +} + +// --------------------------------------------------------------------------- +// Normalized helpers +// --------------------------------------------------------------------------- + +float ParamSpaceExplorer::GetNormalized(size_t i) const { + if (i >= axes_.size()) { + return 0.0f; + } + const auto& a = axes_[i]; + if (!a.getter) { + return 0.0f; + } + if (a.max_val <= a.min_val) + return 0.5f; + return (a.getter() - a.min_val) / (a.max_val - a.min_val); +} + +void ParamSpaceExplorer::SetNormalized(size_t i, float t01) { + if (i >= axes_.size()) { + return; + } + t01 = std::clamp(t01, 0.0f, 1.0f); + const auto& a = axes_[i]; + if (!a.setter) { + return; + } + a.setter(a.min_val + t01 * (a.max_val - a.min_val)); +} + +void ParamSpaceExplorer::GetNormalizedPoint(std::vector& out) const { + out.resize(axes_.size()); + for (size_t i = 0; i < axes_.size(); i++) { + out[i] = GetNormalized(i); + } +} + +void ParamSpaceExplorer::SetNormalizedPoint(const std::vector& pt) { + const size_t n = std::min(pt.size(), axes_.size()); + for (size_t i = 0; i < n; i++) { + SetNormalized(i, pt[i]); + } +} + +void ParamSpaceExplorer::PushTrailPoint() { + if (axes_.empty() || trail_.empty()) { + return; + } + + std::vector pt; + GetNormalizedPoint(pt); + trail_[trail_head_] = pt; + trail_head_ = (trail_head_ + 1) % kTrailCapacity; + if (trail_count_ < kTrailCapacity) + trail_count_++; +} + +// --------------------------------------------------------------------------- +// Axis schema +// --------------------------------------------------------------------------- + +void ParamSpaceExplorer::AddAxis(const std::string& label, const std::string& short_label, float min_val, float max_val, + std::function getter, std::function setter) { + ParamSpaceAxis axis; + axis.label = label; + axis.short_label = short_label.empty() ? MakeCompactLabel(label) : short_label; + axis.min_val = min_val; + axis.max_val = max_val; + axis.getter = std::move(getter); + axis.setter = std::move(setter); + axes_.emplace_back(std::move(axis)); +} + +void ParamSpaceExplorer::AddSingle(const std::string& label_prefix, const std::string& short_prefix, + evo_engine::SingleDistribution& dist, float mean_min, float mean_max, + float deviation_max) { + auto* d = &dist; + AddAxis( + label_prefix + ".mean", short_prefix + "M", mean_min, mean_max, + [d]() { + return d->mean; + }, + [d](float v) { + d->mean = v; + }); + + const float dev_hi = std::max(deviation_max, d->deviation * 2.0f + 0.01f); + AddAxis( + label_prefix + ".deviation", short_prefix + "D", 0.0f, dev_hi, + [d]() { + return d->deviation; + }, + [d](float v) { + d->deviation = std::max(0.0f, v); + }); +} + +void ParamSpaceExplorer::AddCurve(const std::string& label_prefix, const std::string& short_prefix, + evo_engine::Curve2D& curve) { + auto* curve_ptr = &curve; + auto& values = curve_ptr->UnsafeGetValues(); + if (values.empty()) { + return; + } + + float x_min = std::numeric_limits::max(); + float x_max = -std::numeric_limits::max(); + float y_min = std::numeric_limits::max(); + float y_max = -std::numeric_limits::max(); + for (const auto& p : values) { + ExpandRange(p.x, x_min, x_max); + ExpandRange(p.y, y_min, y_max); + } + + float xr_min = 0.0f; + float xr_max = 1.0f; + float yr_min = 0.0f; + float yr_max = 1.0f; + SymmetricRange(x_min, x_max, xr_min, xr_max, 0.5f); + SymmetricRange(y_min, y_max, yr_min, yr_max, 0.5f); + + for (size_t i = 0; i < values.size(); i++) { + const std::string idx = "[" + std::to_string(i) + "]"; + AddAxis( + label_prefix + idx + ".x", short_prefix + "X", xr_min, xr_max, + [curve_ptr, i]() { + auto& pts = curve_ptr->UnsafeGetValues(); + return i < pts.size() ? pts[i].x : 0.0f; + }, + [curve_ptr, i](float v) { + auto& pts = curve_ptr->UnsafeGetValues(); + if (i < pts.size()) { + pts[i].x = v; + } + }); + + AddAxis( + label_prefix + idx + ".y", short_prefix + "Y", yr_min, yr_max, + [curve_ptr, i]() { + auto& pts = curve_ptr->UnsafeGetValues(); + return i < pts.size() ? pts[i].y : 0.0f; + }, + [curve_ptr, i](float v) { + auto& pts = curve_ptr->UnsafeGetValues(); + if (i < pts.size()) { + pts[i].y = v; + } + }); + } +} + +void ParamSpaceExplorer::AddPlotted(const std::string& label_prefix, const std::string& short_prefix, + evo_engine::PlottedDistribution& dist) { + auto* pd = &dist; + + float mean_min_lo = 0.0f; + float mean_min_hi = 1.0f; + SymmetricRange(pd->mean.min_value, pd->mean.max_value, mean_min_lo, mean_min_hi, 0.25f); + AddAxis( + label_prefix + ".mean.min", short_prefix + "Mm", mean_min_lo, mean_min_hi, + [pd]() { + return pd->mean.min_value; + }, + [pd](float v) { + pd->mean.min_value = v; + }); + AddAxis( + label_prefix + ".mean.max", short_prefix + "Mx", mean_min_lo, mean_min_hi, + [pd]() { + return pd->mean.max_value; + }, + [pd](float v) { + pd->mean.max_value = v; + }); + + float dev_min_lo = 0.0f; + float dev_min_hi = 1.0f; + SymmetricRange(pd->deviation.min_value, pd->deviation.max_value, dev_min_lo, dev_min_hi, 0.1f); + AddAxis( + label_prefix + ".deviation.min", short_prefix + "Dm", dev_min_lo, dev_min_hi, + [pd]() { + return pd->deviation.min_value; + }, + [pd](float v) { + pd->deviation.min_value = v; + }); + AddAxis( + label_prefix + ".deviation.max", short_prefix + "Dx", dev_min_lo, dev_min_hi, + [pd]() { + return pd->deviation.max_value; + }, + [pd](float v) { + pd->deviation.max_value = v; + }); + + AddCurve(label_prefix + ".mean.curve", short_prefix + "C1", pd->mean.curve); + AddCurve(label_prefix + ".deviation.curve", short_prefix + "C2", pd->deviation.curve); +} + +uint64_t ParamSpaceExplorer::ComputeSchemaSignature() const { + if (!bound_desc_) { + return 0; + } + + // Hash the registered axis labels and count, then combine with the + // descriptor's structural fingerprint (e.g. dynamic-array sizes). The + // descriptor fingerprint catches schema drift that hasn't been reflected + // in axes_ yet (so OnInspect can auto-rebuild); the axis hash catches the + // post-rebuild state. + uint64_t h = 1469598103934665603ULL; + h = HashCombine(h, static_cast(axes_.size())); + for (const auto& a : axes_) { + h = HashCombine(h, std::hash{}(a.label)); + h = HashCombine(h, std::hash{}(a.short_label)); + } + h = HashCombine(h, bound_desc_->ExplorableSchemaFingerprint()); + return h; +} + +void ParamSpaceExplorer::RebuildAxes() { + if (!bound_desc_) { + axes_.clear(); + return; + } + + axes_.clear(); + + // Delegate axis registration to the descriptor. Any class implementing + // ILSystemExplorableDescriptor enumerates its tunable fields here by + // calling AddSingle / AddPlotted / AddCurve / AddAxis on this explorer. + bound_desc_->RegisterExplorableAxes(*this); + + schema_signature_ = ComputeSchemaSignature(); + + // Resize runtime state. + trail_.assign(kTrailCapacity, std::vector(axes_.size(), 0.0f)); + trail_head_ = 0; + trail_count_ = 0; + + snapshot_a_.assign(axes_.size(), 0.0f); + snapshot_b_.assign(axes_.size(), 0.0f); + has_snapshot_a_ = false; + has_snapshot_b_ = false; + interp_t_ = 0.0f; + + if (!axes_.empty()) { + visible_axis_count_ = std::clamp(visible_axis_count_, 4, std::max(4, static_cast(axes_.size()))); + visible_axis_offset_ = + std::clamp(visible_axis_offset_, 0, std::max(0, static_cast(axes_.size()) - visible_axis_count_)); + PushTrailPoint(); + } +} + +// --------------------------------------------------------------------------- +// Tick - advance motion one frame +// --------------------------------------------------------------------------- + +bool ParamSpaceExplorer::Tick(float dt) { + if (!bound_desc_ || !playing || mode == ParamMotionMode::Stopped || axes_.empty()) + return false; + + time_ += static_cast(dt * speed); + + switch (mode) { + case ParamMotionMode::Lissajous: { + const float t = static_cast(time_); + for (size_t i = 0; i < axes_.size(); i++) { + const float freq = 1.0f + std::fmod(static_cast(i) * 0.6180339f, 17.0f); + const float phase = static_cast(i) * kGoldenAngle; + const float val = 0.5f + 0.5f * std::sin(2.0f * kPi * freq * t + phase); + SetNormalized(i, val); + } + PushTrailPoint(); + return true; + } + case ParamMotionMode::RandomWalk: { + std::uniform_real_distribution step_dist(-0.02f, 0.02f); + for (size_t i = 0; i < axes_.size(); i++) { + float cur = GetNormalized(i); + float next = cur + step_dist(rng_) * speed; + // Reflect at boundaries. + if (next < 0.0f) + next = -next; + if (next > 1.0f) + next = 2.0f - next; + SetNormalized(i, std::clamp(next, 0.0f, 1.0f)); + } + PushTrailPoint(); + return true; + } + case ParamMotionMode::LinearInterp: { + if (!has_snapshot_a_ || !has_snapshot_b_) + return false; + interp_t_ += dt * speed * 0.5f; + if (interp_t_ >= 1.0f) { + interp_t_ = 0.0f; + std::swap(snapshot_a_, snapshot_b_); + } + const size_t n = std::min(snapshot_a_.size(), axes_.size()); + for (size_t i = 0; i < n; i++) { + const float v = snapshot_a_[i] + interp_t_ * (snapshot_b_[i] - snapshot_a_[i]); + SetNormalized(i, v); + } + PushTrailPoint(); + return true; + } + default: + return false; + } +} + +// --------------------------------------------------------------------------- +// Snapshots +// --------------------------------------------------------------------------- + +void ParamSpaceExplorer::SaveSnapshotA() { + GetNormalizedPoint(snapshot_a_); + has_snapshot_a_ = true; +} + +void ParamSpaceExplorer::SaveSnapshotB() { + GetNormalizedPoint(snapshot_b_); + has_snapshot_b_ = true; +} + +void ParamSpaceExplorer::Reset() { + playing = false; + time_ = 0.0; + trail_head_ = 0; + trail_count_ = 0; + interp_t_ = 0.0f; + rng_.seed(42); +} + +// --------------------------------------------------------------------------- +// Parallel-coordinates canvas +// --------------------------------------------------------------------------- + +std::vector ParamSpaceExplorer::GetVisibleAxisIndices() const { + std::vector result; + if (axes_.empty()) { + return result; + } + + const int count = std::clamp(visible_axis_count_, 1, static_cast(axes_.size())); + const int max_offset = std::max(0, static_cast(axes_.size()) - count); + const int offset = std::clamp(visible_axis_offset_, 0, max_offset); + + result.reserve(static_cast(count)); + for (int i = 0; i < count; i++) { + result.emplace_back(offset + i); + } + return result; +} + +void ParamSpaceExplorer::DrawParallelCoords(float width, float height, const std::vector& axis_indices) { + if (axis_indices.size() < 2) { + ImGui::TextUnformatted("Select at least 2 visible axes."); + return; + } + + const ImVec2 canvas_pos = ImGui::GetCursorScreenPos(); + const ImVec2 canvas_size(width, height); + ImDrawList* dl = ImGui::GetWindowDrawList(); + + // Background. + dl->AddRectFilled(canvas_pos, ImVec2(canvas_pos.x + canvas_size.x, canvas_pos.y + canvas_size.y), + IM_COL32(25, 25, 30, 255)); + + // Reserve the space in the layout. + ImGui::Dummy(canvas_size); + + constexpr float kMarginL = 14.0f; + constexpr float kMarginR = 14.0f; + constexpr float kMarginT = 6.0f; + constexpr float kMarginB = 18.0f; + const float plot_w = canvas_size.x - kMarginL - kMarginR; + const float plot_h = canvas_size.y - kMarginT - kMarginB; + if (plot_w < 10.0f || plot_h < 10.0f) + return; + + const int axis_count = static_cast(axis_indices.size()); + const float spacing = plot_w / static_cast(axis_count - 1); + + // Draw vertical axes. + for (int i = 0; i < axis_count; i++) { + const int axis_idx = axis_indices[i]; + const float x = canvas_pos.x + kMarginL + static_cast(i) * spacing; + const float y_top = canvas_pos.y + kMarginT; + const float y_bot = canvas_pos.y + canvas_size.y - kMarginB; + dl->AddLine(ImVec2(x, y_top), ImVec2(x, y_bot), IM_COL32(70, 70, 80, 255)); + + // Short label below axis. + const ImVec2 ts = ImGui::CalcTextSize(axes_[axis_idx].short_label.c_str()); + dl->AddText(ImVec2(x - ts.x * 0.5f, y_bot + 2.0f), IM_COL32(140, 140, 150, 255), + axes_[axis_idx].short_label.c_str()); + } + + // Helper lambda: axis index -> screen x/y from normalized value. + auto axis_xy = [&](int visible_i, float norm) -> ImVec2 { + const float x = canvas_pos.x + kMarginL + static_cast(visible_i) * spacing; + const float y = canvas_pos.y + kMarginT + (1.0f - norm) * plot_h; + return ImVec2(x, y); + }; + + // Draw trail polylines (older = more transparent). + for (int t = 0; t < trail_count_; t++) { + const int idx = (trail_head_ - trail_count_ + t + kTrailCapacity) % kTrailCapacity; + const float alpha = static_cast(t + 1) / static_cast(trail_count_) * 0.35f; + const ImU32 color = IM_COL32(100, 170, 255, static_cast(alpha * 255)); + for (int i = 0; i < axis_count - 1; i++) { + const int a = axis_indices[i]; + const int b = axis_indices[i + 1]; + if (a >= static_cast(trail_[idx].size()) || b >= static_cast(trail_[idx].size())) { + continue; + } + dl->AddLine(axis_xy(i, trail_[idx][a]), axis_xy(i + 1, trail_[idx][b]), color, 1.0f); + } + } + + // Draw current point (bright polyline + dots). + { + std::vector cur; + GetNormalizedPoint(cur); + const ImU32 bright = IM_COL32(255, 220, 80, 255); + for (int i = 0; i < axis_count - 1; i++) { + const int a = axis_indices[i]; + const int b = axis_indices[i + 1]; + if (a >= static_cast(cur.size()) || b >= static_cast(cur.size())) { + continue; + } + dl->AddLine(axis_xy(i, cur[a]), axis_xy(i + 1, cur[b]), bright, 2.0f); + } + for (int i = 0; i < axis_count; i++) { + const int axis_idx = axis_indices[i]; + if (axis_idx >= static_cast(cur.size())) { + continue; + } + dl->AddCircleFilled(axis_xy(i, cur[axis_idx]), 3.0f, bright); + } + + // Value annotations next to dots (on hover only to avoid clutter). + const ImVec2 mouse = ImGui::GetMousePos(); + for (int i = 0; i < axis_count; i++) { + const int axis_idx = axis_indices[i]; + if (axis_idx >= static_cast(cur.size())) { + continue; + } + const ImVec2 dot = axis_xy(i, cur[axis_idx]); + const float dx = mouse.x - dot.x; + const float dy = mouse.y - dot.y; + if (dx * dx + dy * dy < 100.0f) { + char buf[32]; + snprintf(buf, sizeof(buf), "%.4f", axes_[axis_idx].getter ? axes_[axis_idx].getter() : 0.0f); + dl->AddText(ImVec2(dot.x + 6.0f, dot.y - 8.0f), IM_COL32(255, 255, 255, 230), buf); + // Also show full label as tooltip. + ImGui::SetTooltip("%s: %s", axes_[axis_idx].label.c_str(), buf); + } + } + } +} + +// --------------------------------------------------------------------------- +// OnInspect - full explorer UI +// --------------------------------------------------------------------------- + +bool ParamSpaceExplorer::OnInspect() { + if (!bound_desc_) + return false; + + const uint64_t new_signature = ComputeSchemaSignature(); + if (new_signature != schema_signature_) { + RebuildAxes(); + } + + bool changed = false; + bool preferences_changed = false; + + ImGui::Text("Total dimensions: %d", static_cast(axes_.size())); + if (ImGui::SmallButton("Rebuild Axis Schema")) { + RebuildAxes(); + } + + if (axes_.empty()) { + ImGui::TextUnformatted("No axes available."); + return false; + } + + // Mode selector. + int mode_int = static_cast(mode); + if (ImGui::Combo("Motion Mode", &mode_int, kModeNames, 4)) { + mode = static_cast(mode_int); + preferences_changed = true; + } + + if (ImGui::DragFloat("Speed", &speed, 0.01f, 0.01f, 10.0f, "%.2f")) { + preferences_changed = true; + } + + if (ImGui::Button(playing ? "Pause" : "Play")) { + playing = !playing; + preferences_changed = true; + } + ImGui::SameLine(); + if (ImGui::Button("Reset")) { + Reset(); + preferences_changed = true; + PushTrailPoint(); + } + + // Snapshot controls for LinearInterp. + if (mode == ParamMotionMode::LinearInterp) { + if (ImGui::Button("Set A")) { + SaveSnapshotA(); + preferences_changed = true; + } + ImGui::SameLine(); + if (ImGui::Button("Set B")) { + SaveSnapshotB(); + preferences_changed = true; + } + ImGui::SameLine(); + ImGui::Text("A:%s B:%s", has_snapshot_a_ ? "set" : "---", has_snapshot_b_ ? "set" : "---"); + if (!has_snapshot_a_ || !has_snapshot_b_) { + ImGui::TextColored(ImVec4(1, 1, 0, 1), "Set both A and B snapshots before playing."); + } + } + + visible_axis_count_ = std::clamp(visible_axis_count_, 2, std::max(2, static_cast(axes_.size()))); + const int max_offset = std::max(0, static_cast(axes_.size()) - visible_axis_count_); + visible_axis_offset_ = std::clamp(visible_axis_offset_, 0, max_offset); + ImGui::DragInt("Visible Axis Count", &visible_axis_count_, 1.0f, 2, std::max(2, static_cast(axes_.size()))); + ImGui::DragInt("Visible Axis Offset", &visible_axis_offset_, 1.0f, 0, max_offset); + + const auto visible_axis_indices = GetVisibleAxisIndices(); + + // Parallel-coordinates canvas. + const float canvas_w = std::max(ImGui::GetContentRegionAvail().x, 200.0f); + DrawParallelCoords(canvas_w, 180.0f, visible_axis_indices); + + // Per-axis sliders. + if (ImGui::TreeNodeEx("Axis Sliders", ImGuiTreeNodeFlags_DefaultOpen)) { + ImGui::Checkbox("Show Window Only", &sliders_window_only_); + ImGui::InputText("Search", axis_search_.data(), axis_search_.size()); + + int slider_count = 0; + auto try_draw_axis = [&](int axis_idx) { + if (axis_idx < 0 || axis_idx >= static_cast(axes_.size())) { + return; + } + auto& axis = axes_[axis_idx]; + if (!ContainsCaseInsensitive(axis.label.c_str(), axis_search_.data())) { + return; + } + + ImGui::PushID(axis_idx); + float val = axis.getter ? axis.getter() : 0.0f; + const float step = std::max(0.0001f, (axis.max_val - axis.min_val) * 0.005f); + if (ImGui::DragFloat(axis.label.c_str(), &val, step, axis.min_val, axis.max_val, "%.4f")) { + if (axis.setter) { + axis.setter(val); + } + changed = true; + } + ImGui::PopID(); + slider_count++; + }; + + if (sliders_window_only_) { + for (const int axis_idx : visible_axis_indices) { + try_draw_axis(axis_idx); + } + } else { + for (int axis_idx = 0; axis_idx < static_cast(axes_.size()); axis_idx++) { + try_draw_axis(axis_idx); + } + } + + ImGui::Text("Displayed sliders: %d", slider_count); + if (changed) + PushTrailPoint(); + ImGui::TreePop(); + } + + // Tick if playing. + if (playing) { + changed |= Tick(ImGui::GetIO().DeltaTime); + } + + return changed || preferences_changed; +} \ No newline at end of file diff --git a/EvoEngine_Packages/LSystem/src/PineGrowthModel.cpp b/EvoEngine_Packages/LSystem/src/PineGrowthModel.cpp new file mode 100644 index 00000000..f97cc1c5 --- /dev/null +++ b/EvoEngine_Packages/LSystem/src/PineGrowthModel.cpp @@ -0,0 +1,494 @@ +#include "PineGrowthModel.hpp" +#include +#include +#include +#include +#include "ScotsPineDescriptor.hpp" + +using namespace l_system_package; + +namespace { +void UpdateNeedleMaturityState(PineNeedleCluster& cluster, const float thermal_now_years, + const float absolute_age_years) { + if (cluster.maturity_reached) { + return; + } + + const float clamped_chrono_age = std::max(0.0f, absolute_age_years); + if (cluster.continuous_growth.maturation_years <= 0.0f) { + cluster.maturity_reached = true; + cluster.chronological_age_at_maturity_years = clamped_chrono_age; + return; + } + + constexpr float kMaturityEpsilon = 1.0e-4f; + const float normalized_age = cluster.continuous_growth.NormalizedAge(thermal_now_years); + if (normalized_age >= 1.0f - kMaturityEpsilon) { + cluster.maturity_reached = true; + cluster.chronological_age_at_maturity_years = clamped_chrono_age; + } +} + +bool UpdateWhorlBudChronologicalDormancy(PineWhorlBud& bud, const float absolute_age_years, + const int initial_dormancy_years) { + const int previous_remaining = bud.dormancy_years_remaining; + const int completed_years = std::max(0, static_cast(std::floor(std::max(0.0f, absolute_age_years)))); + const int target_remaining = std::max(0, initial_dormancy_years - completed_years); + + // Keep monotonic countdown even under coarse timestep jumps. + bud.dormancy_years_remaining = std::min(bud.dormancy_years_remaining, target_remaining); + + return bud.dormancy_years_remaining != previous_remaining; +} + +bool UpdateNeedleChronologicalAging(PineNeedleCluster& cluster, const float absolute_age_years) { + const int previous_age_years = cluster.age_years; + const float previous_senescence = cluster.senescence_phase; + const bool previous_alive = cluster.alive; + + const float clamped_age_years = std::max(0.0f, absolute_age_years); + cluster.age_years = std::max(0, static_cast(std::floor(clamped_age_years))); + + // Inner cohorts senesce earlier than distal cohorts, approximating + // proximal-first canopy turnover in young pines. + const float s_norm = std::clamp(cluster.s_along_parent_norm, 0.0f, 1.0f); + const float proximality = 1.0f - s_norm; + const float lifespan_years_f = static_cast(cluster.lifespan_years); + constexpr float kMaxProximalLifespanReductionYears = 2.0f; + constexpr float kNodeRandomJitterYears = 0.35f; + const float lifespan_jitter = (cluster.node_random - 0.5f) * 2.0f * kNodeRandomJitterYears; + const float effective_lifespan_years = + std::max(0.25f, lifespan_years_f - proximality * kMaxProximalLifespanReductionYears + lifespan_jitter); + + const float senescence_age_years = + cluster.maturity_reached + ? std::max(0.0f, clamped_age_years - std::max(0.0f, cluster.chronological_age_at_maturity_years)) + : 0.0f; + + if (cluster.maturity_reached && senescence_age_years > effective_lifespan_years) { + const float browning_years = std::max(0.0f, cluster.browning_years); + const float browning_scale = 1.0f - 0.35f * proximality; + const float effective_browning_years = std::max(0.05f, browning_years * browning_scale); + const float phase = + (browning_years > 0.0f) + ? std::clamp((senescence_age_years - effective_lifespan_years) / effective_browning_years, 0.0f, 1.0f) + : 1.0f; + cluster.senescence_phase = phase; + if (phase >= 1.0f) + cluster.alive = false; + } else { + cluster.senescence_phase = 0.0f; + } + + return cluster.age_years != previous_age_years || + std::abs(cluster.senescence_phase - previous_senescence) > 1.0e-5f || cluster.alive != previous_alive; +} + +float HashToUnitOpen01(const uint32_t hash) { + constexpr float kDenominator = 16777217.0f; + const float u = static_cast(hash & 0x00ffffffu) / kDenominator; + return std::clamp(u + (1.0f / kDenominator), 1.0e-6f, 1.0f - 1.0e-6f); +} + +float DeterministicNormalFromNodeRandom(const float node_random, const uint32_t salt) { + constexpr float kTwoPi = 6.28318530717958647692f; + const uint32_t h1 = HashNodeSeed(node_random, salt ^ 0x9e3779b9u); + const uint32_t h2 = HashNodeSeed(node_random, salt ^ 0x85ebca6bu); + const float u1 = HashToUnitOpen01(h1); + const float u2 = HashToUnitOpen01(h2); + const float radius = std::sqrt(-2.0f * std::log(u1)); + return radius * std::cos(kTwoPi * u2); +} + +float EvaluateMaturityMultiplier(const evo_engine::PlottedDistribution& distribution, const float maturity_age_t, + const float node_random, const uint32_t salt) { + const float x = std::clamp(maturity_age_t, 0.0f, 1.0f); + const float mean_value = distribution.mean.GetValue(x); + const float deviation_value = std::max(0.0f, distribution.deviation.GetValue(x)); + if (!(deviation_value > 0.0f)) { + return std::clamp(mean_value, 0.0f, 1.0f); + } + const float z = DeterministicNormalFromNodeRandom(node_random, salt); + return std::clamp(mean_value + deviation_value * z, 0.0f, 1.0f); +} +} // namespace + +void PineGrowthModel::ApplyActiveSampledProfile(const SampledPineParams& profile_sampled) { + sampled = profile_sampled; + graph.data.gravity_m_s2 = sampled.gravity_m_s2; + graph.data.needle_radius_to_stem_thickness_max_ratio = sampled.needle_radius_to_stem_thickness_max_ratio; +} + +void PineGrowthModel::RefreshEngineRulesForActiveProfile() { + engine_.topology_rules = CreatePineTopologyRules(sampled, needle_topology_enabled_); + engine_.growth_rules = CreatePineGrowthRules(sampled); +} + +void PineGrowthModel::SetNeedleTopologyEnabled(const bool enabled) { + if (needle_topology_enabled_ == enabled) { + return; + } + needle_topology_enabled_ = enabled; + if (initialized_) { + RefreshEngineRulesForActiveProfile(); + } +} + +void PineGrowthModel::ActivatePostRepotProfile() { + if (!has_post_repot_profile_ || post_repot_profile_active_) { + return; + } + ApplyActiveSampledProfile(post_repot_sampled_); + RefreshEngineRulesForActiveProfile(); + post_repot_profile_active_ = true; +} + +void PineGrowthModel::GrowToGDDWithProfileSwitch(const float target_gdd, const uint32_t max_growth_steps) { + if (!has_post_repot_profile_ || repot_switch_gdd_ < 0.0f) { + Base::GrowToGDD(target_gdd, max_growth_steps); + return; + } + + auto accumulate_profile = [](Base::GrowthStepProfile& dst, const Base::GrowthStepProfile& src) { + dst.total_seconds += src.total_seconds; + dst.apply_growth_rules_seconds += src.apply_growth_rules_seconds; + dst.apply_topology_rules_seconds += src.apply_topology_rules_seconds; + dst.sort_lists_seconds += src.sort_lists_seconds; + dst.update_node_info_seconds += src.update_node_info_seconds; + dst.propagate_geometry_seconds += src.propagate_geometry_seconds; + dst.topology_scan_seconds += src.topology_scan_seconds; + }; + + uint32_t total_steps = 0; + Base::GrowthStepProfile total_profile{}; + + auto run_segment = [&](const float segment_target, const uint32_t segment_cap) { + Base::GrowToGDD(segment_target, segment_cap); + total_steps += last_growth_steps; + accumulate_profile(total_profile, last_grow_to_gdd_profile); + }; + + if (!post_repot_profile_active_ && accumulated_gdd >= repot_switch_gdd_) { + ActivatePostRepotProfile(); + } + + uint32_t remaining_steps = max_growth_steps; + if (!post_repot_profile_active_ && accumulated_gdd < repot_switch_gdd_ && target_gdd > repot_switch_gdd_) { + const uint32_t pre_cap = (max_growth_steps == 0) ? 0u : remaining_steps; + run_segment(repot_switch_gdd_, pre_cap); + + if (max_growth_steps != 0) { + if (total_steps >= max_growth_steps) { + last_growth_steps = total_steps; + last_grow_to_gdd_profile = total_profile; + return; + } + remaining_steps = max_growth_steps - total_steps; + } + + if (!post_repot_profile_active_ && accumulated_gdd >= repot_switch_gdd_) { + ActivatePostRepotProfile(); + } + } + + const uint32_t final_cap = (max_growth_steps == 0) ? 0u : remaining_steps; + run_segment(target_gdd, final_cap); + + last_growth_steps = total_steps; + last_grow_to_gdd_profile = total_profile; + if (last_growth_steps == 0) { + last_growth_step_profile = {}; + last_grow_to_gdd_profile = {}; + } +} + +void PineGrowthModel::RebuildStemLoadCacheIfNeeded() { + const int graph_version = graph.GetVersion(); + const auto& raw_nodes = graph.PeekRawNodes(); + if (stem_load_cache_graph_version_ == graph_version && stem_load_cache_.size() == raw_nodes.size()) { + return; + } + + stem_load_cache_graph_version_ = graph_version; + stem_load_cache_.assign(raw_nodes.size(), 0.0f); + + const auto& sorted_nodes = graph.PeekSortedNodeList(); + for (auto it = sorted_nodes.rbegin(); it != sorted_nodes.rend(); ++it) { + const auto node_handle = *it; + const auto& node = graph.PeekNode(node_handle); + + // Count structural internodes in the descendant subtree as a lightweight + // child-load proxy for cambial thickening. + float subtree_load = node.data.Is() ? 1.0f : 0.0f; + for (const auto child_handle : node.PeekChildHandles()) { + if (child_handle >= 0 && child_handle < static_cast(stem_load_cache_.size())) { + subtree_load += stem_load_cache_[child_handle]; + } + } + stem_load_cache_[node_handle] = subtree_load; + } +} + +// --------------------------------------------------------------------------- +// Initialize +// --------------------------------------------------------------------------- + +void PineGrowthModel::Initialize(const ScotsPineDescriptor& descriptor, const unsigned int seed, + const glm::vec3& root_position, const glm::quat& root_rotation, + const ScotsPineDescriptor* post_repot_descriptor, const float repot_switch_gdd, + const bool enable_needle_topology) { + needle_topology_enabled_ = enable_needle_topology; + Reset(); + + rng_.seed(seed); + root_position_ = root_position; + root_rotation_ = root_rotation; + + // Sample all distributions from the descriptor (deterministic, fixed RNG order). + pre_repot_sampled_ = descriptor.Sample(rng_); + sampled = pre_repot_sampled_; + + if (post_repot_descriptor && repot_switch_gdd >= 0.0f) { + std::mt19937 post_rng(seed ^ 0x9e3779b9u); + post_repot_sampled_ = post_repot_descriptor->Sample(post_rng); + has_post_repot_profile_ = true; + post_repot_profile_active_ = false; + repot_switch_gdd_ = repot_switch_gdd; + } + + // Sampled once per plant: random initial orientation around +Y. + const glm::quat sampled_root_yaw = + glm::angleAxis(glm::radians(sampled.initial_orientation_yaw_deg), glm::vec3(0.0f, 1.0f, 0.0f)); + root_rotation_ = glm::normalize(root_rotation * sampled_root_yaw); + + // Set up the single leader apex. + graph = PineGraph(1); + // Phase 4: copy plant-wide gravity into PineGraphData so the mesh builder + // can apply body forces without having to re-resolve the descriptor. + ApplyActiveSampledProfile(sampled); + auto& root = graph.RefNode(0); + root.symbol_id = PineSymbol::Apex; + PineApex apex; + apex.order = 0; + apex.node_random = SampleUnit01(rng_); + apex.phyllotaxis_phase = std::fmod(apex.node_random * 360.0f, 360.0f); + apex.year_index = 0; + apex.phytomers_this_year = 0; + // Stamp per-apex resampled scheduling fields used by R0/R1 condition+ + // produce so condition+produce see the same value within a year. + { + auto root_node_rng = MakeNodeRng(apex.node_random, 0xA1F00D00u); + apex.sampled_plastochron_years = pine_detail::SamplePlastochronYears(sampled, root_node_rng); + apex.sampled_max_phytomers_per_seasonal_growth = + pine_detail::SampleMaxPhytomersPerSeasonalGrowth(sampled, root_node_rng); + // Year-1 bare-zone count (R0 never fires in year 1 because the leader + // is constructed with year_index == 0 and the clock starts at year 0). + apex.bare_phytomers_this_year = pine_detail::SampleBarePhytomersThisYear( + sampled, apex.sampled_max_phytomers_per_seasonal_growth, root_node_rng); + apex.previous_season_completion_ratio = 1.0f; + apex.previous_season_vigor = 1.0f; + } + // Allow the leader's first phytomer to emit immediately at t=0; subsequent + // emissions are plastochron-gated in R1 via this timestamp. + apex.t_last_emission_years = -std::max(0.0f, apex.sampled_plastochron_years); + root.data.Set(apex); + root.info.global_position = root_position; + root.info.global_rotation = root_rotation_; + root.info.length = 0.0f; + root.info.thickness = std::max(0.00005f, sampled.leader_internode_thickness_m); + + // Build the derivation engine with the sampled rule sets. + engine_ = PineEngine(); + RefreshEngineRulesForActiveProfile(); + + // Topology budget for the phytomer grammar: + // - leader emits one phytomer per plastochron, capped at + // `max_phytomers_per_seasonal_growth` per year. + // - each whorl bud (when enabled) activates after `whorl_dormancy_years` + // and spawns N lateral apices, which run their own seasonal cycle. + // Upper bound estimate scales with the per-year cap and a generous slack + // for needle-cluster successors and chronological dormancy transitions. + const int branches = std::max(1, sampled.branches_per_whorl); + const int phytomers_per_year = std::max(1, sampled.max_phytomers_per_seasonal_growth); + max_topology_steps_ = std::max(64, phytomers_per_year * 32 * (1 + branches * 16) + 512); + + initialized_ = true; + topology_complete_ = false; +} + +// --------------------------------------------------------------------------- +// CRTP hooks invoked by LSystemGrowthModelBase +// --------------------------------------------------------------------------- + +void PineGrowthModel::UpdateNodeInfoImpl(LGraphNode& node) { + // Keep the thermal (GDD-derived) physiological clock for topology and + // continuous-growth curves. Chronological age is tracked separately in + // node.info.temporal and is used for aging/senescence. + const float thermal_now_years = accumulated_gdd / kPineGddPerYear; + if (graph.data.clock.NowYears() != thermal_now_years) { + graph.data.clock.SetYears(thermal_now_years); + } + + if (node.data.Is()) { + RebuildStemLoadCacheIfNeeded(); + + auto& internode = node.data.Get(); + const float absolute_age_years = std::max(0.0f, node.info.temporal.age_absolute_years); + internode.age_years = std::max(0, static_cast(std::floor(absolute_age_years))); + + float stem_load_scale = 1.0f; + if (internode.order == 0) { + const auto node_handle = node.GetHandle(); + if (node_handle >= 0 && node_handle < static_cast(stem_load_cache_.size())) { + const float subtree_internode_count = std::max(1.0f, stem_load_cache_[node_handle]); + // Area ~ load surrogate => diameter ~ load^0.5; use a softer + // exponent to avoid over-thickening in dense canopies. + stem_load_scale = std::clamp(std::pow(subtree_internode_count, 0.20f), 1.0f, 4.0f); + } + } + + // Thermal maturity age remains GDD-driven, but organ size multipliers are + // now sampled from maturity plot2D distributions (mean/deviation) with a + // fixed per-organ realization. + const float internode_maturity_age_t = + std::clamp(internode.continuous_growth.NormalizedAge(thermal_now_years), 0.0f, 1.0f); + const float internode_length_maturity = + EvaluateMaturityMultiplier(sampled.distributions.internode_length_maturity_curve, internode_maturity_age_t, + internode.node_random, 0x31A1C001u); + const float internode_width_maturity = + EvaluateMaturityMultiplier(sampled.distributions.internode_width_maturity_curve, internode_maturity_age_t, + internode.node_random, 0x31A1C002u); + + if (internode.continuous_growth.maturation_years > 0.0f) { + internode.length = internode.target_length * internode_length_maturity; + internode.thickness = internode.target_thickness * internode_width_maturity * stem_load_scale; + internode.growth_progress = std::clamp(internode_length_maturity, 0.0f, 1.0f); + } else { + internode.length = internode.target_length; + internode.thickness = internode.target_thickness * stem_load_scale; + internode.growth_progress = 1.0f; + } + internode.thickness = std::max(0.00002f, internode.thickness); + node.info.length = internode.length; + node.info.thickness = internode.thickness; + } else if (node.data.Is()) { + auto& cluster = node.data.Get(); + const float absolute_age_years = std::max(0.0f, node.info.temporal.age_absolute_years); + float needle_length_maturity = 1.0f; + if (cluster.continuous_growth.maturation_years > 0.0f) { + const float needle_maturity_age_t = + std::clamp(cluster.continuous_growth.NormalizedAge(thermal_now_years), 0.0f, 1.0f); + needle_length_maturity = EvaluateMaturityMultiplier(sampled.distributions.needle_length_maturity_curve, + needle_maturity_age_t, cluster.node_random, 0x31A1C101u); + } + cluster.length = cluster.target_length * needle_length_maturity; + + // Development to maturity is thermal; once mature, needles are determinate. + UpdateNeedleMaturityState(cluster, thermal_now_years, absolute_age_years); + if (cluster.maturity_reached) { + cluster.length = cluster.target_length; + } + + UpdateNeedleChronologicalAging(cluster, absolute_age_years); + + // Needle clusters do not contribute to flow length / thickness in the + // GeometryPass walk; their geometry is built out-of-band by ScotsPine. + node.info.length = 0.0f; + node.info.thickness = 0.0f; + } else if (node.data.Is()) { + auto& bud = node.data.Get(); + UpdateWhorlBudChronologicalDormancy(bud, std::max(0.0f, node.info.temporal.age_absolute_years), + std::max(0, bud.initial_dormancy_years)); + node.info.length = 0.0f; + node.info.thickness = 0.0f; + } else { + node.info.length = 0.0f; + node.info.thickness = 0.0f; + } +} + +bool PineGrowthModel::IsDevelopmentalSymbolImpl(const LGraphNode& node) const { + // Apex symbols (leader + laterals) are still developing until they exhaust + // vigor or reach max_age. WhorlBud is unused in Phase 3 but counted here + // for forward compatibility. + return node.data.Is() || node.data.Is(); +} + +glm::quat PineGrowthModel::ComputeChildLocalRotationImpl(const LGraphNode& node, + const LGraphNode& /*parent*/) const { + if (node.data.Is()) { + const auto& internode = node.data.Get(); + + auto finite_quat = [](const glm::quat& q) { + return std::isfinite(q.x) && std::isfinite(q.y) && std::isfinite(q.z) && std::isfinite(q.w); + }; + + glm::vec3 bend_axis = internode.bend_axis_local; + if (!std::isfinite(bend_axis.x) || !std::isfinite(bend_axis.y) || !std::isfinite(bend_axis.z) || + glm::dot(bend_axis, bend_axis) < 1e-8f) { + bend_axis = glm::vec3(1.0f, 0.0f, 0.0f); + } else { + bend_axis = glm::normalize(bend_axis); + } + + float safe_curvature = std::clamp(internode.curvature, -89.0f, 89.0f); + if (!std::isfinite(safe_curvature)) { + safe_curvature = 0.0f; + } + + glm::quat roll = glm::angleAxis(glm::radians(internode.roll_angle), glm::vec3(0, 0, -1)); + glm::quat pitch = glm::angleAxis(glm::radians(internode.branch_angle), glm::vec3(1, 0, 0)); + glm::quat bend = glm::angleAxis(glm::radians(safe_curvature), bend_axis); + glm::quat local = glm::normalize(roll * pitch * bend); + if (!finite_quat(local)) { + return glm::quat(1, 0, 0, 0); + } + return local; + } + // Needle clusters, whorl buds, and apices contribute no axis change. + return glm::quat(1, 0, 0, 0); +} + +float PineGrowthModel::ChronologicalGddPerYearImpl() const { + return kPineGddPerYear; +} + +bool PineGrowthModel::UpdateNodeAgingOnlyImpl(LGraphNode& node) { + if (node.data.Is()) { + auto& internode = node.data.Get(); + const int previous_age_years = internode.age_years; + internode.age_years = std::max(0, static_cast(std::floor(node.info.temporal.age_absolute_years))); + return internode.age_years != previous_age_years; + } + + if (node.data.Is()) { + auto& cluster = node.data.Get(); + const float thermal_now_years = accumulated_gdd / kPineGddPerYear; + UpdateNeedleMaturityState(cluster, thermal_now_years, node.info.temporal.age_absolute_years); + return UpdateNeedleChronologicalAging(cluster, node.info.temporal.age_absolute_years); + } + + if (node.data.Is()) { + auto& bud = node.data.Get(); + return UpdateWhorlBudChronologicalDormancy(bud, node.info.temporal.age_absolute_years, + std::max(0, bud.initial_dormancy_years)); + } + + return false; +} + +// --------------------------------------------------------------------------- +// Reset +// --------------------------------------------------------------------------- + +void PineGrowthModel::Reset() { + ResetBase(); + sampled = SampledPineParams(); + pre_repot_sampled_ = SampledPineParams(); + post_repot_sampled_ = SampledPineParams(); + has_post_repot_profile_ = false; + post_repot_profile_active_ = false; + repot_switch_gdd_ = -1.0f; + stem_load_cache_graph_version_ = -1; + stem_load_cache_.clear(); +} diff --git a/EvoEngine_Packages/LSystem/src/ScotsPine.cpp b/EvoEngine_Packages/LSystem/src/ScotsPine.cpp new file mode 100644 index 00000000..5f1f60db --- /dev/null +++ b/EvoEngine_Packages/LSystem/src/ScotsPine.cpp @@ -0,0 +1,1819 @@ +#include "ScotsPine.hpp" +#include "ScotsPineDescriptor.hpp" +#include "ScotsPineModules.hpp" + +// Phase 1 biologically-emergent organ geometry primitives. +#include "CrossSectionProfile.hpp" +#include "ElasticaSolver.hpp" +#include "GeneralizedCylinderMesher.hpp" +#include "GrowthField.hpp" +#include "MaterialProfile.hpp" +#include "OrganCenterline.hpp" +#include "OrganMeshPly.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace l_system_package; +using namespace evo_engine; + +namespace { + +ScotsPine::ColorMode g_scots_pine_color_mode = ScotsPine::ColorMode::Shaded; +std::atomic g_force_cpu_particles_path{false}; + +// Phase 1: when true, replace the per-cluster octahedron marker (legacy, +// kept under [deprecated] block below) with explicit swept generalized- +// cylinder needle geometry. Defaults true; set to false to compare against +// the legacy visual baseline. +std::atomic g_use_generalized_cylinder_needles{true}; + +// Visualization-only knobs (see ScotsPine.hpp for contract notes). +// All three default to neutral values that reproduce existing behaviour +// byte-identically when no caller opts in. +std::atomic g_internode_visual_radius_multiplier{1.0f}; +std::atomic g_render_needles_enabled{true}; +std::atomic g_generate_needle_topology_enabled{true}; +// Leader debug colour stored as four floats; protected by a coarse mutex +// because atomic is not portable. Reads happen once per rebuild +// in the same thread that calls into RebuildGeometry; contention is nil. +std::mutex g_leader_internode_debug_color_mutex; +glm::vec4 g_leader_internode_debug_color{0.0f, 0.0f, 0.0f, 0.0f}; + +bool IsFiniteQuat(const glm::quat& q) { + return std::isfinite(q.x) && std::isfinite(q.y) && std::isfinite(q.z) && std::isfinite(q.w); +} + +bool IsFiniteVec3(const glm::vec3& v) { + return std::isfinite(v.x) && std::isfinite(v.y) && std::isfinite(v.z); +} + +bool IsFiniteVec2(const glm::vec2& v) { + return std::isfinite(v.x) && std::isfinite(v.y); +} + +bool IsFiniteVec4(const glm::vec4& v) { + return std::isfinite(v.x) && std::isfinite(v.y) && std::isfinite(v.z) && std::isfinite(v.w); +} + +glm::vec4 SanitizeFiniteColor(const glm::vec4& value, const glm::vec4& fallback) { + if (!IsFiniteVec4(value)) + return fallback; + return glm::clamp(value, glm::vec4(0.0f), glm::vec4(1.0f)); +} + +bool IsFiniteMat4(const glm::mat4& m) { + for (int c = 0; c < 4; c++) { + for (int r = 0; r < 4; r++) { + if (!std::isfinite(m[c][r])) + return false; + } + } + return true; +} + +bool IsNeedleAggregateMeshValid(const std::vector& vertices, const std::vector& triangles) { + if (vertices.empty() || triangles.empty()) + return true; + + for (const auto& v : vertices) { + if (!IsFiniteVec3(v.position)) { + return false; + } + } + + const uint32_t vertex_count = static_cast(vertices.size()); + for (const auto& tri : triangles) { + if (tri.x >= vertex_count || tri.y >= vertex_count || tri.z >= vertex_count) { + return false; + } + } + + return true; +} + +void SanitizeNeedleAggregateMeshVertices(std::vector& vertices) { + for (auto& v : vertices) { + if (!IsFiniteVec3(v.normal) || glm::length(v.normal) <= 1.0e-8f) { + v.normal = glm::vec3(0.0f, 1.0f, 0.0f); + } else { + v.normal = glm::normalize(v.normal); + } + + if (!IsFiniteVec3(v.tangent) || glm::length(v.tangent) <= 1.0e-8f) { + // Keep tangent orthogonal to the sanitized normal to avoid NaN TBN in shading. + glm::vec3 ref(1.0f, 0.0f, 0.0f); + if (std::abs(glm::dot(ref, v.normal)) > 0.95f) { + ref = glm::vec3(0.0f, 0.0f, 1.0f); + } + v.tangent = glm::normalize(ref - v.normal * glm::dot(ref, v.normal)); + } else { + v.tangent = glm::normalize(v.tangent); + } + + if (!IsFiniteVec2(v.tex_coord)) { + v.tex_coord = glm::vec2(0.0f); + } + + if (!IsFiniteVec4(v.color)) { + v.color = glm::vec4(1.0f); + } else { + v.color = glm::clamp(v.color, glm::vec4(0.0f), glm::vec4(1.0f)); + } + } +} + +bool HasNeedleSenescenceOrAbscission(const PineGraph& graph, const std::vector& sorted_nodes) { + for (const auto handle : sorted_nodes) { + const auto& node = graph.PeekNode(handle); + if (!node.data.template Is()) + continue; + const auto& cluster = node.data.template Get(); + if (!cluster.alive || cluster.senescence_phase > 0.0f) { + return true; + } + } + return false; +} + +glm::vec4 HashToColor(const uint32_t id) { + const float hue = static_cast((id * 2654435761u) & 1023u) / 1024.0f; + const float s = 0.72f; + const float v = 0.92f; + const float h6 = hue * 6.0f; + const int sector = static_cast(h6); + const float f = h6 - static_cast(sector); + const float p = v * (1.0f - s); + const float q = v * (1.0f - s * f); + const float t = v * (1.0f - s * (1.0f - f)); + glm::vec3 rgb(v, t, p); + switch (sector % 6) { + case 0: + rgb = glm::vec3(v, t, p); + break; + case 1: + rgb = glm::vec3(q, v, p); + break; + case 2: + rgb = glm::vec3(p, v, t); + break; + case 3: + rgb = glm::vec3(p, q, v); + break; + case 4: + rgb = glm::vec3(t, p, v); + break; + default: + rgb = glm::vec3(v, p, q); + break; + } + return glm::vec4(rgb, 1.0f); +} + +uint32_t MixBits(const uint32_t value) { + uint32_t x = value; + x ^= x >> 16; + x *= 0x7feb352du; + x ^= x >> 15; + x *= 0x846ca68bu; + x ^= x >> 16; + return x; +} + +float HashToUnitOpen01(const uint32_t hash) { + constexpr float kDenominator = 16777217.0f; + const float u = static_cast(hash & 0x00ffffffu) / kDenominator; + return std::clamp(u + (1.0f / kDenominator), 1.0e-6f, 1.0f - 1.0e-6f); +} + +float DeterministicNormalFromNodeRandom(const float node_random, const uint32_t salt) { + constexpr float kTwoPi = 6.28318530717958647692f; + const float clamped = std::clamp(node_random, 0.0f, 1.0f); + const uint32_t base = static_cast(std::round(clamped * 16777215.0f)); + const uint32_t h1 = MixBits(base ^ salt ^ 0x9e3779b9u); + const uint32_t h2 = MixBits(base ^ salt ^ 0x85ebca6bu); + const float u1 = HashToUnitOpen01(h1); + const float u2 = HashToUnitOpen01(h2); + const float radius = std::sqrt(-2.0f * std::log(u1)); + return radius * std::cos(kTwoPi * u2); +} + +float EvaluatePositionalMultiplier(const evo_engine::PlottedDistribution& distribution, const float s_norm, + const float node_random, const uint32_t salt) { + const float x = std::clamp(s_norm, 0.0f, 1.0f); + const float mean_value = distribution.mean.GetValue(x); + const float deviation_value = std::max(0.0f, distribution.deviation.GetValue(x)); + if (!(deviation_value > 0.0f)) { + return std::max(0.0f, mean_value); + } + const float z = DeterministicNormalFromNodeRandom(node_random, salt); + return std::max(0.0f, mean_value + deviation_value * z); +} + +constexpr float kNeedleCrossSectionTemporalWindowYears = 2.0f; + +float EvaluateTemporalCrossSectionMultiplier(const evo_engine::PlottedDistribution& distribution, + const float cluster_age_years, const float node_random, + const uint32_t salt) { + // Curve x-domain is [0,1]; map 0..2 years of cluster age onto that axis. + const float age_norm = + std::clamp(cluster_age_years / std::max(1.0e-6f, kNeedleCrossSectionTemporalWindowYears), 0.0f, 1.0f); + return EvaluatePositionalMultiplier(distribution, age_norm, node_random, salt); +} + +bool IsInternodeNode(const PineNode& node) { + return node.data.template Is(); +} + +LNodeHandle FindParentInternodeNodeHandle(const PineGraph& graph, const LNodeHandle node_handle) { + auto parent_handle = graph.PeekNode(node_handle).GetParentHandle(); + while (parent_handle >= 0) { + const auto& parent = graph.PeekNode(parent_handle); + if (IsInternodeNode(parent)) + return parent_handle; + parent_handle = parent.GetParentHandle(); + } + return -1; +} + +std::unordered_set CollectInternodeFlowHandles(const PineGraph& graph) { + std::unordered_set retained; + for (const auto flow_handle : graph.PeekSortedFlowList()) { + const auto& flow = graph.PeekFlow(flow_handle); + const auto& node_handles = flow.PeekNodeHandles(); + if (node_handles.empty()) + continue; + bool has_internode = false; + for (const auto h : node_handles) { + if (IsInternodeNode(graph.PeekNode(h))) { + has_internode = true; + break; + } + } + if (has_internode) + retained.emplace(flow_handle); + } + return retained; +} + +LFlowHandle FindParentInternodeFlowHandle(const PineGraph& graph, const LFlowHandle flow_handle, + const std::unordered_set& retained) { + auto parent_flow_handle = graph.PeekFlow(flow_handle).GetParentHandle(); + while (parent_flow_handle >= 0) { + if (retained.find(parent_flow_handle) != retained.end()) + return parent_flow_handle; + parent_flow_handle = graph.PeekFlow(parent_flow_handle).GetParentHandle(); + } + return -1; +} + +void AppendParticlesToMesh(const std::shared_ptr& scene, const Entity& entity, std::vector& out_vertices, + std::vector& out_triangles) { + if (!scene->IsEntityValid(entity) || !scene->HasPrivateComponent(entity)) + return; + const auto particles = scene->GetOrSetPrivateComponent(entity).lock(); + if (!particles) + return; + const auto mesh = particles->mesh.Get(); + const auto particle_info_list = particles->particle_info_list.Get(); + if (!mesh || !particle_info_list) + return; + const auto& source_vertices = mesh->UnsafeGetVertices(); + const auto& source_triangles = mesh->UnsafeGetTriangles(); + const auto& instances = particle_info_list->PeekParticleInfoList(); + if (source_vertices.empty() || source_triangles.empty() || instances.empty()) + return; + + const auto entity_global_transform = scene->GetDataComponent(entity); + for (const auto& instance : instances) { + const glm::mat4 world_transform = entity_global_transform.value * instance.instance_matrix.value; + if (!IsFiniteMat4(world_transform)) + continue; + const glm::mat3 world_3x3(world_transform); + glm::mat3 normal_transform(1.0f); + const float det = glm::determinant(world_3x3); + if (std::isfinite(det) && std::abs(det) > 1e-8f) { + normal_transform = glm::transpose(glm::inverse(world_3x3)); + } + const auto vertex_offset = static_cast(out_vertices.size()); + out_vertices.reserve(out_vertices.size() + source_vertices.size()); + out_triangles.reserve(out_triangles.size() + source_triangles.size()); + for (const auto& sv : source_vertices) { + Vertex v = sv; + v.position = glm::vec3(world_transform * glm::vec4(sv.position, 1.0f)); + const glm::vec3 tn = normal_transform * sv.normal; + if (IsFiniteVec3(tn) && glm::length(tn) > 1e-8f) + v.normal = glm::normalize(tn); + const glm::vec3 tt = normal_transform * sv.tangent; + if (IsFiniteVec3(tt) && glm::length(tt) > 1e-8f) + v.tangent = glm::normalize(tt); + v.color = instance.instance_color; + out_vertices.emplace_back(v); + } + for (const auto& st : source_triangles) { + out_triangles.emplace_back(vertex_offset + st.x, vertex_offset + st.y, vertex_offset + st.z); + } + } +} + +void AppendMeshRendererToMesh(const std::shared_ptr& scene, const Entity& entity, + std::vector& out_vertices, std::vector& out_triangles) { + if (!scene->IsEntityValid(entity) || !scene->HasPrivateComponent(entity)) + return; + const auto mesh_renderer = scene->GetOrSetPrivateComponent(entity).lock(); + if (!mesh_renderer) + return; + const auto mesh = mesh_renderer->mesh.Get(); + if (!mesh) + return; + + const auto& source_vertices = mesh->UnsafeGetVertices(); + const auto& source_triangles = mesh->UnsafeGetTriangles(); + if (source_vertices.empty() || source_triangles.empty()) + return; + + const auto entity_global_transform = scene->GetDataComponent(entity); + const glm::mat4 world_transform = entity_global_transform.value; + if (!IsFiniteMat4(world_transform)) + return; + + const glm::mat3 world_3x3(world_transform); + glm::mat3 normal_transform(1.0f); + const float det = glm::determinant(world_3x3); + if (std::isfinite(det) && std::abs(det) > 1e-8f) { + normal_transform = glm::transpose(glm::inverse(world_3x3)); + } + + const auto vertex_offset = static_cast(out_vertices.size()); + out_vertices.reserve(out_vertices.size() + source_vertices.size()); + out_triangles.reserve(out_triangles.size() + source_triangles.size()); + for (const auto& sv : source_vertices) { + Vertex v = sv; + v.position = glm::vec3(world_transform * glm::vec4(sv.position, 1.0f)); + const glm::vec3 tn = normal_transform * sv.normal; + if (IsFiniteVec3(tn) && glm::length(tn) > 1e-8f) + v.normal = glm::normalize(tn); + const glm::vec3 tt = normal_transform * sv.tangent; + if (IsFiniteVec3(tt) && glm::length(tt) > 1e-8f) + v.tangent = glm::normalize(tt); + out_vertices.emplace_back(v); + } + for (const auto& st : source_triangles) { + out_triangles.emplace_back(vertex_offset + st.x, vertex_offset + st.y, vertex_offset + st.z); + } +} + +// Unit cylinder: radius=1, height=1, along +Y, base at y=0. +void GenerateUnitCylinderMesh(std::vector& vertices, std::vector& indices, + const glm::vec4& bark_color, int segments = 6) { + vertices.clear(); + indices.clear(); + const float angle_step = glm::two_pi() / static_cast(segments); + for (int ring = 0; ring <= 1; ring++) { + const float y = static_cast(ring); + for (int s = 0; s < segments; s++) { + const float angle = angle_step * static_cast(s); + const float cx = std::cos(angle); + const float cz = std::sin(angle); + Vertex v; + v.position = glm::vec3(cx, y, cz); + v.normal = glm::normalize(glm::vec3(cx, 0.0f, cz)); + v.color = bark_color; + v.tex_coord = glm::vec2(static_cast(s) / static_cast(segments), y); + vertices.push_back(v); + } + } + for (int s = 0; s < segments; s++) { + const unsigned int s0 = static_cast(s); + const unsigned int s1 = static_cast((s + 1) % segments); + const unsigned int e0 = s0 + static_cast(segments); + const unsigned int e1 = s1 + static_cast(segments); + indices.push_back(s0); + indices.push_back(e0); + indices.push_back(s1); + indices.push_back(s1); + indices.push_back(e0); + indices.push_back(e1); + } +} + +// Unit octahedron centered at origin, radius=1 along each axis. +void GenerateUnitOctahedronMesh(std::vector& vertices, std::vector& indices, + const glm::vec4& needle_color) { + vertices.clear(); + indices.clear(); + const std::array positions = {glm::vec3(1, 0, 0), glm::vec3(-1, 0, 0), glm::vec3(0, 1, 0), + glm::vec3(0, -1, 0), glm::vec3(0, 0, 1), glm::vec3(0, 0, -1)}; + for (const auto& p : positions) { + Vertex v; + v.position = p; + v.normal = glm::normalize(p); + v.color = needle_color; + v.tex_coord = glm::vec2(0.5f, 0.5f); + vertices.push_back(v); + } + // 8 triangular faces. + const std::array tris = {glm::uvec3(0, 2, 4), glm::uvec3(2, 1, 4), glm::uvec3(1, 3, 4), + glm::uvec3(3, 0, 4), glm::uvec3(2, 0, 5), glm::uvec3(1, 2, 5), + glm::uvec3(3, 1, 5), glm::uvec3(0, 3, 5)}; + for (const auto& t : tris) { + indices.push_back(t.x); + indices.push_back(t.y); + indices.push_back(t.z); + } +} + +// --------------------------------------------------------------------------- +// Phase 1: Build a single aggregate mesh containing every needle in the +// tree, swept as a generalized cylinder along a (currently straight) +// per-needle OrganCenterline using an EllipticProfile with independent +// width/thickness curves. Anchor = parent internode's distal tangent. +// +// This is the visual seam that Phases 2-5 will animate by mutating each +// needle's centerline control points (differential growth + elastica). +// All other geometry stays where it is. +// --------------------------------------------------------------------------- +struct NeedleAnchor { + glm::vec3 base_position; ///< World-space attachment point. + glm::quat orientation; ///< Rotates +Z (needle local forward) to outward direction. + glm::vec3 base_adaxial_world; ///< World-space adaxial reference at the base. +}; + +inline NeedleAnchor ComputeFascicleNeedleAnchor(const PineNode& /*cluster_node*/, const PineNode& parent_internode_node, + float s_along_parent_norm, float roll_offset_deg, + float branching_angle_deg, int needle_index_in_cluster, + int needle_count_in_cluster, float cluster_random) { + // Parent internode tangent and rolled radial basis in world space. + // Using the internode's world rotation preserves phyllotactic roll. + glm::vec3 parent_dir = parent_internode_node.info.GetGlobalDirection(); + if (!IsFiniteVec3(parent_dir) || glm::length(parent_dir) <= 1e-8f) { + parent_dir = glm::vec3(0.0f, 0.0f, -1.0f); + } else { + parent_dir = glm::normalize(parent_dir); + } + + glm::vec3 perp = parent_internode_node.info.global_rotation * glm::vec3(1.0f, 0.0f, 0.0f); + if (!IsFiniteVec3(perp) || glm::length(perp) <= 1e-8f) { + // Fallback keeps anchors valid even if an upstream rotation is degenerate. + glm::vec3 ref(0.0f, 1.0f, 0.0f); + if (std::abs(glm::dot(ref, parent_dir)) > 0.95f) + ref = glm::vec3(1.0f, 0.0f, 0.0f); + perp = ref - parent_dir * glm::dot(ref, parent_dir); + } + const float plen = glm::length(perp); + perp = (plen > 1e-8f) ? (perp / plen) : glm::vec3(1.0f, 0.0f, 0.0f); + + // Apply per-cluster azimuthal phyllotactic roll around the parent axis so + // sibling clusters on the same shoot fan out by ~137.5 degrees. + const glm::quat roll_q = glm::angleAxis(glm::radians(roll_offset_deg), parent_dir); + perp = glm::normalize(roll_q * perp); + + // Upward fan around the parent axis. For the common 2-needle fascicle, keep + // both needles in a narrow V instead of placing them 180 degrees apart + // (which frequently sends one needle downward and reads as "spaghetti"). + const float kFanHalfAngleRad = glm::radians(12.0f); + const float kFanJitterRad = glm::radians(4.0f); + const float fan_jitter = (cluster_random - 0.5f) * 2.0f * kFanJitterRad; + float fan_t = 0.0f; + if (needle_count_in_cluster > 1) { + fan_t = static_cast(needle_index_in_cluster) / static_cast(needle_count_in_cluster - 1); + fan_t = fan_t * 2.0f - 1.0f; + } + const float angle = fan_t * kFanHalfAngleRad + fan_jitter; + const glm::quat about_axis = glm::angleAxis(angle, parent_dir); + const glm::vec3 radial = about_axis * perp; + + // Branching angle is measured from the apical axis (parent_dir). 0 deg + // means fully apical; increasing angle opens the fascicle toward radial. + const float branching_angle_rad = glm::radians(std::clamp(branching_angle_deg, 0.0f, 89.5f)); + const glm::vec3 needle_dir = + glm::normalize(std::cos(branching_angle_rad) * parent_dir + std::sin(branching_angle_rad) * radial); + + // Build a stable local frame instead of a shortest-arc quaternion so the + // local x-z bending plane is locked to the stem-facing radial plane. + // +Z = needle forward, +X = adaxial->abaxial direction (outward from stem), + // +Y = completes a right-handed basis. + const glm::vec3 z_axis = needle_dir; + glm::vec3 x_axis = radial - z_axis * glm::dot(radial, z_axis); + if (!IsFiniteVec3(x_axis) || glm::length(x_axis) <= 1e-8f) { + glm::vec3 ref(0.0f, 1.0f, 0.0f); + if (std::abs(glm::dot(ref, z_axis)) > 0.95f) + ref = glm::vec3(1.0f, 0.0f, 0.0f); + x_axis = ref - z_axis * glm::dot(ref, z_axis); + } + x_axis = glm::normalize(x_axis); + glm::vec3 y_axis = glm::cross(z_axis, x_axis); + if (!IsFiniteVec3(y_axis) || glm::length(y_axis) <= 1e-8f) { + y_axis = glm::vec3(0.0f, 1.0f, 0.0f); + } else { + y_axis = glm::normalize(y_axis); + } + x_axis = glm::normalize(glm::cross(y_axis, z_axis)); + const glm::mat3 basis_world_from_local(x_axis, y_axis, z_axis); + const glm::quat orientation = glm::normalize(glm::quat_cast(basis_world_from_local)); + + // Use -X as the adaxial reference direction for the ellipsoid profile. + const glm::vec3 adaxial_world = glm::normalize(orientation * glm::vec3(-1, 0, 0)); + + // Anchor at fraction `s_along_parent_norm` along the parent shoot's length. + // Parent's `global_position` is the proximal end; segment extends along + // parent_dir for `parent.info.length` units. + const float s_clamped = std::clamp(s_along_parent_norm, 0.0f, 1.0f); + const float parent_length = std::max(0.0f, parent_internode_node.info.length); + const glm::vec3 anchor_pos = parent_internode_node.info.global_position + parent_dir * (s_clamped * parent_length); + + NeedleAnchor anchor; + anchor.base_position = anchor_pos; + anchor.orientation = orientation; + anchor.base_adaxial_world = adaxial_world; + return anchor; +} + +/// Build a single aggregate triangle mesh containing every alive needle in +/// the tree. Vertices are emitted in world space (anchor transforms baked +/// in) so the consuming `Particles` instance uses an identity matrix. +inline void BuildPineNeedleAggregateMesh( + const PineGraph& graph, const std::vector& sorted_nodes, const ScotsPine::ColorMode color_mode, + const glm::vec4& needle_young_color, const glm::vec4& needle_old_color, const float needle_axial_age_span, + const float needle_axial_age_exponent, const PlottedDistribution& needle_cross_section_width_profile, + const PlottedDistribution& needle_cross_section_thickness_profile, + const PlottedDistribution& needle_cross_section_temporal_maturity_curve, + const int needle_fascicular_start_year, const float needle_lignification_factor_year1, + const float needle_lignification_factor_year2plus, const float needle_stomatal_strip_density_year1, + const float needle_stomatal_strip_density_year2plus, const float needle_basal_taper_ratio_year1, + const float needle_basal_taper_ratio_year2plus, const float needle_fascicle_sheath_budget_years, + const float needle_specularity_plasticity_year1, const float needle_specularity_plasticity_year2plus, + std::vector& out_vertices, std::vector& out_triangles, const int station_count, + const int perimeter_count, std::vector* out_needle_skeleton_lines) { + out_vertices.clear(); + out_triangles.clear(); + if (out_needle_skeleton_lines) { + out_needle_skeleton_lines->clear(); + } + + // Geometry parameters (Phase 1 defaults). station/perimeter are caller- + // supplied so auto-grow can use a cheaper tessellation while growth is + // advancing, then recover high quality when growth pauses. + const int kStations = std::max(4, station_count); + const int kPerimeter = std::max(3, perimeter_count); + constexpr float kNeedleDefaultWidthRadiusM = 0.0007f; // ~0.7 mm half-width. + constexpr float kNeedleDefaultThicknessRadiusM = 0.00045f; // ~0.45 mm half-thickness. + + EllipticProfile ellipsoid_profile(/*aspect_ratio=*/1.0f, /*twist_radians=*/0.0f); + + for (const auto handle : sorted_nodes) { + const auto& node = graph.PeekNode(handle); + if (!node.data.template Is()) + continue; + const auto& cluster = node.data.template Get(); + if (!cluster.alive) + continue; + + const LNodeHandle parent_handle = FindParentInternodeNodeHandle(graph, handle); + if (parent_handle < 0) + continue; + const auto& parent_node = graph.PeekNode(parent_handle); + + const float sen = std::clamp(cluster.senescence_phase, 0.0f, 1.0f); + const float cluster_age_years = + std::max(0.0f, graph.data.clock.NowYears() - cluster.continuous_growth.t_init_years); + const float cluster_lifespan_years = std::max(0.25f, static_cast(cluster.lifespan_years)); + const float cluster_age_norm = std::clamp(cluster_age_years / cluster_lifespan_years, 0.0f, 1.0f); + const float cluster_branching_angle_deg = std::clamp(cluster.branching_angle_deg, 0.0f, 89.5f); + const float clamped_axial_exponent = std::max(0.1f, needle_axial_age_exponent); + const float length_mult = glm::mix(1.0f, 0.82f, sen); + const bool year2plus_cohort = cluster.initiation_year_index >= std::max(0, needle_fascicular_start_year); + const float cohort_lignification_factor = year2plus_cohort + ? std::clamp(needle_lignification_factor_year2plus, 0.0f, 2.0f) + : std::clamp(needle_lignification_factor_year1, 0.0f, 2.0f); + const float cohort_stomatal_strip_density = year2plus_cohort + ? std::clamp(needle_stomatal_strip_density_year2plus, 0.0f, 1.0f) + : std::clamp(needle_stomatal_strip_density_year1, 0.0f, 1.0f); + const float cohort_basal_taper_ratio = year2plus_cohort ? std::clamp(needle_basal_taper_ratio_year2plus, 0.6f, 1.2f) + : std::clamp(needle_basal_taper_ratio_year1, 0.6f, 1.2f); + const float cohort_specularity_plasticity = year2plus_cohort + ? std::clamp(needle_specularity_plasticity_year2plus, 0.0f, 1.0f) + : std::clamp(needle_specularity_plasticity_year1, 0.0f, 1.0f); + const float sheath_budget_years = std::max(0.0f, needle_fascicle_sheath_budget_years); + + // Phase 6 seedling realism pass: drive rendered needle thickness from + // material profile radii when available, with Phase 1 taper as a fallback. + const float t_now_years = graph.data.clock.NowYears(); + const float maturation_multiplier = + (cluster.continuous_growth.maturation_years > 0.0f) ? cluster.continuous_growth.Multiplier(t_now_years) : 1.0f; + + // Phase 4: world-frame gravity vector (-Y world-up convention). + const float gravity_mag = graph.data.gravity_m_s2; + const glm::vec3 gravity_world(0.0f, -gravity_mag, 0.0f); + + for (int n = 0; n < std::max(1, cluster.count); ++n) { + const PineNeedleInstanceProfile* needle_profile = (static_cast(n) < cluster.per_needle_profiles.size()) + ? &cluster.per_needle_profiles[static_cast(n)] + : nullptr; + const BilateralGrowthField1D& needle_growth_field = + needle_profile ? needle_profile->growth_field : cluster.growth_field; + const MaterialProfile1D& needle_material_profile = + needle_profile ? needle_profile->material_profile : cluster.material_profile; + MaterialProfile1D matured_material_profile = needle_material_profile; + const float needle_length_scale = needle_profile ? std::clamp(needle_profile->length_scale, 0.05f, 3.0f) : 1.0f; + const float needle_radius_scale = needle_profile ? std::clamp(needle_profile->radius_scale, 0.05f, 3.0f) : 1.0f; + const float needle_wave_amplitude_deg = needle_profile + ? std::clamp(needle_profile->sinusoidal_amplitude_deg, 0.0f, 45.0f) + : std::clamp(cluster.sinusoidal_amplitude_deg, 0.0f, 45.0f); + const float needle_wave_frequency_cycles = + needle_profile ? std::clamp(needle_profile->sinusoidal_frequency_cycles, 0.0f, 12.0f) + : std::clamp(cluster.sinusoidal_frequency_cycles, 0.0f, 12.0f); + const float needle_wave_phase_rad = + needle_profile ? needle_profile->sinusoidal_phase_rad : cluster.sinusoidal_phase_rad; + // Use chronological age so fascicle opening continues through dormant season. + const float needle_relax_years = needle_profile ? std::max(0.0f, needle_profile->branching_relax_years) + : std::max(0.0f, cluster.branching_relax_years); + const float needle_relax_progress = + (needle_relax_years <= 1e-5f) ? 1.0f : std::clamp(cluster_age_years / needle_relax_years, 0.0f, 1.0f); + const float active_branching_angle_deg = cluster_branching_angle_deg * needle_relax_progress; + + const bool has_profile_radii = + std::isfinite(matured_material_profile.base_radius_m) && + std::isfinite(matured_material_profile.tip_radius_m) && + (matured_material_profile.base_radius_m > 0.0f || matured_material_profile.tip_radius_m > 0.0f); + const float fallback_width_radius_m = + has_profile_radii ? std::max(matured_material_profile.base_radius_m, 0.0f) : kNeedleDefaultWidthRadiusM; + const float fallback_thickness_radius_m = + has_profile_radii ? std::max(matured_material_profile.tip_radius_m, 0.0f) : kNeedleDefaultThicknessRadiusM; + const float raw_cluster_width_radius_m = (cluster.cross_section_width_radius_m > 0.0f) + ? cluster.cross_section_width_radius_m + : fallback_width_radius_m; + const float raw_cluster_thickness_radius_m = (cluster.cross_section_thickness_radius_m > 0.0f) + ? cluster.cross_section_thickness_radius_m + : fallback_thickness_radius_m; + const float clamped_cluster_width_radius_m = std::max(raw_cluster_width_radius_m, 0.00002f); + const float clamped_cluster_thickness_radius_m = std::max(raw_cluster_thickness_radius_m, 0.00002f); + std::vector width_radius_table; + std::vector thickness_radius_table; + width_radius_table.reserve(static_cast(kStations)); + thickness_radius_table.reserve(static_cast(kStations)); + const float radius_vigor_scale = std::clamp(cluster.render_radius_scale * needle_radius_scale, 0.10f, 4.0f); + const uint32_t node_hash = static_cast(node.GetIndex()); + const uint32_t needle_hash = static_cast(n); + const uint32_t width_seed = node_hash ^ (needle_hash * 0x9e3779b9u) ^ 0x2d9c8f13u; + const uint32_t thickness_seed = node_hash ^ (needle_hash * 0x85ebca6bu) ^ 0xa5b35705u; + const uint32_t temporal_seed = node_hash ^ (needle_hash * 0xc2b2ae35u) ^ 0x4f1bbcdcu; + const float temporal_cross_section_multiplier = EvaluateTemporalCrossSectionMultiplier( + needle_cross_section_temporal_maturity_curve, cluster_age_years, cluster.node_random, temporal_seed); + for (int i = 0; i < kStations; ++i) { + const float s_norm = static_cast(i) / static_cast(kStations - 1); + const float width_profile_multiplier = EvaluatePositionalMultiplier( + needle_cross_section_width_profile, s_norm, cluster.node_random, width_seed ^ static_cast(i)); + const float thickness_profile_multiplier = + EvaluatePositionalMultiplier(needle_cross_section_thickness_profile, s_norm, cluster.node_random, + thickness_seed ^ static_cast(i)); + const float raw_width_radius = + clamped_cluster_width_radius_m * width_profile_multiplier * temporal_cross_section_multiplier; + const float raw_thickness_radius = + clamped_cluster_thickness_radius_m * thickness_profile_multiplier * temporal_cross_section_multiplier; + const float scaled_width_radius = raw_width_radius * radius_vigor_scale; + const float scaled_thickness_radius = raw_thickness_radius * radius_vigor_scale; + const float safe_width_radius = std::max(scaled_width_radius, 0.00002f); + const float safe_thickness_radius = std::max(scaled_thickness_radius, 0.00002f); + const float basal_taper_multiplier = glm::mix(cohort_basal_taper_ratio, 1.0f, s_norm); + const float tapered_width_radius = std::max(safe_width_radius * basal_taper_multiplier, 0.00002f); + const float tapered_thickness_radius = std::max(safe_thickness_radius * basal_taper_multiplier, 0.00002f); + width_radius_table.push_back(tapered_width_radius); + thickness_radius_table.push_back(tapered_thickness_radius); + } + const float base_radius_m = width_radius_table.empty() ? kNeedleDefaultWidthRadiusM : width_radius_table.front(); + const float secondary_base_radius_m = thickness_radius_table.empty() + ? kNeedleDefaultThicknessRadiusM + : std::max(thickness_radius_table.front(), 0.00002f); + + // Phase 3: bent centerline driven by a per-needle bilateral growth + // field, ramped by the cluster's continuous-growth multiplier. + const float length = std::max(0.001f, cluster.length * length_mult * needle_length_scale); + OrganCenterline intrinsic_centerline = + BuildBentNeedleCenterline(length, /*segments=*/kStations - 1, needle_growth_field, maturation_multiplier, + needle_wave_amplitude_deg, needle_wave_frequency_cycles, needle_wave_phase_rad); + + const bool mechanics_active = matured_material_profile.IsActive() && gravity_mag > 0.0f; + const NeedleAnchor anchor = + ComputeFascicleNeedleAnchor(node, parent_node, cluster.s_along_parent_norm, cluster.roll_offset_deg, + active_branching_angle_deg, n, cluster.count, cluster.node_random); + + // Phase 4: per-needle elastica solve. The intrinsic centerline gives + // kappa_intrinsic(s) and the deflection plane (local x-z); gravity is + // projected from world into needle-local coordinates and decomposed + // onto that plane. When the material profile is inert, this whole + // block is skipped and we fall back to the Phase 3 intrinsic shape. + OrganCenterline centerline = intrinsic_centerline; + if (mechanics_active) { + // Project gravity into the needle's local frame. + const glm::quat q_inv = glm::conjugate(anchor.orientation); + const glm::vec3 g_local = q_inv * gravity_world; + // Use only the in-plane (x, z) components - the cross-plane Y + // component would induce twist; Phase 4 intentionally restricts + // bending to the same plane the intrinsic curvature lives in. + const glm::vec2 g_xz(g_local.x, g_local.z); + + // Sample intrinsic curvature from the un-deflected centerline by + // numerical differentiation of its tangent angle. (Closed-form + // would require exposing the field math here; this is cheap.) + std::vector kappa_table(kStations, 0.0f); + { + const float ds = length / static_cast(kStations - 1); + float prev_theta = 0.0f; + for (int i = 0; i < kStations; ++i) { + const float s_i = static_cast(i) * ds; + const auto sample = intrinsic_centerline.Sample(s_i); + const float theta_i = std::atan2(sample.tangent.x, sample.tangent.z); + if (i == 0) { + kappa_table[0] = 0.0f; + } else { + kappa_table[i] = (theta_i - prev_theta) / ds; + } + prev_theta = theta_i; + } + } + + PlanarElasticaInput esi; + esi.length_m = length; + esi.station_count = kStations; + esi.intrinsic_curvature_per_m = [&kappa_table](float s_norm) { + const int N = static_cast(kappa_table.size()); + const float idx_f = std::clamp(s_norm, 0.0f, 1.0f) * static_cast(N - 1); + const int i0 = std::clamp(static_cast(std::floor(idx_f)), 0, N - 1); + const int i1 = std::min(i0 + 1, N - 1); + const float u = idx_f - static_cast(i0); + return kappa_table[i0] * (1.0f - u) + kappa_table[i1] * u; + }; + esi.bending_stiffness_Pa_m4 = [&matured_material_profile, t_now_years](float s_norm) { + return matured_material_profile.BendingStiffness_Pa_m4(s_norm, t_now_years); + }; + esi.mass_per_length_kg_m = [&matured_material_profile](float s_norm) { + return matured_material_profile.MassPerLength_kg_m(s_norm); + }; + esi.gravity_acceleration_xz_m_s2 = g_xz; + esi.relaxation = 0.6f; + esi.tolerance_rad = 1e-5f; + esi.max_iterations = 24; + const PlanarElasticaOutput eso = SolvePlanarElastica(esi); + + // Convert solver positions back into a centerline (planar, x-z). + OrganCenterline deflected; + auto& cps = deflected.MutableControlPoints(); + cps.resize(static_cast(kStations)); + for (int i = 0; i < kStations; ++i) { + cps[static_cast(i)] = glm::vec3(eso.positions_xz[i].x, 0.0f, eso.positions_xz[i].y); + } + deflected.Invalidate(); + centerline = std::move(deflected); + } + + if (out_needle_skeleton_lines) { + ScotsPine::NeedleSkeletonLine line; + line.cluster_node_handle = static_cast(handle); + line.parent_node_handle = static_cast(parent_handle); + line.needle_index = n; + + const float centerline_length = std::max(0.0f, centerline.TotalLength()); + const int station_n = std::max(2, kStations); + line.points_world.reserve(static_cast(station_n)); + + for (int si = 0; si < station_n; ++si) { + const float s_norm = static_cast(si) / static_cast(station_n - 1); + const float s = centerline_length * s_norm; + const auto sample = centerline.Sample(s); + const glm::vec3 world_pos = anchor.base_position + anchor.orientation * sample.position; + line.points_world.emplace_back(world_pos); + } + + out_needle_skeleton_lines->emplace_back(std::move(line)); + } + + GeneralizedCylinderMesherConfig cfg; + cfg.station_count = kStations; + cfg.perimeter_count = kPerimeter; + std::vector station_lignification; + std::vector station_stripe_proxy; + std::vector station_stripe_scale; + std::vector station_sheath_visibility; + station_lignification.reserve(static_cast(kStations)); + station_stripe_proxy.reserve(static_cast(kStations)); + station_stripe_scale.reserve(static_cast(kStations)); + station_sheath_visibility.reserve(static_cast(kStations)); + + const float stripe_strength = cohort_stomatal_strip_density * cohort_specularity_plasticity * 0.08f; + for (int i = 0; i < kStations; ++i) { + const float s_norm = static_cast(i) / static_cast(kStations - 1); + const float axial = std::pow(s_norm, clamped_axial_exponent); + const float axial_shift = (axial - 0.5f) * 2.0f; + const float segment_age_norm = std::clamp(cluster_age_norm + needle_axial_age_span * axial_shift, 0.0f, 1.0f); + float segment_oldness = std::clamp(std::max(segment_age_norm, sen) * cohort_lignification_factor, 0.0f, 1.0f); + float sheath_visibility = 0.0f; + if (year2plus_cohort && sheath_budget_years > 0.0f) { + const float sheath_progress = std::clamp(cluster_age_years / sheath_budget_years, 0.0f, 1.0f); + const float sheath_mask = std::clamp(1.0f - s_norm * 3.5f, 0.0f, 1.0f); + sheath_visibility = std::clamp(sheath_progress * sheath_mask, 0.0f, 1.0f); + segment_oldness = std::clamp(std::max(segment_oldness, sheath_visibility), 0.0f, 1.0f); + } + const float stripe_phase = (s_norm * 48.0f + cluster.node_random * 17.0f) * glm::two_pi(); + const float stripe_wave = 0.5f + 0.5f * std::sin(stripe_phase); + const float stripe_scale = 1.0f - stripe_strength + stripe_strength * stripe_wave; + const float stripe_proxy = + std::clamp(cohort_stomatal_strip_density * cohort_specularity_plasticity * stripe_wave, 0.0f, 1.0f); + + station_lignification.emplace_back(segment_oldness); + station_stripe_proxy.emplace_back(stripe_proxy); + station_stripe_scale.emplace_back(stripe_scale); + station_sheath_visibility.emplace_back(sheath_visibility); + } + + if (color_mode == ScotsPine::ColorMode::ByNode) { + const glm::vec4 node_color = HashToColor(static_cast(node.GetIndex())); + cfg.vertex_color = node_color; + cfg.station_color_table.assign(static_cast(kStations), node_color); + } else if (color_mode == ScotsPine::ColorMode::ByInstance) { + // ByInstance is driven by particle instance tint; keep vertex colors neutral. + cfg.vertex_color = glm::vec4(1.0f); + cfg.station_color_table.clear(); + } else { + cfg.station_color_table.clear(); + cfg.station_color_table.reserve(static_cast(kStations)); + for (int i = 0; i < kStations; ++i) { + const float segment_oldness = station_lignification[static_cast(i)]; + const float stripe_proxy = station_stripe_proxy[static_cast(i)]; + const float stripe_scale = station_stripe_scale[static_cast(i)]; + const float sheath_visibility = station_sheath_visibility[static_cast(i)]; + glm::vec4 segment_color; + if (color_mode == ScotsPine::ColorMode::NeedleLignification) { + const glm::vec3 low(0.05f, 0.28f, 0.08f); + const glm::vec3 high(0.75f, 0.42f, 0.10f); + segment_color = glm::vec4(glm::mix(low, high, segment_oldness), 1.0f); + } else if (color_mode == ScotsPine::ColorMode::NeedleStripeProxy) { + const glm::vec3 low(0.04f, 0.08f, 0.20f); + const glm::vec3 high(0.80f, 0.95f, 1.00f); + segment_color = glm::vec4(glm::mix(low, high, stripe_proxy), 1.0f); + } else if (color_mode == ScotsPine::ColorMode::NeedleSheath) { + const glm::vec3 low(0.06f, 0.06f, 0.06f); + const glm::vec3 high(0.95f, 0.75f, 0.20f); + segment_color = glm::vec4(glm::mix(low, high, sheath_visibility), 1.0f); + } else { + segment_color = glm::mix(needle_young_color, needle_old_color, segment_oldness); + segment_color.r *= stripe_scale; + segment_color.g *= stripe_scale; + segment_color.b *= stripe_scale; + } + cfg.station_color_table.emplace_back(segment_color); + } + cfg.vertex_color = cfg.station_color_table.front(); + } + cfg.anchor_position = anchor.base_position; + cfg.anchor_rotation = anchor.orientation; + cfg.base_radius = base_radius_m; + cfg.radius_table = width_radius_table; + cfg.secondary_base_radius = secondary_base_radius_m; + cfg.secondary_radius_table = thickness_radius_table; + cfg.base_adaxial = glm::vec3(1, 0, 0); // +X in needle-local frame. + + const std::size_t vertex_start = out_vertices.size(); + SweepGeneralizedCylinder(centerline, ellipsoid_profile, cfg, out_vertices, out_triangles); + const std::size_t appended_vertices = out_vertices.size() - vertex_start; + const std::size_t expected_vertices = static_cast(kStations) * static_cast(kPerimeter); + const std::size_t metadata_vertices = std::min(appended_vertices, expected_vertices); + // Pack station signals for future shader-side parity checks and overlays. + for (std::size_t local_index = 0; local_index < metadata_vertices; ++local_index) { + const std::size_t station_index = + std::min(static_cast(kStations - 1), local_index / static_cast(kPerimeter)); + auto& vertex = out_vertices[vertex_start + local_index]; + vertex.vertex_info1 = station_lignification[station_index]; + vertex.vertex_info2 = station_stripe_proxy[station_index]; + vertex.vertex_info3 = station_sheath_visibility[station_index]; + vertex.vertex_info4.x = cohort_stomatal_strip_density; + vertex.vertex_info4.y = cohort_specularity_plasticity; + } + } + } +} + +} // namespace + +// =========================================================================== + +float ScotsPine::GetInfancyTargetGDD() const { + float descriptor_tgt = 18000.0f; + if (auto desc = const_cast(descriptor_ref).Get()) { + std::mt19937 rng(seed); + descriptor_tgt = std::max(0.0f, SampleDistribution(desc->target_gdd, rng)); + } + if (descriptor_tgt <= 0.0f) { + return 0.0f; + } + constexpr float kWarmStartRatio = 0.20f; + constexpr float kWarmStartMinGdd = 250.0f; + constexpr float kWarmStartMaxGdd = 900.0f; + const float warm_start = std::clamp(descriptor_tgt * kWarmStartRatio, kWarmStartMinGdd, kWarmStartMaxGdd); + return std::min(warm_start, descriptor_tgt); +} + +void ScotsPine::SetGlobalColorMode(const ColorMode mode) { + g_scots_pine_color_mode = mode; +} +ScotsPine::ColorMode ScotsPine::GetGlobalColorMode() { + return g_scots_pine_color_mode; +} +void ScotsPine::SetForceCpuParticlesPath(const bool force) { + g_force_cpu_particles_path.store(force, std::memory_order_relaxed); +} +bool ScotsPine::IsForceCpuParticlesPath() { + return g_force_cpu_particles_path.load(std::memory_order_relaxed); +} + +void ScotsPine::SetInternodeVisualRadiusMultiplier(const float multiplier) { + // Clamp to a sensible non-negative range. Caller may pass 0 to make the + // visualised trunk vanish; negative values are nonsensical here. + const float safe = std::isfinite(multiplier) ? std::max(multiplier, 0.0f) : 1.0f; + g_internode_visual_radius_multiplier.store(safe, std::memory_order_relaxed); +} +float ScotsPine::GetInternodeVisualRadiusMultiplier() { + return g_internode_visual_radius_multiplier.load(std::memory_order_relaxed); +} + +void ScotsPine::SetRenderNeedlesEnabled(const bool enabled) { + g_render_needles_enabled.store(enabled, std::memory_order_relaxed); +} +bool ScotsPine::IsRenderNeedlesEnabled() { + return g_render_needles_enabled.load(std::memory_order_relaxed); +} + +void ScotsPine::SetGenerateNeedleTopologyEnabled(const bool enabled) { + g_generate_needle_topology_enabled.store(enabled, std::memory_order_relaxed); +} +bool ScotsPine::IsGenerateNeedleTopologyEnabled() { + return g_generate_needle_topology_enabled.load(std::memory_order_relaxed); +} + +void ScotsPine::SetLeaderInternodeDebugColor(const glm::vec4& color) { + std::lock_guard lock(g_leader_internode_debug_color_mutex); + g_leader_internode_debug_color = color; +} +glm::vec4 ScotsPine::GetLeaderInternodeDebugColor() { + std::lock_guard lock(g_leader_internode_debug_color_mutex); + return g_leader_internode_debug_color; +} + +namespace { +std::shared_ptr ResolvePostRepotDescriptorForGrowth(ScotsPine& pine) { + if (!pine.enable_repot_profile_switch) { + return nullptr; + } + return pine.post_repot_descriptor_ref.Get(); +} + +float ResolveRepotSwitchGddForGrowth(const ScotsPine& pine, + const std::shared_ptr& post_descriptor) { + if (!post_descriptor) { + return -1.0f; + } + return std::max(0.0f, pine.repot_switch_gdd); +} + +bool ResolveNeedleTopologyEnabledForGrowth() { + return g_generate_needle_topology_enabled.load(std::memory_order_relaxed); +} +} // namespace + +// =========================================================================== + +void ScotsPine::ClearGeometryEntities() const { + if (render_target_) { + render_target_->ClearAllChannels(); + } + last_needle_skeleton_lines.clear(); + + // One-time migration cleanup for legacy child entities from pre-channel builds. + const auto scene = GetScene(); + if (!scene) { + return; + } + const auto self = GetOwner(); + if (!scene->IsEntityValid(self)) { + return; + } + + const auto children = scene->GetChildren(self); + for (const auto& child : children) { + const auto name = scene->GetEntityName(child); + if (name == "Pine Stem Container" || name == "Pine Needles Container" || name == "Pine Internodes" || + name == "Pine Needles" || name == "Pine Needles Geometry") { + scene->DeleteEntity(child); + } + } +} + +// =========================================================================== +// Generate / Preview / Grow +// =========================================================================== + +void ScotsPine::GenerateGeometryEntities(const bool uncapped_growth) { + ClearGeometryEntities(); + growth_model.Reset(); + GrowToTargetGDD(uncapped_growth); +} + +void ScotsPine::GeneratePreviewGeometryEntities(const float preview_target_gdd, + const uint32_t preview_max_growth_steps) { + ClearGeometryEntities(); + growth_model.Reset(); + + const auto& times = GetApplication().GetTimes(); + const double grow_start = times.Now(); + auto descriptor = descriptor_ref.Get(); + if (!descriptor) { + last_grow_seconds = 0.0; + return; + } + const auto post_descriptor = ResolvePostRepotDescriptorForGrowth(*this); + const bool enable_needle_topology = ResolveNeedleTopologyEnabledForGrowth(); + growth_model.Initialize(*descriptor, seed, glm::vec3(0), kDefaultRootRotation, post_descriptor.get(), + ResolveRepotSwitchGddForGrowth(*this, post_descriptor), enable_needle_topology); + const float clamped_target_gdd = std::min(target_gdd, std::max(0.0f, preview_target_gdd)); + const uint32_t step_cap = std::max(1u, preview_max_growth_steps); + growth_model.GrowToGDDWithProfileSwitch(clamped_target_gdd, step_cap); + + last_grow_seconds = times.Now() - grow_start; + RebuildGeometry(); +} + +void ScotsPine::GrowToTargetGDD(const bool uncapped_growth) { + (void)uncapped_growth; + const auto& times = GetApplication().GetTimes(); + const double grow_start = times.Now(); + auto descriptor = descriptor_ref.Get(); + if (!descriptor) { + last_grow_seconds = 0.0; + return; + } + const auto post_descriptor = ResolvePostRepotDescriptorForGrowth(*this); + const float repot_switch_gdd_value = ResolveRepotSwitchGddForGrowth(*this, post_descriptor); + const bool enable_needle_topology = ResolveNeedleTopologyEnabledForGrowth(); + bool reinitialized = false; + if (!growth_model.IsInitialized()) { + growth_model.Initialize(*descriptor, seed, glm::vec3(0), kDefaultRootRotation, post_descriptor.get(), + repot_switch_gdd_value, enable_needle_topology); + reinitialized = true; + } + const bool topology_policy_changed = growth_model.IsNeedleTopologyEnabled() != enable_needle_topology; + // Backward-scrubbing: if the user has dragged target_gdd backward beyond a step, + // re-init from scratch so geometry shrinks instead of being stuck at the high-water mark. + const float gdd_step = std::max(1e-5f, growth_model.gdd_per_growth_step); + if (topology_policy_changed || target_gdd + gdd_step < growth_model.accumulated_gdd) { + growth_model.Initialize(*descriptor, seed, glm::vec3(0), kDefaultRootRotation, post_descriptor.get(), + repot_switch_gdd_value, enable_needle_topology); + reinitialized = true; + } + growth_model.GrowToGDDWithProfileSwitch(target_gdd); + last_grow_seconds = times.Now() - grow_start; + + const float current_internode_visual_radius_multiplier = + std::max(0.0f, g_internode_visual_radius_multiplier.load(std::memory_order_relaxed)); + const bool current_render_needles_enabled = g_render_needles_enabled.load(std::memory_order_relaxed); + const glm::vec4 current_leader_debug_color = []() { + std::lock_guard lock(g_leader_internode_debug_color_mutex); + return g_leader_internode_debug_color; + }(); + const int current_color_mode = static_cast(GetGlobalColorMode()); + const auto approx_equal = [](const float a, const float b) { + return std::isfinite(a) && std::isfinite(b) && std::abs(a - b) <= 1.0e-6f; + }; + const auto vec4_equal = [&](const glm::vec4& a, const glm::vec4& b) { + return approx_equal(a.x, b.x) && approx_equal(a.y, b.y) && approx_equal(a.z, b.z) && approx_equal(a.w, b.w); + }; + const bool visual_settings_changed = + !approx_equal(last_applied_internode_visual_radius_multiplier, current_internode_visual_radius_multiplier) || + last_applied_render_needles_enabled != current_render_needles_enabled || + !vec4_equal(last_applied_leader_debug_color, current_leader_debug_color) || + last_applied_color_mode != current_color_mode; + + // Auto-grow calls this every frame; when no growth step was taken, a full + // mesh rebuild is wasted work. Preserve exact behavior on explicit + // reinitialization (backward scrub), where geometry must always be refreshed. + // But loaded scenes may deserialize stale particle buffers; if any visual-only + // knob changed, force one rebuild even when growth itself did not advance. + if (!reinitialized && growth_model.last_growth_steps == 0 && !visual_settings_changed) { + return; + } + RebuildGeometry(); +} + +void ScotsPine::SetSeasonalChronologicalMode(const bool enable_independent_chronological_clock) { + growth_model.SetChronologicalCoupledToThermal(!enable_independent_chronological_clock); +} + +bool ScotsPine::AdvanceChronologicalAging(const float delta_years) { + if (!std::isfinite(delta_years) || delta_years <= 0.0f) { + return false; + } + + auto descriptor = descriptor_ref.Get(); + if (!descriptor) { + return false; + } + + if (!growth_model.IsInitialized()) { + const auto post_descriptor = ResolvePostRepotDescriptorForGrowth(*this); + const bool enable_needle_topology = ResolveNeedleTopologyEnabledForGrowth(); + growth_model.Initialize(*descriptor, seed, glm::vec3(0), kDefaultRootRotation, post_descriptor.get(), + ResolveRepotSwitchGddForGrowth(*this, post_descriptor), enable_needle_topology); + } + + growth_model.AdvanceChronologicalYears(delta_years); + const bool changed = growth_model.AgeOnlyStep(); + if (changed) { + RebuildGeometry(); + } + return changed; +} + +// =========================================================================== +// Rebuild geometry from current growth model state +// =========================================================================== + +void ScotsPine::RebuildGeometry() { + const auto& times = GetApplication().GetTimes(); + const double rebuild_start = times.Now(); + last_invalid_instance_count = 0; + last_internode_count = 0; + last_needle_count = 0; + last_node_count = 0; + + if (!growth_model.IsInitialized()) { + last_rebuild_seconds = 0.0; + return; + } + + const auto scene = GetScene(); + const auto owner = GetOwner(); + if (!scene || !scene->IsEntityValid(owner)) { + last_rebuild_seconds = 0.0; + return; + } + + if (!render_target_ || render_target_->GetRootEntity() != owner) { + render_target_ = std::make_unique(scene, owner); + } + + const auto color_mode = GetGlobalColorMode(); + const glm::vec4 instance_color = HashToColor(owner.GetIndex()); + const glm::vec4 kDefaultNeedleColor(0.16f, 0.45f, 0.18f, 1.0f); + const glm::vec4 kDefaultNeedleOldColor(0.42f, 0.27f, 0.10f, 1.0f); + const glm::vec4 kDefaultStemColor(0.83f, 0.72f, 0.50f, 1.0f); + const glm::vec4 kDefaultStemOldColor(0.45f, 0.30f, 0.20f, 1.0f); + constexpr float kDefaultNeedleAxialAgeSpan = 0.35f; + constexpr float kDefaultNeedleAxialAgeExponent = 1.0f; + constexpr float kDefaultInternodeAgeExponent = 1.0f; + + glm::vec4 needle_base_color = kDefaultNeedleColor; + glm::vec4 needle_old_color = kDefaultNeedleOldColor; + glm::vec4 stem_base_color = kDefaultStemColor; + glm::vec4 stem_old_color = kDefaultStemOldColor; + float needle_axial_age_span = kDefaultNeedleAxialAgeSpan; + float needle_axial_age_exponent = kDefaultNeedleAxialAgeExponent; + float internode_age_exponent = kDefaultInternodeAgeExponent; + if (const auto descriptor = descriptor_ref.Get()) { + needle_base_color = descriptor->needle_color_rgba; + needle_old_color = descriptor->needle_old_color_rgba; + stem_base_color = descriptor->main_stem_color_rgba; + stem_old_color = descriptor->main_stem_old_color_rgba; + needle_axial_age_span = descriptor->needle_axial_age_span; + needle_axial_age_exponent = descriptor->needle_axial_age_exponent; + internode_age_exponent = descriptor->internode_age_exponent; + } + needle_base_color = SanitizeFiniteColor(needle_base_color, kDefaultNeedleColor); + needle_old_color = SanitizeFiniteColor(needle_old_color, kDefaultNeedleOldColor); + stem_base_color = SanitizeFiniteColor(stem_base_color, kDefaultStemColor); + stem_old_color = SanitizeFiniteColor(stem_old_color, kDefaultStemOldColor); + + const float needle_color_energy = std::max(needle_base_color.r, std::max(needle_base_color.g, needle_base_color.b)); + const float needle_old_color_energy = std::max(needle_old_color.r, std::max(needle_old_color.g, needle_old_color.b)); + if (needle_color_energy <= 1.0e-4f && needle_old_color_energy <= 1.0e-4f) { + needle_base_color = kDefaultNeedleColor; + needle_old_color = kDefaultNeedleOldColor; + } + const float stem_color_energy = std::max(stem_base_color.r, std::max(stem_base_color.g, stem_base_color.b)); + const float stem_old_color_energy = std::max(stem_old_color.r, std::max(stem_old_color.g, stem_old_color.b)); + if (stem_color_energy <= 1.0e-4f && stem_old_color_energy <= 1.0e-4f) { + stem_base_color = kDefaultStemColor; + stem_old_color = kDefaultStemOldColor; + } + + const float t_now_years = growth_model.graph.data.clock.NowYears(); + const float max_internode_age_years = std::max(1.0f, static_cast(growth_model.sampled.needle_lifespan_years)); + + static thread_local std::vector internode_infos_cache; + static thread_local std::vector needle_infos_cache; + + const auto& sorted = growth_model.graph.PeekSortedNodeList(); + last_node_count = static_cast(sorted.size()); + + // -- Internodes (instance channel) -- + { + auto& infos = internode_infos_cache; + infos.clear(); + infos.reserve(sorted.size()); + const glm::quat cylinder_axis_fix = glm::angleAxis(-glm::half_pi(), glm::vec3(1.0f, 0.0f, 0.0f)); + + const float internode_visual_radius_multiplier = + std::max(0.0f, g_internode_visual_radius_multiplier.load(std::memory_order_relaxed)); + const glm::vec4 leader_debug_color = []() { + std::lock_guard lock(g_leader_internode_debug_color_mutex); + return g_leader_internode_debug_color; + }(); + const bool leader_debug_color_active = leader_debug_color.a > 0.0f; + + for (const auto handle : sorted) { + const auto& node = growth_model.graph.PeekNode(handle); + if (!node.data.template Is()) + continue; + const auto& internode = node.data.template Get(); + if (node.info.length <= 0.0f) + continue; + if (!IsFiniteVec3(node.info.global_position) || !std::isfinite(node.info.length) || + !std::isfinite(node.info.thickness)) { + last_invalid_instance_count++; + continue; + } + const float per_node_visual_multiplier = node.info.order == 0 ? internode_visual_radius_multiplier : 1.0f; + const float half_thick = node.info.thickness * 0.5f * per_node_visual_multiplier; + if (half_thick <= 0.0f) + continue; + + const float internode_age_years = std::max(0.0f, t_now_years - internode.continuous_growth.t_init_years); + float internode_age_norm = std::clamp(internode_age_years / max_internode_age_years, 0.0f, 1.0f); + internode_age_norm = std::pow(internode_age_norm, std::max(0.1f, internode_age_exponent)); + glm::vec4 stem_age_color = glm::mix(stem_base_color, stem_old_color, internode_age_norm); + stem_age_color.a = 1.0f; + const glm::vec4 stem_type_color(stem_base_color.r, stem_base_color.g, stem_base_color.b, 1.0f); + + glm::quat instance_rotation = glm::normalize(node.info.global_rotation * cylinder_axis_fix); + if (!IsFiniteQuat(instance_rotation)) { + instance_rotation = glm::quat(1, 0, 0, 0); + last_invalid_instance_count++; + } + + const glm::mat4 model = glm::translate(node.info.global_position) * glm::mat4_cast(instance_rotation) * + glm::scale(glm::vec3(half_thick, node.info.length, half_thick)); + if (!IsFiniteMat4(model)) { + last_invalid_instance_count++; + continue; + } + + ParticleInfo pi; + pi.instance_matrix.value = model; + if (color_mode == ColorMode::ByNode) { + pi.instance_color = HashToColor(static_cast(node.GetIndex())); + } else if (color_mode == ColorMode::ByInstance) { + pi.instance_color = instance_color; + } else if (color_mode == ColorMode::ByType) { + pi.instance_color = stem_type_color; + } else { + pi.instance_color = stem_age_color; + } + if (leader_debug_color_active && node.info.order == 0) { + pi.instance_color = leader_debug_color; + } + pi.instance_color.a = 1.0f; + infos.push_back(pi); + } + + last_internode_count = static_cast(infos.size()); + if (infos.empty()) { + render_target_->RemoveInstanceChannel(kChannelInternodes); + } else { + std::shared_ptr internode_mesh; + std::shared_ptr internode_material; + const auto& channels = render_target_->GetInstanceChannels(); + if (const auto it = channels.find(kChannelInternodes); it != channels.end()) { + internode_mesh = it->second->GetInstanceMesh(); + internode_material = it->second->GetInstanceMaterial(); + } + if (!internode_mesh) { + internode_mesh = AssetManager::CreateTemporaryAsset(); + } + if (!internode_material) { + internode_material = AssetManager::CreateTemporaryAsset(); + } + + bool needs_mesh_rebuild = true; + if (internode_mesh) { + const auto& vertices = internode_mesh->UnsafeGetVertices(); + const auto& triangles = internode_mesh->UnsafeGetTriangles(); + needs_mesh_rebuild = vertices.empty() || triangles.empty(); + if (!needs_mesh_rebuild) { + for (const auto& vertex : vertices) { + const auto color = vertex.color; + const bool near_white = std::abs(color.r - 1.0f) <= 1.0e-3f && std::abs(color.g - 1.0f) <= 1.0e-3f && + std::abs(color.b - 1.0f) <= 1.0e-3f; + if (!IsFiniteVec4(color) || !near_white) { + needs_mesh_rebuild = true; + break; + } + } + } + } + + if (needs_mesh_rebuild) { + std::vector cyl_verts; + std::vector cyl_idx; + GenerateUnitCylinderMesh(cyl_verts, cyl_idx, glm::vec4(1.0f)); + VertexAttributes attrs{}; + attrs.normal = true; + attrs.color = true; + attrs.tex_coord = true; + internode_mesh->SetVertices(attrs, cyl_verts, cyl_idx); + } + + internode_material->vertex_color_only = true; + internode_material->SetAlbedoTexture(nullptr); + internode_material->material_properties.albedo_color = glm::vec3(1.0f); + internode_material->draw_settings.blending = false; + internode_material->material_properties.metallic = 0.0f; + internode_material->material_properties.specular = 0.12f; + internode_material->material_properties.specular_tint = 0.05f; + internode_material->material_properties.roughness = 0.86f; + internode_material->material_properties.transmission = 0.0f; + internode_material->material_properties.subsurface_factor = 0.0f; + internode_material->material_properties.clear_coat = 0.0f; + + if (auto* channel = render_target_->GetOrCreateInstanceChannel(kChannelInternodes, "Pine Internodes", + internode_mesh, internode_material)) { + channel->Stage(std::move(infos)); + } + } + } + + // -- Needles (mesh channel by default, instance channel legacy fallback) -- + const bool use_new_needle_geometry = g_use_generalized_cylinder_needles.load(std::memory_order_relaxed); + const bool render_needles_enabled = g_render_needles_enabled.load(std::memory_order_relaxed); + + if (!render_needles_enabled) { + render_target_->RemoveMeshChannel(kChannelNeedles); + render_target_->RemoveInstanceChannel(kChannelNeedles); + last_needle_skeleton_lines.clear(); + last_needle_count = 0; + } else if (use_new_needle_geometry) { + static thread_local std::vector needle_geom_vertices; + static thread_local std::vector needle_geom_triangles; + + const bool senescence_active = HasNeedleSenescenceOrAbscission(growth_model.graph, sorted); + const bool growth_active_lod = growth_model.last_growth_steps > 0 && !senescence_active; + const int needle_station_count = std::max(4, growth_model.sampled.needle_segment_count + 1); + const int needle_perimeter_count = growth_active_lod ? 5 : 8; + + BuildPineNeedleAggregateMesh( + growth_model.graph, sorted, color_mode, needle_base_color, needle_old_color, needle_axial_age_span, + needle_axial_age_exponent, growth_model.sampled.distributions.needle_cross_section_width_profile, + growth_model.sampled.distributions.needle_cross_section_thickness_profile, + growth_model.sampled.distributions.needle_cross_section_temporal_maturity_curve, + growth_model.sampled.needle_fascicular_start_year, growth_model.sampled.needle_lignification_factor_year1, + growth_model.sampled.needle_lignification_factor_year2plus, + growth_model.sampled.needle_stomatal_strip_density_year1, + growth_model.sampled.needle_stomatal_strip_density_year2plus, + growth_model.sampled.needle_basal_taper_ratio_year1, growth_model.sampled.needle_basal_taper_ratio_year2plus, + growth_model.sampled.needle_fascicle_sheath_budget_years, + growth_model.sampled.needle_specularity_plasticity_year1, + growth_model.sampled.needle_specularity_plasticity_year2plus, needle_geom_vertices, needle_geom_triangles, + needle_station_count, needle_perimeter_count, &last_needle_skeleton_lines); + + SanitizeNeedleAggregateMeshVertices(needle_geom_vertices); + last_needle_count = needle_geom_triangles.empty() + ? 0u + : static_cast(needle_geom_vertices.size() / + static_cast(needle_station_count * needle_perimeter_count)); + + const bool needle_mesh_valid = IsNeedleAggregateMeshValid(needle_geom_vertices, needle_geom_triangles); + if (!needle_mesh_valid) { + last_invalid_instance_count++; + } + + render_target_->RemoveInstanceChannel(kChannelNeedles); + if (needle_mesh_valid && !needle_geom_triangles.empty()) { + const glm::vec3 by_instance_tint = glm::clamp(glm::vec3(instance_color), glm::vec3(0.0f), glm::vec3(1.0f)); + + if (auto* mesh_channel = render_target_->GetOrCreateMeshChannel(kChannelNeedles, "Pine Needles Geometry")) { + if (const auto needle_material = mesh_channel->GetMaterial()) { + const float needle_specularity = + std::clamp(0.5f * (growth_model.sampled.needle_specularity_plasticity_year1 + + growth_model.sampled.needle_specularity_plasticity_year2plus), + 0.0f, 1.0f); + const float stomatal_density = + std::clamp(0.5f * (growth_model.sampled.needle_stomatal_strip_density_year1 + + growth_model.sampled.needle_stomatal_strip_density_year2plus), + 0.0f, 1.0f); + const float needle_lignification = + std::clamp(0.5f * (growth_model.sampled.needle_lignification_factor_year1 + + growth_model.sampled.needle_lignification_factor_year2plus), + 0.0f, 2.0f); + + needle_material->vertex_color_only = true; + needle_material->SetAlbedoTexture(nullptr); + needle_material->material_properties.albedo_color = + color_mode == ColorMode::ByInstance ? by_instance_tint : glm::vec3(1.0f); + needle_material->draw_settings.blending = false; + needle_material->draw_settings.cull_mode = VK_CULL_MODE_NONE; + needle_material->material_properties.metallic = 0.0f; + needle_material->material_properties.specular = std::clamp(0.04f + 0.08f * needle_specularity, 0.04f, 0.12f); + needle_material->material_properties.specular_tint = + std::clamp(0.02f + 0.05f * stomatal_density, 0.0f, 0.10f); + needle_material->material_properties.roughness = + std::clamp(0.90f + 0.06f * needle_lignification, 0.85f, 0.98f); + needle_material->material_properties.subsurface_factor = 0.0f; + needle_material->material_properties.ior = 1.33f; + needle_material->material_properties.transmission = 0.0f; + needle_material->material_properties.transmission_roughness = 1.0f; + needle_material->material_properties.clear_coat = 0.0f; + needle_material->material_properties.clear_coat_roughness = 1.0f; + needle_material->material_properties.emission = 0.0f; + } + + VertexAttributes attrs{}; + attrs.normal = true; + attrs.tangent = true; + attrs.color = true; + attrs.tex_coord = true; + mesh_channel->Stage(attrs, std::move(needle_geom_vertices), std::move(needle_geom_triangles)); + } + } else { + render_target_->RemoveMeshChannel(kChannelNeedles); + } + } else { + last_needle_skeleton_lines.clear(); + auto& infos = needle_infos_cache; + infos.clear(); + + for (const auto handle : sorted) { + const auto& node = growth_model.graph.PeekNode(handle); + if (!node.data.template Is()) + continue; + const auto& cluster = node.data.template Get(); + const glm::vec3 pos = node.info.global_position; + if (!IsFiniteVec3(pos)) { + last_invalid_instance_count++; + continue; + } + const float size = std::max(0.005f, cluster.target_length * 0.05f); + ParticleInfo pi; + const glm::mat4 model = glm::translate(pos) * glm::scale(glm::vec3(size)); + if (!IsFiniteMat4(model)) { + last_invalid_instance_count++; + continue; + } + pi.instance_matrix.value = model; + if (color_mode == ColorMode::ByNode) { + pi.instance_color = HashToColor(static_cast(node.GetIndex())); + } else if (color_mode == ColorMode::ByInstance) { + pi.instance_color = instance_color; + } else { + pi.instance_color = needle_base_color; + } + infos.push_back(pi); + } + + last_needle_count = static_cast(infos.size()); + render_target_->RemoveMeshChannel(kChannelNeedles); + if (infos.empty()) { + render_target_->RemoveInstanceChannel(kChannelNeedles); + } else { + std::shared_ptr needle_mesh; + std::shared_ptr needle_material; + const auto& channels = render_target_->GetInstanceChannels(); + if (const auto it = channels.find(kChannelNeedles); it != channels.end()) { + needle_mesh = it->second->GetInstanceMesh(); + needle_material = it->second->GetInstanceMaterial(); + } + if (!needle_mesh) { + needle_mesh = AssetManager::CreateTemporaryAsset(); + } + if (!needle_material) { + needle_material = AssetManager::CreateTemporaryAsset(); + } + + std::vector oct_verts; + std::vector oct_idx; + GenerateUnitOctahedronMesh(oct_verts, oct_idx, glm::vec4(1.0f)); + VertexAttributes attrs{}; + attrs.normal = true; + attrs.color = true; + attrs.tex_coord = true; + needle_mesh->SetVertices(attrs, oct_verts, oct_idx); + + needle_material->vertex_color_only = false; + needle_material->SetAlbedoTexture(nullptr); + needle_material->material_properties.albedo_color = glm::vec3(1.0f); + needle_material->draw_settings.blending = false; + + if (auto* channel = render_target_->GetOrCreateInstanceChannel(kChannelNeedles, "Pine Needles", needle_mesh, + needle_material)) { + channel->Stage(std::move(infos)); + } + } + } + + if (render_target_) { + render_target_->FlushPending(); + } + + last_applied_internode_visual_radius_multiplier = + std::max(0.0f, g_internode_visual_radius_multiplier.load(std::memory_order_relaxed)); + last_applied_render_needles_enabled = g_render_needles_enabled.load(std::memory_order_relaxed); + { + std::lock_guard lock(g_leader_internode_debug_color_mutex); + last_applied_leader_debug_color = g_leader_internode_debug_color; + } + last_applied_color_mode = static_cast(GetGlobalColorMode()); + last_rebuild_seconds = times.Now() - rebuild_start; +} + +// =========================================================================== +// Export +// =========================================================================== + +void ScotsPine::ExportObj(const std::filesystem::path& path) const { + const auto scene = GetScene(); + if (!scene) + return; + + if (!render_target_) { + EVOENGINE_ERROR("Pine mesh export failed: no render channels available."); + return; + } + + std::vector vertices; + std::vector triangles; + + for (const auto& [channel_id, channel] : render_target_->GetInstanceChannels()) { + (void)channel_id; + if (!channel) { + continue; + } + const auto entity = channel->GetEntity(); + if (scene->IsEntityValid(entity)) { + AppendParticlesToMesh(scene, entity, vertices, triangles); + } + } + for (const auto& [channel_id, channel] : render_target_->GetMeshChannels()) { + (void)channel_id; + if (!channel) { + continue; + } + const auto entity = channel->GetEntity(); + if (scene->IsEntityValid(entity)) { + AppendMeshRendererToMesh(scene, entity, vertices, triangles); + } + } + + if (vertices.empty() || triangles.empty()) { + EVOENGINE_ERROR("Pine mesh export failed: no pine channel geometry available."); + return; + } + + const auto mesh = AssetManager::CreateTemporaryAsset(); + VertexAttributes vertex_attributes{}; + vertex_attributes.normal = true; + vertex_attributes.tangent = true; + vertex_attributes.color = true; + vertex_attributes.tex_coord = true; + mesh->SetVertices(vertex_attributes, vertices, triangles); + + if (!mesh->Export(path)) { + EVOENGINE_ERROR("Pine mesh export failed!"); + } +} + +void ScotsPine::ExportFlowGraph(YAML::Emitter& out) { + out << YAML::Key << "Flows" << YAML::Value << YAML::BeginSeq; + if (!growth_model.IsInitialized()) { + out << YAML::EndSeq; + return; + } + auto& graph = growth_model.graph; + graph.SortLists(); + graph.CalculateFlows(); + const auto retained = CollectInternodeFlowHandles(graph); + for (const auto flow_handle : graph.PeekSortedFlowList()) { + if (retained.find(flow_handle) == retained.end()) + continue; + const auto& flow = graph.PeekFlow(flow_handle); + const auto parent_flow_handle = FindParentInternodeFlowHandle(graph, flow_handle, retained); + out << YAML::BeginMap; + out << YAML::Key << "I" << YAML::Value << flow_handle; + out << YAML::Key << "PI" << YAML::Value << parent_flow_handle; + out << YAML::Key << "SP" << YAML::Value << flow.info.global_start_position; + out << YAML::Key << "SD" << YAML::Value << flow.info.global_start_rotation * glm::vec3(0, 0, -1); + out << YAML::Key << "ST" << YAML::Value << flow.info.start_thickness; + out << YAML::Key << "EP" << YAML::Value << flow.info.global_end_position; + out << YAML::Key << "ED" << YAML::Value << flow.info.global_end_rotation * glm::vec3(0, 0, -1); + out << YAML::Key << "ET" << YAML::Value << flow.info.end_thickness; + out << YAML::EndMap; + } + out << YAML::EndSeq; +} + +void ScotsPine::ExportFlowGraph(const std::filesystem::path& path) { + try { + YAML::Emitter out; + out << YAML::BeginMap; + ExportFlowGraph(out); + out << YAML::EndMap; + std::ofstream output_file(path.string()); + output_file << out.c_str(); + output_file.flush(); + } catch (const std::exception& e) { + EVOENGINE_ERROR(std::string("Failed to save: ") + e.what()); + } +} + +void ScotsPine::ExportNodeGraph(YAML::Emitter& out) { + out << YAML::Key << "Nodes" << YAML::Value << YAML::BeginSeq; + if (!growth_model.IsInitialized()) { + out << YAML::EndSeq; + return; + } + auto& graph = growth_model.graph; + graph.SortLists(); + graph.CalculateFlows(); + const auto retained = CollectInternodeFlowHandles(graph); + for (const auto node_handle : graph.PeekSortedNodeList()) { + const auto& node = graph.PeekNode(node_handle); + if (!IsInternodeNode(node)) + continue; + const auto parent_node_handle = FindParentInternodeNodeHandle(graph, node_handle); + auto flow_handle = node.GetFlowHandle(); + while (flow_handle >= 0 && retained.find(flow_handle) == retained.end()) { + flow_handle = graph.PeekFlow(flow_handle).GetParentHandle(); + } + out << YAML::BeginMap; + out << YAML::Key << "I" << YAML::Value << node_handle; + out << YAML::Key << "PI" << YAML::Value << parent_node_handle; + out << YAML::Key << "FI" << YAML::Value << flow_handle; + out << YAML::Key << "SP" << YAML::Value << node.info.global_position; + out << YAML::Key << "EP" << YAML::Value << node.info.GetGlobalEndPosition(); + out << YAML::Key << "D" << YAML::Value << node.info.GetGlobalDirection(); + out << YAML::Key << "T" << YAML::Value << node.info.thickness; + out << YAML::EndMap; + } + out << YAML::EndSeq; +} + +void ScotsPine::ExportNodeGraph(const std::filesystem::path& path) { + try { + YAML::Emitter out; + out << YAML::BeginMap; + ExportNodeGraph(out); + out << YAML::EndMap; + std::ofstream output_file(path.string()); + output_file << out.c_str(); + output_file.flush(); + } catch (const std::exception& e) { + EVOENGINE_ERROR(std::string("Failed to save: ") + e.what()); + } +} + +void ScotsPine::ExportNeedleSkeleton(YAML::Emitter& out) { + const int segment_count = growth_model.IsInitialized() ? std::max(1, growth_model.sampled.needle_segment_count) : 1; + + out << YAML::Key << "NeedleSegmentCount" << YAML::Value << segment_count; + out << YAML::Key << "NeedleStationCount" << YAML::Value << (segment_count + 1); + out << YAML::Key << "Needles" << YAML::Value << YAML::BeginSeq; + for (const auto& line : last_needle_skeleton_lines) { + out << YAML::BeginMap; + out << YAML::Key << "CI" << YAML::Value << line.cluster_node_handle; + out << YAML::Key << "PI" << YAML::Value << line.parent_node_handle; + out << YAML::Key << "NI" << YAML::Value << line.needle_index; + out << YAML::Key << "P" << YAML::Value << YAML::BeginSeq; + for (const auto& point : line.points_world) { + out << point; + } + out << YAML::EndSeq; + out << YAML::EndMap; + } + out << YAML::EndSeq; +} + +void ScotsPine::ExportNeedleSkeleton(const std::filesystem::path& path) { + try { + YAML::Emitter out; + out << YAML::BeginMap; + ExportNeedleSkeleton(out); + out << YAML::EndMap; + std::ofstream output_file(path.string()); + output_file << out.c_str(); + output_file.flush(); + } catch (const std::exception& e) { + EVOENGINE_ERROR(std::string("Failed to save: ") + e.what()); + } +} + +// =========================================================================== +// Component lifecycle / inspector +// =========================================================================== + +void ScotsPine::OnDestroy() { + ClearGeometryEntities(); + render_target_.reset(); +} + +bool ScotsPine::OnInspect(const std::shared_ptr& editor_layer) { + bool changed = false; + + if (editor_layer->DragAndDropButton(descriptor_ref, "Descriptor")) + changed = true; + + if (editor_layer->DragAndDropButton(post_repot_descriptor_ref, "Post-Repot Descriptor")) { + changed = true; + } + + if (ImGui::Checkbox("Enable Repot Profile Switch", &enable_repot_profile_switch)) { + changed = true; + } + if (enable_repot_profile_switch) { + if (ImGui::DragFloat("Repot Switch GDD", &repot_switch_gdd, 10.0f, 0.0f, 200000.0f, "%.1f")) { + repot_switch_gdd = std::max(0.0f, repot_switch_gdd); + changed = true; + } + } + + int seed_int = static_cast(seed); + if (ImGui::DragInt("Seed", &seed_int, 1, 0, 999999)) { + seed = static_cast(seed_int); + changed = true; + } + + if (ImGui::DragFloat("Target GDD", &target_gdd, 1.0f, 0.0f, 200000.0f, "%.1f")) + changed = true; + + if (ImGui::Button("Generate")) { + GenerateGeometryEntities(); + changed = true; + } + ImGui::SameLine(); + if (ImGui::Button("Clear")) { + ClearGeometryEntities(); + changed = true; + } + + if (growth_model.IsInitialized()) { + ImGui::Separator(); + ImGui::Text("GDD: %.1f", growth_model.accumulated_gdd); + ImGui::Text("Topology: %s", growth_model.IsTopologyComplete() ? "Complete" : "Pending"); + const auto& sorted = growth_model.graph.PeekSortedNodeList(); + ImGui::Text("Nodes: %d", static_cast(sorted.size())); + ImGui::Text("Internodes: %u Needles: %u", last_internode_count, last_needle_count); + } + + return changed; +} + +void ScotsPine::Serialize(YAML::Emitter& out) const { + descriptor_ref.Save("descriptor_ref", out); + post_repot_descriptor_ref.Save("post_repot_descriptor_ref", out); + out << YAML::Key << "seed" << YAML::Value << seed; + out << YAML::Key << "target_gdd" << YAML::Value << target_gdd; + out << YAML::Key << "enable_repot_profile_switch" << YAML::Value << enable_repot_profile_switch; + out << YAML::Key << "repot_switch_gdd" << YAML::Value << repot_switch_gdd; +} + +void ScotsPine::Deserialize(const YAML::Node& in) { + descriptor_ref.Load("descriptor_ref", in); + post_repot_descriptor_ref.Load("post_repot_descriptor_ref", in); + if (in["seed"]) + seed = in["seed"].as(); + if (in["target_gdd"]) { + target_gdd = in["target_gdd"].as(); + } else if (in["target_year"]) { + target_gdd = static_cast(in["target_year"].as()) * kPineGddPerYear; + } + if (in["enable_repot_profile_switch"]) { + enable_repot_profile_switch = in["enable_repot_profile_switch"].as(); + } + if (in["repot_switch_gdd"]) { + repot_switch_gdd = std::max(0.0f, in["repot_switch_gdd"].as()); + } +} + +void ScotsPine::CollectAssetRef(std::vector& list) { + list.push_back(descriptor_ref); + list.push_back(post_repot_descriptor_ref); +} diff --git a/EvoEngine_Packages/LSystem/src/ScotsPineDescriptor.cpp b/EvoEngine_Packages/LSystem/src/ScotsPineDescriptor.cpp new file mode 100644 index 00000000..6e897fa8 --- /dev/null +++ b/EvoEngine_Packages/LSystem/src/ScotsPineDescriptor.cpp @@ -0,0 +1,2232 @@ +#include "ScotsPineDescriptor.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "LSystemDescriptorDefaults.hpp" +#include "ScotsPine.hpp" + +using namespace l_system_package; +using namespace evo_engine; + +// =========================================================================== +// File-local helpers (defaults file resolution, loading, target_gdd sampling). +// File extension: .spine +// =========================================================================== +namespace { + +constexpr float kPi = 3.14159265358979323846f; +constexpr char kScotsPineDescriptorName[] = "ScotsPineDescriptor"; + +const std::array kScotsPineResourceCandidates = { + std::filesystem::path("./LSystemProjectAssets/Assets/New ScotsPineDescriptor.spine"), + std::filesystem::path("./Resources/DigitalAgricultureProject/Assets/New ScotsPineDescriptor.spine"), + std::filesystem::path("./DigitalAgricultureProject/Assets/New ScotsPineDescriptor.spine"), + std::filesystem::path("./04_EvoEngine/Resources/DigitalAgricultureProject/Assets/") / + "New ScotsPineDescriptor.spine", + std::filesystem::path("./LSystemResources/Defaults/ScotsPineDescriptor_Default.spine"), + std::filesystem::path("./EvoEngine_Packages/LSystem/Internals/LSystemResources/Defaults/") / + "ScotsPineDescriptor_Default.spine", + std::filesystem::path("./EvoEngine_Plugins/LSystem/Internals/LSystemResources/Defaults/") / + "ScotsPineDescriptor_Default.spine", + std::filesystem::path("./04_EvoEngine/EvoEngine_Packages/LSystem/Internals/") / + "LSystemResources/Defaults/ScotsPineDescriptor_Default.spine"}; + +const std::array kScotsPineProjectAssetCandidates = { + std::filesystem::path("LSystemProjectAssets") / "Assets" / "New ScotsPineDescriptor.spine", + std::filesystem::path("LSystem") / "New ScotsPineDescriptor.spine", "New ScotsPineDescriptor.spine"}; + +const std::array kScotsPineWritableTemplateCandidates = { + std::filesystem::path("./Resources/DigitalAgricultureProject/Assets/") / "New ScotsPineDescriptor.spine", + std::filesystem::path("./EvoEngine_Packages/LSystem/Internals/LSystemResources/Defaults/") / + "ScotsPineDescriptor_Default.spine"}; + +const std::filesystem::path kScotsPineFallbackDefaultsPath = + std::filesystem::path("./LSystemResources/Defaults/ScotsPineDescriptor_Default.spine"); + +void SetCurveToSinusoidalRange(evo_engine::Curve2D& curve, const float y0, const float y1, + const int sample_count = 17) { + curve.SetTangent(false); + auto& values = curve.UnsafeGetValues(); + values.clear(); + const int safe_samples = std::max(2, sample_count); + const float v0 = std::clamp(y0, 0.0f, 1.0f); + const float v1 = std::clamp(y1, 0.0f, 1.0f); + for (int i = 0; i < safe_samples; ++i) { + const float x = static_cast(i) / static_cast(safe_samples - 1); + const float ramp = 0.5f - 0.5f * std::cos(kPi * x); + const float y = v0 + (v1 - v0) * ramp; + values.emplace_back(x, y); + } +} + +void SetCurveToSinusoidal01(evo_engine::Curve2D& curve, const int sample_count = 17) { + SetCurveToSinusoidalRange(curve, 0.0f, 1.0f, sample_count); +} + +void SetCurveLinear01(evo_engine::Curve2D& curve, const float y0, const float y1, const int sample_count = 9) { + curve.SetTangent(false); + auto& values = curve.UnsafeGetValues(); + values.clear(); + const int safe_samples = std::max(2, sample_count); + const float v0 = std::clamp(y0, 0.0f, 1.0f); + const float v1 = std::clamp(y1, 0.0f, 1.0f); + for (int i = 0; i < safe_samples; ++i) { + const float x = static_cast(i) / static_cast(safe_samples - 1); + const float y = v0 + (v1 - v0) * x; + values.emplace_back(x, y); + } +} + +void ConfigureMaturityDistributionDefaults(evo_engine::PlottedDistribution& distribution, + const float default_deviation_max = 0.0f) { + distribution.mean.min_value = 0.0f; + distribution.mean.max_value = 1.0f; + SetCurveToSinusoidal01(distribution.mean.curve); + + distribution.deviation.min_value = 0.0f; + distribution.deviation.max_value = std::max(0.0f, default_deviation_max); + SetCurveToSinusoidal01(distribution.deviation.curve); +} + +void ConfigureNeedleCrossSectionProfileDefaults(evo_engine::PlottedDistribution& distribution, + const float base_multiplier, const float tip_multiplier, + const float default_deviation_max = 0.0f) { + distribution.mean.min_value = 0.0f; + distribution.mean.max_value = 1.0f; + SetCurveLinear01(distribution.mean.curve, base_multiplier, tip_multiplier, 2); + + distribution.deviation.min_value = 0.0f; + distribution.deviation.max_value = std::max(0.0f, default_deviation_max); + SetCurveLinear01(distribution.deviation.curve, 0.0f, 0.0f, 2); +} + +void ConfigureNeedleCrossSectionTemporalMaturityDefaults(evo_engine::PlottedDistribution& distribution, + const float default_deviation_max = 0.0f) { + distribution.mean.min_value = 0.0f; + distribution.mean.max_value = 1.0f; + SetCurveToSinusoidalRange(distribution.mean.curve, 0.25f, 1.0f); + + distribution.deviation.min_value = 0.0f; + distribution.deviation.max_value = std::max(0.0f, default_deviation_max); + SetCurveLinear01(distribution.deviation.curve, 0.0f, 0.0f); +} + +void ConfigurePineMaturityDefaults(ScotsPineDescriptor& descriptor) { + ConfigureMaturityDistributionDefaults(descriptor.internode_length_maturity_curve); + ConfigureMaturityDistributionDefaults(descriptor.internode_width_maturity_curve); + ConfigureMaturityDistributionDefaults(descriptor.needle_length_maturity_curve); + ConfigureNeedleCrossSectionProfileDefaults(descriptor.needle_cross_section_width_profile, 0.348f, 0.098f); + ConfigureNeedleCrossSectionProfileDefaults(descriptor.needle_cross_section_thickness_profile, 0.5f, 0.184f); + ConfigureNeedleCrossSectionTemporalMaturityDefaults(descriptor.needle_cross_section_temporal_maturity_curve); +} + +double GetSteadyTimeSeconds() { + return std::chrono::duration(std::chrono::steady_clock::now().time_since_epoch()).count(); +} + +std::filesystem::path ResolveDefaultScotsPineDescriptorPath() { + return descriptor_defaults::ResolveExistingDefaultsPath(kScotsPineResourceCandidates, + kScotsPineProjectAssetCandidates); +} + +std::filesystem::path ResolveWritableScotsPineDescriptorDefaultsPath() { + return descriptor_defaults::ResolveWritableDefaultsPath( + kScotsPineResourceCandidates, kScotsPineProjectAssetCandidates, kScotsPineWritableTemplateCandidates, + kScotsPineFallbackDefaultsPath); +} + +void LoadSingleDistributionWithScalarFallback(const YAML::Node& in, const char* key, + evo_engine::SingleDistribution& distribution) { + if (!in[key]) + return; + const auto& node = in[key]; + if (node.IsMap()) { + distribution.Load(key, in); + return; + } + if (node.IsScalar()) { + distribution.mean = node.as(); + distribution.deviation = 0.0f; + } +} + +/// Backward-compat loader for fields that were renamed from `*_gdd` (heat +/// sum) to `*_years` (chronological) by the FSPM Rule of Ontogeny audit. +/// If `legacy_key` exists in `in`, read its value (map-or-scalar) and +/// divide both mean and deviation by `kPineGddPerYear` so the distribution +/// lands in years on the renamed member. Called only when the new +/// `*_years` key is absent. +void LoadLegacyGddDistributionAsYears(const YAML::Node& in, const char* legacy_key, + evo_engine::SingleDistribution& years_distribution) { + if (!in[legacy_key]) + return; + evo_engine::SingleDistribution tmp{}; + LoadSingleDistributionWithScalarFallback(in, legacy_key, tmp); + years_distribution.mean = std::max(0.0f, tmp.mean) / kPineGddPerYear; + years_distribution.deviation = std::max(0.0f, tmp.deviation) / kPineGddPerYear; +} + +float SampleTargetGddForSeed(const evo_engine::SingleDistribution& distribution, const uint32_t seed) { + std::mt19937 rng(seed); + return std::max(0.0f, SampleDistribution(distribution, rng)); +} + +bool LoadScotsPineDescriptorDefaultsFromFile(ScotsPineDescriptor& descriptor, const std::filesystem::path& file_path) { + YAML::Node defaults; + if (!descriptor_defaults::LoadDefaultsYamlMap(file_path, defaults, kScotsPineDescriptorName)) { + return false; + } + descriptor.Deserialize(defaults); + return true; +} + +} // namespace + +// =========================================================================== +// Constructor - load defaults from disk if available. +// =========================================================================== +ScotsPineDescriptor::ScotsPineDescriptor() { + ConfigurePineMaturityDefaults(*this); + const auto defaults_path = ResolveDefaultScotsPineDescriptorPath(); + if (!LoadScotsPineDescriptorDefaultsFromFile(*this, defaults_path)) { + static bool warned_once = false; + if (!warned_once) { + warned_once = true; + EVOENGINE_WARNING("ScotsPineDescriptor defaults file not found or invalid. Using inline member defaults."); + } + } +} + +std::filesystem::path ScotsPineDescriptor::ResolveWritableDefaultsPath() const { + return ResolveWritableScotsPineDescriptorDefaultsPath(); +} + +// =========================================================================== +// Sample +// +// RNG draw order (LOCKED - append-only for reproducibility across schema +// extensions). The phytomer model schema is the canonical baseline. +// +// 1. plant_random +// 2. max_branching_order +// 3. plastochron_gdd -> years = gdd / kPineGddPerYear +// 4. max_phytomers_per_seasonal_growth +// 5. branches_per_whorl +// 6. whorl_dormancy_years -> chronological (years on disk) +// 7. branch_insertion_angle_deg +// 8. branch_roll_phyllotaxis_deg +// 9. internode_length_m +// 10. leader_internode_thickness_m +// 11. lateral_length_ratio +// 12. lateral_thickness_ratio +// 13. bare_zone_fraction +// 14. needle_count_per_cluster +// 15. needle_length_m +// 16. needle_lifespan_years -> chronological (years on disk) +// 17. needle_browning_years -> chronological (years on disk) +// 18. needle_flush_delay_gdd -> years = gdd / kPineGddPerYear +// 19. internode_maturation_gdd -> years = gdd / kPineGddPerYear +// 20. needle_maturation_gdd -> years = gdd / kPineGddPerYear +// 21. gravitropism_first_order -> main-stem-only curvature (leader order 0) +// 22. tropism array (per entry: usage roll, dir_x/y/z, strength) +// 23. internode_length_per_node_cv +// 24. internode_thickness_per_node_cv +// 25. branch_angle_per_node_sigma_deg +// 26. roll_phyllotaxis_per_node_sigma_deg +// 27. needle_curvature_adaxial_bias +// 28. needle_curvature_abaxial_bias +// 29. needle_curvature_gradient_per_arclen +// 30. needle_diameter_for_curvature_m +// 31. needle_young_modulus_baseline_Pa +// 32. needle_lignification_maturation_years +// 33. needle_cross_section_width_max_m +// 34. needle_cross_section_thickness_max_m +// 35. needle_density_kg_m3 +// 36. gravity_m_s2 +// 37. needle_per_needle_length_cv +// 38. needle_per_needle_curvature_cv +// 39. needle_per_needle_radius_cv +// 40. needle_per_needle_modulus_cv +// 41. needle_per_needle_density_cv +// 42. needle_sinusoidal_amplitude_deg +// 43. needle_sinusoidal_frequency_cycles +// 44. needle_sinusoidal_phase_randomness_deg +// 45. needle_per_needle_wave_amplitude_cv +// 46. needle_per_needle_wave_frequency_cv +// 47. needle_per_needle_wave_phase_cv +// 48. initial_orientation_yaw_deg -> sampled once per plant (root yaw around +Y) +// +// Non-stochastic scalar controls (for example `needle_segment_count`) are +// copied/clamped without RNG draws to preserve the locked draw order above. +// =========================================================================== +SampledPineParams ScotsPineDescriptor::Sample(std::mt19937& rng) const { + SampledPineParams out; + + out.plant_random = SampleUnit01(rng); + + out.max_branching_order = + std::clamp(static_cast(std::round(SampleDistribution(max_branching_order, rng))), 0, 4); + out.plastochron_years = std::max(1e-4f, SampleDistribution(plastochron_gdd, rng) / kPineGddPerYear); + out.max_phytomers_per_seasonal_growth = + std::clamp(static_cast(std::round(SampleDistribution(max_phytomers_per_seasonal_growth, rng))), 1, 1024); + + out.branches_per_whorl = std::clamp(static_cast(std::round(SampleDistribution(branches_per_whorl, rng))), 0, 12); + // Chronological (years). No GDD->years conversion. + out.whorl_dormancy_years = std::max(0.0f, SampleDistribution(whorl_dormancy_years, rng)); + out.branch_insertion_angle_deg = std::clamp(SampleDistribution(branch_insertion_angle_deg, rng), -85.0f, 85.0f); + out.branch_roll_phyllotaxis_deg = SampleDistribution(branch_roll_phyllotaxis_deg, rng); + + out.internode_length_m = std::clamp(SampleDistribution(internode_length_m, rng), 0.0001f, 0.50f); + out.leader_internode_thickness_m = std::clamp(SampleDistribution(leader_internode_thickness_m, rng), 0.0002f, 0.05f); + // Keep stem slenderness physically plausible (avoid "fat stump" defaults). + const float max_sane_thickness = std::max(0.0002f, 0.40f * out.internode_length_m * 8.0f); + out.leader_internode_thickness_m = std::min(out.leader_internode_thickness_m, max_sane_thickness); + out.lateral_length_ratio = std::clamp(SampleDistribution(lateral_length_ratio, rng), 0.05f, 1.0f); + out.lateral_thickness_ratio = std::clamp(SampleDistribution(lateral_thickness_ratio, rng), 0.05f, 1.5f); + + out.bare_zone_fraction = std::clamp(SampleDistribution(bare_zone_fraction, rng), 0.0f, 0.95f); + out.needle_count_per_cluster = + std::clamp(static_cast(std::round(SampleDistribution(needle_count_per_cluster, rng))), 1, 6); + out.needle_segment_count = std::clamp(needle_segment_count, 3, 128); + out.needle_length_m = std::clamp(SampleDistribution(needle_length_m, rng), 0.005f, 0.30f); + // Chronological lifespan and browning (years). No GDD->years conversion. + out.needle_lifespan_years = std::max(1, static_cast(std::round(SampleDistribution(needle_lifespan_years, rng)))); + out.needle_browning_years = std::max(0.0f, SampleDistribution(needle_browning_years, rng)); + out.needle_flush_delay_years = std::max(0.0f, SampleDistribution(needle_flush_delay_gdd, rng) / kPineGddPerYear); + out.internode_maturation_years = + std::clamp(SampleDistribution(internode_maturation_gdd, rng) / kPineGddPerYear, 0.0f, 4.0f); + out.needle_maturation_years = + std::clamp(SampleDistribution(needle_maturation_gdd, rng) / kPineGddPerYear, 0.0f, 1.0f); + out.needle_order_length_attenuation = std::clamp(needle_order_length_attenuation, 0.0f, 1.0f); + out.needle_order_radius_attenuation = std::clamp(needle_order_radius_attenuation, 0.0f, 1.0f); + out.needle_order_min_length_scale = std::clamp(needle_order_min_length_scale, 0.10f, 1.0f); + out.needle_order_min_radius_scale = std::clamp(needle_order_min_radius_scale, 0.10f, 1.0f); + out.needle_intra_year_base_ratio = std::clamp(needle_intra_year_base_ratio, 0.0f, 1.0f); + out.needle_intra_year_sigmoid_steepness = std::max(0.01f, needle_intra_year_sigmoid_steepness); + out.needle_intra_year_sigmoid_midpoint_fraction = std::clamp(needle_intra_year_sigmoid_midpoint_fraction, 0.0f, 1.0f); + out.needle_intra_year_late_decay_start_fraction = std::clamp(needle_intra_year_late_decay_start_fraction, 0.0f, 1.0f); + out.needle_intra_year_late_decay_end_scale = std::clamp(needle_intra_year_late_decay_end_scale, 0.0f, 2.0f); + out.needle_fascicular_start_year = std::clamp(needle_fascicular_start_year, 0, 16); + out.needle_year2plus_length_multiplier = std::max(0.0f, needle_year2plus_length_multiplier); + out.needle_year2plus_width_multiplier = std::max(0.0f, needle_year2plus_width_multiplier); + out.needle_year2plus_thickness_multiplier = std::max(0.0f, needle_year2plus_thickness_multiplier); + out.needle_lignification_factor_year1 = std::clamp(needle_lignification_factor_year1, 0.0f, 2.0f); + out.needle_lignification_factor_year2plus = std::clamp(needle_lignification_factor_year2plus, 0.0f, 2.0f); + out.needle_stomatal_strip_density_year1 = std::clamp(needle_stomatal_strip_density_year1, 0.0f, 1.0f); + out.needle_stomatal_strip_density_year2plus = std::clamp(needle_stomatal_strip_density_year2plus, 0.0f, 1.0f); + out.needle_basal_taper_ratio_year1 = std::clamp(needle_basal_taper_ratio_year1, 0.6f, 1.2f); + out.needle_basal_taper_ratio_year2plus = std::clamp(needle_basal_taper_ratio_year2plus, 0.6f, 1.2f); + out.needle_fascicle_sheath_budget_years = std::max(0.0f, needle_fascicle_sheath_budget_gdd / kPineGddPerYear); + out.needle_specularity_plasticity_year1 = std::clamp(needle_specularity_plasticity_year1, 0.0f, 1.0f); + out.needle_specularity_plasticity_year2plus = std::clamp(needle_specularity_plasticity_year2plus, 0.0f, 1.0f); + out.needle_bud_storage_vigor_strength = std::clamp(needle_bud_storage_vigor_strength, 0.0f, 1.0f); + out.needle_bud_storage_completion_floor = std::clamp(needle_bud_storage_completion_floor, 0.0f, 1.0f); + // Per-plant copy of the user-exposed needle radius cap. Bounded to a wide + // but sane envelope: 0 disables the cap entirely (uncapped needles), and 4x + // is well above what any realistic Scots pine needle would need relative + // to its parent shoot. + out.needle_radius_to_stem_thickness_max_ratio = std::clamp(needle_radius_to_stem_thickness_max_ratio, 0.0f, 4.0f); + + out.gravitropism_first_order = SampleDistribution(gravitropism_first_order, rng); + + // Dynamic tropism array (mirrors MaizeTasselDescriptor logic). + for (const auto& entry : tropisms) { + const float usage_chance = std::clamp(entry.usage_chance_percent, 0.0f, 100.0f); + if (SampleUnit01(rng) * 100.0f > usage_chance) { + continue; + } + SampledTropism st; + const float dx = SampleDistribution(entry.direction_x, rng); + const float dy = SampleDistribution(entry.direction_y, rng); + const float dz = SampleDistribution(entry.direction_z, rng); + const glm::vec3 dir(dx, dy, dz); + const float len = glm::length(dir); + st.direction = (len > 0.001f) ? dir / len : glm::vec3(0.0f, -1.0f, 0.0f); + st.strength = SampleDistribution(entry.strength, rng); + st.order_response = entry.order_response; + out.tropisms.push_back(std::move(st)); + } + + // Per-shoot stochastic noise. + out.internode_length_per_node_cv = std::max(0.0f, SampleDistribution(internode_length_per_node_cv, rng)); + out.internode_thickness_per_node_cv = std::max(0.0f, SampleDistribution(internode_thickness_per_node_cv, rng)); + out.branch_angle_per_node_sigma_deg = std::max(0.0f, SampleDistribution(branch_angle_per_node_sigma_deg, rng)); + out.roll_phyllotaxis_per_node_sigma_deg = + std::max(0.0f, SampleDistribution(roll_phyllotaxis_per_node_sigma_deg, rng)); + + // Needle curvature. + out.needle_curvature_adaxial_bias = SampleDistribution(needle_curvature_adaxial_bias, rng); + out.needle_curvature_abaxial_bias = SampleDistribution(needle_curvature_abaxial_bias, rng); + out.needle_curvature_gradient_per_arclen = SampleDistribution(needle_curvature_gradient_per_arclen, rng); + out.needle_diameter_for_curvature_m = + std::clamp(SampleDistribution(needle_diameter_for_curvature_m, rng), 0.0f, 0.005f); + + // Needle mechanics. + out.needle_young_modulus_baseline_Pa = std::max(0.0f, SampleDistribution(needle_young_modulus_baseline_Pa, rng)); + out.needle_lignification_maturation_years = + std::max(0.0f, SampleDistribution(needle_lignification_maturation_years, rng)); + // Keep width/thickness uncapped and independent. Only enforce + // non-negativity so invalid negatives do not propagate. + out.needle_cross_section_width_max_m = std::max(0.0f, SampleDistribution(needle_cross_section_width_max_m, rng)); + out.needle_cross_section_thickness_max_m = + std::max(0.0f, SampleDistribution(needle_cross_section_thickness_max_m, rng)); + out.needle_density_kg_m3 = std::max(0.0f, SampleDistribution(needle_density_kg_m3, rng)); + out.gravity_m_s2 = std::max(0.0f, SampleDistribution(gravity_m_s2, rng)); + out.needle_per_needle_length_cv = std::max(0.0f, SampleDistribution(needle_per_needle_length_cv, rng)); + out.needle_per_needle_curvature_cv = std::max(0.0f, SampleDistribution(needle_per_needle_curvature_cv, rng)); + out.needle_per_needle_radius_cv = std::max(0.0f, SampleDistribution(needle_per_needle_radius_cv, rng)); + out.needle_per_needle_modulus_cv = std::max(0.0f, SampleDistribution(needle_per_needle_modulus_cv, rng)); + out.needle_per_needle_density_cv = std::max(0.0f, SampleDistribution(needle_per_needle_density_cv, rng)); + out.needle_sinusoidal_amplitude_deg = std::max(0.0f, SampleDistribution(needle_sinusoidal_amplitude_deg, rng)); + out.needle_sinusoidal_frequency_cycles = std::max(0.0f, SampleDistribution(needle_sinusoidal_frequency_cycles, rng)); + out.needle_sinusoidal_phase_randomness_deg = + std::max(0.0f, SampleDistribution(needle_sinusoidal_phase_randomness_deg, rng)); + out.needle_per_needle_wave_amplitude_cv = + std::max(0.0f, SampleDistribution(needle_per_needle_wave_amplitude_cv, rng)); + out.needle_per_needle_wave_frequency_cv = + std::max(0.0f, SampleDistribution(needle_per_needle_wave_frequency_cv, rng)); + out.needle_per_needle_wave_phase_cv = std::max(0.0f, SampleDistribution(needle_per_needle_wave_phase_cv, rng)); + + // Plant-wide random initial orientation around +Y. + out.initial_orientation_yaw_deg = SampleDistribution(initial_orientation_yaw_deg, rng); + + // ---- Per-emission distributions ----------------------------------------- + // Copy each distribution verbatim so production rules can resample at + // every consumption site (per phytomer / per whorl / per needle cluster). + // No additional RNG draws here; the rule call sites will draw fresh from + // their own per-node RNGs (see `MakeNodeRng` in LSystemRuleHelpers.hpp). + out.distributions.max_branching_order = max_branching_order; + out.distributions.plastochron_gdd = plastochron_gdd; + out.distributions.max_phytomers_per_seasonal_growth = max_phytomers_per_seasonal_growth; + out.distributions.branches_per_whorl = branches_per_whorl; + out.distributions.whorl_dormancy_years = whorl_dormancy_years; + out.distributions.branch_insertion_angle_deg = branch_insertion_angle_deg; + out.distributions.branch_roll_phyllotaxis_deg = branch_roll_phyllotaxis_deg; + out.distributions.internode_length_m = internode_length_m; + out.distributions.leader_internode_thickness_m = leader_internode_thickness_m; + out.distributions.lateral_length_ratio = lateral_length_ratio; + out.distributions.lateral_thickness_ratio = lateral_thickness_ratio; + out.distributions.internode_length_maturity_curve = internode_length_maturity_curve; + out.distributions.internode_width_maturity_curve = internode_width_maturity_curve; + out.distributions.bare_zone_fraction = bare_zone_fraction; + out.distributions.needle_count_per_cluster = needle_count_per_cluster; + out.distributions.needle_length_m = needle_length_m; + out.distributions.needle_length_maturity_curve = needle_length_maturity_curve; + out.distributions.needle_cross_section_width_max_m = needle_cross_section_width_max_m; + out.distributions.needle_cross_section_thickness_max_m = needle_cross_section_thickness_max_m; + out.distributions.needle_cross_section_width_profile = needle_cross_section_width_profile; + out.distributions.needle_cross_section_thickness_profile = needle_cross_section_thickness_profile; + out.distributions.needle_cross_section_temporal_maturity_curve = needle_cross_section_temporal_maturity_curve; + out.distributions.needle_lifespan_years = needle_lifespan_years; + out.distributions.needle_browning_years = needle_browning_years; + out.distributions.needle_flush_delay_gdd = needle_flush_delay_gdd; + out.distributions.internode_maturation_gdd = internode_maturation_gdd; + out.distributions.needle_maturation_gdd = needle_maturation_gdd; + out.distributions.needle_branching_angle_deg = needle_branching_angle_deg; + out.distributions.needle_branching_relax_gdd = needle_branching_relax_gdd; + out.distributions.needle_curvature_adaxial_bias = needle_curvature_adaxial_bias; + out.distributions.needle_curvature_abaxial_bias = needle_curvature_abaxial_bias; + out.distributions.needle_curvature_gradient_per_arclen = needle_curvature_gradient_per_arclen; + out.distributions.needle_diameter_for_curvature_m = needle_diameter_for_curvature_m; + out.distributions.needle_sinusoidal_amplitude_deg = needle_sinusoidal_amplitude_deg; + out.distributions.needle_sinusoidal_frequency_cycles = needle_sinusoidal_frequency_cycles; + out.distributions.needle_sinusoidal_phase_randomness_deg = needle_sinusoidal_phase_randomness_deg; + out.distributions.needle_young_modulus_baseline_Pa = needle_young_modulus_baseline_Pa; + out.distributions.needle_lignification_maturation_years = needle_lignification_maturation_years; + out.distributions.needle_density_kg_m3 = needle_density_kg_m3; + out.distributions.needle_per_needle_length_cv = needle_per_needle_length_cv; + out.distributions.needle_per_needle_curvature_cv = needle_per_needle_curvature_cv; + out.distributions.needle_per_needle_radius_cv = needle_per_needle_radius_cv; + out.distributions.needle_per_needle_modulus_cv = needle_per_needle_modulus_cv; + out.distributions.needle_per_needle_density_cv = needle_per_needle_density_cv; + out.distributions.needle_per_needle_wave_amplitude_cv = needle_per_needle_wave_amplitude_cv; + out.distributions.needle_per_needle_wave_frequency_cv = needle_per_needle_wave_frequency_cv; + out.distributions.needle_per_needle_wave_phase_cv = needle_per_needle_wave_phase_cv; + out.distributions.gravitropism_first_order = gravitropism_first_order; + out.distributions.internode_length_per_node_cv = internode_length_per_node_cv; + out.distributions.internode_thickness_per_node_cv = internode_thickness_per_node_cv; + out.distributions.branch_angle_per_node_sigma_deg = branch_angle_per_node_sigma_deg; + out.distributions.roll_phyllotaxis_per_node_sigma_deg = roll_phyllotaxis_per_node_sigma_deg; + + return out; +} + +// =========================================================================== +// Instantiate - create entity with ScotsPine private component +// =========================================================================== +Entity ScotsPineDescriptor::Instantiate() const { + const auto scene = GetApplication().GetActiveScene(); + if (!scene) + return {}; + + const auto entity = scene->CreateEntity(GetTitle()); + const auto pine = scene->GetOrSetPrivateComponent(entity).lock(); + pine->descriptor_ref = GetSelf(); + pine->target_gdd = SampleTargetGddForSeed(target_gdd, pine->seed); + pine->GenerateGeometryEntities(); + + return entity; +} + +// =========================================================================== +// Inspector UI +// =========================================================================== +bool ScotsPineDescriptor::OnInspect(const std::shared_ptr& editor_layer) { + bool changed = false; + bool editor_preferences_changed = false; + + const auto show_item_hover_description = [](const char* description) { + if (!description || description[0] == '\0') + return; + if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) { + ImGui::SetTooltip("%s", description); + } + }; + + // -- Instantiation controls -- + if (ImGui::Button("Instantiate")) { + editor_layer->SetSelectedEntity(Instantiate()); + } + show_item_hover_description("Create a new ScotsPine entity using this descriptor and select it in the scene."); + + ImGui::SameLine(); + if (ImGui::Checkbox("Live Preview", &live_preview)) { + editor_preferences_changed = true; + if (!live_preview) { + live_preview_dirty_ = false; + live_preview_was_dragging_ = false; + live_preview_needs_full_apply_ = false; + } + } + show_item_hover_description("Regenerate matching pines while editing this descriptor."); + + if (ImGui::DragFloat("Live Preview Rate (Hz)", &live_preview_rate_hz, 0.25f, 1.0f, 60.0f, "%.1f")) { + live_preview_rate_hz = std::clamp(live_preview_rate_hz, 1.0f, 60.0f); + editor_preferences_changed = true; + } + show_item_hover_description("Maximum live-preview apply frequency."); + + if (ImGui::Checkbox("Representative Only While Dragging", &live_preview_representative_only)) { + editor_preferences_changed = true; + } + show_item_hover_description("While dragging controls, preview only one matching pine."); + + if (ImGui::Checkbox("Cap Preview Target GDD", &live_preview_cap_target_gdd)) { + editor_preferences_changed = true; + } + show_item_hover_description("Clamp preview simulation GDD so live updates stay fast on very mature trees."); + + if (ImGui::DragFloat("Preview Max GDD", &live_preview_max_gdd, 50.0f, 0.0f, 100000.0f, "%.1f")) { + live_preview_max_gdd = std::max(0.0f, live_preview_max_gdd); + editor_preferences_changed = true; + } + show_item_hover_description("Upper GDD limit used when preview capping is enabled."); + + if (ImGui::DragInt("Preview Max Growth Steps", &live_preview_max_growth_steps, 1.0f, 1, 10000)) { + live_preview_max_growth_steps = std::clamp(live_preview_max_growth_steps, 1, 10000); + editor_preferences_changed = true; + } + show_item_hover_description("Maximum derivation/growth iterations used by drag-time preview updates."); + + if (live_preview_apply_count_ > 0) { + const double avg_apply_ms = live_preview_total_apply_ms_ / static_cast(live_preview_apply_count_); + ImGui::Text("Preview last/avg ms: %.3f / %.3f", live_preview_last_apply_ms_, avg_apply_ms); + } + ImGui::Text("Preview requests/applied/coalesced: %u / %u / %u", live_preview_request_count_, + live_preview_apply_count_, live_preview_coalesced_count_); + + if (ImGui::SmallButton("Reset Preview Stats")) { + live_preview_request_count_ = 0; + live_preview_apply_count_ = 0; + live_preview_coalesced_count_ = 0; + live_preview_last_apply_ms_ = 0.0; + live_preview_total_apply_ms_ = 0.0; + } + show_item_hover_description("Reset live-preview timing and coalescing counters."); + + // -- Grid instantiation -- + if (ImGui::TreeNodeEx("Grid Instantiate")) { + ImGui::DragInt("Rows", &grid_rows, 1, 1, 50); + show_item_hover_description("Number of rows for grid instantiation."); + ImGui::DragInt("Cols", &grid_cols, 1, 1, 50); + show_item_hover_description("Number of columns for grid instantiation."); + ImGui::DragFloat("Spacing", &grid_spacing, 0.1f, 0.5f, 50.0f); + show_item_hover_description("World-space spacing between neighboring grid pines."); + + if (ImGui::Button("Instantiate Grid")) { + const auto scene = GetApplication().GetActiveScene(); + if (scene) { + const auto container = scene->CreateEntity("Pine Grid"); + const float offset_y = (static_cast(grid_rows) - 1.0f) * grid_spacing * 0.5f; + const float offset_z = (static_cast(grid_cols) - 1.0f) * grid_spacing * 0.5f; + const auto base_seed = + static_cast(std::chrono::steady_clock::now().time_since_epoch().count() & 0xFFFFFFFFu); + for (int i = 0; i < grid_rows; i++) { + for (int j = 0; j < grid_cols; j++) { + const auto entity = + scene->CreateEntity(GetTitle() + " [" + std::to_string(i) + "," + std::to_string(j) + "]"); + const auto pine = scene->GetOrSetPrivateComponent(entity).lock(); + pine->descriptor_ref = GetSelf(); + pine->seed = base_seed + static_cast(i * grid_cols + j); + pine->target_gdd = SampleTargetGddForSeed(target_gdd, pine->seed); + + scene->SetParent(entity, container, false); + + Transform transform; + transform.SetPosition(glm::vec3(0.0f, static_cast(i) * grid_spacing - offset_y, + static_cast(j) * grid_spacing - offset_z)); + scene->SetDataComponent(entity, transform); + + pine->GenerateGeometryEntities(); + } + } + } + } + show_item_hover_description("Spawn a grid of ScotsPine entities from this descriptor with unique seeds."); + + ImGui::SameLine(); + if (ImGui::Button("Delete Grid")) { + const auto scene = GetApplication().GetActiveScene(); + if (scene) { + const auto* pine_entities_ptr = scene->UnsafeGetPrivateComponentOwnersList(); + if (pine_entities_ptr) { + const std::vector pine_entities = *pine_entities_ptr; + std::vector to_delete; + std::vector containers; + for (const auto& entity : pine_entities) { + if (!scene->IsEntityValid(entity)) + continue; + auto pine = scene->GetOrSetPrivateComponent(entity).lock(); + if (!pine) + continue; + if (pine->descriptor_ref.Get().get() == this) { + to_delete.push_back(entity); + const auto parent = scene->GetParent(entity); + if (scene->IsEntityValid(parent) && scene->GetEntityName(parent) == "Pine Grid") { + containers.push_back(parent); + } + } + } + for (const auto& entity : to_delete) + scene->DeleteEntity(entity); + std::sort(containers.begin(), containers.end(), [](const Entity& a, const Entity& b) { + return a.GetIndex() < b.GetIndex(); + }); + containers.erase(std::unique(containers.begin(), containers.end()), containers.end()); + for (const auto& container : containers) { + if (scene->IsEntityValid(container)) + scene->DeleteEntity(container); + } + } + } + } + show_item_hover_description( + "Delete ScotsPine entities that use this descriptor and remove now-empty grid containers."); + + ImGui::TreePop(); + } + + // -- Triangle instantiation -- + if (ImGui::TreeNodeEx("Triangle Instantiate")) { + ImGui::DragFloat("Side Length", &triangle_side_length, 0.1f); + show_item_hover_description( + "World-space side length for an equilateral 3-pine triangle on the horizontal XZ plane."); + + if (ImGui::Button("Instantiate Triangle")) { + const auto scene = GetApplication().GetActiveScene(); + if (scene) { + const auto container = scene->CreateEntity("Pine Triangle"); + const float side_length = triangle_side_length; + const float half_side = side_length * 0.5f; + const float triangle_height = side_length * std::sqrt(3.0f) * 0.5f; + const float centroid_to_apex = (2.0f / 3.0f) * triangle_height; + const float centroid_to_base = (1.0f / 3.0f) * triangle_height; + const std::array triangle_positions = { + glm::vec3(0.0f, 0.0f, centroid_to_apex), + glm::vec3(-half_side, 0.0f, -centroid_to_base), + glm::vec3(half_side, 0.0f, -centroid_to_base), + }; + + const auto base_seed = + static_cast(std::chrono::steady_clock::now().time_since_epoch().count() & 0xFFFFFFFFu); + for (int i = 0; i < static_cast(triangle_positions.size()); ++i) { + const auto entity = scene->CreateEntity(GetTitle() + " [T" + std::to_string(i) + "]"); + const auto pine = scene->GetOrSetPrivateComponent(entity).lock(); + pine->descriptor_ref = GetSelf(); + pine->seed = base_seed + static_cast(i); + pine->target_gdd = SampleTargetGddForSeed(target_gdd, pine->seed); + + scene->SetParent(entity, container, false); + + Transform transform; + transform.SetPosition(triangle_positions[static_cast(i)]); + scene->SetDataComponent(entity, transform); + + pine->GenerateGeometryEntities(); + } + editor_layer->SetSelectedEntity(container); + } + } + show_item_hover_description("Spawn 3 ScotsPine entities in an equilateral triangle with unique seeds."); + + ImGui::SameLine(); + if (ImGui::Button("Delete Triangle")) { + const auto scene = GetApplication().GetActiveScene(); + if (scene) { + const auto* pine_entities_ptr = scene->UnsafeGetPrivateComponentOwnersList(); + if (pine_entities_ptr) { + const std::vector pine_entities = *pine_entities_ptr; + std::vector to_delete; + std::vector containers; + for (const auto& entity : pine_entities) { + if (!scene->IsEntityValid(entity)) + continue; + auto pine = scene->GetOrSetPrivateComponent(entity).lock(); + if (!pine) + continue; + if (pine->descriptor_ref.Get().get() == this) { + to_delete.push_back(entity); + const auto parent = scene->GetParent(entity); + if (scene->IsEntityValid(parent) && scene->GetEntityName(parent) == "Pine Triangle") { + containers.push_back(parent); + } + } + } + for (const auto& entity : to_delete) + scene->DeleteEntity(entity); + std::sort(containers.begin(), containers.end(), [](const Entity& a, const Entity& b) { + return a.GetIndex() < b.GetIndex(); + }); + containers.erase(std::unique(containers.begin(), containers.end()), containers.end()); + for (const auto& container : containers) { + if (scene->IsEntityValid(container)) + scene->DeleteEntity(container); + } + } + } + } + show_item_hover_description( + "Delete ScotsPine entities that use this descriptor and remove now-empty triangle containers."); + + ImGui::TreePop(); + } + + ImGui::Separator(); + + // -- Parameter Space Explorer -- + if (ImGui::TreeNodeEx("Parameter Space Explorer")) { + if (!explorer_.IsBound()) + explorer_.Bind(*this); + if (explorer_.OnInspect()) { + changed = true; + } + show_item_hover_description("Interactive parameter sweep and sensitivity exploration tools for this descriptor."); + ImGui::TreePop(); + } + + ImGui::Separator(); + + // -- Global development clock -- + if (ImGui::TreeNodeEx("Global Development", ImGuiTreeNodeFlags_DefaultOpen)) { + changed |= target_gdd.OnInspect("Target GDD", 1.0f, + "Distribution of target GDD used by Instantiate, Grid spawn, and Triangle spawn."); + changed |= plastochron_gdd.OnInspect("Plastochron (GDD)", 10.0f, + "Physiological time between consecutive phytomer events on an axis."); + changed |= max_phytomers_per_seasonal_growth.OnInspect("Max Phytomers per Seasonal Growth", 0.5f, + "Phytomers (internode + optional needle cluster) emitted " + "per active season before the apex pauses until next year."); + + // -- Per-pine calendar / GDD-per-day fields -- + // Consumed by LSystemLayer::SamplePineTemporalParameters() and applied + // in the per-pine update path. delta_gdd = sampled_gdd_per_day * delta_days + // where delta_days = chronological_days_per_second * dt. + changed |= gdd_per_day.OnInspect("GDD per Day", 0.1f, + "Per-pine thermal accumulation rate. Sampled per plant; multiplied by " + "chronological day delta from LSystemLayer."); + changed |= growing_season_start_day.OnInspect( + "Growing Season Start Day", 1.0f, + "Per-pine active season start day-of-year (0-365). Sampled per plant; gates pine growth in LSystemLayer."); + changed |= growing_season_end_day.OnInspect( + "Growing Season End Day", 1.0f, + "Per-pine active season end day-of-year (0-365). Sampled per plant; gates pine growth in LSystemLayer."); + + auto clamp_nonnegative_distribution = [&](evo_engine::SingleDistribution& distribution) { + const float old_mean = distribution.mean; + const float old_deviation = distribution.deviation; + distribution.mean = std::max(0.0f, distribution.mean); + distribution.deviation = std::max(0.0f, distribution.deviation); + if (std::abs(distribution.mean - old_mean) > 1.0e-6f || + std::abs(distribution.deviation - old_deviation) > 1.0e-6f) { + changed = true; + } + }; + auto clamp_integer_day_distribution = [&](evo_engine::SingleDistribution& distribution) { + const float old_mean = distribution.mean; + const float old_deviation = distribution.deviation; + distribution.mean = std::clamp(distribution.mean, 0.0f, 365.0f); + distribution.deviation = std::max(0.0f, std::round(distribution.deviation)); + if (std::abs(distribution.mean - old_mean) > 1.0e-6f || + std::abs(distribution.deviation - old_deviation) > 1.0e-6f) { + changed = true; + } + }; + clamp_nonnegative_distribution(gdd_per_day); + clamp_integer_day_distribution(growing_season_start_day); + clamp_integer_day_distribution(growing_season_end_day); + + ImGui::TreePop(); + } + + // -- Main stem geometry -- + if (ImGui::TreeNodeEx("Main Stem (Leader Axis)", ImGuiTreeNodeFlags_DefaultOpen)) { + changed |= internode_length_m.OnInspect("Phytomer Internode Length (m)", 0.001f, + "Length of one phytomer's internode in metres."); + changed |= leader_internode_thickness_m.OnInspect( + "Main Stem Width (Diameter, m)", 0.0001f, + "Main stem thickness control. This is the leader internode diameter in metres."); + changed |= initial_orientation_yaw_deg.OnInspect( + "Initial Orientation Yaw (deg)", 1.0f, + "Sampled once per plant and applied as root yaw around +Y. Set deviation > 0 for random initial orientation."); + if (ImGui::ColorEdit4("Main Stem Color", &main_stem_color_rgba.x)) { + changed = true; + } + show_item_hover_description("Young stem color used by stem and branch internodes in Shaded and ByType modes."); + if (ImGui::ColorEdit4("Main Stem Old Color", &main_stem_old_color_rgba.x)) { + changed = true; + } + show_item_hover_description("Old stem color reached as internodes approach descriptor max age."); + if (ImGui::DragFloat("Main Stem Age Exponent", &internode_age_exponent, 0.05f, 0.1f, 4.0f, "%.2f")) { + internode_age_exponent = std::clamp(internode_age_exponent, 0.1f, 4.0f); + changed = true; + } + show_item_hover_description( + "Response curve for stem aging color. 1 = linear, >1 delays browning, <1 accelerates it."); + ImGui::Text("Mean Radius (m): %.6f", std::max(0.0f, leader_internode_thickness_m.mean) * 0.5f); + changed |= lateral_length_ratio.OnInspect("Lateral Length Ratio", 0.05f); + show_item_hover_description("Lateral shoot length = leader_length * ratio^order."); + changed |= lateral_thickness_ratio.OnInspect("Lateral Thickness Ratio", 0.05f); + show_item_hover_description("Lateral shoot thickness = leader_thickness * ratio^order."); + ImGui::TreePop(); + } + + // -- Main stem branching -- + if (ImGui::TreeNodeEx("Main Stem Branching (Whorl Buds)", ImGuiTreeNodeFlags_DefaultOpen)) { + changed |= max_branching_order.OnInspect("Max Branching Order", 0.5f); + show_item_hover_description("0 = leader only, 1 = primary laterals, 2 = secondary laterals."); + changed |= branches_per_whorl.OnInspect("Branches per Whorl", 0.5f); + show_item_hover_description("Lateral count spawned at whorl bud activation."); + changed |= whorl_dormancy_years.OnInspect("Whorl Dormancy (years)", 0.05f, + "Chronological years a whorl bud waits before activating laterals. " + "Bud release is chilling/photoperiod-driven, NOT heat-sum-driven " + "(FSPM Rule of Ontogeny). Default 1 yr = annual Scots pine cycle."); + changed |= branch_insertion_angle_deg.OnInspect("Branch Insertion Angle (deg)", 1.0f); + show_item_hover_description("Angle laterals depart parent (degrees)."); + changed |= branch_roll_phyllotaxis_deg.OnInspect("Branch Roll Phyllotaxis (deg)", 1.0f); + show_item_hover_description("Golden-angle azimuth offset between consecutive laterals and needles."); + ImGui::TreePop(); + } + + // -- Maturity shape curves -- + if (ImGui::TreeNodeEx("Maturity Shape Curves", ImGuiTreeNodeFlags_DefaultOpen)) { + static int selected_maturity_variable = 0; + constexpr const char* kMaturityVariables[] = {"Internode Length", "Internode Width", "Needle Length"}; + + ImGui::Combo("Variable", &selected_maturity_variable, kMaturityVariables, IM_ARRAYSIZE(kMaturityVariables)); + show_item_hover_description( + "Choose which maturity-controlled variable to edit. " + "x = normalized maturity age of the specific organ instance; " + "y = multiplier in [0,1], where 1 means use full max length/width."); + + evo_engine::PlottedDistribution* selected_distribution = &internode_length_maturity_curve; + const char* selected_label = "Internode Length Maturity Response"; + switch (std::clamp(selected_maturity_variable, 0, 2)) { + case 0: + default: + selected_distribution = &internode_length_maturity_curve; + selected_label = "Internode Length Maturity Response"; + break; + case 1: + selected_distribution = &internode_width_maturity_curve; + selected_label = "Internode Width Maturity Response"; + break; + case 2: + selected_distribution = &needle_length_maturity_curve; + selected_label = "Needle Length Maturity Response"; + break; + } + + evo_engine::PlottedDistributionSettings maturity_settings; + maturity_settings.tip = + "Two plotted controls are exposed: mean and variance over maturity age. " + "For a fixed organ instance, runtime samples one deterministic realization and " + "applies it along this curve over age."; + maturity_settings.mean_settings.m_tip = + "Mean maturity response curve. x = maturity age fraction [0,1], y = size multiplier [0,1]."; + maturity_settings.dev_settings.m_tip = + "Variance (sigma) over maturity age. Runtime uses a fixed per-organ realization (no frame jitter)."; + changed |= selected_distribution->OnInspect(selected_label, maturity_settings); + + auto clamp_plot_01 = [](evo_engine::Plot2D& plot) { + plot.min_value = std::clamp(plot.min_value, 0.0f, 1.0f); + plot.max_value = std::clamp(plot.max_value, 0.0f, 1.0f); + if (plot.max_value < plot.min_value) { + std::swap(plot.min_value, plot.max_value); + } + }; + clamp_plot_01(selected_distribution->mean); + clamp_plot_01(selected_distribution->deviation); + + ImGui::TreePop(); + } + + // -- Needles: Quick Shape Presets -- + if (ImGui::TreeNodeEx("Needles - Quick Shape Presets", ImGuiTreeNodeFlags_DefaultOpen)) { + static const char* kShapePresets[] = {"(no change)", "Straight", "Slight Curve", + "Strong Curve", "Wavy", "Drooping (gravity)"}; + static int s_selected_shape_preset = 0; + if (ImGui::Combo("Shape Preset##quick_needle_shape", &s_selected_shape_preset, kShapePresets, + IM_ARRAYSIZE(kShapePresets))) { + auto apply_preset = [&](float adaxial, float abaxial, float gradient, float diameter_for_curvature_m, + float wave_amp_deg, float wave_freq, float wave_phase_rand_deg, bool enable_droop) { + needle_curvature_adaxial_bias.mean = adaxial; + needle_curvature_abaxial_bias.mean = abaxial; + needle_curvature_gradient_per_arclen.mean = gradient; + needle_diameter_for_curvature_m.mean = diameter_for_curvature_m; + needle_sinusoidal_amplitude_deg.mean = wave_amp_deg; + needle_sinusoidal_frequency_cycles.mean = wave_freq; + needle_sinusoidal_phase_randomness_deg.mean = wave_phase_rand_deg; + if (enable_droop) { + if (needle_young_modulus_baseline_Pa.mean <= 0.0f) { + needle_young_modulus_baseline_Pa.mean = 1.8e7f; + } + if (needle_density_kg_m3.mean <= 0.0f) { + needle_density_kg_m3.mean = 800.0f; + } + if (gravity_m_s2.mean <= 0.0f) { + gravity_m_s2.mean = 9.81f; + } + } + }; + switch (s_selected_shape_preset) { + case 1: + apply_preset(0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, false); + changed = true; + break; + case 2: + apply_preset(0.000f, 0.010f, 0.0015f, 0.001f, 0.0f, 0.0f, 0.0f, false); + changed = true; + break; + case 3: + apply_preset(0.000f, 0.030f, 0.0040f, 0.002f, 0.0f, 0.0f, 0.0f, false); + changed = true; + break; + case 4: + apply_preset(0.0f, 0.0f, 0.0f, 0.0f, 4.0f, 2.0f, 5.0f, false); + changed = true; + break; + case 5: + apply_preset(0.000f, 0.005f, 0.0010f, 0.001f, 0.0f, 0.0f, 0.0f, true); + changed = true; + break; + case 0: + default: + break; + } + s_selected_shape_preset = 0; + } + show_item_hover_description( + "Writes a coordinated set of curvature, waviness, and (for Drooping) " + "mechanics fields."); + ImGui::TreePop(); + } + + // -- Needle cross-section controls -- + if (ImGui::TreeNodeEx("Needle Cross Section", ImGuiTreeNodeFlags_DefaultOpen)) { + auto clamp_nonnegative_distribution = [&](evo_engine::SingleDistribution& distribution) { + const float old_mean = distribution.mean; + const float old_deviation = distribution.deviation; + distribution.mean = std::max(0.0f, distribution.mean); + distribution.deviation = std::max(0.0f, distribution.deviation); + if (std::abs(distribution.mean - old_mean) > 1.0e-6f || + std::abs(distribution.deviation - old_deviation) > 1.0e-6f) { + changed = true; + } + }; + auto clamp_profile_distribution = [&](evo_engine::PlottedDistribution& distribution, const float max_value) { + auto clamp_plot = [&](evo_engine::Plot2D& plot) { + const float old_min = plot.min_value; + const float old_max = plot.max_value; + plot.min_value = std::clamp(plot.min_value, 0.0f, 1.0f); + plot.max_value = std::clamp(plot.max_value, 0.0f, max_value); + if (plot.max_value < plot.min_value) { + std::swap(plot.min_value, plot.max_value); + } + if (std::abs(plot.min_value - old_min) > 1.0e-6f || std::abs(plot.max_value - old_max) > 1.0e-6f) { + changed = true; + } + }; + clamp_plot(distribution.mean); + clamp_plot(distribution.deviation); + }; + auto inspect_axis = [&](const char* axis_name, evo_engine::SingleDistribution& max_distribution, + evo_engine::PlottedDistribution& profile_distribution, const char* max_label, + const char* profile_label, const char* tooltip) { + if (ImGui::TreeNodeEx(axis_name, ImGuiTreeNodeFlags_DefaultOpen)) { + changed |= max_distribution.OnInspect(max_label, 0.00005f, tooltip); + clamp_nonnegative_distribution(max_distribution); + + evo_engine::PlottedDistributionSettings profile_settings; + profile_settings.tip = + "Base-to-tip multiplier profile for the selected cross-section axis. " + "x = normalized arc length from base (0) to tip (1)."; + profile_settings.mean_settings.m_tip = + "Mean multiplier profile in [0,4]. 1 keeps the max diameter; 0 collapses axis radius; " + "values >1 enlarge it."; + profile_settings.dev_settings.m_tip = "Variance (sigma) profile in [0,4] around the mean profile."; + changed |= profile_distribution.OnInspect(profile_label, profile_settings); + clamp_profile_distribution(profile_distribution, 4.0f); + + ImGui::Text("Current mean max diameter: %.3f mm", std::max(0.0f, max_distribution.mean) * 1000.0f); + ImGui::TreePop(); + } + }; + + inspect_axis("Width", needle_cross_section_width_max_m, needle_cross_section_width_profile, + "Max Width Diameter (m)", "Width Profile (Base -> Tip)", + "Maximum full width (major axis diameter) before profile multiplier."); + inspect_axis("Thickness", needle_cross_section_thickness_max_m, needle_cross_section_thickness_profile, + "Max Thickness Diameter (m)", "Thickness Profile (Base -> Tip)", + "Maximum full thickness (minor axis diameter) before profile multiplier."); + + evo_engine::PlottedDistributionSettings temporal_settings; + temporal_settings.tip = + "Shared temporal multiplier applied to both width and thickness. " + "x = normalized maturity age where x=1 corresponds to 2 years since initiation."; + temporal_settings.mean_settings.m_tip = + "Mean multiplier in [0,1]. Default is sinusoidal: 0.25 at t=0 years to 1.0 at t=2 years."; + temporal_settings.dev_settings.m_tip = "Variance (sigma) profile around the temporal mean in [0,1]."; + changed |= needle_cross_section_temporal_maturity_curve.OnInspect("Shared Temporal Width/Thickness Maturity", + temporal_settings); + clamp_profile_distribution(needle_cross_section_temporal_maturity_curve, 1.0f); + + ImGui::TreePop(); + } + + // -- Needles -- + if (ImGui::TreeNodeEx("Needles (Layout and Lifecycle)", ImGuiTreeNodeFlags_DefaultOpen)) { + changed |= bare_zone_fraction.OnInspect( + "Bare Zone Fraction", 0.01f, + "Temporal fraction at the start of each year that emits internode-only phytomers [0, 0.95)."); + changed |= needle_count_per_cluster.OnInspect("Needles per Cluster", 0.5f); + show_item_hover_description("Pinus sylvestris fascicle count (typically 2)."); + if (ImGui::DragInt("Needle Segments", &needle_segment_count, 1.0f, 3, 128)) { + needle_segment_count = std::clamp(needle_segment_count, 3, 128); + changed = true; + } + show_item_hover_description("Longitudinal segments per needle centerline. Mesh stations = segments + 1."); + changed |= needle_length_m.OnInspect("Needle Length (m)", 0.001f, "Length of needles in metres."); + changed |= needle_lifespan_years.OnInspect("Needle Lifespan (years)", 0.1f, + "Chronological years a needle stays alive post-maturity. Senescence " + "is calendar-driven, NOT heat-sum-driven (FSPM Rule of Ontogeny). " + "Scots pine typical: 3-4 yr."); + changed |= needle_browning_years.OnInspect("Needle Browning (years)", 0.05f, + "Chronological years from senescence onset to abscission."); + changed |= needle_flush_delay_gdd.OnInspect("Needle Flush Delay (GDD)", 10.0f, + "Delay from phytomer emergence to needle flush."); + changed |= internode_maturation_gdd.OnInspect("Shoot Maturation (GDD)", 10.0f, + "Thermal time from emergence to mature internode length."); + changed |= needle_maturation_gdd.OnInspect("Needle Maturation (GDD)", 10.0f, + "Thermal time from flush to mature needle length."); + changed |= needle_branching_angle_deg.OnInspect( + "Needle Branching Angle (deg)", 0.25f, "Final branching angle from the parent axis reached after relaxation."); + changed |= needle_branching_relax_gdd.OnInspect("Needle Branching Relaxation (GDD-equivalent)", 10.0f, + "Converted using 1500 GDD/year, then applied against chronological " + "age so relaxation continues during dormant season."); + if (ImGui::DragFloat("Order Needle Length Attenuation", &needle_order_length_attenuation, 0.01f, 0.0f, 1.0f, + "%.3f")) { + needle_order_length_attenuation = std::clamp(needle_order_length_attenuation, 0.0f, 1.0f); + changed = true; + } + show_item_hover_description( + "Linear per-order reduction applied to needle length: scale = max(min, 1 - attenuation*order)."); + if (ImGui::DragFloat("Order Needle Radius Attenuation", &needle_order_radius_attenuation, 0.01f, 0.0f, 1.0f, + "%.3f")) { + needle_order_radius_attenuation = std::clamp(needle_order_radius_attenuation, 0.0f, 1.0f); + changed = true; + } + show_item_hover_description( + "Linear per-order reduction applied to needle thickness: scale = max(min, 1 - attenuation*order)."); + if (ImGui::DragFloat("Order Needle Length Min Scale", &needle_order_min_length_scale, 0.01f, 0.10f, 1.00f, + "%.3f")) { + needle_order_min_length_scale = std::clamp(needle_order_min_length_scale, 0.10f, 1.00f); + changed = true; + } + show_item_hover_description("Lower bound for branch-order needle length scaling."); + if (ImGui::DragFloat("Order Needle Radius Min Scale", &needle_order_min_radius_scale, 0.01f, 0.10f, 1.00f, + "%.3f")) { + needle_order_min_radius_scale = std::clamp(needle_order_min_radius_scale, 0.10f, 1.00f); + changed = true; + } + show_item_hover_description("Lower bound for branch-order needle thickness scaling."); + if (ImGui::TreeNodeEx("Initiation Capacity Mapping", ImGuiTreeNodeFlags_DefaultOpen)) { + if (ImGui::DragFloat("Intra-Year Base Ratio", &needle_intra_year_base_ratio, 0.01f, 0.0f, 1.0f, "%.3f")) { + needle_intra_year_base_ratio = std::clamp(needle_intra_year_base_ratio, 0.0f, 1.0f); + changed = true; + } + show_item_hover_description( + "Lower bound for early-phytomer capacity." + " Runtime applies L_actual = L_max * w_intra(i) * w_inter(year,vigor)."); + + if (ImGui::DragFloat("Intra-Year Sigmoid Steepness", &needle_intra_year_sigmoid_steepness, 0.10f, 0.01f, 32.0f, + "%.3f")) { + needle_intra_year_sigmoid_steepness = std::max(0.01f, needle_intra_year_sigmoid_steepness); + changed = true; + } + show_item_hover_description("Steepness k of the intra-year sigmoid over normalized phytomer index."); + + if (ImGui::DragFloat("Intra-Year Sigmoid Midpoint", &needle_intra_year_sigmoid_midpoint_fraction, 0.01f, 0.0f, + 1.0f, "%.3f")) { + needle_intra_year_sigmoid_midpoint_fraction = + std::clamp(needle_intra_year_sigmoid_midpoint_fraction, 0.0f, 1.0f); + changed = true; + } + show_item_hover_description("Normalized phytomer index where intra-year capacity reaches 50% ramp."); + + if (ImGui::DragFloat("Late-Season Decay Start", &needle_intra_year_late_decay_start_fraction, 0.01f, 0.0f, 1.0f, + "%.3f")) { + needle_intra_year_late_decay_start_fraction = + std::clamp(needle_intra_year_late_decay_start_fraction, 0.0f, 1.0f); + changed = true; + } + show_item_hover_description("Normalized phytomer index where optional late-season decay begins."); + + if (ImGui::DragFloat("Late-Season End Scale", &needle_intra_year_late_decay_end_scale, 0.01f, 0.0f, 2.0f, + "%.3f")) { + needle_intra_year_late_decay_end_scale = std::clamp(needle_intra_year_late_decay_end_scale, 0.0f, 2.0f); + changed = true; + } + show_item_hover_description("Multiplier reached at the final phytomer if late-season decay is active."); + + if (ImGui::DragInt("Fascicular Start Year", &needle_fascicular_start_year, 1.0f, 0, 16)) { + needle_fascicular_start_year = std::clamp(needle_fascicular_start_year, 0, 16); + changed = true; + } + show_item_hover_description( + "Year index where year2+ multipliers become active. 0 applies them from first-year shoots."); + + if (ImGui::DragFloat("Year2+ Length Multiplier", &needle_year2plus_length_multiplier, 0.01f, 0.0f, 8.0f, + "%.3f")) { + needle_year2plus_length_multiplier = std::max(0.0f, needle_year2plus_length_multiplier); + changed = true; + } + show_item_hover_description("Inter-year multiplier for needle target length."); + + if (ImGui::DragFloat("Year2+ Width Multiplier", &needle_year2plus_width_multiplier, 0.01f, 0.0f, 8.0f, "%.3f")) { + needle_year2plus_width_multiplier = std::max(0.0f, needle_year2plus_width_multiplier); + changed = true; + } + show_item_hover_description("Inter-year multiplier for needle major-axis width."); + + if (ImGui::DragFloat("Year2+ Thickness Multiplier", &needle_year2plus_thickness_multiplier, 0.01f, 0.0f, 8.0f, + "%.3f")) { + needle_year2plus_thickness_multiplier = std::max(0.0f, needle_year2plus_thickness_multiplier); + changed = true; + } + show_item_hover_description("Inter-year multiplier for needle minor-axis thickness."); + + if (ImGui::DragFloat("Year1 Lignification Factor", &needle_lignification_factor_year1, 0.01f, 0.0f, 2.0f, + "%.3f")) { + needle_lignification_factor_year1 = std::clamp(needle_lignification_factor_year1, 0.0f, 2.0f); + changed = true; + } + show_item_hover_description("Scales visual maturation response for first-year needle cohorts."); + + if (ImGui::DragFloat("Year2+ Lignification Factor", &needle_lignification_factor_year2plus, 0.01f, 0.0f, 2.0f, + "%.3f")) { + needle_lignification_factor_year2plus = std::clamp(needle_lignification_factor_year2plus, 0.0f, 2.0f); + changed = true; + } + show_item_hover_description("Scales visual maturation response for year2+ needle cohorts."); + + if (ImGui::DragFloat("Year1 Stomatal Strip Density", &needle_stomatal_strip_density_year1, 0.01f, 0.0f, 1.0f, + "%.3f")) { + needle_stomatal_strip_density_year1 = std::clamp(needle_stomatal_strip_density_year1, 0.0f, 1.0f); + changed = true; + } + show_item_hover_description("Proxy density for procedural stomatal striping in first-year cohorts."); + + if (ImGui::DragFloat("Year2+ Stomatal Strip Density", &needle_stomatal_strip_density_year2plus, 0.01f, 0.0f, 1.0f, + "%.3f")) { + needle_stomatal_strip_density_year2plus = std::clamp(needle_stomatal_strip_density_year2plus, 0.0f, 1.0f); + changed = true; + } + show_item_hover_description("Proxy density for procedural stomatal striping in year2+ cohorts."); + + if (ImGui::DragFloat("Year1 Basal Taper Ratio", &needle_basal_taper_ratio_year1, 0.01f, 0.6f, 1.2f, "%.3f")) { + needle_basal_taper_ratio_year1 = std::clamp(needle_basal_taper_ratio_year1, 0.6f, 1.2f); + changed = true; + } + show_item_hover_description("Needle-base radius multiplier for first-year cohorts. 1.0 disables base taper."); + + if (ImGui::DragFloat("Year2+ Basal Taper Ratio", &needle_basal_taper_ratio_year2plus, 0.01f, 0.6f, 1.2f, + "%.3f")) { + needle_basal_taper_ratio_year2plus = std::clamp(needle_basal_taper_ratio_year2plus, 0.6f, 1.2f); + changed = true; + } + show_item_hover_description("Needle-base radius multiplier for year2+ cohorts. 1.0 disables base taper."); + + if (ImGui::DragFloat("Fascicle Sheath Budget (GDD)", &needle_fascicle_sheath_budget_gdd, 10.0f, 0.0f, 5000.0f, + "%.1f")) { + needle_fascicle_sheath_budget_gdd = std::max(0.0f, needle_fascicle_sheath_budget_gdd); + changed = true; + } + show_item_hover_description("Characteristic thermal budget for sheath maturation near needle bases."); + + if (ImGui::DragFloat("Year1 Specularity Plasticity", &needle_specularity_plasticity_year1, 0.01f, 0.0f, 1.0f, + "%.3f")) { + needle_specularity_plasticity_year1 = std::clamp(needle_specularity_plasticity_year1, 0.0f, 1.0f); + changed = true; + } + show_item_hover_description("How strongly first-year micro-variation tracks maturity cues."); + + if (ImGui::DragFloat("Year2+ Specularity Plasticity", &needle_specularity_plasticity_year2plus, 0.01f, 0.0f, 1.0f, + "%.3f")) { + needle_specularity_plasticity_year2plus = std::clamp(needle_specularity_plasticity_year2plus, 0.0f, 1.0f); + changed = true; + } + show_item_hover_description("How strongly year2+ micro-variation tracks maturity cues."); + + if (ImGui::DragFloat("Bud-Storage Vigor Strength", &needle_bud_storage_vigor_strength, 0.01f, 0.0f, 1.0f, + "%.3f")) { + needle_bud_storage_vigor_strength = std::clamp(needle_bud_storage_vigor_strength, 0.0f, 1.0f); + changed = true; + } + show_item_hover_description("Blend factor from 1.0 to previous-season vigor proxy for inter-year capacity."); + + if (ImGui::DragFloat("Bud-Storage Completion Floor", &needle_bud_storage_completion_floor, 0.01f, 0.0f, 1.0f, + "%.3f")) { + needle_bud_storage_completion_floor = std::clamp(needle_bud_storage_completion_floor, 0.0f, 1.0f); + changed = true; + } + show_item_hover_description("Lower clamp applied to completion ratio before vigor carry-over."); + ImGui::TreePop(); + } + if (ImGui::DragFloat("[deprecated] Needle Width Cap (unused)", &needle_radius_to_stem_thickness_max_ratio, 0.05f, + 0.0f, 4.0f, "%.3f")) { + needle_radius_to_stem_thickness_max_ratio = std::clamp(needle_radius_to_stem_thickness_max_ratio, 0.0f, 4.0f); + changed = true; + } + show_item_hover_description( + "Deprecated no-op. Needle width and thickness are uncapped and no " + "longer tied to stem thickness."); + if (ImGui::ColorEdit4("Needle Color", &needle_color_rgba.x)) { + changed = true; + } + show_item_hover_description("Young needle color used for newly flushed or low-age segments."); + if (ImGui::ColorEdit4("Needle Old Color", &needle_old_color_rgba.x)) { + changed = true; + } + show_item_hover_description("Old needle color reached by high-age or strongly senescent segments."); + if (ImGui::DragFloat("Needle Axial Age Span", &needle_axial_age_span, 0.01f, -1.0f, 1.0f, "%.3f")) { + needle_axial_age_span = std::clamp(needle_axial_age_span, -1.0f, 1.0f); + changed = true; + } + show_item_hover_description( + "Along-needle age shift. Positive biases older color toward tip; negative biases older color toward base."); + if (ImGui::DragFloat("Needle Axial Age Exponent", &needle_axial_age_exponent, 0.05f, 0.1f, 4.0f, "%.2f")) { + needle_axial_age_exponent = std::clamp(needle_axial_age_exponent, 0.1f, 4.0f); + changed = true; + } + show_item_hover_description( + "Shape of along-needle gradient response. 1 = linear, >1 concentrates changes near one end."); + ImGui::TreePop(); + } + + // -- Needle curvature (bilateral differential growth field) -- + if (ImGui::TreeNodeEx("Needle Shape (Curvature Field)")) { + changed |= needle_curvature_adaxial_bias.OnInspect( + "Adaxial Elongation Bias", 0.001f, "Dimensionless adaxial side elongation. Positive bends needle toward stem."); + changed |= needle_curvature_abaxial_bias.OnInspect( + "Abaxial Elongation Bias", 0.001f, + "Dimensionless abaxial side elongation. Positive bends needle away from stem."); + changed |= needle_curvature_gradient_per_arclen.OnInspect( + "Curvature Gradient (per s_norm)", 0.001f, + "Linear gradient added to (abaxial - adaxial) along normalized arc length."); + changed |= needle_diameter_for_curvature_m.OnInspect( + "Effective Diameter (m)", 0.0001f, + "Cross-section diameter used to convert strain differential into curvature. " + "Set > 0 to activate the field."); + changed |= needle_sinusoidal_amplitude_deg.OnInspect( + "Sinusoidal Wave Amplitude (deg)", 0.10f, + "Additional intrinsic waviness amplitude applied along the needle; 0 keeps arc-only behavior."); + changed |= needle_sinusoidal_frequency_cycles.OnInspect("Sinusoidal Wave Frequency (cycles)", 0.05f, + "Number of waviness cycles along full needle length."); + changed |= needle_sinusoidal_phase_randomness_deg.OnInspect( + "Sinusoidal Phase Randomness (deg)", 0.10f, + "Sampled phase jitter magnitude combined with deterministic per-needle phase."); + ImGui::TreePop(); + } + + // -- Needle mechanics (elastica) -- + if (ImGui::TreeNodeEx("Needle Mechanics (Elastica)")) { + changed |= needle_young_modulus_baseline_Pa.OnInspect( + "Young's Modulus Baseline (Pa)", 1e6f, "Asymptotic Young's modulus at maturity. 0 = solver disabled."); + changed |= needle_lignification_maturation_years.OnInspect("Lignification Maturation (yr)", 0.05f, + "Sigmoid maturation duration for E(t)."); + changed |= needle_density_kg_m3.OnInspect("Tissue Density (kg/m^3)", 10.0f, + "Used to derive distributed weight per unit arc length."); + changed |= gravity_m_s2.OnInspect("Gravity (m/s^2)", 0.1f, "World-frame gravity magnitude. 0 = no body force."); + ImGui::TreePop(); + } + + if (ImGui::TreeNodeEx("Needle Per-Needle Variability")) { + changed |= needle_per_needle_length_cv.OnInspect( + "Length CV", 0.01f, "CV-style variation across needles within a cluster for length scale."); + changed |= needle_per_needle_curvature_cv.OnInspect( + "Curvature CV", 0.01f, "CV-style variation across needles within a cluster for curvature-field magnitude."); + changed |= needle_per_needle_radius_cv.OnInspect( + "Radius CV", 0.01f, "CV-style variation across needles within a cluster for cross-section axis scale."); + changed |= needle_per_needle_modulus_cv.OnInspect( + "Young's Modulus CV", 0.01f, + "CV-style variation across needles within a cluster for baseline Young's modulus."); + changed |= needle_per_needle_density_cv.OnInspect( + "Density CV", 0.01f, "CV-style variation across needles within a cluster for tissue density."); + changed |= needle_per_needle_wave_amplitude_cv.OnInspect( + "Wave Amplitude CV", 0.01f, + "CV-style variation across needles within a cluster for sinusoidal waviness amplitude."); + changed |= needle_per_needle_wave_frequency_cv.OnInspect( + "Wave Frequency CV", 0.01f, + "CV-style variation across needles within a cluster for sinusoidal waviness frequency."); + changed |= needle_per_needle_wave_phase_cv.OnInspect("Wave Phase CV", 0.01f, + "CV-style scaling of per-needle sinusoidal phase randomness."); + ImGui::TreePop(); + } + + // -- Tropism -- + if (ImGui::TreeNodeEx("Tropism (Global)")) { + changed |= gravitropism_first_order.OnInspect( + "Main Stem Tropism (deg/GDD)", 0.0001f, + "Per-GDD curvature applied to leader internodes only (branch order 0). Positive bends upward."); + ImGui::TreePop(); + } + + // -- Per-shoot stochastic noise -- + if (ImGui::TreeNodeEx("Stochastic Variation (Per Shoot)")) { + changed |= internode_length_per_node_cv.OnInspect( + "Internode Length CV", 0.005f, "Per-internode Gaussian CV on phytomer length. 0 = deterministic."); + changed |= internode_thickness_per_node_cv.OnInspect( + "Internode Thickness CV", 0.005f, "Per-internode Gaussian CV on shoot thickness. 0 = deterministic."); + changed |= branch_angle_per_node_sigma_deg.OnInspect("Branch Angle Sigma (deg)", 0.5f, + "Per-lateral additive Gaussian sigma on insertion angle."); + changed |= roll_phyllotaxis_per_node_sigma_deg.OnInspect( + "Roll Phyllotaxis Sigma (deg)", 0.5f, "Per-lateral additive Gaussian sigma on phyllotaxis roll."); + ImGui::TreePop(); + } + + // ============================================================================== + // Deprecated controls (no runtime effect). + // + // These fields are sampled and serialized, but no consumer in the current + // pine growth path iterates them. They are kept declared per the workspace + // policy ("never remove unused code unless explicitly told to"). The foldout + // is collapsed by default so they stay out of the way. + // ============================================================================== + ImGui::Separator(); + if (ImGui::TreeNodeEx("Deprecated (no runtime effect)")) { + ImGui::TextWrapped( + "The field(s) below are serialized and sampled but have no consumer" + " in the current pine growth path. Edits round-trip through YAML but" + " do not affect generated geometry."); + + // -- Dynamic tropism array ([deprecated] for pine) -- + // The pine-side tropisms vector is sampled into SampledPineParams::tropisms + // but no pine consumer iterates it. The Maize tassel side does iterate + // its analogous vector (MaizeTasselRules.hpp), so the type stays alive. + // The active stem tropism for pine is the scalar `gravitropism_first_order` + // in the "Tropism (Global)" group above. + if (ImGui::TreeNodeEx("Dynamic Tropisms [deprecated]")) { + ImGui::TextWrapped( + "[deprecated] No pine consumer reads sampled.tropisms. Use" + " \"Tropism (Global)\" -> Main Stem Tropism instead."); + if (ImGui::Button("+ Add Tropism")) { + tropisms.emplace_back(); + changed = true; + } + show_item_hover_description("Add a directional tropism entry. [deprecated] no runtime effect."); + + int remove_index = -1; + for (size_t i = 0; i < tropisms.size(); ++i) { + ImGui::PushID(static_cast(i)); + const std::string label = "Tropism #" + std::to_string(i); + if (ImGui::TreeNodeEx(label.c_str(), ImGuiTreeNodeFlags_DefaultOpen)) { + auto& entry = tropisms[i]; + changed |= entry.direction_x.OnInspect("Direction X", 0.05f); + changed |= entry.direction_y.OnInspect("Direction Y", 0.05f); + changed |= entry.direction_z.OnInspect("Direction Z", 0.05f); + changed |= entry.strength.OnInspect("Strength", 0.05f); + if (ImGui::DragFloat("Usage Chance (%)", &entry.usage_chance_percent, 1.0f, 0.0f, 100.0f, "%.1f")) { + entry.usage_chance_percent = std::clamp(entry.usage_chance_percent, 0.0f, 100.0f); + changed = true; + } + changed |= entry.order_response.OnInspect("Order Response (vs branching order)"); + if (ImGui::Button("Remove")) + remove_index = static_cast(i); + ImGui::TreePop(); + } + ImGui::PopID(); + } + if (remove_index >= 0) { + tropisms.erase(tropisms.begin() + remove_index); + changed = true; + } + ImGui::TreePop(); + } + + ImGui::TreePop(); + } + + if (editor_preferences_changed) { + // Editor preferences are persisted via Serialize/Deserialize; they don't + // mark the asset content "changed" for revision tracking. + } + + return changed; +} + +// =========================================================================== +// Serialize +// =========================================================================== +void ScotsPineDescriptor::Serialize(YAML::Emitter& out) const { + // Phytomer scheduling. + max_branching_order.Save("max_branching_order", out); + plastochron_gdd.Save("plastochron_gdd", out); + max_phytomers_per_seasonal_growth.Save("max_phytomers_per_seasonal_growth", out); + + // Whorl architecture. + branches_per_whorl.Save("branches_per_whorl", out); + whorl_dormancy_years.Save("whorl_dormancy_years", out); + branch_insertion_angle_deg.Save("branch_insertion_angle_deg", out); + branch_roll_phyllotaxis_deg.Save("branch_roll_phyllotaxis_deg", out); + + // Phytomer dimensions. + internode_length_m.Save("internode_length_m", out); + leader_internode_thickness_m.Save("leader_internode_thickness_m", out); + // Alias for discoverability in .spine files. + leader_internode_thickness_m.Save("main_stem_width_m", out); + lateral_length_ratio.Save("lateral_length_ratio", out); + lateral_thickness_ratio.Save("lateral_thickness_ratio", out); + out << YAML::Key << "main_stem_color_rgba" << YAML::Value << main_stem_color_rgba; + out << YAML::Key << "main_stem_old_color_rgba" << YAML::Value << main_stem_old_color_rgba; + out << YAML::Key << "internode_age_exponent" << YAML::Value << internode_age_exponent; + + // Needles. + bare_zone_fraction.Save("bare_zone_fraction", out); + needle_count_per_cluster.Save("needle_count_per_cluster", out); + out << YAML::Key << "needle_segment_count" << YAML::Value << needle_segment_count; + needle_length_m.Save("needle_length_m", out); + needle_lifespan_years.Save("needle_lifespan_years", out); + needle_browning_years.Save("needle_browning_years", out); + needle_flush_delay_gdd.Save("needle_flush_delay_gdd", out); + internode_maturation_gdd.Save("internode_maturation_gdd", out); + needle_maturation_gdd.Save("needle_maturation_gdd", out); + needle_branching_angle_deg.Save("needle_branching_angle_deg", out); + needle_branching_relax_gdd.Save("needle_branching_relax_gdd", out); + internode_length_maturity_curve.Save("internode_length_maturity_curve", out); + internode_width_maturity_curve.Save("internode_width_maturity_curve", out); + needle_length_maturity_curve.Save("needle_length_maturity_curve", out); + needle_cross_section_width_max_m.Save("needle_cross_section_width_max_m", out); + needle_cross_section_thickness_max_m.Save("needle_cross_section_thickness_max_m", out); + needle_cross_section_width_profile.Save("needle_cross_section_width_profile", out); + needle_cross_section_thickness_profile.Save("needle_cross_section_thickness_profile", out); + needle_cross_section_temporal_maturity_curve.Save("needle_cross_section_temporal_maturity_curve", out); + out << YAML::Key << "needle_order_length_attenuation" << YAML::Value << needle_order_length_attenuation; + out << YAML::Key << "needle_order_radius_attenuation" << YAML::Value << needle_order_radius_attenuation; + out << YAML::Key << "needle_order_min_length_scale" << YAML::Value << needle_order_min_length_scale; + out << YAML::Key << "needle_order_min_radius_scale" << YAML::Value << needle_order_min_radius_scale; + out << YAML::Key << "needle_intra_year_base_ratio" << YAML::Value << needle_intra_year_base_ratio; + out << YAML::Key << "needle_intra_year_sigmoid_steepness" << YAML::Value << needle_intra_year_sigmoid_steepness; + out << YAML::Key << "needle_intra_year_sigmoid_midpoint_fraction" << YAML::Value + << needle_intra_year_sigmoid_midpoint_fraction; + out << YAML::Key << "needle_intra_year_late_decay_start_fraction" << YAML::Value + << needle_intra_year_late_decay_start_fraction; + out << YAML::Key << "needle_intra_year_late_decay_end_scale" << YAML::Value << needle_intra_year_late_decay_end_scale; + out << YAML::Key << "needle_fascicular_start_year" << YAML::Value << needle_fascicular_start_year; + out << YAML::Key << "needle_year2plus_length_multiplier" << YAML::Value << needle_year2plus_length_multiplier; + out << YAML::Key << "needle_year2plus_width_multiplier" << YAML::Value << needle_year2plus_width_multiplier; + out << YAML::Key << "needle_year2plus_thickness_multiplier" << YAML::Value << needle_year2plus_thickness_multiplier; + out << YAML::Key << "needle_lignification_factor_year1" << YAML::Value << needle_lignification_factor_year1; + out << YAML::Key << "needle_lignification_factor_year2plus" << YAML::Value << needle_lignification_factor_year2plus; + out << YAML::Key << "needle_stomatal_strip_density_year1" << YAML::Value << needle_stomatal_strip_density_year1; + out << YAML::Key << "needle_stomatal_strip_density_year2plus" << YAML::Value + << needle_stomatal_strip_density_year2plus; + out << YAML::Key << "needle_basal_taper_ratio_year1" << YAML::Value << needle_basal_taper_ratio_year1; + out << YAML::Key << "needle_basal_taper_ratio_year2plus" << YAML::Value << needle_basal_taper_ratio_year2plus; + out << YAML::Key << "needle_fascicle_sheath_budget_gdd" << YAML::Value << needle_fascicle_sheath_budget_gdd; + out << YAML::Key << "needle_specularity_plasticity_year1" << YAML::Value << needle_specularity_plasticity_year1; + out << YAML::Key << "needle_specularity_plasticity_year2plus" << YAML::Value + << needle_specularity_plasticity_year2plus; + out << YAML::Key << "needle_bud_storage_vigor_strength" << YAML::Value << needle_bud_storage_vigor_strength; + out << YAML::Key << "needle_bud_storage_completion_floor" << YAML::Value << needle_bud_storage_completion_floor; + out << YAML::Key << "needle_radius_to_stem_thickness_max_ratio" << YAML::Value + << needle_radius_to_stem_thickness_max_ratio; + out << YAML::Key << "needle_color_rgba" << YAML::Value << needle_color_rgba; + out << YAML::Key << "needle_old_color_rgba" << YAML::Value << needle_old_color_rgba; + out << YAML::Key << "needle_axial_age_span" << YAML::Value << needle_axial_age_span; + out << YAML::Key << "needle_axial_age_exponent" << YAML::Value << needle_axial_age_exponent; + + // Needle curvature. + needle_curvature_adaxial_bias.Save("needle_curvature_adaxial_bias", out); + needle_curvature_abaxial_bias.Save("needle_curvature_abaxial_bias", out); + needle_curvature_gradient_per_arclen.Save("needle_curvature_gradient_per_arclen", out); + needle_diameter_for_curvature_m.Save("needle_diameter_for_curvature_m", out); + needle_sinusoidal_amplitude_deg.Save("needle_sinusoidal_amplitude_deg", out); + needle_sinusoidal_frequency_cycles.Save("needle_sinusoidal_frequency_cycles", out); + needle_sinusoidal_phase_randomness_deg.Save("needle_sinusoidal_phase_randomness_deg", out); + + // Needle mechanics. + needle_young_modulus_baseline_Pa.Save("needle_young_modulus_baseline_Pa", out); + needle_lignification_maturation_years.Save("needle_lignification_maturation_years", out); + needle_density_kg_m3.Save("needle_density_kg_m3", out); + gravity_m_s2.Save("gravity_m_s2", out); + needle_per_needle_length_cv.Save("needle_per_needle_length_cv", out); + needle_per_needle_curvature_cv.Save("needle_per_needle_curvature_cv", out); + needle_per_needle_radius_cv.Save("needle_per_needle_radius_cv", out); + needle_per_needle_modulus_cv.Save("needle_per_needle_modulus_cv", out); + needle_per_needle_density_cv.Save("needle_per_needle_density_cv", out); + needle_per_needle_wave_amplitude_cv.Save("needle_per_needle_wave_amplitude_cv", out); + needle_per_needle_wave_frequency_cv.Save("needle_per_needle_wave_frequency_cv", out); + needle_per_needle_wave_phase_cv.Save("needle_per_needle_wave_phase_cv", out); + + // Tropism. + gravitropism_first_order.Save("gravitropism_first_order", out); + initial_orientation_yaw_deg.Save("initial_orientation_yaw_deg", out); + + // Per-instance target. + target_gdd.Save("target_gdd", out); + gdd_per_day.Save("gdd_per_day", out); + growing_season_start_day.Save("growing_season_start_day", out); + growing_season_end_day.Save("growing_season_end_day", out); + + // Per-shoot stochastic noise. + internode_length_per_node_cv.Save("internode_length_per_node_cv", out); + internode_thickness_per_node_cv.Save("internode_thickness_per_node_cv", out); + branch_angle_per_node_sigma_deg.Save("branch_angle_per_node_sigma_deg", out); + roll_phyllotaxis_per_node_sigma_deg.Save("roll_phyllotaxis_per_node_sigma_deg", out); + + // Editor preferences. + out << YAML::Key << "live_preview" << YAML::Value << live_preview; + out << YAML::Key << "live_preview_rate_hz" << YAML::Value << live_preview_rate_hz; + out << YAML::Key << "live_preview_representative_only" << YAML::Value << live_preview_representative_only; + out << YAML::Key << "live_preview_cap_target_gdd" << YAML::Value << live_preview_cap_target_gdd; + out << YAML::Key << "live_preview_max_gdd" << YAML::Value << live_preview_max_gdd; + out << YAML::Key << "live_preview_max_growth_steps" << YAML::Value << live_preview_max_growth_steps; + out << YAML::Key << "grid_rows" << YAML::Value << grid_rows; + out << YAML::Key << "grid_cols" << YAML::Value << grid_cols; + out << YAML::Key << "grid_spacing" << YAML::Value << grid_spacing; + out << YAML::Key << "triangle_side_length" << YAML::Value << triangle_side_length; + + // Tropism array. + out << YAML::Key << "tropism_count" << YAML::Value << static_cast(tropisms.size()); + for (size_t i = 0; i < tropisms.size(); ++i) { + const std::string prefix = "tropism_" + std::to_string(i) + "_"; + const auto& entry = tropisms[i]; + entry.direction_x.Save(prefix + "dir_x", out); + entry.direction_y.Save(prefix + "dir_y", out); + entry.direction_z.Save(prefix + "dir_z", out); + entry.strength.Save(prefix + "strength", out); + out << YAML::Key << (prefix + "usage_chance_percent") << YAML::Value + << std::clamp(entry.usage_chance_percent, 0.0f, 100.0f); + entry.order_response.Save(prefix + "order_response", out); + } +} + +// =========================================================================== +// Deserialize +// +// Backward-compat policy: +// - Legacy base/tip radius and simple taper keys are mapped into the new +// ellipsoid cross-section width/thickness controls when new keys are absent. +// - Other older experimental keys remain ignored. +// Missing keys retain inline member defaults. +// =========================================================================== +void ScotsPineDescriptor::Deserialize(const YAML::Node& in) { + ConfigurePineMaturityDefaults(*this); + + LoadSingleDistributionWithScalarFallback(in, "max_branching_order", max_branching_order); + LoadSingleDistributionWithScalarFallback(in, "plastochron_gdd", plastochron_gdd); + LoadSingleDistributionWithScalarFallback(in, "max_phytomers_per_seasonal_growth", max_phytomers_per_seasonal_growth); + + LoadSingleDistributionWithScalarFallback(in, "branches_per_whorl", branches_per_whorl); + // Clock-rule fix: prefer years key; fall back to legacy GDD key with /1500. + if (in["whorl_dormancy_years"]) { + LoadSingleDistributionWithScalarFallback(in, "whorl_dormancy_years", whorl_dormancy_years); + } else { + LoadLegacyGddDistributionAsYears(in, "whorl_dormancy_gdd", whorl_dormancy_years); + } + LoadSingleDistributionWithScalarFallback(in, "branch_insertion_angle_deg", branch_insertion_angle_deg); + LoadSingleDistributionWithScalarFallback(in, "branch_roll_phyllotaxis_deg", branch_roll_phyllotaxis_deg); + + LoadSingleDistributionWithScalarFallback(in, "internode_length_m", internode_length_m); + LoadSingleDistributionWithScalarFallback(in, "leader_internode_thickness_m", leader_internode_thickness_m); + // Backward/forward alias support. + LoadSingleDistributionWithScalarFallback(in, "main_stem_width_m", leader_internode_thickness_m); + LoadSingleDistributionWithScalarFallback(in, "lateral_length_ratio", lateral_length_ratio); + LoadSingleDistributionWithScalarFallback(in, "lateral_thickness_ratio", lateral_thickness_ratio); + if (in["main_stem_color_rgba"]) { + main_stem_color_rgba = in["main_stem_color_rgba"].as(); + } + if (in["main_stem_old_color_rgba"]) { + main_stem_old_color_rgba = in["main_stem_old_color_rgba"].as(); + } + if (in["internode_age_exponent"]) { + internode_age_exponent = std::clamp(in["internode_age_exponent"].as(), 0.1f, 4.0f); + } + + LoadSingleDistributionWithScalarFallback(in, "bare_zone_fraction", bare_zone_fraction); + LoadSingleDistributionWithScalarFallback(in, "needle_count_per_cluster", needle_count_per_cluster); + if (in["needle_segment_count"]) { + needle_segment_count = std::clamp(in["needle_segment_count"].as(), 3, 128); + } + LoadSingleDistributionWithScalarFallback(in, "needle_length_m", needle_length_m); + // Clock-rule fix: prefer years keys; fall back to legacy GDD keys with /1500. + if (in["needle_lifespan_years"]) { + LoadSingleDistributionWithScalarFallback(in, "needle_lifespan_years", needle_lifespan_years); + } else { + LoadLegacyGddDistributionAsYears(in, "needle_lifespan_gdd", needle_lifespan_years); + } + if (in["needle_browning_years"]) { + LoadSingleDistributionWithScalarFallback(in, "needle_browning_years", needle_browning_years); + } else { + LoadLegacyGddDistributionAsYears(in, "needle_browning_gdd", needle_browning_years); + } + LoadSingleDistributionWithScalarFallback(in, "needle_flush_delay_gdd", needle_flush_delay_gdd); + LoadSingleDistributionWithScalarFallback(in, "internode_maturation_gdd", internode_maturation_gdd); + LoadSingleDistributionWithScalarFallback(in, "needle_maturation_gdd", needle_maturation_gdd); + LoadSingleDistributionWithScalarFallback(in, "needle_branching_angle_deg", needle_branching_angle_deg); + LoadSingleDistributionWithScalarFallback(in, "needle_branching_relax_gdd", needle_branching_relax_gdd); + const bool has_new_cross_section_width_max = static_cast(in["needle_cross_section_width_max_m"]); + const bool has_new_cross_section_thickness_max = static_cast(in["needle_cross_section_thickness_max_m"]); + const bool has_new_cross_section_width_profile = static_cast(in["needle_cross_section_width_profile"]); + const bool has_new_cross_section_thickness_profile = static_cast(in["needle_cross_section_thickness_profile"]); + internode_length_maturity_curve.Load("internode_length_maturity_curve", in); + internode_width_maturity_curve.Load("internode_width_maturity_curve", in); + needle_length_maturity_curve.Load("needle_length_maturity_curve", in); + LoadSingleDistributionWithScalarFallback(in, "needle_cross_section_width_max_m", needle_cross_section_width_max_m); + LoadSingleDistributionWithScalarFallback(in, "needle_cross_section_thickness_max_m", + needle_cross_section_thickness_max_m); + needle_cross_section_width_profile.Load("needle_cross_section_width_profile", in); + needle_cross_section_thickness_profile.Load("needle_cross_section_thickness_profile", in); + needle_cross_section_temporal_maturity_curve.Load("needle_cross_section_temporal_maturity_curve", in); + + evo_engine::SingleDistribution legacy_base_radius_m{0.0f}; + evo_engine::SingleDistribution legacy_tip_radius_m{0.0f}; + const bool has_legacy_base_radius = static_cast(in["needle_base_radius_m"]); + const bool has_legacy_tip_radius = static_cast(in["needle_tip_radius_m"]); + if (has_legacy_base_radius) { + LoadSingleDistributionWithScalarFallback(in, "needle_base_radius_m", legacy_base_radius_m); + } + if (has_legacy_tip_radius) { + LoadSingleDistributionWithScalarFallback(in, "needle_tip_radius_m", legacy_tip_radius_m); + } + + if ((!has_new_cross_section_width_max || !has_new_cross_section_thickness_max) && + (has_legacy_base_radius || has_legacy_tip_radius)) { + const float legacy_width_max_mean_m = std::max(0.0f, legacy_base_radius_m.mean) * 2.0f; + const float legacy_width_max_dev_m = std::max(0.0f, legacy_base_radius_m.deviation) * 2.0f; + const float legacy_tip_diameter_mean_m = std::max(0.0f, legacy_tip_radius_m.mean) * 2.0f; + const float legacy_tip_diameter_dev_m = std::max(0.0f, legacy_tip_radius_m.deviation) * 2.0f; + if (!has_new_cross_section_width_max) { + needle_cross_section_width_max_m.mean = legacy_width_max_mean_m; + needle_cross_section_width_max_m.deviation = legacy_width_max_dev_m; + } + if (!has_new_cross_section_thickness_max) { + const float fallback_thickness_mean_m = + (legacy_tip_diameter_mean_m > 0.0f) ? legacy_tip_diameter_mean_m : legacy_width_max_mean_m; + const float fallback_thickness_dev_m = + (legacy_tip_diameter_dev_m > 0.0f) ? legacy_tip_diameter_dev_m : legacy_width_max_dev_m; + needle_cross_section_thickness_max_m.mean = fallback_thickness_mean_m; + needle_cross_section_thickness_max_m.deviation = fallback_thickness_dev_m; + } + } + + float legacy_tip_taper_ratio = 0.36f; + if (in["needle_simple_tip_taper_ratio"]) { + legacy_tip_taper_ratio = std::clamp(in["needle_simple_tip_taper_ratio"].as(), 0.0f, 1.0f); + } + if (!has_new_cross_section_width_profile) { + ConfigureNeedleCrossSectionProfileDefaults(needle_cross_section_width_profile, 1.0f, legacy_tip_taper_ratio); + } + if (!has_new_cross_section_thickness_profile) { + ConfigureNeedleCrossSectionProfileDefaults(needle_cross_section_thickness_profile, 1.0f, legacy_tip_taper_ratio); + } + + needle_cross_section_width_max_m.mean = std::max(0.0f, needle_cross_section_width_max_m.mean); + needle_cross_section_width_max_m.deviation = std::max(0.0f, needle_cross_section_width_max_m.deviation); + needle_cross_section_thickness_max_m.mean = std::max(0.0f, needle_cross_section_thickness_max_m.mean); + needle_cross_section_thickness_max_m.deviation = std::max(0.0f, needle_cross_section_thickness_max_m.deviation); + auto clamp_plot_range = [](evo_engine::Plot2D& plot, const float max_value) { + plot.min_value = std::clamp(plot.min_value, 0.0f, 1.0f); + plot.max_value = std::clamp(plot.max_value, 0.0f, max_value); + if (plot.max_value < plot.min_value) { + std::swap(plot.min_value, plot.max_value); + } + }; + clamp_plot_range(needle_cross_section_width_profile.mean, 4.0f); + clamp_plot_range(needle_cross_section_width_profile.deviation, 4.0f); + clamp_plot_range(needle_cross_section_thickness_profile.mean, 4.0f); + clamp_plot_range(needle_cross_section_thickness_profile.deviation, 4.0f); + clamp_plot_range(needle_cross_section_temporal_maturity_curve.mean, 1.0f); + clamp_plot_range(needle_cross_section_temporal_maturity_curve.deviation, 1.0f); + if (in["needle_order_length_attenuation"]) { + needle_order_length_attenuation = std::clamp(in["needle_order_length_attenuation"].as(), 0.0f, 1.0f); + } + if (in["needle_order_radius_attenuation"]) { + needle_order_radius_attenuation = std::clamp(in["needle_order_radius_attenuation"].as(), 0.0f, 1.0f); + } + if (in["needle_order_min_length_scale"]) { + needle_order_min_length_scale = std::clamp(in["needle_order_min_length_scale"].as(), 0.10f, 1.00f); + } + if (in["needle_order_min_radius_scale"]) { + needle_order_min_radius_scale = std::clamp(in["needle_order_min_radius_scale"].as(), 0.10f, 1.00f); + } + if (in["needle_intra_year_base_ratio"]) { + needle_intra_year_base_ratio = std::clamp(in["needle_intra_year_base_ratio"].as(), 0.0f, 1.0f); + } + if (in["needle_intra_year_sigmoid_steepness"]) { + needle_intra_year_sigmoid_steepness = std::max(0.01f, in["needle_intra_year_sigmoid_steepness"].as()); + } + if (in["needle_intra_year_sigmoid_midpoint_fraction"]) { + needle_intra_year_sigmoid_midpoint_fraction = + std::clamp(in["needle_intra_year_sigmoid_midpoint_fraction"].as(), 0.0f, 1.0f); + } + if (in["needle_intra_year_late_decay_start_fraction"]) { + needle_intra_year_late_decay_start_fraction = + std::clamp(in["needle_intra_year_late_decay_start_fraction"].as(), 0.0f, 1.0f); + } + if (in["needle_intra_year_late_decay_end_scale"]) { + needle_intra_year_late_decay_end_scale = + std::clamp(in["needle_intra_year_late_decay_end_scale"].as(), 0.0f, 2.0f); + } + if (in["needle_fascicular_start_year"]) { + needle_fascicular_start_year = std::clamp(in["needle_fascicular_start_year"].as(), 0, 16); + } + if (in["needle_year2plus_length_multiplier"]) { + needle_year2plus_length_multiplier = std::max(0.0f, in["needle_year2plus_length_multiplier"].as()); + } + if (in["needle_year2plus_width_multiplier"]) { + needle_year2plus_width_multiplier = std::max(0.0f, in["needle_year2plus_width_multiplier"].as()); + } + if (in["needle_year2plus_thickness_multiplier"]) { + needle_year2plus_thickness_multiplier = std::max(0.0f, in["needle_year2plus_thickness_multiplier"].as()); + } + if (in["needle_lignification_factor_year1"]) { + needle_lignification_factor_year1 = std::clamp(in["needle_lignification_factor_year1"].as(), 0.0f, 2.0f); + } + if (in["needle_lignification_factor_year2plus"]) { + needle_lignification_factor_year2plus = + std::clamp(in["needle_lignification_factor_year2plus"].as(), 0.0f, 2.0f); + } + if (in["needle_stomatal_strip_density_year1"]) { + needle_stomatal_strip_density_year1 = std::clamp(in["needle_stomatal_strip_density_year1"].as(), 0.0f, 1.0f); + } + if (in["needle_stomatal_strip_density_year2plus"]) { + needle_stomatal_strip_density_year2plus = + std::clamp(in["needle_stomatal_strip_density_year2plus"].as(), 0.0f, 1.0f); + } + if (in["needle_basal_taper_ratio_year1"]) { + needle_basal_taper_ratio_year1 = std::clamp(in["needle_basal_taper_ratio_year1"].as(), 0.6f, 1.2f); + } + if (in["needle_basal_taper_ratio_year2plus"]) { + needle_basal_taper_ratio_year2plus = std::clamp(in["needle_basal_taper_ratio_year2plus"].as(), 0.6f, 1.2f); + } + if (in["needle_fascicle_sheath_budget_gdd"]) { + needle_fascicle_sheath_budget_gdd = std::max(0.0f, in["needle_fascicle_sheath_budget_gdd"].as()); + } + if (in["needle_specularity_plasticity_year1"]) { + needle_specularity_plasticity_year1 = std::clamp(in["needle_specularity_plasticity_year1"].as(), 0.0f, 1.0f); + } + if (in["needle_specularity_plasticity_year2plus"]) { + needle_specularity_plasticity_year2plus = + std::clamp(in["needle_specularity_plasticity_year2plus"].as(), 0.0f, 1.0f); + } + if (in["needle_bud_storage_vigor_strength"]) { + needle_bud_storage_vigor_strength = std::clamp(in["needle_bud_storage_vigor_strength"].as(), 0.0f, 1.0f); + } + if (in["needle_bud_storage_completion_floor"]) { + needle_bud_storage_completion_floor = std::clamp(in["needle_bud_storage_completion_floor"].as(), 0.0f, 1.0f); + } + if (in["needle_radius_to_stem_thickness_max_ratio"]) { + needle_radius_to_stem_thickness_max_ratio = + std::clamp(in["needle_radius_to_stem_thickness_max_ratio"].as(), 0.0f, 4.0f); + } + if (in["needle_color_rgba"]) { + needle_color_rgba = in["needle_color_rgba"].as(); + } + if (in["needle_old_color_rgba"]) { + needle_old_color_rgba = in["needle_old_color_rgba"].as(); + } + if (in["needle_axial_age_span"]) { + needle_axial_age_span = std::clamp(in["needle_axial_age_span"].as(), -1.0f, 1.0f); + } + if (in["needle_axial_age_exponent"]) { + needle_axial_age_exponent = std::clamp(in["needle_axial_age_exponent"].as(), 0.1f, 4.0f); + } + + LoadSingleDistributionWithScalarFallback(in, "needle_curvature_adaxial_bias", needle_curvature_adaxial_bias); + LoadSingleDistributionWithScalarFallback(in, "needle_curvature_abaxial_bias", needle_curvature_abaxial_bias); + LoadSingleDistributionWithScalarFallback(in, "needle_curvature_gradient_per_arclen", + needle_curvature_gradient_per_arclen); + LoadSingleDistributionWithScalarFallback(in, "needle_diameter_for_curvature_m", needle_diameter_for_curvature_m); + LoadSingleDistributionWithScalarFallback(in, "needle_sinusoidal_amplitude_deg", needle_sinusoidal_amplitude_deg); + LoadSingleDistributionWithScalarFallback(in, "needle_sinusoidal_frequency_cycles", + needle_sinusoidal_frequency_cycles); + LoadSingleDistributionWithScalarFallback(in, "needle_sinusoidal_phase_randomness_deg", + needle_sinusoidal_phase_randomness_deg); + + LoadSingleDistributionWithScalarFallback(in, "needle_young_modulus_baseline_Pa", needle_young_modulus_baseline_Pa); + LoadSingleDistributionWithScalarFallback(in, "needle_lignification_maturation_years", + needle_lignification_maturation_years); + LoadSingleDistributionWithScalarFallback(in, "needle_density_kg_m3", needle_density_kg_m3); + LoadSingleDistributionWithScalarFallback(in, "gravity_m_s2", gravity_m_s2); + LoadSingleDistributionWithScalarFallback(in, "needle_per_needle_length_cv", needle_per_needle_length_cv); + LoadSingleDistributionWithScalarFallback(in, "needle_per_needle_curvature_cv", needle_per_needle_curvature_cv); + LoadSingleDistributionWithScalarFallback(in, "needle_per_needle_radius_cv", needle_per_needle_radius_cv); + LoadSingleDistributionWithScalarFallback(in, "needle_per_needle_modulus_cv", needle_per_needle_modulus_cv); + LoadSingleDistributionWithScalarFallback(in, "needle_per_needle_density_cv", needle_per_needle_density_cv); + LoadSingleDistributionWithScalarFallback(in, "needle_per_needle_wave_amplitude_cv", + needle_per_needle_wave_amplitude_cv); + LoadSingleDistributionWithScalarFallback(in, "needle_per_needle_wave_frequency_cv", + needle_per_needle_wave_frequency_cv); + LoadSingleDistributionWithScalarFallback(in, "needle_per_needle_wave_phase_cv", needle_per_needle_wave_phase_cv); + + LoadSingleDistributionWithScalarFallback(in, "gravitropism_first_order", gravitropism_first_order); + LoadSingleDistributionWithScalarFallback(in, "initial_orientation_yaw_deg", initial_orientation_yaw_deg); + + LoadSingleDistributionWithScalarFallback(in, "target_gdd", target_gdd); + LoadSingleDistributionWithScalarFallback(in, "gdd_per_day", gdd_per_day); + LoadSingleDistributionWithScalarFallback(in, "growing_season_start_day", growing_season_start_day); + LoadSingleDistributionWithScalarFallback(in, "growing_season_end_day", growing_season_end_day); + + gdd_per_day.mean = std::max(0.0f, gdd_per_day.mean); + gdd_per_day.deviation = std::max(0.0f, gdd_per_day.deviation); + growing_season_start_day.mean = std::clamp(growing_season_start_day.mean, 0.0f, 365.0f); + growing_season_start_day.deviation = std::max(0.0f, std::round(growing_season_start_day.deviation)); + growing_season_end_day.mean = std::clamp(growing_season_end_day.mean, 0.0f, 365.0f); + growing_season_end_day.deviation = std::max(0.0f, std::round(growing_season_end_day.deviation)); + + LoadSingleDistributionWithScalarFallback(in, "internode_length_per_node_cv", internode_length_per_node_cv); + LoadSingleDistributionWithScalarFallback(in, "internode_thickness_per_node_cv", internode_thickness_per_node_cv); + LoadSingleDistributionWithScalarFallback(in, "branch_angle_per_node_sigma_deg", branch_angle_per_node_sigma_deg); + LoadSingleDistributionWithScalarFallback(in, "roll_phyllotaxis_per_node_sigma_deg", + roll_phyllotaxis_per_node_sigma_deg); + + if (in["live_preview"]) + live_preview = in["live_preview"].as(); + if (in["live_preview_rate_hz"]) + live_preview_rate_hz = in["live_preview_rate_hz"].as(); + if (in["live_preview_representative_only"]) + live_preview_representative_only = in["live_preview_representative_only"].as(); + if (in["live_preview_cap_target_gdd"]) + live_preview_cap_target_gdd = in["live_preview_cap_target_gdd"].as(); + if (in["live_preview_max_gdd"]) + live_preview_max_gdd = in["live_preview_max_gdd"].as(); + if (in["live_preview_max_growth_steps"]) + live_preview_max_growth_steps = in["live_preview_max_growth_steps"].as(); + if (in["grid_rows"]) + grid_rows = in["grid_rows"].as(); + if (in["grid_cols"]) + grid_cols = in["grid_cols"].as(); + if (in["grid_spacing"]) + grid_spacing = in["grid_spacing"].as(); + if (in["triangle_side_length"]) + triangle_side_length = in["triangle_side_length"].as(); + + tropisms.clear(); + if (in["tropism_count"]) { + const int count = std::max(0, in["tropism_count"].as()); + tropisms.reserve(count); + for (int i = 0; i < count; ++i) { + const std::string prefix = "tropism_" + std::to_string(i) + "_"; + TropismEntry entry; + LoadSingleDistributionWithScalarFallback(in, (prefix + "dir_x").c_str(), entry.direction_x); + LoadSingleDistributionWithScalarFallback(in, (prefix + "dir_y").c_str(), entry.direction_y); + LoadSingleDistributionWithScalarFallback(in, (prefix + "dir_z").c_str(), entry.direction_z); + LoadSingleDistributionWithScalarFallback(in, (prefix + "strength").c_str(), entry.strength); + const std::string usage_key = prefix + "usage_chance_percent"; + if (in[usage_key]) + entry.usage_chance_percent = in[usage_key].as(); + entry.order_response.Load(prefix + "order_response", in); + tropisms.emplace_back(std::move(entry)); + } + } +} + +// =========================================================================== +// ParamSpaceExplorer axis registration. +// =========================================================================== +void ScotsPineDescriptor::RegisterExplorableAxes(ParamSpaceExplorer& explorer) { + auto& d = *this; + + // -- Phytomer scheduling -- + explorer.AddSingle("max_branching_order", "MBO", d.max_branching_order, 0.0f, 4.0f, 2.0f); + explorer.AddSingle("plastochron_gdd", "PLG", d.plastochron_gdd, 50.0f, 6000.0f, 1500.0f); + explorer.AddSingle("max_phytomers_per_seasonal_growth", "MPS", d.max_phytomers_per_seasonal_growth, 1.0f, 64.0f, + 12.0f); + + // -- Whorl architecture -- + explorer.AddSingle("branches_per_whorl", "BPW", d.branches_per_whorl, 0.0f, 10.0f, 5.0f); + explorer.AddSingle("whorl_dormancy_years", "WDY", d.whorl_dormancy_years, 0.0f, 4.0f, 1.0f); + explorer.AddSingle("branch_insertion_angle_deg", "BIA", d.branch_insertion_angle_deg, -85.0f, 85.0f, 60.0f); + explorer.AddSingle("branch_roll_phyllotaxis_deg", "BRP", d.branch_roll_phyllotaxis_deg, 0.0f, 360.0f, 137.5f); + + // -- Phytomer dimensions -- + explorer.AddSingle("internode_length_m", "ILM", d.internode_length_m, 0.0001f, 0.500f, 0.012f); + explorer.AddSingle("main_stem_width_m", "MSW", d.leader_internode_thickness_m, 0.0001f, 0.0500f, 0.0030f); + explorer.AddSingle("lateral_length_ratio", "LLR", d.lateral_length_ratio, 0.1f, 1.5f, 0.7f); + explorer.AddSingle("lateral_thickness_ratio", "LTR", d.lateral_thickness_ratio, 0.1f, 1.5f, 0.6f); + + // -- Needles -- + explorer.AddSingle("bare_zone_fraction", "BZF", d.bare_zone_fraction, 0.0f, 0.95f, 0.0f); + explorer.AddSingle("needle_count_per_cluster", "NCC", d.needle_count_per_cluster, 1.0f, 6.0f, 2.0f); + { + auto* value_ptr = &d.needle_segment_count; + explorer.AddAxis( + "needle_segment_count", "NSG", 3.0f, 128.0f, + [value_ptr]() { + return static_cast(*value_ptr); + }, + [value_ptr](float value) { + *value_ptr = std::clamp(static_cast(std::round(value)), 3, 128); + }); + } + explorer.AddSingle("needle_length_m", "NLM", d.needle_length_m, 0.001f, 0.200f, 0.025f); + explorer.AddSingle("needle_lifespan_years", "NLY", d.needle_lifespan_years, 0.0f, 10.0f, 4.0f); + explorer.AddSingle("needle_browning_years", "NBY", d.needle_browning_years, 0.0f, 4.0f, 1.0f); + explorer.AddSingle("needle_flush_delay_gdd", "NFD", d.needle_flush_delay_gdd, 0.0f, 3000.0f, 0.0f); + explorer.AddSingle("internode_maturation_gdd", "IMG", d.internode_maturation_gdd, 0.0f, 3000.0f, 60.0f); + explorer.AddSingle("needle_maturation_gdd", "NMG", d.needle_maturation_gdd, 0.0f, 6000.0f, 120.0f); + explorer.AddSingle("needle_branching_angle_deg", "NBA", d.needle_branching_angle_deg, 0.0f, 89.5f, 72.0f); + explorer.AddSingle("needle_branching_relax_gdd", "NRG", d.needle_branching_relax_gdd, 0.0f, 6000.0f, 220.0f); + explorer.AddPlotted("internode_length_maturity_curve", "ILC", d.internode_length_maturity_curve); + explorer.AddPlotted("internode_width_maturity_curve", "IWC", d.internode_width_maturity_curve); + explorer.AddPlotted("needle_length_maturity_curve", "NLC", d.needle_length_maturity_curve); + explorer.AddSingle("needle_cross_section_width_max_m", "NCW", d.needle_cross_section_width_max_m, 0.0f, 0.02f, + 0.0018f); + explorer.AddSingle("needle_cross_section_thickness_max_m", "NCT", d.needle_cross_section_thickness_max_m, 0.0f, 0.02f, + 0.0011f); + explorer.AddPlotted("needle_cross_section_width_profile", "NWP", d.needle_cross_section_width_profile); + explorer.AddPlotted("needle_cross_section_thickness_profile", "NTP", d.needle_cross_section_thickness_profile); + explorer.AddPlotted("needle_cross_section_temporal_maturity_curve", "NTM", + d.needle_cross_section_temporal_maturity_curve); + { + auto* value_ptr = &d.needle_intra_year_base_ratio; + explorer.AddAxis( + "needle_intra_year_base_ratio", "NIB", 0.0f, 1.0f, + [value_ptr]() { + return *value_ptr; + }, + [value_ptr](float value) { + *value_ptr = std::clamp(value, 0.0f, 1.0f); + }); + } + { + auto* value_ptr = &d.needle_intra_year_sigmoid_steepness; + explorer.AddAxis( + "needle_intra_year_sigmoid_steepness", "NIS", 0.01f, 32.0f, + [value_ptr]() { + return *value_ptr; + }, + [value_ptr](float value) { + *value_ptr = std::max(0.01f, value); + }); + } + { + auto* value_ptr = &d.needle_intra_year_sigmoid_midpoint_fraction; + explorer.AddAxis( + "needle_intra_year_sigmoid_midpoint_fraction", "NIM", 0.0f, 1.0f, + [value_ptr]() { + return *value_ptr; + }, + [value_ptr](float value) { + *value_ptr = std::clamp(value, 0.0f, 1.0f); + }); + } + { + auto* value_ptr = &d.needle_intra_year_late_decay_start_fraction; + explorer.AddAxis( + "needle_intra_year_late_decay_start_fraction", "NDS", 0.0f, 1.0f, + [value_ptr]() { + return *value_ptr; + }, + [value_ptr](float value) { + *value_ptr = std::clamp(value, 0.0f, 1.0f); + }); + } + { + auto* value_ptr = &d.needle_intra_year_late_decay_end_scale; + explorer.AddAxis( + "needle_intra_year_late_decay_end_scale", "NDE", 0.0f, 2.0f, + [value_ptr]() { + return *value_ptr; + }, + [value_ptr](float value) { + *value_ptr = std::clamp(value, 0.0f, 2.0f); + }); + } + { + auto* value_ptr = &d.needle_fascicular_start_year; + explorer.AddAxis( + "needle_fascicular_start_year", "NFY", 0.0f, 16.0f, + [value_ptr]() { + return static_cast(*value_ptr); + }, + [value_ptr](float value) { + *value_ptr = std::clamp(static_cast(std::round(value)), 0, 16); + }); + } + { + auto* value_ptr = &d.needle_year2plus_length_multiplier; + explorer.AddAxis( + "needle_year2plus_length_multiplier", "N2L", 0.0f, 8.0f, + [value_ptr]() { + return *value_ptr; + }, + [value_ptr](float value) { + *value_ptr = std::max(0.0f, value); + }); + } + { + auto* value_ptr = &d.needle_year2plus_width_multiplier; + explorer.AddAxis( + "needle_year2plus_width_multiplier", "N2W", 0.0f, 8.0f, + [value_ptr]() { + return *value_ptr; + }, + [value_ptr](float value) { + *value_ptr = std::max(0.0f, value); + }); + } + { + auto* value_ptr = &d.needle_year2plus_thickness_multiplier; + explorer.AddAxis( + "needle_year2plus_thickness_multiplier", "N2T", 0.0f, 8.0f, + [value_ptr]() { + return *value_ptr; + }, + [value_ptr](float value) { + *value_ptr = std::max(0.0f, value); + }); + } + { + auto* value_ptr = &d.needle_lignification_factor_year1; + explorer.AddAxis( + "needle_lignification_factor_year1", "NL1", 0.0f, 2.0f, + [value_ptr]() { + return *value_ptr; + }, + [value_ptr](float value) { + *value_ptr = std::clamp(value, 0.0f, 2.0f); + }); + } + { + auto* value_ptr = &d.needle_lignification_factor_year2plus; + explorer.AddAxis( + "needle_lignification_factor_year2plus", "NL2", 0.0f, 2.0f, + [value_ptr]() { + return *value_ptr; + }, + [value_ptr](float value) { + *value_ptr = std::clamp(value, 0.0f, 2.0f); + }); + } + { + auto* value_ptr = &d.needle_stomatal_strip_density_year1; + explorer.AddAxis( + "needle_stomatal_strip_density_year1", "NS1", 0.0f, 1.0f, + [value_ptr]() { + return *value_ptr; + }, + [value_ptr](float value) { + *value_ptr = std::clamp(value, 0.0f, 1.0f); + }); + } + { + auto* value_ptr = &d.needle_stomatal_strip_density_year2plus; + explorer.AddAxis( + "needle_stomatal_strip_density_year2plus", "NS2", 0.0f, 1.0f, + [value_ptr]() { + return *value_ptr; + }, + [value_ptr](float value) { + *value_ptr = std::clamp(value, 0.0f, 1.0f); + }); + } + { + auto* value_ptr = &d.needle_basal_taper_ratio_year1; + explorer.AddAxis( + "needle_basal_taper_ratio_year1", "NB1", 0.6f, 1.2f, + [value_ptr]() { + return *value_ptr; + }, + [value_ptr](float value) { + *value_ptr = std::clamp(value, 0.6f, 1.2f); + }); + } + { + auto* value_ptr = &d.needle_basal_taper_ratio_year2plus; + explorer.AddAxis( + "needle_basal_taper_ratio_year2plus", "NB2", 0.6f, 1.2f, + [value_ptr]() { + return *value_ptr; + }, + [value_ptr](float value) { + *value_ptr = std::clamp(value, 0.6f, 1.2f); + }); + } + { + auto* value_ptr = &d.needle_fascicle_sheath_budget_gdd; + explorer.AddAxis( + "needle_fascicle_sheath_budget_gdd", "NSB", 0.0f, 5000.0f, + [value_ptr]() { + return *value_ptr; + }, + [value_ptr](float value) { + *value_ptr = std::max(0.0f, value); + }); + } + { + auto* value_ptr = &d.needle_specularity_plasticity_year1; + explorer.AddAxis( + "needle_specularity_plasticity_year1", "NP1", 0.0f, 1.0f, + [value_ptr]() { + return *value_ptr; + }, + [value_ptr](float value) { + *value_ptr = std::clamp(value, 0.0f, 1.0f); + }); + } + { + auto* value_ptr = &d.needle_specularity_plasticity_year2plus; + explorer.AddAxis( + "needle_specularity_plasticity_year2plus", "NP2", 0.0f, 1.0f, + [value_ptr]() { + return *value_ptr; + }, + [value_ptr](float value) { + *value_ptr = std::clamp(value, 0.0f, 1.0f); + }); + } + { + auto* value_ptr = &d.needle_bud_storage_vigor_strength; + explorer.AddAxis( + "needle_bud_storage_vigor_strength", "NBV", 0.0f, 1.0f, + [value_ptr]() { + return *value_ptr; + }, + [value_ptr](float value) { + *value_ptr = std::clamp(value, 0.0f, 1.0f); + }); + } + { + auto* value_ptr = &d.needle_bud_storage_completion_floor; + explorer.AddAxis( + "needle_bud_storage_completion_floor", "NBC", 0.0f, 1.0f, + [value_ptr]() { + return *value_ptr; + }, + [value_ptr](float value) { + *value_ptr = std::clamp(value, 0.0f, 1.0f); + }); + } + + // -- Needle curvature -- + explorer.AddSingle("needle_curvature_adaxial_bias", "NCA", d.needle_curvature_adaxial_bias, -0.1f, 0.1f, 0.003f); + explorer.AddSingle("needle_curvature_abaxial_bias", "NCB", d.needle_curvature_abaxial_bias, -0.1f, 0.1f, 0.010f); + explorer.AddSingle("needle_curvature_gradient_per_arclen", "NCG", d.needle_curvature_gradient_per_arclen, -0.05f, + 0.05f, 0.0015f); + explorer.AddSingle("needle_diameter_for_curvature_m", "NDC", d.needle_diameter_for_curvature_m, 0.0f, 0.005f, 0.001f); + explorer.AddSingle("needle_sinusoidal_amplitude_deg", "NSA", d.needle_sinusoidal_amplitude_deg, 0.0f, 45.0f, 0.0f); + explorer.AddSingle("needle_sinusoidal_frequency_cycles", "NSF", d.needle_sinusoidal_frequency_cycles, 0.0f, 12.0f, + 0.0f); + explorer.AddSingle("needle_sinusoidal_phase_randomness_deg", "NSP", d.needle_sinusoidal_phase_randomness_deg, 0.0f, + 180.0f, 0.0f); + + // -- Needle mechanics -- + explorer.AddSingle("needle_young_modulus_baseline_Pa", "YMB", d.needle_young_modulus_baseline_Pa, 0.0f, 5e9f, 1e9f); + explorer.AddSingle("needle_lignification_maturation_years", "LMY", d.needle_lignification_maturation_years, 0.0f, + 5.0f, 1.0f); + // Width cap exposed as a sweepable axis. Default 0.45 = legacy behavior. + // Note: this is a plain scalar field; AddAxis is used so the explorer can + // read/write it directly without needing a SingleDistribution wrapper. + { + auto* ratio_ptr = &d.needle_radius_to_stem_thickness_max_ratio; + explorer.AddAxis( + "needle_radius_to_stem_thickness_max_ratio", "NRC", 0.0f, 4.0f, + [ratio_ptr]() { + return *ratio_ptr; + }, + [ratio_ptr](float v) { + *ratio_ptr = std::clamp(v, 0.0f, 4.0f); + }); + } + explorer.AddSingle("needle_density_kg_m3", "NDK", d.needle_density_kg_m3, 0.0f, 2000.0f, 800.0f); + explorer.AddSingle("gravity_m_s2", "GRV", d.gravity_m_s2, 0.0f, 25.0f, 9.81f); + explorer.AddSingle("needle_per_needle_length_cv", "NLC", d.needle_per_needle_length_cv, 0.0f, 1.0f, 0.0f); + explorer.AddSingle("needle_per_needle_curvature_cv", "NCCV", d.needle_per_needle_curvature_cv, 0.0f, 1.0f, 0.0f); + explorer.AddSingle("needle_per_needle_radius_cv", "NRCV", d.needle_per_needle_radius_cv, 0.0f, 1.0f, 0.0f); + explorer.AddSingle("needle_per_needle_modulus_cv", "NMCV", d.needle_per_needle_modulus_cv, 0.0f, 1.0f, 0.0f); + explorer.AddSingle("needle_per_needle_density_cv", "NDCV", d.needle_per_needle_density_cv, 0.0f, 1.0f, 0.0f); + explorer.AddSingle("needle_per_needle_wave_amplitude_cv", "NWAC", d.needle_per_needle_wave_amplitude_cv, 0.0f, 1.0f, + 0.0f); + explorer.AddSingle("needle_per_needle_wave_frequency_cv", "NWFC", d.needle_per_needle_wave_frequency_cv, 0.0f, 1.0f, + 0.0f); + explorer.AddSingle("needle_per_needle_wave_phase_cv", "NWPC", d.needle_per_needle_wave_phase_cv, 0.0f, 1.0f, 0.0f); + + // -- Tropism -- + explorer.AddSingle("gravitropism_first_order", "GFO", d.gravitropism_first_order, 0.0f, 0.001f, 0.0001f); + explorer.AddSingle("initial_orientation_yaw_deg", "IOY", d.initial_orientation_yaw_deg, -180.0f, 180.0f, 0.0f); + + // -- Per-instance growth target -- + explorer.AddSingle("target_gdd", "TGD", d.target_gdd, 0.0f, 30000.0f, 6000.0f); + explorer.AddSingle("gdd_per_day", "GPD", d.gdd_per_day, 0.0f, 50.0f, 2.0f); + explorer.AddSingle("growing_season_start_day", "GSS", d.growing_season_start_day, 0.0f, 365.0f, 60.0f); + explorer.AddSingle("growing_season_end_day", "GSE", d.growing_season_end_day, 0.0f, 365.0f, 334.0f); + + // -- Per-shoot stochastic noise -- + explorer.AddSingle("internode_length_per_node_cv", "ILC", d.internode_length_per_node_cv, 0.0f, 1.0f, 0.1f); + explorer.AddSingle("internode_thickness_per_node_cv", "STC", d.internode_thickness_per_node_cv, 0.0f, 1.0f, 0.1f); + explorer.AddSingle("branch_angle_per_node_sigma_deg", "BAS", d.branch_angle_per_node_sigma_deg, 0.0f, 30.0f, 5.0f); + explorer.AddSingle("roll_phyllotaxis_per_node_sigma_deg", "RPS", d.roll_phyllotaxis_per_node_sigma_deg, 0.0f, 30.0f, + 5.0f); + + // -- Dynamic tropism dimensions -- + for (size_t i = 0; i < d.tropisms.size(); i++) { + auto& tropism = d.tropisms[i]; + const std::string p = "tropism[" + std::to_string(i) + "]"; + const std::string s = "T" + std::to_string(i); + + explorer.AddSingle(p + ".direction_x", s + "X", tropism.direction_x, -1.0f, 1.0f, 1.0f); + explorer.AddSingle(p + ".direction_y", s + "Y", tropism.direction_y, -1.0f, 1.0f, 1.0f); + explorer.AddSingle(p + ".direction_z", s + "Z", tropism.direction_z, -1.0f, 1.0f, 1.0f); + explorer.AddSingle(p + ".strength", s + "S", tropism.strength, -5.0f, 5.0f, 5.0f); + + auto* tropism_ptr = &d.tropisms[i]; + explorer.AddAxis( + p + ".usage_chance_percent", s + "U", 0.0f, 100.0f, + [tropism_ptr]() { + return tropism_ptr->usage_chance_percent; + }, + [tropism_ptr](float v) { + tropism_ptr->usage_chance_percent = std::clamp(v, 0.0f, 100.0f); + }); + + explorer.AddPlotted(p + ".order_response", s + "O", tropism.order_response); + } +} diff --git a/Resources/LSystemProject/Assets/New Scene.evescene b/Resources/LSystemProject/Assets/New Scene.evescene new file mode 100644 index 00000000..50b685f0 --- /dev/null +++ b/Resources/LSystemProject/Assets/New Scene.evescene @@ -0,0 +1,83 @@ +environment: + background_color: [1, 1, 1] + environment_gamma: 2.2 + ambient_light_intensity: 0.8 + environment_type: 0 + environmental_map: + asset_handle_: 0 + type_name_: "" +main_camera: + entity_handle_: 9962119892863856444 + private_component_type_name_: Camera +entity_metadata_list: + - n: Main Camera + h: 9962119892863856444 + e: true + s: false + r: 9962119892863856444 + pc: + - tn: Camera + e: true + x: 1 + y: 1 + use_clear_color: false + clear_color: [0, 0, 0, 1] + near_distance: 0.1 + far_distance: 200 + fov: 120 + background_intensity: 1 + fade_ratio: 0.8 + fade_factor: 1 + sample_size: 4 + bounce: 4 + gamma: 2.2 + skybox: + asset_handle_: 14 + type_name_: Cubemap + post_processing_stack_ref: + asset_handle_: 5260668087931866328 + type_name_: PostProcessingStack + - n: Directional Light + h: 3451007345618465229 + e: true + s: false + r: 3451007345618465229 + pc: + - tn: DirectionalLight + e: true + cast_shadow: true + bias: 0.1 + diffuse: [1, 1, 1] + diffuse_brightness: 3 + light_size: 0.01 + normal_offset: 0.05 +systems_: + [] +LocalAssets: + - type_name: PostProcessingStack + handle: 5260668087931866328 +data_component_storage_list: + - entity_size: 130 + chunk_capacity: 126 + entity_alive_count: 2 + data_component_types: + - type_name: Transform + type_size: 64 + type_offset: 0 + - type_name: GlobalTransform + type_size: 64 + type_offset: 64 + - type_name: TransformUpdateFlag + type_size: 2 + type_offset: 128 + chunk_array: + - h: 9962119892863856444 + dc: + - d: !!binary "AACAPwAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAoEAAACBBAACAPw==" + - d: !!binary "AACAPwAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAACAPw==" + - d: !!binary "AAE=" + - h: 3451007345618465229 + dc: + - d: !!binary "AACAPwAAAAAAAAAAAAAAAAAAAAAAAIAz//9/PwAAAAAAAAAA//9/vwAAgDMAAAAAAAAAAAAAAAAAAAAAAACAPw==" + - d: !!binary "AACAPwAAAAAAAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAACAPw==" + - d: !!binary "AAE=" \ No newline at end of file diff --git a/Resources/LSystemProject/Assets/New Scene.evescene.evefilemeta b/Resources/LSystemProject/Assets/New Scene.evescene.evefilemeta new file mode 100644 index 00000000..658efe3f --- /dev/null +++ b/Resources/LSystemProject/Assets/New Scene.evescene.evefilemeta @@ -0,0 +1,4 @@ +asset_extension_: .evescene +asset_file_name_: New Scene +asset_type_name_: Scene +asset_handle_: 10542394882133086192 \ No newline at end of file diff --git a/Resources/LSystemProject/test.eveproj b/Resources/LSystemProject/test.eveproj new file mode 100644 index 00000000..301b72b9 --- /dev/null +++ b/Resources/LSystemProject/test.eveproj @@ -0,0 +1 @@ +start_scene_handle: 10542394882133086192 \ No newline at end of file