Merge pull request 'feat(addon-system): create kindred-addon-sdk package (#249)' (#259) from feat/kindred-addon-sdk into main
Some checks failed
Build and Test / build (push) Failing after 1m37s

Reviewed-on: #259
This commit was merged in pull request #259.
This commit is contained in:
2026-02-17 14:49:32 +00:00
12 changed files with 595 additions and 0 deletions

3
mods/sdk/Init.py Normal file
View File

@@ -0,0 +1,3 @@
import FreeCAD
FreeCAD.Console.PrintLog("kindred-addon-sdk loaded\n")

3
mods/sdk/InitGui.py Normal file
View File

@@ -0,0 +1,3 @@
import FreeCAD
FreeCAD.Console.PrintLog("kindred-addon-sdk GUI initialized\n")

View File

@@ -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",
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
SDK_VERSION = "0.1.0"

23
mods/sdk/package.xml Normal file
View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<package format="1" xmlns="https://wiki.freecad.org/Package_Metadata">
<name>sdk</name>
<description>Kindred Create addon SDK - stable API for addon integration</description>
<version>0.1.0</version>
<maintainer email="info@kindredsystems.io">Kindred Systems</maintainer>
<license file="LICENSE">LGPL-2.1-or-later</license>
<content>
<workbench>
<classname>SdkWorkbench</classname>
<subdirectory>./</subdirectory>
</workbench>
</content>
<kindred>
<min_create_version>0.1.0</min_create_version>
<load_priority>0</load_priority>
<pure_python>true</pure_python>
</kindred>
</package>

View File

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