From e667aceead4d6d96445ba6924b0a327ceeba7d39 Mon Sep 17 00:00:00 2001 From: forbes-0023 Date: Tue, 17 Feb 2026 08:36:27 -0600 Subject: [PATCH] feat(addon-system): create kindred-addon-sdk package (#249) Add mods/sdk/ with the kindred_sdk Python package providing a stable API layer for addon integration with Kindred Create platform features. Modules: - context: editing context/overlay registration wrappers - theme: YAML-driven palette system (Catppuccin Mocha) - origin: FileOrigin registration helpers - dock: deferred dock panel registration - compat: version detection utilities The SDK loads at priority 0 (before all other addons) via the existing manifest-driven loader. Theme colors are defined in a single YAML palette file instead of hardcoded Python dicts, enabling future theme support and eliminating color duplication across addons. Closes #249 --- mods/sdk/Init.py | 3 + mods/sdk/InitGui.py | 3 + mods/sdk/kindred_sdk/__init__.py | 34 ++++ mods/sdk/kindred_sdk/compat.py | 21 ++ mods/sdk/kindred_sdk/context.py | 152 +++++++++++++++ mods/sdk/kindred_sdk/dock.py | 73 +++++++ mods/sdk/kindred_sdk/origin.py | 42 ++++ .../palettes/catppuccin-mocha.yaml | 46 +++++ mods/sdk/kindred_sdk/theme.py | 181 ++++++++++++++++++ mods/sdk/kindred_sdk/version.py | 1 + mods/sdk/package.xml | 23 +++ src/Mod/Create/CMakeLists.txt | 16 ++ 12 files changed, 595 insertions(+) create mode 100644 mods/sdk/Init.py create mode 100644 mods/sdk/InitGui.py create mode 100644 mods/sdk/kindred_sdk/__init__.py create mode 100644 mods/sdk/kindred_sdk/compat.py create mode 100644 mods/sdk/kindred_sdk/context.py create mode 100644 mods/sdk/kindred_sdk/dock.py create mode 100644 mods/sdk/kindred_sdk/origin.py create mode 100644 mods/sdk/kindred_sdk/palettes/catppuccin-mocha.yaml create mode 100644 mods/sdk/kindred_sdk/theme.py create mode 100644 mods/sdk/kindred_sdk/version.py create mode 100644 mods/sdk/package.xml diff --git a/mods/sdk/Init.py b/mods/sdk/Init.py new file mode 100644 index 0000000000..ff8a50301d --- /dev/null +++ b/mods/sdk/Init.py @@ -0,0 +1,3 @@ +import FreeCAD + +FreeCAD.Console.PrintLog("kindred-addon-sdk loaded\n") diff --git a/mods/sdk/InitGui.py b/mods/sdk/InitGui.py new file mode 100644 index 0000000000..67b994c53e --- /dev/null +++ b/mods/sdk/InitGui.py @@ -0,0 +1,3 @@ +import FreeCAD + +FreeCAD.Console.PrintLog("kindred-addon-sdk GUI initialized\n") diff --git a/mods/sdk/kindred_sdk/__init__.py b/mods/sdk/kindred_sdk/__init__.py new file mode 100644 index 0000000000..67f8e400b9 --- /dev/null +++ b/mods/sdk/kindred_sdk/__init__.py @@ -0,0 +1,34 @@ +# kindred-addon-sdk — stable API for Kindred Create addon integration + +from kindred_sdk.version import SDK_VERSION +from kindred_sdk.context import ( + register_context, + unregister_context, + register_overlay, + unregister_overlay, + inject_commands, + current_context, + refresh_context, +) +from kindred_sdk.theme import get_theme_tokens, load_palette +from kindred_sdk.origin import register_origin, unregister_origin +from kindred_sdk.dock import register_dock_panel +from kindred_sdk.compat import create_version, freecad_version + +__all__ = [ + "SDK_VERSION", + "register_context", + "unregister_context", + "register_overlay", + "unregister_overlay", + "inject_commands", + "current_context", + "refresh_context", + "get_theme_tokens", + "load_palette", + "register_origin", + "unregister_origin", + "register_dock_panel", + "create_version", + "freecad_version", +] diff --git a/mods/sdk/kindred_sdk/compat.py b/mods/sdk/kindred_sdk/compat.py new file mode 100644 index 0000000000..6a63f0b41f --- /dev/null +++ b/mods/sdk/kindred_sdk/compat.py @@ -0,0 +1,21 @@ +"""Version detection utilities.""" + +import FreeCAD + + +def create_version(): + """Return the Kindred Create version string (e.g. ``"0.1.3"``).""" + try: + from version import VERSION + + return VERSION + except ImportError: + return "0.0.0" + + +def freecad_version(): + """Return the FreeCAD base version as a tuple of strings. + + Example: ``("1", "0", "0", "2025.01.01")``. + """ + return tuple(FreeCAD.Version()) diff --git a/mods/sdk/kindred_sdk/context.py b/mods/sdk/kindred_sdk/context.py new file mode 100644 index 0000000000..41019b5492 --- /dev/null +++ b/mods/sdk/kindred_sdk/context.py @@ -0,0 +1,152 @@ +"""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. +""" + +import FreeCAD + + +def _gui(): + """Lazy import of FreeCADGui (not available in console mode).""" + import FreeCADGui + + return FreeCADGui + + +def register_context(context_id, label, color, toolbars, match, priority=50): + """Register an editing context. + + Parameters + ---------- + context_id : str + Unique identifier (e.g. ``"myaddon.edit"``). + label : str + Display label template. Supports ``{name}`` placeholder. + color : str + Hex color for the breadcrumb (e.g. ``"#f38ba8"``). + toolbars : list[str] + Toolbar names to show when this context is active. + match : callable + Zero-argument callable returning *True* when this context is active. + priority : int, optional + Higher values are checked first. Default 50. + """ + if not isinstance(context_id, str): + raise TypeError(f"context_id must be str, got {type(context_id).__name__}") + if not isinstance(toolbars, list): + raise TypeError(f"toolbars must be list, got {type(toolbars).__name__}") + if not callable(match): + raise TypeError("match must be callable") + + try: + _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" + ) + + +def unregister_context(context_id): + """Remove a previously registered editing context.""" + if not isinstance(context_id, str): + raise TypeError(f"context_id must be str, got {type(context_id).__name__}") + + try: + _gui().unregisterEditingContext(context_id) + except Exception as e: + FreeCAD.Console.PrintWarning( + f"kindred_sdk: Failed to unregister context '{context_id}': {e}\n" + ) + + +def register_overlay(overlay_id, toolbars, match): + """Register an editing overlay. + + Overlays add toolbars to whatever context is currently active when + *match* returns True. + + Parameters + ---------- + overlay_id : str + Unique overlay identifier. + toolbars : list[str] + Toolbar names to append. + match : callable + Zero-argument callable returning *True* when the overlay applies. + """ + if not isinstance(overlay_id, str): + raise TypeError(f"overlay_id must be str, got {type(overlay_id).__name__}") + if not isinstance(toolbars, list): + raise TypeError(f"toolbars must be list, got {type(toolbars).__name__}") + if not callable(match): + raise TypeError("match must be callable") + + try: + _gui().registerEditingOverlay(overlay_id, toolbars, match) + except Exception as e: + FreeCAD.Console.PrintWarning( + f"kindred_sdk: Failed to register overlay '{overlay_id}': {e}\n" + ) + + +def unregister_overlay(overlay_id): + """Remove a previously registered editing overlay.""" + if not isinstance(overlay_id, str): + raise TypeError(f"overlay_id must be str, got {type(overlay_id).__name__}") + + try: + _gui().unregisterEditingOverlay(overlay_id) + except Exception as e: + FreeCAD.Console.PrintWarning( + f"kindred_sdk: Failed to unregister overlay '{overlay_id}': {e}\n" + ) + + +def inject_commands(context_id, toolbar_name, commands): + """Inject commands into a context's toolbar. + + Parameters + ---------- + context_id : str + Target context identifier. + toolbar_name : str + Toolbar within that context. + commands : list[str] + Command names to add. + """ + if not isinstance(context_id, str): + raise TypeError(f"context_id must be str, got {type(context_id).__name__}") + if not isinstance(toolbar_name, str): + raise TypeError(f"toolbar_name must be str, got {type(toolbar_name).__name__}") + if not isinstance(commands, list): + raise TypeError(f"commands must be list, got {type(commands).__name__}") + + try: + _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" + ) + + +def current_context(): + """Return the current editing context as a dict. + + Keys: ``id``, ``label``, ``color``, ``toolbars``, ``breadcrumb``, + ``breadcrumbColors``. Returns ``None`` if no context is active. + """ + try: + return _gui().currentEditingContext() + except Exception as e: + 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() + except Exception as e: + FreeCAD.Console.PrintWarning(f"kindred_sdk: Failed to refresh context: {e}\n") diff --git a/mods/sdk/kindred_sdk/dock.py b/mods/sdk/kindred_sdk/dock.py new file mode 100644 index 0000000000..f1e9eb3d24 --- /dev/null +++ b/mods/sdk/kindred_sdk/dock.py @@ -0,0 +1,73 @@ +"""Deferred dock panel registration helper. + +Replaces the manual ``QTimer.singleShot()`` + duplicate-check + +try/except pattern used in ``src/Mod/Create/InitGui.py``. +""" + +import FreeCAD + +_AREA_MAP = { + "left": 1, # Qt.LeftDockWidgetArea + "right": 2, # Qt.RightDockWidgetArea + "top": 4, # Qt.TopDockWidgetArea + "bottom": 8, # Qt.BottomDockWidgetArea +} + + +def register_dock_panel(object_name, title, widget_factory, area="right", delay_ms=0): + """Register a dock panel, optionally deferred. + + Parameters + ---------- + object_name : str + Qt object name for duplicate prevention. + title : str + Dock widget title bar text. + widget_factory : callable + Zero-argument callable returning a ``QWidget``. Called only + when the panel is actually created (after *delay_ms*). + area : str, optional + Dock area: ``"left"``, ``"right"``, ``"top"``, or ``"bottom"``. + Default ``"right"``. + delay_ms : int, optional + Milliseconds to wait before creating the panel. Default 0 + (immediate, but still posted to the event loop). + """ + if not isinstance(object_name, str): + raise TypeError(f"object_name must be str, got {type(object_name).__name__}") + if not callable(widget_factory): + raise TypeError("widget_factory must be callable") + + qt_area = _AREA_MAP.get(area) + if qt_area is None: + raise ValueError(f"area must be one of {list(_AREA_MAP)}, got {area!r}") + + def _create(): + try: + from PySide import QtCore, QtWidgets + + import FreeCADGui + + mw = FreeCADGui.getMainWindow() + if mw is None: + return + + if mw.findChild(QtWidgets.QDockWidget, object_name): + return + + widget = widget_factory() + panel = QtWidgets.QDockWidget(title, mw) + panel.setObjectName(object_name) + panel.setWidget(widget) + mw.addDockWidget(QtCore.Qt.DockWidgetArea(qt_area), panel) + except Exception as e: + FreeCAD.Console.PrintLog(f"kindred_sdk: Dock panel '{object_name}' skipped: {e}\n") + + try: + from PySide.QtCore import QTimer + + QTimer.singleShot(max(0, delay_ms), _create) + except Exception as e: + FreeCAD.Console.PrintLog( + f"kindred_sdk: Could not schedule dock panel '{object_name}': {e}\n" + ) diff --git a/mods/sdk/kindred_sdk/origin.py b/mods/sdk/kindred_sdk/origin.py new file mode 100644 index 0000000000..02bb953c3b --- /dev/null +++ b/mods/sdk/kindred_sdk/origin.py @@ -0,0 +1,42 @@ +"""FileOrigin registration wrappers. + +Wraps ``FreeCADGui.addOrigin()`` / ``removeOrigin()`` with validation +and error handling. Addons implement the FileOrigin duck-typed +interface directly (see Silo's ``SiloOrigin`` for the full contract). +""" + +import FreeCAD + +_REQUIRED_METHODS = ("id", "name", "type", "ownsDocument") + + +def _gui(): + import FreeCADGui + + return FreeCADGui + + +def register_origin(origin): + """Register a FileOrigin with FreeCADGui. + + *origin* must be a Python object implementing at least ``id()``, + ``name()``, ``type()``, and ``ownsDocument(doc)`` methods. + """ + missing = [m for m in _REQUIRED_METHODS if not hasattr(origin, m)] + if missing: + raise TypeError(f"origin is missing required methods: {', '.join(missing)}") + + try: + _gui().addOrigin(origin) + FreeCAD.Console.PrintLog(f"kindred_sdk: Registered origin '{origin.id()}'\n") + except Exception as e: + FreeCAD.Console.PrintWarning(f"kindred_sdk: Failed to register origin: {e}\n") + + +def unregister_origin(origin): + """Remove a previously registered FileOrigin.""" + try: + _gui().removeOrigin(origin) + FreeCAD.Console.PrintLog(f"kindred_sdk: Unregistered origin '{origin.id()}'\n") + except Exception as e: + FreeCAD.Console.PrintWarning(f"kindred_sdk: Failed to unregister origin: {e}\n") diff --git a/mods/sdk/kindred_sdk/palettes/catppuccin-mocha.yaml b/mods/sdk/kindred_sdk/palettes/catppuccin-mocha.yaml new file mode 100644 index 0000000000..490f8fecf9 --- /dev/null +++ b/mods/sdk/kindred_sdk/palettes/catppuccin-mocha.yaml @@ -0,0 +1,46 @@ +name: Catppuccin Mocha +slug: catppuccin-mocha + +colors: + rosewater: "#f5e0dc" + flamingo: "#f2cdcd" + pink: "#f5c2e7" + mauve: "#cba6f7" + red: "#f38ba8" + maroon: "#eba0ac" + peach: "#fab387" + yellow: "#f9e2af" + green: "#a6e3a1" + teal: "#94e2d5" + sky: "#89dceb" + sapphire: "#74c7ec" + blue: "#89b4fa" + lavender: "#b4befe" + text: "#cdd6f4" + subtext1: "#bac2de" + subtext0: "#a6adc8" + overlay2: "#9399b2" + overlay1: "#7f849c" + overlay0: "#6c7086" + surface2: "#585b70" + surface1: "#45475a" + surface0: "#313244" + base: "#1e1e2e" + mantle: "#181825" + crust: "#11111b" + +roles: + background: base + background.toolbar: mantle + background.darkest: crust + foreground: text + foreground.muted: subtext0 + foreground.subtle: overlay1 + accent.primary: mauve + accent.info: blue + accent.success: green + accent.warning: yellow + accent.error: red + border: surface1 + selection: surface2 + input.background: surface0 diff --git a/mods/sdk/kindred_sdk/theme.py b/mods/sdk/kindred_sdk/theme.py new file mode 100644 index 0000000000..e554903260 --- /dev/null +++ b/mods/sdk/kindred_sdk/theme.py @@ -0,0 +1,181 @@ +"""YAML-driven theme system. + +Loads color palettes from YAML files and provides runtime access to +color tokens, semantic roles, QSS template formatting, and FreeCAD +preference-pack value conversion. +""" + +import os +import re + +import FreeCAD + + +class Palette: + """A loaded color palette with raw tokens and semantic roles.""" + + def __init__(self, name, slug, colors, roles): + self.name = name + self.slug = slug + self.colors = dict(colors) + self.roles = {k: colors[v] for k, v in roles.items() if v in colors} + + def get(self, key): + """Look up a color by role first, then by raw color name. + + Returns the hex string or *None* if not found. + """ + return self.roles.get(key) or self.colors.get(key) + + @staticmethod + def hex_to_rgba_uint(hex_color): + """Convert ``#RRGGBB`` to FreeCAD's unsigned-int RGBA format. + + >>> Palette.hex_to_rgba_uint("#cdd6f4") + 3453416703 + """ + h = hex_color.lstrip("#") + r = int(h[0:2], 16) + g = int(h[2:4], 16) + b = int(h[4:6], 16) + a = 255 + return (r << 24) | (g << 16) | (b << 8) | a + + def format_qss(self, template): + """Substitute ``{token}`` placeholders in a QSS template string. + + Both raw color names (``{blue}``) and dotted role names + (``{accent.primary}``) are supported. Dotted names are tried + first so they take precedence over any same-named color. + + Unknown tokens are left as-is. + """ + lookup = {} + lookup.update(self.colors) + # Roles use dotted names which aren't valid Python identifiers, + # so we do regex-based substitution. + lookup.update(self.roles) + + def _replace(m): + key = m.group(1) + return lookup.get(key, m.group(0)) + + return re.sub(r"\{([a-z][a-z0-9_.]*)\}", _replace, template) + + def __repr__(self): + return f"Palette({self.name!r}, {len(self.colors)} colors, {len(self.roles)} roles)" + + +# --------------------------------------------------------------------------- +# YAML loading with fallback +# --------------------------------------------------------------------------- + + +def _load_yaml(path): + """Load a YAML file, preferring PyYAML if available.""" + try: + import yaml + + with open(path) as f: + return yaml.safe_load(f) + except ImportError: + return _load_yaml_fallback(path) + + +def _load_yaml_fallback(path): + """Minimal YAML parser for flat key-value palette files. + + Handles the subset of YAML used by palette files: top-level keys + with string/scalar values, and one level of nested mappings. + """ + data = {} + current_section = None + + with open(path) as f: + for line in f: + stripped = line.rstrip() + + # Skip blank lines and comments + if not stripped or stripped.startswith("#"): + continue + + # Detect indentation + indent = len(line) - len(line.lstrip()) + + # Top-level key + if indent == 0 and ":" in stripped: + key, _, value = stripped.partition(":") + key = key.strip() + value = value.strip().strip('"').strip("'") + if value: + data[key] = value + current_section = None + else: + # Start of a nested section + current_section = key + data[current_section] = {} + continue + + # Nested key (indented) + if current_section is not None and indent > 0 and ":" in stripped: + key, _, value = stripped.partition(":") + key = key.strip() + value = value.strip().strip('"').strip("'") + data[current_section][key] = value + + return data + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +_cache = {} + +_PALETTES_DIR = os.path.join(os.path.dirname(__file__), "palettes") + + +def load_palette(name="catppuccin-mocha"): + """Load a named palette from the ``palettes/`` directory. + + Results are cached; subsequent calls with the same *name* return + the same ``Palette`` instance. + """ + if name in _cache: + return _cache[name] + + path = os.path.join(_PALETTES_DIR, f"{name}.yaml") + if not os.path.isfile(path): + FreeCAD.Console.PrintWarning(f"kindred_sdk: Palette file not found: {path}\n") + return None + + try: + raw = _load_yaml(path) + except Exception as e: + FreeCAD.Console.PrintWarning(f"kindred_sdk: Failed to load palette '{name}': {e}\n") + return None + + palette = Palette( + name=raw.get("name", name), + slug=raw.get("slug", name), + colors=raw.get("colors", {}), + roles=raw.get("roles", {}), + ) + _cache[name] = palette + + FreeCAD.Console.PrintLog( + f"kindred_sdk: Loaded palette '{palette.name}' ({len(palette.colors)} colors)\n" + ) + return palette + + +def get_theme_tokens(name="catppuccin-mocha"): + """Return a dict of ``{token_name: "#hex"}`` for all colors in a palette. + + This is a convenience shorthand for ``load_palette(name).colors``. + Returns a copy so callers cannot mutate the cached palette. + """ + palette = load_palette(name) + if palette is None: + return {} + return dict(palette.colors) diff --git a/mods/sdk/kindred_sdk/version.py b/mods/sdk/kindred_sdk/version.py new file mode 100644 index 0000000000..597e21ba21 --- /dev/null +++ b/mods/sdk/kindred_sdk/version.py @@ -0,0 +1 @@ +SDK_VERSION = "0.1.0" diff --git a/mods/sdk/package.xml b/mods/sdk/package.xml new file mode 100644 index 0000000000..1577f8702b --- /dev/null +++ b/mods/sdk/package.xml @@ -0,0 +1,23 @@ + + + + sdk + Kindred Create addon SDK - stable API for addon integration + 0.1.0 + Kindred Systems + LGPL-2.1-or-later + + + + SdkWorkbench + ./ + + + + + 0.1.0 + 0 + true + + + diff --git a/src/Mod/Create/CMakeLists.txt b/src/Mod/Create/CMakeLists.txt index af2bcab4f3..ab610853cd 100644 --- a/src/Mod/Create/CMakeLists.txt +++ b/src/Mod/Create/CMakeLists.txt @@ -54,3 +54,19 @@ install( DESTINATION mods/silo/silo-client ) + +# Install SDK +install( + DIRECTORY + ${CMAKE_SOURCE_DIR}/mods/sdk/kindred_sdk + DESTINATION + mods/sdk +) +install( + FILES + ${CMAKE_SOURCE_DIR}/mods/sdk/package.xml + ${CMAKE_SOURCE_DIR}/mods/sdk/Init.py + ${CMAKE_SOURCE_DIR}/mods/sdk/InitGui.py + DESTINATION + mods/sdk +)