From f44aba738846e8766ace0d460293e6b74586fc2f Mon Sep 17 00:00:00 2001 From: forbes Date: Fri, 27 Feb 2026 14:05:07 -0600 Subject: [PATCH] feat(sdk): migrate editing context API to kcsdk (#351) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add context/overlay registration, injection, query, and refresh to the KCSDK C++ library and kcsdk pybind11 module. New files: - src/Gui/SDK/Types.h — ContextDef, OverlayDef, ContextSnapshot structs (plain C++, no Qt in public API) Modified: - src/Gui/SDK/SDKRegistry.h/.cpp — register_context/overlay, unregister, inject_commands, current_context, refresh (delegates to EditingContextResolver with std↔Qt conversion) - src/Gui/SDK/CMakeLists.txt — add Types.h, link FreeCADGui - src/Gui/SDK/bindings/kcsdk_py.cpp — bind all context functions with GIL-safe match callable wrapping and dict-based snapshot return - mods/sdk/kindred_sdk/context.py — try kcsdk first, fall back to FreeCADGui for backwards compatibility --- mods/sdk/kindred_sdk/context.py | 49 ++++++++--- src/Gui/SDK/CMakeLists.txt | 2 + src/Gui/SDK/SDKRegistry.cpp | 108 ++++++++++++++++++++++- src/Gui/SDK/SDKRegistry.h | 26 ++++++ src/Gui/SDK/Types.h | 68 +++++++++++++++ src/Gui/SDK/bindings/kcsdk_py.cpp | 140 ++++++++++++++++++++++++++++++ 6 files changed, 382 insertions(+), 11 deletions(-) create mode 100644 src/Gui/SDK/Types.h diff --git a/mods/sdk/kindred_sdk/context.py b/mods/sdk/kindred_sdk/context.py index 41019b5492..659105f041 100644 --- a/mods/sdk/kindred_sdk/context.py +++ b/mods/sdk/kindred_sdk/context.py @@ -1,12 +1,17 @@ """Editing context and overlay registration wrappers. -Thin wrappers around FreeCADGui editing context bindings. If the -underlying C++ API changes during an upstream rebase, only this module -needs to be updated. +Routes through the ``kcsdk`` C++ module when available, falling back to +the legacy ``FreeCADGui`` Python bindings for backwards compatibility. """ import FreeCAD +# Try to import the C++ SDK module; None if not yet built/installed. +try: + import kcsdk as _kcsdk +except ImportError: + _kcsdk = None + def _gui(): """Lazy import of FreeCADGui (not available in console mode).""" @@ -41,7 +46,12 @@ def register_context(context_id, label, color, toolbars, match, priority=50): raise TypeError("match must be callable") try: - _gui().registerEditingContext(context_id, label, color, toolbars, match, priority) + if _kcsdk is not None: + _kcsdk.register_context(context_id, label, color, toolbars, match, priority) + else: + _gui().registerEditingContext( + context_id, label, color, toolbars, match, priority + ) except Exception as e: FreeCAD.Console.PrintWarning( f"kindred_sdk: Failed to register context '{context_id}': {e}\n" @@ -54,7 +64,10 @@ def unregister_context(context_id): raise TypeError(f"context_id must be str, got {type(context_id).__name__}") try: - _gui().unregisterEditingContext(context_id) + if _kcsdk is not None: + _kcsdk.unregister_context(context_id) + else: + _gui().unregisterEditingContext(context_id) except Exception as e: FreeCAD.Console.PrintWarning( f"kindred_sdk: Failed to unregister context '{context_id}': {e}\n" @@ -84,7 +97,10 @@ def register_overlay(overlay_id, toolbars, match): raise TypeError("match must be callable") try: - _gui().registerEditingOverlay(overlay_id, toolbars, match) + if _kcsdk is not None: + _kcsdk.register_overlay(overlay_id, toolbars, match) + else: + _gui().registerEditingOverlay(overlay_id, toolbars, match) except Exception as e: FreeCAD.Console.PrintWarning( f"kindred_sdk: Failed to register overlay '{overlay_id}': {e}\n" @@ -97,7 +113,10 @@ def unregister_overlay(overlay_id): raise TypeError(f"overlay_id must be str, got {type(overlay_id).__name__}") try: - _gui().unregisterEditingOverlay(overlay_id) + if _kcsdk is not None: + _kcsdk.unregister_overlay(overlay_id) + else: + _gui().unregisterEditingOverlay(overlay_id) except Exception as e: FreeCAD.Console.PrintWarning( f"kindred_sdk: Failed to unregister overlay '{overlay_id}': {e}\n" @@ -124,7 +143,10 @@ def inject_commands(context_id, toolbar_name, commands): raise TypeError(f"commands must be list, got {type(commands).__name__}") try: - _gui().injectEditingCommands(context_id, toolbar_name, commands) + if _kcsdk is not None: + _kcsdk.inject_commands(context_id, toolbar_name, commands) + else: + _gui().injectEditingCommands(context_id, toolbar_name, commands) except Exception as e: FreeCAD.Console.PrintWarning( f"kindred_sdk: Failed to inject commands into '{context_id}': {e}\n" @@ -138,15 +160,22 @@ def current_context(): ``breadcrumbColors``. Returns ``None`` if no context is active. """ try: + if _kcsdk is not None: + return _kcsdk.current_context() return _gui().currentEditingContext() except Exception as e: - FreeCAD.Console.PrintWarning(f"kindred_sdk: Failed to get current context: {e}\n") + FreeCAD.Console.PrintWarning( + f"kindred_sdk: Failed to get current context: {e}\n" + ) return None def refresh_context(): """Force re-resolution and update of the editing context.""" try: - _gui().refreshEditingContext() + if _kcsdk is not None: + _kcsdk.refresh() + else: + _gui().refreshEditingContext() except Exception as e: FreeCAD.Console.PrintWarning(f"kindred_sdk: Failed to refresh context: {e}\n") diff --git a/src/Gui/SDK/CMakeLists.txt b/src/Gui/SDK/CMakeLists.txt index 24e37f5e2c..783d18a8ce 100644 --- a/src/Gui/SDK/CMakeLists.txt +++ b/src/Gui/SDK/CMakeLists.txt @@ -2,6 +2,7 @@ set(KCSDK_SRCS KCSDKGlobal.h + Types.h SDKRegistry.h SDKRegistry.cpp ) @@ -17,6 +18,7 @@ target_include_directories(KCSDK target_link_libraries(KCSDK PRIVATE FreeCADBase + FreeCADGui ) if(FREECAD_WARN_ERROR) diff --git a/src/Gui/SDK/SDKRegistry.cpp b/src/Gui/SDK/SDKRegistry.cpp index d625a9758a..c05610ef81 100644 --- a/src/Gui/SDK/SDKRegistry.cpp +++ b/src/Gui/SDK/SDKRegistry.cpp @@ -23,11 +23,55 @@ #include "SDKRegistry.h" +#include +#include + #include +#include namespace KCSDK { +// -- Helpers: std ↔ Qt conversion (internal) -------------------------------- + +namespace +{ + +QString toQString(const std::string& s) +{ + return QString::fromUtf8(s.data(), static_cast(s.size())); +} + +std::string fromQString(const QString& s) +{ + QByteArray utf8 = s.toUtf8(); + return std::string(utf8.constData(), utf8.size()); +} + +QStringList toQStringList(const std::vector& v) +{ + QStringList out; + out.reserve(static_cast(v.size())); + for (const auto& s : v) { + out.append(toQString(s)); + } + return out; +} + +std::vector fromQStringList(const QStringList& list) +{ + std::vector out; + out.reserve(list.size()); + for (const auto& s : list) { + out.push_back(fromQString(s)); + } + return out; +} + +} // anonymous namespace + +// -- Singleton -------------------------------------------------------------- + SDKRegistry& SDKRegistry::instance() { static SDKRegistry reg; @@ -44,8 +88,70 @@ SDKRegistry::~SDKRegistry() = default; std::vector SDKRegistry::available() const { std::lock_guard lock(mutex_); - // No providers registered yet — will be populated in subsequent phases. return {}; } +// -- Editing context API ---------------------------------------------------- + +void SDKRegistry::registerContext(const ContextDef& def) +{ + Gui::ContextDefinition guiDef; + guiDef.id = toQString(def.id); + guiDef.labelTemplate = toQString(def.labelTemplate); + guiDef.color = toQString(def.color); + guiDef.toolbars = toQStringList(def.toolbars); + guiDef.priority = def.priority; + guiDef.match = def.match; + + Gui::EditingContextResolver::instance()->registerContext(guiDef); +} + +void SDKRegistry::unregisterContext(const std::string& id) +{ + Gui::EditingContextResolver::instance()->unregisterContext(toQString(id)); +} + +void SDKRegistry::registerOverlay(const OverlayDef& def) +{ + Gui::OverlayDefinition guiDef; + guiDef.id = toQString(def.id); + guiDef.toolbars = toQStringList(def.toolbars); + guiDef.match = def.match; + + Gui::EditingContextResolver::instance()->registerOverlay(guiDef); +} + +void SDKRegistry::unregisterOverlay(const std::string& id) +{ + Gui::EditingContextResolver::instance()->unregisterOverlay(toQString(id)); +} + +void SDKRegistry::injectCommands(const std::string& contextId, + const std::string& toolbarName, + const std::vector& commands) +{ + Gui::EditingContextResolver::instance()->injectCommands( + toQString(contextId), toQString(toolbarName), toQStringList(commands)); +} + +ContextSnapshot SDKRegistry::currentContext() const +{ + Gui::EditingContext ctx = + Gui::EditingContextResolver::instance()->currentContext(); + + ContextSnapshot snap; + snap.id = fromQString(ctx.id); + snap.label = fromQString(ctx.label); + snap.color = fromQString(ctx.color); + snap.toolbars = fromQStringList(ctx.toolbars); + snap.breadcrumb = fromQStringList(ctx.breadcrumb); + snap.breadcrumbColors = fromQStringList(ctx.breadcrumbColors); + return snap; +} + +void SDKRegistry::refresh() +{ + Gui::EditingContextResolver::instance()->refresh(); +} + } // namespace KCSDK diff --git a/src/Gui/SDK/SDKRegistry.h b/src/Gui/SDK/SDKRegistry.h index 050f2760a9..17b2a0c410 100644 --- a/src/Gui/SDK/SDKRegistry.h +++ b/src/Gui/SDK/SDKRegistry.h @@ -29,6 +29,7 @@ #include #include "KCSDKGlobal.h" +#include "Types.h" namespace KCSDK { @@ -55,6 +56,31 @@ public: /// Return names of all registered providers (across all provider types). std::vector available() const; + // -- Editing context API (delegates to EditingContextResolver) ---------- + + /// Register an editing context. + void registerContext(const ContextDef& def); + + /// Remove a previously registered editing context. + void unregisterContext(const std::string& id); + + /// Register an editing overlay (additive, not exclusive). + void registerOverlay(const OverlayDef& def); + + /// Remove a previously registered editing overlay. + void unregisterOverlay(const std::string& id); + + /// Inject additional commands into a context's toolbar. + void injectCommands(const std::string& contextId, + const std::string& toolbarName, + const std::vector& commands); + + /// Return a snapshot of the current editing context. + ContextSnapshot currentContext() const; + + /// Force re-resolution of the editing context. + void refresh(); + private: SDKRegistry(); diff --git a/src/Gui/SDK/Types.h b/src/Gui/SDK/Types.h new file mode 100644 index 0000000000..45fad4856d --- /dev/null +++ b/src/Gui/SDK/Types.h @@ -0,0 +1,68 @@ +// 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 * + * . * + * * + ***************************************************************************/ + +#ifndef KCSDK_TYPES_H +#define KCSDK_TYPES_H + +#include +#include +#include + +#include "KCSDKGlobal.h" + +namespace KCSDK +{ + +/// Plain-C++ context definition. No Qt types in the public SDK API. +struct KCSDKExport ContextDef +{ + std::string id; + std::string labelTemplate; + std::string color; + std::vector toolbars; + int priority = 50; + std::function match; +}; + +/// Plain-C++ overlay definition. +struct KCSDKExport OverlayDef +{ + std::string id; + std::vector toolbars; + std::function match; +}; + +/// Snapshot of the resolved editing context (read-only). +struct KCSDKExport ContextSnapshot +{ + std::string id; + std::string label; + std::string color; + std::vector toolbars; + std::vector breadcrumb; + std::vector breadcrumbColors; +}; + +} // namespace KCSDK + +#endif // KCSDK_TYPES_H diff --git a/src/Gui/SDK/bindings/kcsdk_py.cpp b/src/Gui/SDK/bindings/kcsdk_py.cpp index fc30cb4515..cdb4db8c47 100644 --- a/src/Gui/SDK/bindings/kcsdk_py.cpp +++ b/src/Gui/SDK/bindings/kcsdk_py.cpp @@ -23,12 +23,56 @@ #include #include +#include #include +#include namespace py = pybind11; using namespace KCSDK; +// -- Dict conversion helpers ------------------------------------------------ + +namespace +{ + +/// Convert a ContextSnapshot to a Python dict (same keys as ApplicationPy). +py::dict contextSnapshotToDict(const ContextSnapshot& snap) +{ + py::dict d; + d["id"] = snap.id; + d["label"] = snap.label; + d["color"] = snap.color; + d["toolbars"] = snap.toolbars; + d["breadcrumb"] = snap.breadcrumb; + d["breadcrumbColors"] = snap.breadcrumbColors; + return d; +} + +/// Wrap a Python callable as a GIL-safe std::function. +/// The py::object is copied (reference counted) so it survives beyond the +/// calling scope. The GIL is acquired before every invocation. +std::function wrapMatchCallable(py::object pyCallable) +{ + // Copy the py::object to prevent GC. + auto held = std::make_shared(std::move(pyCallable)); + return [held]() -> bool { + py::gil_scoped_acquire gil; + try { + py::object result = (*held)(); + return result.cast(); + } + catch (py::error_already_set& e) { + e.discard_as_unraisable(__func__); + return false; + } + }; +} + +} // anonymous namespace + +// -- Module ----------------------------------------------------------------- + PYBIND11_MODULE(kcsdk, m) { m.doc() = "KCSDK — Kindred Create addon SDK C++ API"; @@ -37,4 +81,100 @@ PYBIND11_MODULE(kcsdk, m) m.def("available", []() { return SDKRegistry::instance().available(); }, "Return names of all registered providers."); + + // -- Editing context API ------------------------------------------------ + + m.def("register_context", + [](const std::string& id, + const std::string& label, + const std::string& color, + const std::vector& toolbars, + py::object match, + int priority) { + if (!py::isinstance(match) && !py::hasattr(match, "__call__")) { + throw py::type_error("match must be callable"); + } + ContextDef def; + def.id = id; + def.labelTemplate = label; + def.color = color; + def.toolbars = toolbars; + def.priority = priority; + def.match = wrapMatchCallable(std::move(match)); + SDKRegistry::instance().registerContext(def); + }, + py::arg("id"), + py::arg("label"), + py::arg("color"), + py::arg("toolbars"), + py::arg("match"), + py::arg("priority") = 50, + "Register an editing context.\n\n" + "Parameters\n" + "----------\n" + "id : str\n Unique context identifier.\n" + "label : str\n Display label template ({name} placeholder).\n" + "color : str\n Hex color for breadcrumb.\n" + "toolbars : list[str]\n Toolbar names to show when active.\n" + "match : callable\n Zero-arg callable returning True when active.\n" + "priority : int\n Higher values checked first (default 50)."); + + m.def("unregister_context", + [](const std::string& id) { + SDKRegistry::instance().unregisterContext(id); + }, + py::arg("id"), + "Unregister an editing context."); + + m.def("register_overlay", + [](const std::string& id, + const std::vector& toolbars, + py::object match) { + if (!py::isinstance(match) && !py::hasattr(match, "__call__")) { + throw py::type_error("match must be callable"); + } + OverlayDef def; + def.id = id; + def.toolbars = toolbars; + def.match = wrapMatchCallable(std::move(match)); + SDKRegistry::instance().registerOverlay(def); + }, + py::arg("id"), + py::arg("toolbars"), + py::arg("match"), + "Register an editing overlay (additive toolbars)."); + + m.def("unregister_overlay", + [](const std::string& id) { + SDKRegistry::instance().unregisterOverlay(id); + }, + py::arg("id"), + "Unregister an editing overlay."); + + m.def("inject_commands", + [](const std::string& contextId, + const std::string& toolbarName, + const std::vector& commands) { + SDKRegistry::instance().injectCommands(contextId, toolbarName, commands); + }, + py::arg("context_id"), + py::arg("toolbar_name"), + py::arg("commands"), + "Inject commands into a context's toolbar."); + + m.def("current_context", + []() -> py::object { + ContextSnapshot snap = SDKRegistry::instance().currentContext(); + if (snap.id.empty()) { + return py::none(); + } + return contextSnapshotToDict(snap); + }, + "Return the current editing context as a dict, or None."); + + m.def("refresh", + []() { + SDKRegistry::instance().refresh(); + }, + "Force re-resolution of the editing context."); } -- 2.49.1