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).
This commit is contained in:
forbes
2026-02-19 16:07:37 -06:00
parent 47e6c14461
commit 76b91c6597
5 changed files with 445 additions and 75 deletions

View File

@@ -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})

View File

@@ -107,7 +107,7 @@ public:
virtual SolveResult drag_step(
const std::vector<SolveResult::PartResult>& /*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 ────────────────────────────────────────────────

View File

@@ -0,0 +1,37 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
/****************************************************************************
* *
* Copyright (c) 2025 Kindred Systems <development@kindred-systems.com> *
* *
* 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 *
* <https://www.gnu.org/licenses/>. *
* *
***************************************************************************/
#include <FCGlobal.h>
#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

View File

@@ -0,0 +1,346 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
/****************************************************************************
* *
* Copyright (c) 2025 Kindred Systems <development@kindred-systems.com> *
* *
* 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 *
* <https://www.gnu.org/licenses/>. *
* *
***************************************************************************/
#include "SolverRegistry.h"
#include <Base/Console.h>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <sstream>
#ifdef _WIN32
# define WIN32_LEAN_AND_MEAN
# include <windows.h>
#else
# include <dlfcn.h>
#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<void*>(LoadLibraryA(path));
#else
return dlopen(path, RTLD_LAZY);
#endif
}
void* get_symbol(void* handle, const char* symbol)
{
#ifdef _WIN32
return reinterpret_cast<void*>(
GetProcAddress(static_cast<HMODULE>(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<char*>(&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<int>(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<HMODULE>(handle));
#else
dlclose(handle);
#endif
}
// ── Registration ───────────────────────────────────────────────────
bool SolverRegistry::register_solver(const std::string& name, CreateSolverFn factory)
{
std::lock_guard<std::mutex> 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<IKCSolver> SolverRegistry::get(const std::string& name) const
{
std::lock_guard<std::mutex> 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<std::string> SolverRegistry::available() const
{
std::lock_guard<std::mutex> lock(mutex_);
std::vector<std::string> names;
names.reserve(factories_.size());
for (const auto& [name, _] : factories_) {
names.push_back(name);
}
return names;
}
std::vector<BaseJointKind> 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<std::mutex> lock(mutex_);
if (factories_.find(name) == factories_.end()) {
return false;
}
default_name_ = name;
return true;
}
std::string SolverRegistry::get_default() const
{
std::lock_guard<std::mutex> 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<ApiVersionFn>(
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<CreateFn>(
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<IKCSolver> 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<IKCSolver> {
return std::unique_ptr<IKCSolver>(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: <install_prefix>/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

View File

@@ -32,6 +32,7 @@
#include <vector>
#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<std::unique_ptr<IKCSolver>()>;
/// 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<std::unique_ptr<IKCSolver>()>;
/// 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<std::mutex> 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<IKCSolver> get(const std::string& name = {}) const
{
std::lock_guard<std::mutex> 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<IKCSolver> get(const std::string& name = {}) const;
/// Return the names of all registered solvers.
std::vector<std::string> available() const
{
std::lock_guard<std::mutex> lock(mutex_);
std::vector<std::string> names;
names.reserve(factories_.size());
for (const auto& [name, _] : factories_) {
names.push_back(name);
}
return names;
}
std::vector<std::string> available() const;
/// Query which BaseJointKind values a named solver supports.
/// Creates a temporary instance to call supported_joints().
std::vector<BaseJointKind> joints_for(const std::string& name) const
{
auto solver = get(name);
if (!solver) {
return {};
}
return solver->supported_joints();
}
std::vector<BaseJointKind> 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<std::mutex> 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<std::mutex> 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. <install_prefix>/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<std::string, CreateSolverFn> factories_;
std::string default_name_;
std::vector<void*> handles_; // loaded plugin library handles
};
} // namespace KCSolve