From 76b91c65974a3769ecd7b14d43f2b392ce94bb4e Mon Sep 17 00:00:00 2001 From: forbes Date: Thu, 19 Feb 2026 16:07:37 -0600 Subject: [PATCH] feat(solver): implement SolverRegistry with plugin loading (#293) Phase 1b of the pluggable solver system. Converts KCSolve from a header-only INTERFACE target to a SHARED library and implements the SolverRegistry with dynamic plugin discovery. Changes: - Add KCSolveGlobal.h export macro header (KCSolveExport) - Move SolverRegistry method bodies from header to SolverRegistry.cpp - Implement scan() with dlopen/LoadLibrary plugin loading - Add scan_default_paths() for KCSOLVE_PLUGIN_PATH + system paths - Plugin entry points: kcsolve_api_version() + kcsolve_create() - API version checking (major version compatibility) - Convert CMakeLists.txt from INTERFACE to SHARED library - Link FreeCADBase (PRIVATE) for Console logging - Link dl on POSIX for dynamic loading - Fix -Wmissing-field-initializers warnings in IKCSolver.h defaults The registry discovers plugins by scanning directories for shared libraries that export the kcsolve C entry points. Plugins are validated for API version compatibility before registration. Manual registration via register_solver() remains available for built-in solvers (e.g. OndselAdapter in Phase 1c). --- src/Mod/Assembly/Solver/CMakeLists.txt | 35 ++- src/Mod/Assembly/Solver/IKCSolver.h | 6 +- src/Mod/Assembly/Solver/KCSolveGlobal.h | 37 +++ src/Mod/Assembly/Solver/SolverRegistry.cpp | 346 +++++++++++++++++++++ src/Mod/Assembly/Solver/SolverRegistry.h | 96 ++---- 5 files changed, 445 insertions(+), 75 deletions(-) create mode 100644 src/Mod/Assembly/Solver/KCSolveGlobal.h create mode 100644 src/Mod/Assembly/Solver/SolverRegistry.cpp diff --git a/src/Mod/Assembly/Solver/CMakeLists.txt b/src/Mod/Assembly/Solver/CMakeLists.txt index 51717be77e..b3d78d5681 100644 --- a/src/Mod/Assembly/Solver/CMakeLists.txt +++ b/src/Mod/Assembly/Solver/CMakeLists.txt @@ -1,12 +1,39 @@ # SPDX-License-Identifier: LGPL-2.1-or-later -# Phase 1a: header-only INTERFACE library. -# Phase 1b will convert to SHARED when .cpp files are added. +set(KCSolve_SRCS + KCSolveGlobal.h + Types.h + IKCSolver.h + SolverRegistry.h + SolverRegistry.cpp +) -add_library(KCSolve INTERFACE) +add_library(KCSolve SHARED ${KCSolve_SRCS}) target_include_directories(KCSolve - INTERFACE + PUBLIC ${CMAKE_SOURCE_DIR}/src ${CMAKE_BINARY_DIR}/src ) + +target_compile_definitions(KCSolve + PRIVATE + CMAKE_INSTALL_PREFIX="${CMAKE_INSTALL_PREFIX}" +) + +target_link_libraries(KCSolve + PRIVATE + FreeCADBase +) + +# Platform-specific dynamic loading library +if(NOT WIN32) + target_link_libraries(KCSolve PRIVATE ${CMAKE_DL_LIBS}) +endif() + +if(FREECAD_WARN_ERROR) + target_compile_warn_error(KCSolve) +endif() + +SET_BIN_DIR(KCSolve KCSolve /Mod/Assembly) +INSTALL(TARGETS KCSolve DESTINATION ${CMAKE_INSTALL_LIBDIR}) diff --git a/src/Mod/Assembly/Solver/IKCSolver.h b/src/Mod/Assembly/Solver/IKCSolver.h index cd9ac78a9b..e81d3c6dd8 100644 --- a/src/Mod/Assembly/Solver/IKCSolver.h +++ b/src/Mod/Assembly/Solver/IKCSolver.h @@ -107,7 +107,7 @@ public: virtual SolveResult drag_step( const std::vector& /*drag_placements*/) { - return SolveResult {SolveStatus::Success}; + return SolveResult {SolveStatus::Success, {}, -1, {}, 0}; } /// End an interactive drag session and finalize state. @@ -123,7 +123,7 @@ public: /// Default: delegates to solve() (ignoring simulation params). virtual SolveResult run_kinematic(const SolveContext& /*ctx*/) { - return SolveResult {SolveStatus::Failed}; + return SolveResult {SolveStatus::Failed, {}, -1, {}, 0}; } /// Number of simulation frames available after run_kinematic(). @@ -136,7 +136,7 @@ public: /// @pre index < num_frames() virtual SolveResult update_for_frame(std::size_t /*index*/) { - return SolveResult {SolveStatus::Failed}; + return SolveResult {SolveStatus::Failed, {}, -1, {}, 0}; } // ── Diagnostics ──────────────────────────────────────────────── diff --git a/src/Mod/Assembly/Solver/KCSolveGlobal.h b/src/Mod/Assembly/Solver/KCSolveGlobal.h new file mode 100644 index 0000000000..4985ef6934 --- /dev/null +++ b/src/Mod/Assembly/Solver/KCSolveGlobal.h @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +/**************************************************************************** + * * + * Copyright (c) 2025 Kindred Systems * + * * + * This file is part of FreeCAD. * + * * + * FreeCAD is free software: you can redistribute it and/or modify it * + * under the terms of the GNU Lesser General Public License as * + * published by the Free Software Foundation, either version 2.1 of the * + * License, or (at your option) any later version. * + * * + * FreeCAD is distributed in the hope that it will be useful, but * + * WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * + * Lesser General Public License for more details. * + * * + * You should have received a copy of the GNU Lesser General Public * + * License along with FreeCAD. If not, see * + * . * + * * + ***************************************************************************/ + +#include + +#ifndef KCSOLVE_GLOBAL_H +#define KCSOLVE_GLOBAL_H + +#ifndef KCSolveExport +# ifdef KCSolve_EXPORTS +# define KCSolveExport FREECAD_DECL_EXPORT +# else +# define KCSolveExport FREECAD_DECL_IMPORT +# endif +#endif + +#endif // KCSOLVE_GLOBAL_H diff --git a/src/Mod/Assembly/Solver/SolverRegistry.cpp b/src/Mod/Assembly/Solver/SolverRegistry.cpp new file mode 100644 index 0000000000..a3544e939a --- /dev/null +++ b/src/Mod/Assembly/Solver/SolverRegistry.cpp @@ -0,0 +1,346 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +/**************************************************************************** + * * + * Copyright (c) 2025 Kindred Systems * + * * + * This file is part of FreeCAD. * + * * + * FreeCAD is free software: you can redistribute it and/or modify it * + * under the terms of the GNU Lesser General Public License as * + * published by the Free Software Foundation, either version 2.1 of the * + * License, or (at your option) any later version. * + * * + * FreeCAD is distributed in the hope that it will be useful, but * + * WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * + * Lesser General Public License for more details. * + * * + * You should have received a copy of the GNU Lesser General Public * + * License along with FreeCAD. If not, see * + * . * + * * + ***************************************************************************/ + +#include "SolverRegistry.h" + +#include + +#include +#include +#include +#include + +#ifdef _WIN32 +# define WIN32_LEAN_AND_MEAN +# include +#else +# include +#endif + +namespace fs = std::filesystem; + +namespace +{ + +// Platform extension for shared libraries. +#ifdef _WIN32 +constexpr const char* PLUGIN_EXT = ".dll"; +constexpr char PATH_SEP = ';'; +#elif defined(__APPLE__) +constexpr const char* PLUGIN_EXT = ".dylib"; +constexpr char PATH_SEP = ':'; +#else +constexpr const char* PLUGIN_EXT = ".so"; +constexpr char PATH_SEP = ':'; +#endif + +// Dynamic library loading wrappers. +void* open_library(const char* path) +{ +#ifdef _WIN32 + return static_cast(LoadLibraryA(path)); +#else + return dlopen(path, RTLD_LAZY); +#endif +} + +void* get_symbol(void* handle, const char* symbol) +{ +#ifdef _WIN32 + return reinterpret_cast( + GetProcAddress(static_cast(handle), symbol)); +#else + return dlsym(handle, symbol); +#endif +} + +std::string load_error() +{ +#ifdef _WIN32 + DWORD err = GetLastError(); + char* msg = nullptr; + FormatMessageA(FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM, + nullptr, err, 0, reinterpret_cast(&msg), 0, nullptr); + std::string result = msg ? msg : "unknown error"; + LocalFree(msg); + return result; +#else + const char* err = dlerror(); + return err ? err : "unknown error"; +#endif +} + +/// Parse major version from a version string like "1.0" or "2.1.3". +/// Returns -1 on failure. +int parse_major_version(const char* version_str) +{ + if (!version_str) { + return -1; + } + char* end = nullptr; + long major = std::strtol(version_str, &end, 10); + if (end == version_str || major < 0) { + return -1; + } + return static_cast(major); +} + +} // anonymous namespace + + +namespace KCSolve +{ + +// Plugin C entry point types. +using ApiVersionFn = const char* (*)(); +using CreateFn = IKCSolver* (*)(); + + +// ── Singleton ────────────────────────────────────────────────────── + +SolverRegistry& SolverRegistry::instance() +{ + static SolverRegistry reg; + return reg; +} + +SolverRegistry::SolverRegistry() = default; + +SolverRegistry::~SolverRegistry() +{ + for (void* handle : handles_) { + close_handle(handle); + } +} + +void SolverRegistry::close_handle(void* handle) +{ + if (!handle) { + return; + } +#ifdef _WIN32 + FreeLibrary(static_cast(handle)); +#else + dlclose(handle); +#endif +} + + +// ── Registration ─────────────────────────────────────────────────── + +bool SolverRegistry::register_solver(const std::string& name, CreateSolverFn factory) +{ + std::lock_guard lock(mutex_); + auto [it, inserted] = factories_.emplace(name, std::move(factory)); + if (!inserted) { + Base::Console().warning("KCSolve: solver '%s' already registered, skipping\n", + name.c_str()); + return false; + } + if (default_name_.empty()) { + default_name_ = name; + } + Base::Console().log("KCSolve: registered solver '%s'\n", name.c_str()); + return true; +} + + +// ── Lookup ───────────────────────────────────────────────────────── + +std::unique_ptr SolverRegistry::get(const std::string& name) const +{ + std::lock_guard lock(mutex_); + const std::string& key = name.empty() ? default_name_ : name; + if (key.empty()) { + return nullptr; + } + auto it = factories_.find(key); + if (it == factories_.end()) { + return nullptr; + } + return it->second(); +} + +std::vector SolverRegistry::available() const +{ + std::lock_guard lock(mutex_); + std::vector names; + names.reserve(factories_.size()); + for (const auto& [name, _] : factories_) { + names.push_back(name); + } + return names; +} + +std::vector SolverRegistry::joints_for(const std::string& name) const +{ + auto solver = get(name); + if (!solver) { + return {}; + } + return solver->supported_joints(); +} + +bool SolverRegistry::set_default(const std::string& name) +{ + std::lock_guard lock(mutex_); + if (factories_.find(name) == factories_.end()) { + return false; + } + default_name_ = name; + return true; +} + +std::string SolverRegistry::get_default() const +{ + std::lock_guard lock(mutex_); + return default_name_; +} + + +// ── Plugin scanning ──────────────────────────────────────────────── + +void SolverRegistry::scan(const std::string& directory) +{ + std::error_code ec; + if (!fs::is_directory(directory, ec)) { + // Non-existent directories are not an error — just skip. + return; + } + + Base::Console().log("KCSolve: scanning '%s' for plugins\n", directory.c_str()); + + for (const auto& entry : fs::directory_iterator(directory, ec)) { + if (ec) { + Base::Console().warning("KCSolve: error iterating '%s': %s\n", + directory.c_str(), ec.message().c_str()); + break; + } + + if (!entry.is_regular_file(ec)) { + continue; + } + + const auto& path = entry.path(); + if (path.extension() != PLUGIN_EXT) { + continue; + } + + const std::string path_str = path.string(); + + // Load the shared library. + void* handle = open_library(path_str.c_str()); + if (!handle) { + Base::Console().warning("KCSolve: failed to load '%s': %s\n", + path_str.c_str(), load_error().c_str()); + continue; + } + + // Check API version. + auto version_fn = reinterpret_cast( + get_symbol(handle, "kcsolve_api_version")); + if (!version_fn) { + // Not a KCSolve plugin — silently skip. + close_handle(handle); + continue; + } + + const char* version_str = version_fn(); + int major = parse_major_version(version_str); + if (major != API_VERSION_MAJOR) { + Base::Console().warning( + "KCSolve: plugin '%s' has incompatible API version '%s' " + "(expected major %d)\n", + path_str.c_str(), + version_str ? version_str : "(null)", + API_VERSION_MAJOR); + close_handle(handle); + continue; + } + + // Get the factory symbol. + auto create_fn = reinterpret_cast( + get_symbol(handle, "kcsolve_create")); + if (!create_fn) { + Base::Console().warning( + "KCSolve: plugin '%s' missing kcsolve_create() symbol\n", + path_str.c_str()); + close_handle(handle); + continue; + } + + // Create a temporary instance to get the solver name. + std::unique_ptr probe(create_fn()); + if (!probe) { + Base::Console().warning( + "KCSolve: plugin '%s' kcsolve_create() returned null\n", + path_str.c_str()); + close_handle(handle); + continue; + } + + std::string solver_name = probe->name(); + probe.reset(); + + // Wrap the C function pointer in a factory lambda. + CreateSolverFn factory = [create_fn]() -> std::unique_ptr { + return std::unique_ptr(create_fn()); + }; + + if (register_solver(solver_name, std::move(factory))) { + handles_.push_back(handle); + Base::Console().log("KCSolve: loaded plugin '%s' from '%s'\n", + solver_name.c_str(), path_str.c_str()); + } + else { + // Duplicate name — close the handle. + close_handle(handle); + } + } +} + +void SolverRegistry::scan_default_paths() +{ + // 1. KCSOLVE_PLUGIN_PATH environment variable. + const char* env_path = std::getenv("KCSOLVE_PLUGIN_PATH"); + if (env_path && env_path[0] != '\0') { + std::istringstream stream(env_path); + std::string dir; + while (std::getline(stream, dir, PATH_SEP)) { + if (!dir.empty()) { + scan(dir); + } + } + } + + // 2. System install path: /lib/kcsolve/ + // Derive from the executable location or use a compile-time path. + // For now, use a path relative to the FreeCAD lib directory. + std::error_code ec; + fs::path system_dir = fs::path(CMAKE_INSTALL_PREFIX) / "lib" / "kcsolve"; + if (fs::is_directory(system_dir, ec)) { + scan(system_dir.string()); + } +} + +} // namespace KCSolve diff --git a/src/Mod/Assembly/Solver/SolverRegistry.h b/src/Mod/Assembly/Solver/SolverRegistry.h index ea0dee13cc..056274dc33 100644 --- a/src/Mod/Assembly/Solver/SolverRegistry.h +++ b/src/Mod/Assembly/Solver/SolverRegistry.h @@ -32,6 +32,7 @@ #include #include "IKCSolver.h" +#include "KCSolveGlobal.h" namespace KCSolve { @@ -39,6 +40,9 @@ namespace KCSolve /// Factory function that creates a solver instance. using CreateSolverFn = std::function()>; +/// Current KCSolve API major version. Plugins must match this to load. +constexpr int API_VERSION_MAJOR = 1; + /// Singleton registry for pluggable solver backends. /// /// Solver plugins register themselves at module load time via @@ -55,108 +59,64 @@ using CreateSolverFn = std::function()>; /// auto solver = KCSolve::SolverRegistry::instance().get(); // default /// auto solver = KCSolve::SolverRegistry::instance().get("ondsel"); -class SolverRegistry +class KCSolveExport SolverRegistry { public: /// Access the singleton instance. - static SolverRegistry& instance() - { - static SolverRegistry reg; - return reg; - } + static SolverRegistry& instance(); + + ~SolverRegistry(); /// Register a solver backend. /// @param name Unique solver name (e.g. "ondsel"). /// @param factory Factory function that creates solver instances. /// @return true if registration succeeded, false if name taken. - bool register_solver(const std::string& name, CreateSolverFn factory) - { - std::lock_guard lock(mutex_); - auto [it, inserted] = factories_.emplace(name, std::move(factory)); - if (inserted && default_name_.empty()) { - default_name_ = name; // first registered becomes default - } - return inserted; - } + bool register_solver(const std::string& name, CreateSolverFn factory); /// Create an instance of the named solver. /// @param name Solver name. If empty, uses the default solver. /// @return Solver instance, or nullptr if not found. - std::unique_ptr get(const std::string& name = {}) const - { - std::lock_guard lock(mutex_); - const std::string& key = name.empty() ? default_name_ : name; - if (key.empty()) { - return nullptr; - } - auto it = factories_.find(key); - if (it == factories_.end()) { - return nullptr; - } - return it->second(); - } + std::unique_ptr get(const std::string& name = {}) const; /// Return the names of all registered solvers. - std::vector available() const - { - std::lock_guard lock(mutex_); - std::vector names; - names.reserve(factories_.size()); - for (const auto& [name, _] : factories_) { - names.push_back(name); - } - return names; - } + std::vector available() const; /// Query which BaseJointKind values a named solver supports. /// Creates a temporary instance to call supported_joints(). - std::vector joints_for(const std::string& name) const - { - auto solver = get(name); - if (!solver) { - return {}; - } - return solver->supported_joints(); - } + std::vector joints_for(const std::string& name) const; /// Set the default solver name. /// @return true if the name is registered, false otherwise. - bool set_default(const std::string& name) - { - std::lock_guard lock(mutex_); - if (factories_.find(name) == factories_.end()) { - return false; - } - default_name_ = name; - return true; - } + bool set_default(const std::string& name); /// Get the default solver name. - std::string get_default() const - { - std::lock_guard lock(mutex_); - return default_name_; - } + std::string get_default() const; - /// Scan a directory for solver plugins (Phase 1b). - /// Currently a no-op placeholder. Will dlopen/LoadLibrary shared - /// objects that export kcsolve_create() / kcsolve_api_version(). - void scan(const std::string& /*directory*/) - { - } + /// Scan a directory for solver plugin shared libraries. + /// Each plugin must export kcsolve_api_version() and kcsolve_create(). + /// Non-existent or empty directories are handled gracefully. + void scan(const std::string& directory); + + /// Scan all default plugin discovery paths: + /// 1. KCSOLVE_PLUGIN_PATH env var (colon-separated, semicolon on Windows) + /// 2. /lib/kcsolve/ + void scan_default_paths(); private: - SolverRegistry() = default; - ~SolverRegistry() = default; + SolverRegistry(); SolverRegistry(const SolverRegistry&) = delete; SolverRegistry& operator=(const SolverRegistry&) = delete; SolverRegistry(SolverRegistry&&) = delete; SolverRegistry& operator=(SolverRegistry&&) = delete; + /// Close a single plugin handle (platform-specific). + static void close_handle(void* handle); + mutable std::mutex mutex_; std::unordered_map factories_; std::string default_name_; + std::vector handles_; // loaded plugin library handles }; } // namespace KCSolve