Compare commits

..

1 Commits

Author SHA1 Message Date
4d13313e42 fix(origin): return absolute icon path from SiloOrigin.icon()
The icon() method returned bare 'silo' which BitmapFactory could not
resolve. Return the absolute path to silo.svg so the C++ side can load
the icon directly.
2026-02-12 13:40:56 -06:00
19 changed files with 220 additions and 2940 deletions

View File

@@ -1,7 +0,0 @@
forbes <contact@kindred-systems.com> forbes <joseph.forbes@kindred-systems.com>
forbes <contact@kindred-systems.com> forbes <zoe.forbes@kindred-systems.com>
forbes <contact@kindred-systems.com> forbes-0023 <joseph.forbes@kindred-systems.com>
forbes <contact@kindred-systems.com> forbes-0023 <zoe.forbes@kindred-systems.com>
forbes <contact@kindred-systems.com> josephforbes23 <joseph.forbes@kindred-systems.com>
forbes <contact@kindred-systems.com> Zoe Forbes <forbes@copernicus-9.kindred.internal>
forbes <contact@kindred-systems.com> admin <admin@kindred-systems.com>

13
Init.py
View File

@@ -1,13 +0,0 @@
"""Silo addon — console initialization.
Adds the shared silo-client package to sys.path so that
``import silo_client`` works from silo_commands.py and other modules.
"""
import os
import sys
_mod_dir = os.path.dirname(os.path.abspath(__file__))
_client_dir = os.path.join(_mod_dir, "silo-client")
if os.path.isdir(_client_dir) and _client_dir not in sys.path:
sys.path.insert(0, _client_dir)

View File

@@ -1,265 +0,0 @@
"""Kindred Silo addon — GUI initialization.
Registers the SiloWorkbench, Silo file origin, overlay context,
dock panels (auth + activity), document observer, and start page override.
"""
import os
import FreeCAD
import FreeCADGui
FreeCAD.Console.PrintMessage("Kindred Silo InitGui.py loading...\n")
# ---------------------------------------------------------------------------
# Workbench
# ---------------------------------------------------------------------------
class SiloWorkbench(FreeCADGui.Workbench):
"""Kindred Silo workbench for item database integration."""
MenuText = "Kindred Silo"
ToolTip = "Item database and part management for Kindred Create"
Icon = ""
def __init__(self):
icon_path = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"freecad",
"resources",
"icons",
"silo.svg",
)
if os.path.exists(icon_path):
self.__class__.Icon = icon_path
def Initialize(self):
"""Called when workbench is first activated."""
import silo_commands
# Register Silo as a file origin in the unified origin system
try:
import silo_origin
silo_origin.register_silo_origin()
except Exception as e:
FreeCAD.Console.PrintWarning(f"Could not register Silo origin: {e}\n")
# Silo origin toolbar — shown as an overlay on any context when the
# active document is Silo-tracked. Registered as Unavailable so
# EditingContextResolver controls visibility via the overlay system.
self.silo_toolbar_commands = [
"Silo_Commit",
"Silo_Pull",
"Silo_Push",
"Separator",
"Silo_Info",
"Silo_BOM",
"Silo_Jobs",
]
self.appendToolbar("Silo Origin", self.silo_toolbar_commands, "Unavailable")
# Silo menu provides admin/management commands.
self.menu_commands = [
"Silo_Info",
"Silo_BOM",
"Silo_Jobs",
"Silo_TagProjects",
"Silo_SetStatus",
"Silo_Rollback",
"Silo_SaveAsTemplate",
"Separator",
"Silo_Settings",
"Silo_Auth",
"Silo_Runners",
"Silo_StartPanel",
"Silo_Diag",
]
self.appendMenu("Silo", self.menu_commands)
def Activated(self):
"""Called when workbench is activated."""
FreeCAD.Console.PrintMessage("Kindred Silo workbench activated\n")
FreeCADGui.runCommand("Silo_StartPanel", 0)
def Deactivated(self):
pass
def GetClassName(self):
return "Gui::PythonWorkbench"
FreeCADGui.addWorkbench(SiloWorkbench())
FreeCAD.Console.PrintMessage("Silo workbench registered\n")
# ---------------------------------------------------------------------------
# Silo overlay context — adds "Silo Origin" toolbar to any active context
# when the current document is Silo-tracked.
# ---------------------------------------------------------------------------
def _register_silo_overlay():
"""Register the Silo overlay after the Silo workbench has initialised."""
def _silo_overlay_match():
"""Return True if the active document is Silo-tracked."""
try:
doc = FreeCAD.ActiveDocument
if not doc:
return False
from silo_origin import get_silo_origin
origin = get_silo_origin()
return origin.ownsDocument(doc)
except Exception:
return False
try:
from kindred_sdk import register_overlay
register_overlay(
"silo", # overlay id
["Silo Origin"], # toolbar names to append
_silo_overlay_match, # match function
)
except Exception as e:
FreeCAD.Console.PrintWarning(f"Silo overlay registration failed: {e}\n")
# ---------------------------------------------------------------------------
# Document observer — builds the Silo metadata tree when .kc files open.
# ---------------------------------------------------------------------------
def _register_silo_document_observer():
"""Register the Silo document observer for .kc tree building."""
try:
import silo_document
silo_document.register()
except Exception as e:
FreeCAD.Console.PrintLog(f"Silo: document observer registration skipped: {e}\n")
# ---------------------------------------------------------------------------
# Dock panels — auth and activity widgets via SDK
# ---------------------------------------------------------------------------
def _setup_silo_auth_panel():
"""Dock the Silo authentication panel in the right-hand side panel."""
try:
from kindred_sdk import register_dock_panel
def _factory():
import silo_commands
auth = silo_commands.SiloAuthDockWidget()
# Prevent GC of the auth timer by stashing on the widget
auth.widget._auth = auth
return auth.widget
register_dock_panel("SiloDatabaseAuth", "Database Auth", _factory)
except Exception as e:
FreeCAD.Console.PrintLog(f"Silo: auth panel skipped: {e}\n")
def _setup_silo_activity_panel():
"""Show a dock widget with recent Silo database activity."""
try:
from kindred_sdk import register_dock_panel
def _factory():
from PySide import QtWidgets
widget = QtWidgets.QWidget()
layout = QtWidgets.QVBoxLayout(widget)
activity_list = QtWidgets.QListWidget()
layout.addWidget(activity_list)
try:
import silo_commands
items = silo_commands._client.list_items()
if isinstance(items, list):
for item in items[:20]:
pn = item.get("part_number", "")
desc = item.get("description", "")
updated = item.get("updated_at", "")
if updated:
updated = updated[:10]
activity_list.addItem(f"{pn} - {desc} - {updated}")
if activity_list.count() == 0:
activity_list.addItem("(No items in database)")
except Exception:
activity_list.addItem("(Unable to connect to Silo database)")
return widget
register_dock_panel("SiloDatabaseActivity", "Database Activity", _factory)
except Exception as e:
FreeCAD.Console.PrintLog(f"Silo: activity panel skipped: {e}\n")
# ---------------------------------------------------------------------------
# First-start check
# ---------------------------------------------------------------------------
def _check_silo_first_start():
"""Show Silo settings dialog on first startup if not yet configured."""
try:
param = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Mod/KindredSilo")
if not param.GetBool("FirstStartChecked", False):
param.SetBool("FirstStartChecked", True)
if not param.GetString("ApiUrl", ""):
FreeCADGui.runCommand("Silo_Settings")
except Exception as e:
FreeCAD.Console.PrintLog(f"Silo: first-start check skipped: {e}\n")
# ---------------------------------------------------------------------------
# Start page override — must happen before the C++ StartLauncher fires
# at ~100ms after GUI init.
# ---------------------------------------------------------------------------
try:
import silo_start
silo_start.register()
except Exception as e:
FreeCAD.Console.PrintWarning(f"Silo Start page override failed: {e}\n")
# ---------------------------------------------------------------------------
# Handle kindred:// URLs passed as command-line arguments on cold start.
# ---------------------------------------------------------------------------
def _handle_startup_urls():
"""Process any kindred:// URLs passed as command-line arguments."""
import sys
from silo_commands import handle_kindred_url
for arg in sys.argv[1:]:
if arg.startswith("kindred://"):
handle_kindred_url(arg)
# ---------------------------------------------------------------------------
# Deferred setup — staggered timers for non-blocking startup
# ---------------------------------------------------------------------------
from PySide import QtCore as _QtCore
_QtCore.QTimer.singleShot(500, _handle_startup_urls)
_QtCore.QTimer.singleShot(600, _register_silo_document_observer)
_QtCore.QTimer.singleShot(2000, _setup_silo_auth_panel)
_QtCore.QTimer.singleShot(2500, _register_silo_overlay)
_QtCore.QTimer.singleShot(3000, _check_silo_first_start)
_QtCore.QTimer.singleShot(4000, _setup_silo_activity_panel)

View File

@@ -45,7 +45,6 @@ class SiloWorkbench(FreeCADGui.Workbench):
"Separator",
"Silo_Info",
"Silo_BOM",
"Silo_Jobs",
]
self.appendToolbar("Silo Origin", self.silo_toolbar_commands, "Unavailable")
@@ -53,15 +52,12 @@ class SiloWorkbench(FreeCADGui.Workbench):
self.menu_commands = [
"Silo_Info",
"Silo_BOM",
"Silo_Jobs",
"Silo_TagProjects",
"Silo_SetStatus",
"Silo_Rollback",
"Silo_SaveAsTemplate",
"Separator",
"Silo_Settings",
"Silo_Auth",
"Silo_Runners",
"Silo_StartPanel",
"Silo_Diag",
]
@@ -107,9 +103,7 @@ def _register_silo_overlay():
return False
try:
from kindred_sdk import register_overlay
register_overlay(
FreeCADGui.registerEditingOverlay(
"silo", # overlay id
["Silo Origin"], # toolbar names to append
_silo_overlay_match, # match function

View File

@@ -1,317 +0,0 @@
"""BOM extraction engine for FreeCAD Assembly documents.
Extracts cross-document ``App::Link`` components from an Assembly,
resolves Silo UUIDs to part numbers, diffs against the server BOM,
and applies adds/quantity updates via individual API calls.
No GUI dependencies -- usable in both desktop and headless mode.
Public API
----------
extract_bom_entries(doc) -> List[BomEntry]
resolve_entries(entries, client) -> (resolved, unresolved)
diff_bom(local_entries, remote_entries) -> BomDiff
apply_bom_diff(diff, parent_pn, client) -> BomResult
sync_bom_after_upload(doc, part_number, client) -> Optional[BomResult]
"""
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple
import FreeCAD
# ---------------------------------------------------------------------------
# Data structures
# ---------------------------------------------------------------------------
@dataclass
class BomEntry:
"""A single BOM line extracted from an Assembly."""
silo_item_id: Optional[str] # UUID from SiloItemId property
part_number: Optional[str] # resolved via get_item_by_uuid()
label: str # FreeCAD Label of the linked object
doc_path: str # FileName of the linked document
quantity: int # summed from ElementCount + individual links
consolidation_warning: bool = False # multiple individual links to same source
@dataclass
class BomDiff:
"""Result of diffing local assembly BOM against server BOM."""
added: List[Dict[str, Any]] = field(default_factory=list)
removed: List[Dict[str, Any]] = field(default_factory=list)
quantity_changed: List[Dict[str, Any]] = field(default_factory=list)
unchanged: List[Dict[str, Any]] = field(default_factory=list)
@dataclass
class BomResult:
"""Summary of a BOM sync operation."""
added_count: int = 0
updated_count: int = 0
unreferenced_count: int = 0
unresolved_count: int = 0
errors: List[str] = field(default_factory=list)
# ---------------------------------------------------------------------------
# Assembly detection helpers
# ---------------------------------------------------------------------------
def _find_assembly(doc):
"""Return the first ``Assembly::AssemblyObject`` in *doc*, or ``None``."""
for obj in doc.Objects:
if obj.isDerivedFrom("Assembly::AssemblyObject"):
return obj
return None
def _get_silo_item_id(doc):
"""Read ``SiloItemId`` from the tracked object in *doc*.
Returns ``None`` if the document has no tracked object or no UUID.
"""
for obj in doc.Objects:
if hasattr(obj, "SiloItemId") and obj.SiloItemId:
return obj.SiloItemId
return None
def _link_count(link_obj) -> int:
"""Return the instance count for an ``App::Link``.
``ElementCount > 0`` means link array; otherwise single link (qty 1).
"""
element_count = getattr(link_obj, "ElementCount", 0)
return element_count if element_count > 0 else 1
# ---------------------------------------------------------------------------
# Extraction
# ---------------------------------------------------------------------------
def extract_bom_entries(doc) -> List[BomEntry]:
"""Walk the Assembly in *doc* and collect cross-document link entries.
Returns an empty list if the document has no Assembly or no
cross-document links. Only first-level children are extracted;
sub-assemblies commit their own BOMs separately.
"""
assembly = _find_assembly(doc)
if assembly is None:
return []
# Key by linked document path to merge duplicates.
entries: Dict[str, BomEntry] = {}
for obj in assembly.Group:
# Accept App::Link (single or array) and App::LinkElement.
if not (
obj.isDerivedFrom("App::Link") or obj.isDerivedFrom("App::LinkElement")
):
continue
linked = obj.getLinkedObject()
if linked is None:
continue
# Skip in-document links (construction / layout geometry).
if linked.Document == doc:
continue
linked_doc = linked.Document
doc_path = linked_doc.FileName or linked_doc.Name
qty = _link_count(obj)
if doc_path in entries:
entries[doc_path].quantity += qty
entries[doc_path].consolidation_warning = True
else:
entries[doc_path] = BomEntry(
silo_item_id=_get_silo_item_id(linked_doc),
part_number=None,
label=linked.Label,
doc_path=doc_path,
quantity=qty,
)
return list(entries.values())
# ---------------------------------------------------------------------------
# Resolution
# ---------------------------------------------------------------------------
def resolve_entries(
entries: List[BomEntry], client
) -> Tuple[List[BomEntry], List[BomEntry]]:
"""Resolve ``SiloItemId`` UUIDs to part numbers via the API.
Returns ``(resolved, unresolved)``. Unresolved entries have no
``SiloItemId`` or the UUID lookup failed.
"""
resolved: List[BomEntry] = []
unresolved: List[BomEntry] = []
for entry in entries:
if not entry.silo_item_id:
unresolved.append(entry)
continue
try:
item = client.get_item_by_uuid(entry.silo_item_id)
entry.part_number = item["part_number"]
resolved.append(entry)
except Exception:
unresolved.append(entry)
return resolved, unresolved
# ---------------------------------------------------------------------------
# Diff
# ---------------------------------------------------------------------------
def diff_bom(
local_entries: List[BomEntry],
remote_entries: List[Dict[str, Any]],
) -> BomDiff:
"""Diff local assembly BOM against server BOM.
*local_entries*: resolved ``BomEntry`` list.
*remote_entries*: raw dicts from ``client.get_bom()`` with keys
``child_part_number`` and ``quantity``.
"""
local_map = {e.part_number: e.quantity for e in local_entries}
remote_map = {e["child_part_number"]: e.get("quantity", 1) for e in remote_entries}
diff = BomDiff()
for pn, qty in local_map.items():
if pn not in remote_map:
diff.added.append({"part_number": pn, "quantity": qty})
elif remote_map[pn] != qty:
diff.quantity_changed.append(
{
"part_number": pn,
"local_quantity": qty,
"remote_quantity": remote_map[pn],
}
)
else:
diff.unchanged.append({"part_number": pn, "quantity": qty})
for pn, qty in remote_map.items():
if pn not in local_map:
diff.removed.append({"part_number": pn, "quantity": qty})
return diff
# ---------------------------------------------------------------------------
# Apply
# ---------------------------------------------------------------------------
def apply_bom_diff(diff: BomDiff, parent_pn: str, client) -> BomResult:
"""Apply adds and quantity updates to the server BOM.
Uses individual CRUD calls (Phase 1 fallback). Phase 2 replaces
this with a single ``POST /items/{pn}/bom/merge`` call.
Removed entries are NEVER deleted -- only warned about.
Each call is individually wrapped so one failure does not block others.
"""
result = BomResult()
for entry in diff.added:
try:
client.add_bom_entry(
parent_pn,
entry["part_number"],
quantity=entry["quantity"],
rel_type="component",
)
result.added_count += 1
except Exception as e:
result.errors.append(f"add {entry['part_number']}: {e}")
for entry in diff.quantity_changed:
try:
client.update_bom_entry(
parent_pn,
entry["part_number"],
quantity=entry["local_quantity"],
)
result.updated_count += 1
except Exception as e:
result.errors.append(f"update {entry['part_number']}: {e}")
result.unreferenced_count = len(diff.removed)
if diff.removed:
pns = ", ".join(e["part_number"] for e in diff.removed)
FreeCAD.Console.PrintWarning(
f"BOM sync: {result.unreferenced_count} server entries "
f"not in assembly (not deleted): {pns}\n"
)
return result
# ---------------------------------------------------------------------------
# Top-level orchestrator
# ---------------------------------------------------------------------------
def sync_bom_after_upload(doc, part_number: str, client) -> Optional[BomResult]:
"""Full BOM sync pipeline: extract, resolve, diff, apply.
Returns ``None`` if *doc* is not an assembly or has no cross-document
links. Returns a ``BomResult`` with summary counts otherwise.
"""
entries = extract_bom_entries(doc)
if not entries:
return None
resolved, unresolved = resolve_entries(entries, client)
# Log consolidation warnings.
for entry in entries:
if entry.consolidation_warning:
FreeCAD.Console.PrintWarning(
f"BOM sync: {entry.label} ({entry.doc_path}) has multiple "
f"individual links. Consider using a link array "
f"(ElementCount) for cleaner assembly management.\n"
)
# Log unresolved components.
for entry in unresolved:
FreeCAD.Console.PrintWarning(
f"BOM sync: {entry.label} ({entry.doc_path}) has no Silo "
f"part number -- excluded from BOM.\n"
)
if not resolved:
result = BomResult()
result.unresolved_count = len(unresolved)
return result
# Fetch current server BOM.
try:
remote = client.get_bom(part_number)
except Exception:
remote = []
diff = diff_bom(resolved, remote)
result = apply_bom_diff(diff, part_number, client)
result.unresolved_count = len(unresolved)
return result

View File

@@ -1,463 +0,0 @@
"""DAG extraction engine for FreeCAD documents.
Extracts the feature tree from a FreeCAD document as nodes and edges
for syncing to the Silo server. No GUI dependencies -- usable in both
desktop and headless (``--console``) mode.
Public API
----------
classify_type(type_id) -> Optional[str]
compute_properties_hash(obj) -> str
extract_dag(doc) -> (nodes, edges)
"""
import hashlib
import json
import math
from typing import Any, Dict, List, Optional, Set, Tuple
# ---------------------------------------------------------------------------
# TypeId -> DAG node type mapping
# ---------------------------------------------------------------------------
_TYPE_MAP: Dict[str, str] = {
# Sketch
"Sketcher::SketchObject": "sketch",
# Sketch-based additive
"PartDesign::Pad": "pad",
"PartDesign::Revolution": "pad",
"PartDesign::AdditivePipe": "pad",
"PartDesign::AdditiveLoft": "pad",
"PartDesign::AdditiveHelix": "pad",
# Sketch-based subtractive
"PartDesign::Pocket": "pocket",
"PartDesign::Groove": "pocket",
"PartDesign::Hole": "pocket",
"PartDesign::SubtractivePipe": "pocket",
"PartDesign::SubtractiveLoft": "pocket",
"PartDesign::SubtractiveHelix": "pocket",
# Dress-up
"PartDesign::Fillet": "fillet",
"PartDesign::Chamfer": "chamfer",
"PartDesign::Draft": "chamfer",
"PartDesign::Thickness": "chamfer",
# Transformations
"PartDesign::Mirrored": "pad",
"PartDesign::LinearPattern": "pad",
"PartDesign::PolarPattern": "pad",
"PartDesign::Scaled": "pad",
"PartDesign::MultiTransform": "pad",
# Boolean
"PartDesign::Boolean": "pad",
# Additive primitives
"PartDesign::AdditiveBox": "pad",
"PartDesign::AdditiveCylinder": "pad",
"PartDesign::AdditiveSphere": "pad",
"PartDesign::AdditiveCone": "pad",
"PartDesign::AdditiveEllipsoid": "pad",
"PartDesign::AdditiveTorus": "pad",
"PartDesign::AdditivePrism": "pad",
"PartDesign::AdditiveWedge": "pad",
# Subtractive primitives
"PartDesign::SubtractiveBox": "pocket",
"PartDesign::SubtractiveCylinder": "pocket",
"PartDesign::SubtractiveSphere": "pocket",
"PartDesign::SubtractiveCone": "pocket",
"PartDesign::SubtractiveEllipsoid": "pocket",
"PartDesign::SubtractiveTorus": "pocket",
"PartDesign::SubtractivePrism": "pocket",
"PartDesign::SubtractiveWedge": "pocket",
# Containers
"PartDesign::Body": "body",
"App::Part": "part",
"Part::Feature": "part",
# Datum / reference
"PartDesign::Point": "datum",
"PartDesign::Line": "datum",
"PartDesign::Plane": "datum",
"PartDesign::CoordinateSystem": "datum",
"PartDesign::ShapeBinder": "datum",
"PartDesign::SubShapeBinder": "datum",
}
def classify_type(type_id: str) -> Optional[str]:
"""Map a FreeCAD TypeId string to a DAG node type.
Returns one of ``sketch``, ``pad``, ``pocket``, ``fillet``,
``chamfer``, ``body``, ``part``, ``datum``, or ``None`` if the
TypeId is not a recognized feature.
"""
return _TYPE_MAP.get(type_id)
# ---------------------------------------------------------------------------
# Properties hash
# ---------------------------------------------------------------------------
def _safe_float(val: Any) -> Any:
"""Convert a float to a JSON-safe value, replacing NaN/Infinity with 0."""
if isinstance(val, float) and (math.isnan(val) or math.isinf(val)):
return 0.0
return val
def _prop_value(obj: Any, name: str) -> Any:
"""Safely read ``obj.<name>.Value``, returning *None* on failure."""
try:
return _safe_float(getattr(obj, name).Value)
except Exception:
return None
def _prop_raw(obj: Any, name: str) -> Any:
"""Safely read ``obj.<name>``, returning *None* on failure."""
try:
return getattr(obj, name)
except Exception:
return None
def _link_name(obj: Any, name: str) -> Optional[str]:
"""Return the ``.Name`` of a linked object property, or *None*."""
try:
link = getattr(obj, name)
if isinstance(link, (list, tuple)):
link = link[0]
return link.Name if link is not None else None
except Exception:
return None
def _collect_inputs(obj: Any) -> Dict[str, Any]:
"""Extract the parametric inputs that affect *obj*'s geometry.
The returned dict is JSON-serialized and hashed to produce the
``properties_hash`` for the DAG node.
"""
tid = obj.TypeId
inputs: Dict[str, Any] = {"_type": tid}
# --- Sketch ---
if tid == "Sketcher::SketchObject":
inputs["geometry_count"] = _prop_raw(obj, "GeometryCount")
inputs["constraint_count"] = _prop_raw(obj, "ConstraintCount")
try:
inputs["geometry"] = obj.Shape.exportBrepToString()
except Exception:
pass
return inputs
# --- Extrude (Pad / Pocket) ---
if tid in ("PartDesign::Pad", "PartDesign::Pocket"):
inputs["length"] = _prop_value(obj, "Length")
inputs["type"] = str(_prop_raw(obj, "Type") or "")
inputs["reversed"] = _prop_raw(obj, "Reversed")
inputs["sketch"] = _link_name(obj, "Profile")
return inputs
# --- Revolution / Groove ---
if tid in ("PartDesign::Revolution", "PartDesign::Groove"):
inputs["angle"] = _prop_value(obj, "Angle")
inputs["type"] = str(_prop_raw(obj, "Type") or "")
inputs["reversed"] = _prop_raw(obj, "Reversed")
inputs["sketch"] = _link_name(obj, "Profile")
return inputs
# --- Hole ---
if tid == "PartDesign::Hole":
inputs["diameter"] = _prop_value(obj, "Diameter")
inputs["depth"] = _prop_value(obj, "Depth")
inputs["threaded"] = _prop_raw(obj, "Threaded")
inputs["thread_type"] = str(_prop_raw(obj, "ThreadType") or "")
inputs["depth_type"] = str(_prop_raw(obj, "DepthType") or "")
inputs["sketch"] = _link_name(obj, "Profile")
return inputs
# --- Pipe / Loft / Helix (additive + subtractive) ---
if tid in (
"PartDesign::AdditivePipe",
"PartDesign::SubtractivePipe",
"PartDesign::AdditiveLoft",
"PartDesign::SubtractiveLoft",
"PartDesign::AdditiveHelix",
"PartDesign::SubtractiveHelix",
):
inputs["sketch"] = _link_name(obj, "Profile")
inputs["spine"] = _link_name(obj, "Spine")
return inputs
# --- Fillet ---
if tid == "PartDesign::Fillet":
inputs["radius"] = _prop_value(obj, "Radius")
return inputs
# --- Chamfer ---
if tid == "PartDesign::Chamfer":
inputs["chamfer_type"] = str(_prop_raw(obj, "ChamferType") or "")
inputs["size"] = _prop_value(obj, "Size")
inputs["size2"] = _prop_value(obj, "Size2")
inputs["angle"] = _prop_value(obj, "Angle")
return inputs
# --- Draft ---
if tid == "PartDesign::Draft":
inputs["angle"] = _prop_value(obj, "Angle")
inputs["reversed"] = _prop_raw(obj, "Reversed")
return inputs
# --- Thickness ---
if tid == "PartDesign::Thickness":
inputs["value"] = _prop_value(obj, "Value")
inputs["reversed"] = _prop_raw(obj, "Reversed")
inputs["mode"] = str(_prop_raw(obj, "Mode") or "")
inputs["join"] = str(_prop_raw(obj, "Join") or "")
return inputs
# --- Mirrored ---
if tid == "PartDesign::Mirrored":
inputs["mirror_plane"] = _link_name(obj, "MirrorPlane")
return inputs
# --- LinearPattern ---
if tid == "PartDesign::LinearPattern":
inputs["direction"] = _link_name(obj, "Direction")
inputs["reversed"] = _prop_raw(obj, "Reversed")
inputs["length"] = _prop_value(obj, "Length")
inputs["occurrences"] = _prop_value(obj, "Occurrences")
return inputs
# --- PolarPattern ---
if tid == "PartDesign::PolarPattern":
inputs["axis"] = _link_name(obj, "Axis")
inputs["reversed"] = _prop_raw(obj, "Reversed")
inputs["angle"] = _prop_value(obj, "Angle")
inputs["occurrences"] = _prop_value(obj, "Occurrences")
return inputs
# --- Scaled ---
if tid == "PartDesign::Scaled":
inputs["factor"] = _prop_value(obj, "Factor")
inputs["occurrences"] = _prop_value(obj, "Occurrences")
return inputs
# --- MultiTransform ---
if tid == "PartDesign::MultiTransform":
try:
inputs["transform_count"] = len(obj.Transformations)
except Exception:
pass
return inputs
# --- Boolean ---
if tid == "PartDesign::Boolean":
inputs["type"] = str(_prop_raw(obj, "Type") or "")
return inputs
# --- Primitives (additive) ---
if tid in (
"PartDesign::AdditiveBox",
"PartDesign::SubtractiveBox",
):
inputs["length"] = _prop_value(obj, "Length")
inputs["width"] = _prop_value(obj, "Width")
inputs["height"] = _prop_value(obj, "Height")
return inputs
if tid in (
"PartDesign::AdditiveCylinder",
"PartDesign::SubtractiveCylinder",
):
inputs["radius"] = _prop_value(obj, "Radius")
inputs["height"] = _prop_value(obj, "Height")
inputs["angle"] = _prop_value(obj, "Angle")
return inputs
if tid in (
"PartDesign::AdditiveSphere",
"PartDesign::SubtractiveSphere",
):
inputs["radius"] = _prop_value(obj, "Radius")
return inputs
if tid in (
"PartDesign::AdditiveCone",
"PartDesign::SubtractiveCone",
):
inputs["radius1"] = _prop_value(obj, "Radius1")
inputs["radius2"] = _prop_value(obj, "Radius2")
inputs["height"] = _prop_value(obj, "Height")
return inputs
if tid in (
"PartDesign::AdditiveEllipsoid",
"PartDesign::SubtractiveEllipsoid",
):
inputs["radius1"] = _prop_value(obj, "Radius1")
inputs["radius2"] = _prop_value(obj, "Radius2")
inputs["radius3"] = _prop_value(obj, "Radius3")
return inputs
if tid in (
"PartDesign::AdditiveTorus",
"PartDesign::SubtractiveTorus",
):
inputs["radius1"] = _prop_value(obj, "Radius1")
inputs["radius2"] = _prop_value(obj, "Radius2")
return inputs
if tid in (
"PartDesign::AdditivePrism",
"PartDesign::SubtractivePrism",
):
inputs["polygon"] = _prop_raw(obj, "Polygon")
inputs["circumradius"] = _prop_value(obj, "Circumradius")
inputs["height"] = _prop_value(obj, "Height")
return inputs
if tid in (
"PartDesign::AdditiveWedge",
"PartDesign::SubtractiveWedge",
):
for dim in (
"Xmin",
"Ymin",
"Zmin",
"X2min",
"Z2min",
"Xmax",
"Ymax",
"Zmax",
"X2max",
"Z2max",
):
inputs[dim.lower()] = _prop_value(obj, dim)
return inputs
# --- Datum / ShapeBinder ---
if tid in (
"PartDesign::Point",
"PartDesign::Line",
"PartDesign::Plane",
"PartDesign::CoordinateSystem",
"PartDesign::ShapeBinder",
"PartDesign::SubShapeBinder",
):
try:
p = obj.Placement
inputs["position"] = {
"x": _safe_float(p.Base.x),
"y": _safe_float(p.Base.y),
"z": _safe_float(p.Base.z),
}
inputs["rotation"] = {
"axis_x": _safe_float(p.Rotation.Axis.x),
"axis_y": _safe_float(p.Rotation.Axis.y),
"axis_z": _safe_float(p.Rotation.Axis.z),
"angle": _safe_float(p.Rotation.Angle),
}
except Exception:
pass
return inputs
# --- Body / Part (containers) ---
if tid in ("PartDesign::Body", "App::Part", "Part::Feature"):
try:
inputs["child_count"] = len(obj.Group)
except Exception:
inputs["child_count"] = 0
return inputs
# --- Fallback ---
inputs["label"] = obj.Label
return inputs
def compute_properties_hash(obj: Any) -> str:
"""Return a SHA-256 hex digest of *obj*'s parametric inputs.
The hash is used for memoization -- if a node's inputs haven't
changed since the last validation run, re-validation can be skipped.
"""
inputs = _collect_inputs(obj)
canonical = json.dumps(inputs, sort_keys=True, default=str)
return hashlib.sha256(canonical.encode()).hexdigest()
# ---------------------------------------------------------------------------
# DAG extraction
# ---------------------------------------------------------------------------
def extract_dag(
doc: Any,
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
"""Walk a FreeCAD document and return ``(nodes, edges)``.
*nodes* is a list of dicts matching the Silo ``PUT /dag`` payload
schema. *edges* connects dependencies (source) to dependents
(target).
Only objects whose TypeId is recognized by :func:`classify_type`
are included. Edges are limited to pairs where **both** endpoints
are included, preventing dangling references to internal objects
such as ``App::Origin``.
"""
# Pass 1 -- identify included objects
included: Set[str] = set()
classified: Dict[str, str] = {} # obj.Name -> node_type
for obj in doc.Objects:
if not hasattr(obj, "TypeId"):
continue
node_type = classify_type(obj.TypeId)
if node_type is not None:
included.add(obj.Name)
classified[obj.Name] = node_type
# Pass 2 -- build nodes and edges
nodes: List[Dict[str, Any]] = []
edges: List[Dict[str, Any]] = []
seen_edges: Set[Tuple[str, str]] = set()
for obj in doc.Objects:
if obj.Name not in included:
continue
nodes.append(
{
"node_key": obj.Name,
"node_type": classified[obj.Name],
"properties_hash": compute_properties_hash(obj),
"metadata": {
"label": obj.Label,
"type_id": obj.TypeId,
},
}
)
# Walk dependencies: OutList contains objects this one depends on
try:
out_list = obj.OutList
except Exception:
continue
for dep in out_list:
if not hasattr(dep, "Name"):
continue
if dep.Name not in included:
continue
edge_key = (dep.Name, obj.Name)
if edge_key in seen_edges:
continue
seen_edges.add(edge_key)
edges.append(
{
"source_key": dep.Name,
"target_key": obj.Name,
"edge_type": "depends_on",
}
)
return nodes, edges

View File

@@ -12,17 +12,4 @@
<subdirectory>./</subdirectory>
</workbench>
</content>
<!-- Kindred Create extensions -->
<kindred>
<min_create_version>0.1.0</min_create_version>
<load_priority>60</load_priority>
<pure_python>true</pure_python>
<dependencies>
<dependency>sdk</dependency>
</dependencies>
<contexts>
<context id="*" action="overlay"/>
</contexts>
</kindred>
</package>

View File

@@ -1,156 +0,0 @@
"""Headless runner entry points for silorunner compute jobs.
These functions are invoked via ``create --console -e`` by the
silorunner binary. They must work without a display server.
Entry Points
------------
dag_extract(input_path, output_path)
Extract feature DAG and write JSON.
validate(input_path, output_path)
Rebuild all features and report pass/fail per node.
export(input_path, output_path, format='step')
Export geometry to STEP, IGES, STL, or OBJ.
"""
import json
import FreeCAD
def dag_extract(input_path, output_path):
"""Extract the feature DAG from a Create file.
Parameters
----------
input_path : str
Path to the ``.kc`` or ``.FCStd`` file.
output_path : str
Path to write the JSON output.
Output JSON::
{"nodes": [...], "edges": [...]}
"""
from dag import extract_dag
doc = FreeCAD.openDocument(input_path)
try:
nodes, edges = extract_dag(doc)
with open(output_path, "w") as f:
json.dump({"nodes": nodes, "edges": edges}, f)
FreeCAD.Console.PrintMessage(
f"DAG extracted: {len(nodes)} nodes, {len(edges)} edges -> {output_path}\n"
)
finally:
FreeCAD.closeDocument(doc.Name)
def validate(input_path, output_path):
"""Validate a Create file by rebuilding all features.
Parameters
----------
input_path : str
Path to the ``.kc`` or ``.FCStd`` file.
output_path : str
Path to write the JSON output.
Output JSON::
{
"valid": true/false,
"nodes": [
{"node_key": "Pad001", "state": "clean", "message": null, "properties_hash": "..."},
...
]
}
"""
from dag import classify_type, compute_properties_hash
doc = FreeCAD.openDocument(input_path)
try:
doc.recompute()
results = []
all_valid = True
for obj in doc.Objects:
if not hasattr(obj, "TypeId"):
continue
node_type = classify_type(obj.TypeId)
if node_type is None:
continue
state = "clean"
message = None
if hasattr(obj, "isValid") and not obj.isValid():
state = "failed"
message = f"Feature {obj.Label} failed to recompute"
all_valid = False
results.append(
{
"node_key": obj.Name,
"state": state,
"message": message,
"properties_hash": compute_properties_hash(obj),
}
)
with open(output_path, "w") as f:
json.dump({"valid": all_valid, "nodes": results}, f)
status = "PASS" if all_valid else "FAIL"
FreeCAD.Console.PrintMessage(
f"Validation {status}: {len(results)} nodes -> {output_path}\n"
)
finally:
FreeCAD.closeDocument(doc.Name)
def export(input_path, output_path, format="step"):
"""Export a Create file to an external geometry format.
Parameters
----------
input_path : str
Path to the ``.kc`` or ``.FCStd`` file.
output_path : str
Path to write the exported file.
format : str
One of ``step``, ``iges``, ``stl``, ``obj``.
"""
import Part
doc = FreeCAD.openDocument(input_path)
try:
shapes = [
obj.Shape for obj in doc.Objects if hasattr(obj, "Shape") and obj.Shape
]
if not shapes:
raise ValueError("No geometry found in document")
compound = Part.makeCompound(shapes)
format_lower = format.lower()
if format_lower == "step":
compound.exportStep(output_path)
elif format_lower == "iges":
compound.exportIges(output_path)
elif format_lower == "stl":
import Mesh
Mesh.export([compound], output_path)
elif format_lower == "obj":
import Mesh
Mesh.export([compound], output_path)
else:
raise ValueError(f"Unsupported format: {format}")
FreeCAD.Console.PrintMessage(
f"Exported {format_lower.upper()} -> {output_path}\n"
)
finally:
FreeCAD.closeDocument(doc.Name)

View File

@@ -10,6 +10,9 @@ backward-compatible :class:`SchemaFormDialog` modal.
"""
import json
import urllib.error
import urllib.parse
import urllib.request
import FreeCAD
from PySide import QtCore, QtGui, QtWidgets
@@ -234,11 +237,9 @@ class SchemaFormWidget(QtWidgets.QWidget):
self._prop_groups = [] # list of _CollapsibleGroup to clear on category change
self._categories = {}
self._projects = []
self._templates = [] # List[TemplateInfo]
self._load_schema_data()
self._build_ui()
self._update_template_combo()
# Part number preview debounce timer
self._pn_timer = QtCore.QTimer(self)
@@ -253,9 +254,7 @@ class SchemaFormWidget(QtWidgets.QWidget):
try:
schema = self._client.get_schema()
segments = schema.get("segments", [])
cat_segment = next(
(s for s in segments if s.get("name") == "category"), None
)
cat_segment = next((s for s in segments if s.get("name") == "category"), None)
if cat_segment and cat_segment.get("values"):
self._categories = cat_segment["values"]
except Exception as e:
@@ -266,20 +265,19 @@ class SchemaFormWidget(QtWidgets.QWidget):
except Exception:
self._projects = []
try:
from templates import discover_templates, get_search_paths
self._templates = discover_templates(get_search_paths())
except Exception as e:
FreeCAD.Console.PrintWarning(
f"Schema form: failed to discover templates: {e}\n"
)
self._templates = []
def _fetch_properties(self, category: str) -> dict:
"""Fetch merged property definitions for a category."""
from silo_commands import _get_api_url, _get_auth_headers, _get_ssl_context
api_url = _get_api_url().rstrip("/")
url = f"{api_url}/schemas/kindred-rd/properties?category={urllib.parse.quote(category)}"
req = urllib.request.Request(url, method="GET")
req.add_header("Accept", "application/json")
for k, v in _get_auth_headers().items():
req.add_header(k, v)
try:
data = self._client.get_property_schema(category=category)
resp = urllib.request.urlopen(req, context=_get_ssl_context(), timeout=5)
data = json.loads(resp.read().decode("utf-8"))
return data.get("properties", data)
except Exception as e:
FreeCAD.Console.PrintWarning(
@@ -289,10 +287,19 @@ class SchemaFormWidget(QtWidgets.QWidget):
def _generate_pn_preview(self, category: str) -> str:
"""Call the server to preview the next part number."""
from silo_commands import _get_schema_name
from silo_commands import _get_api_url, _get_auth_headers, _get_ssl_context
api_url = _get_api_url().rstrip("/")
url = f"{api_url}/generate-part-number"
payload = json.dumps({"schema": "kindred-rd", "category": category}).encode()
req = urllib.request.Request(url, data=payload, method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("Accept", "application/json")
for k, v in _get_auth_headers().items():
req.add_header(k, v)
try:
data = self._client.generate_part_number(_get_schema_name(), category)
resp = urllib.request.urlopen(req, context=_get_ssl_context(), timeout=5)
data = json.loads(resp.read().decode("utf-8"))
return data.get("part_number", "")
except Exception:
return ""
@@ -315,9 +322,7 @@ class SchemaFormWidget(QtWidgets.QWidget):
# Part number preview banner
self._pn_label = QtWidgets.QLabel("Part Number: \u2014")
self._pn_label.setStyleSheet(
"font-size: 16px; font-weight: bold; padding: 8px;"
)
self._pn_label.setStyleSheet("font-size: 16px; font-weight: bold; padding: 8px;")
self._pn_label.setAlignment(QtCore.Qt.AlignCenter)
root.addWidget(self._pn_label)
@@ -339,15 +344,8 @@ class SchemaFormWidget(QtWidgets.QWidget):
self._type_combo = QtWidgets.QComboBox()
for t in _ITEM_TYPES:
self._type_combo.addItem(t.capitalize(), t)
self._type_combo.currentIndexChanged.connect(
lambda _: self._update_template_combo()
)
fl.addRow("Type:", self._type_combo)
self._template_combo = QtWidgets.QComboBox()
self._template_combo.addItem("(No template)", None)
fl.addRow("Template:", self._template_combo)
self._desc_edit = QtWidgets.QLineEdit()
self._desc_edit.setPlaceholderText("Item description")
fl.addRow("Description:", self._desc_edit)
@@ -427,25 +425,11 @@ class SchemaFormWidget(QtWidgets.QWidget):
root.addLayout(btn_layout)
# -- template filtering -------------------------------------------------
def _update_template_combo(self):
"""Repopulate the template combo based on current type and category."""
from templates import filter_templates
self._template_combo.clear()
self._template_combo.addItem("(No template)", None)
item_type = self._type_combo.currentData() or ""
category = self._cat_picker.selected_category() or ""
for t in filter_templates(self._templates, item_type, category):
self._template_combo.addItem(t.name, t.path)
# -- category change ----------------------------------------------------
def _on_category_changed(self, category: str):
"""Rebuild property groups when category selection changes."""
self._create_btn.setEnabled(bool(category))
self._update_template_combo()
# Remove old property groups
for group in self._prop_groups:
@@ -577,7 +561,6 @@ class SchemaFormWidget(QtWidgets.QWidget):
"long_description": long_description,
"projects": selected_projects if selected_projects else None,
"properties": properties if properties else None,
"template_path": self._template_combo.currentData(),
}
def _on_create(self):
@@ -591,10 +574,8 @@ class SchemaFormWidget(QtWidgets.QWidget):
return
try:
from silo_commands import _get_schema_name
result = self._client.create_item(
_get_schema_name(),
"kindred-rd",
data["category"],
data["description"],
projects=data["projects"],

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,8 @@ delegating operations to the established Silo infrastructure while
providing the standardized origin interface.
"""
import os
import FreeCAD
import FreeCADGui
from silo_commands import (
@@ -30,7 +32,7 @@ class SiloOrigin:
Key behaviors:
- Documents are always stored locally (hybrid local-remote model)
- Database tracks metadata, part numbers, and revision history
- Server stores revision files for sync/backup
- MinIO stores revision snapshots for sync/backup
- Identity is tracked by UUID (SiloItemId), displayed as part number
"""
@@ -61,8 +63,10 @@ class SiloOrigin:
return self._nickname
def icon(self) -> str:
"""Return icon name for BitmapFactory."""
return "silo"
"""Return icon path for BitmapFactory."""
return os.path.join(
os.path.dirname(os.path.abspath(__file__)), "resources", "icons", "silo.svg"
)
def type(self) -> int:
"""Return origin type (OriginType.PLM = 1)."""
@@ -565,9 +569,12 @@ def register_silo_origin():
This should be called during workbench initialization to make
Silo available as a file origin.
"""
from kindred_sdk import register_origin
register_origin(get_silo_origin())
origin = get_silo_origin()
try:
FreeCADGui.addOrigin(origin)
FreeCAD.Console.PrintLog("Registered Silo origin\n")
except Exception as e:
FreeCAD.Console.PrintWarning(f"Could not register Silo origin: {e}\n")
def unregister_silo_origin():
@@ -577,7 +584,9 @@ def unregister_silo_origin():
"""
global _silo_origin
if _silo_origin:
from kindred_sdk import unregister_origin
unregister_origin(_silo_origin)
try:
FreeCADGui.removeOrigin(_silo_origin)
FreeCAD.Console.PrintLog("Unregistered Silo origin\n")
except Exception as e:
FreeCAD.Console.PrintWarning(f"Could not unregister Silo origin: {e}\n")
_silo_origin = None

View File

@@ -19,10 +19,23 @@ from PySide import QtCore, QtGui, QtWidgets
# ---------------------------------------------------------------------------
# Catppuccin Mocha palette
# ---------------------------------------------------------------------------
# Catppuccin Mocha palette — sourced from kindred-addon-sdk
from kindred_sdk.theme import get_theme_tokens
_MOCHA = get_theme_tokens()
_MOCHA = {
"base": "#1e1e2e",
"mantle": "#181825",
"crust": "#11111b",
"surface0": "#313244",
"surface1": "#45475a",
"surface2": "#585b70",
"text": "#cdd6f4",
"subtext0": "#a6adc8",
"subtext1": "#bac2de",
"blue": "#89b4fa",
"green": "#a6e3a1",
"red": "#f38ba8",
"peach": "#fab387",
"lavender": "#b4befe",
"overlay0": "#6c7086",
}
_PREF_GROUP = "User parameter:BaseApp/Preferences/Mod/KindredSilo"

View File

@@ -1,167 +0,0 @@
"""Template discovery and metadata for Kindred Create .kc templates.
A template is a normal ``.kc`` file that contains a ``silo/template.json``
descriptor inside the ZIP archive. This module scans known directories for
template files, parses their descriptors, and provides filtering helpers
used by the schema form UI.
Search paths (checked in order, later shadows earlier by name):
1. ``{silo_addon}/templates/`` — system templates shipped with the addon
2. ``{userAppData}/Templates/`` — personal templates (sister to Macro/)
3. ``~/projects/templates/`` — org-shared project templates
"""
import json
import os
import shutil
import tempfile
import zipfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import List, Optional
@dataclass
class TemplateInfo:
"""Parsed template descriptor from ``silo/template.json``."""
path: str # Absolute path to the .kc file
name: str = ""
description: str = ""
item_types: List[str] = field(default_factory=list)
categories: List[str] = field(default_factory=list)
icon: str = ""
author: str = ""
tags: List[str] = field(default_factory=list)
def read_template_info(kc_path: str) -> Optional[TemplateInfo]:
"""Read ``silo/template.json`` from a ``.kc`` file.
Returns a :class:`TemplateInfo` if the file is a valid template,
or ``None`` if the file does not contain a template descriptor.
"""
try:
with zipfile.ZipFile(kc_path, "r") as zf:
if "silo/template.json" not in zf.namelist():
return None
raw = zf.read("silo/template.json")
data = json.loads(raw)
return TemplateInfo(
path=str(kc_path),
name=data.get("name", Path(kc_path).stem),
description=data.get("description", ""),
item_types=data.get("item_types", []),
categories=data.get("categories", []),
icon=data.get("icon", ""),
author=data.get("author", ""),
tags=data.get("tags", []),
)
except Exception:
return None
def inject_template_json(kc_path: str, template_info: dict) -> bool:
"""Inject ``silo/template.json`` into a ``.kc`` (ZIP) file.
Rewrites the ZIP to avoid duplicate entries. Returns ``True`` on
success, raises on I/O errors.
"""
if not os.path.isfile(kc_path):
return False
payload = json.dumps(template_info, indent=2).encode("utf-8")
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".kc")
os.close(tmp_fd)
try:
with zipfile.ZipFile(kc_path, "r") as zin:
with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zout:
for item in zin.infolist():
if item.filename == "silo/template.json":
continue
zout.writestr(item, zin.read(item.filename))
zout.writestr("silo/template.json", payload)
shutil.move(tmp_path, kc_path)
return True
except Exception:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
raise
def get_default_template_dir() -> str:
"""Return the user templates directory, creating it if needed.
Located at ``{userAppData}/Templates/``, a sister to ``Macro/``.
"""
import FreeCAD
d = os.path.join(FreeCAD.getUserAppDataDir(), "Templates")
os.makedirs(d, exist_ok=True)
return d
def discover_templates(search_paths: List[str]) -> List[TemplateInfo]:
"""Scan search paths for ``.kc`` files containing ``silo/template.json``.
Later paths shadow earlier paths by template name.
"""
by_name = {}
for search_dir in search_paths:
if not os.path.isdir(search_dir):
continue
for filename in sorted(os.listdir(search_dir)):
if not filename.lower().endswith(".kc"):
continue
full_path = os.path.join(search_dir, filename)
info = read_template_info(full_path)
if info is not None:
by_name[info.name] = info
return sorted(by_name.values(), key=lambda t: t.name)
def get_search_paths() -> List[str]:
"""Return the ordered list of template search directories."""
paths = []
# 1. System templates (shipped with the silo addon)
system_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates")
paths.append(system_dir)
# 2. User app-data templates (personal, sister to Macro/)
try:
import FreeCAD
user_app_templates = os.path.join(FreeCAD.getUserAppDataDir(), "Templates")
paths.append(user_app_templates)
except Exception:
pass
# 3. Shared project templates
try:
from silo_commands import get_projects_dir
user_dir = str(get_projects_dir() / "templates")
paths.append(user_dir)
except Exception:
pass
return paths
def filter_templates(
templates: List[TemplateInfo],
item_type: str = "",
category: str = "",
) -> List[TemplateInfo]:
"""Filter templates by item type and category prefix."""
result = []
for t in templates:
if item_type and t.item_types and item_type not in t.item_types:
continue
if category and t.categories:
if not any(category.startswith(prefix) for prefix in t.categories):
continue
result.append(t)
return result

View File

@@ -1,78 +0,0 @@
#!/usr/bin/env python3
"""Inject silo/template.json into a .kc file to make it a template.
Usage:
python inject_template.py <kc_file> <name> [--type part|assembly] [--description "..."]
Examples:
python inject_template.py part.kc "Part (Generic)" --type part
python inject_template.py sheet-metal-part.kc "Sheet Metal Part" --type part \
--description "Body with SheetMetal base feature and laser-cut job"
python inject_template.py assembly.kc "Assembly" --type assembly
"""
import argparse
import json
import os
import sys
# Allow importing from the parent freecad/ directory when run standalone
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from templates import inject_template_json
def main():
parser = argparse.ArgumentParser(
description="Inject template metadata into a .kc file"
)
parser.add_argument("kc_file", help="Path to the .kc file")
parser.add_argument("name", help="Template display name")
parser.add_argument(
"--type",
dest="item_types",
action="append",
default=[],
help="Item type filter (part, assembly). Can be repeated.",
)
parser.add_argument("--description", default="", help="Template description")
parser.add_argument("--icon", default="", help="Icon identifier")
parser.add_argument("--author", default="Kindred Systems", help="Author name")
parser.add_argument(
"--category",
dest="categories",
action="append",
default=[],
help="Category prefix filter. Can be repeated. Empty = all.",
)
parser.add_argument(
"--tag",
dest="tags",
action="append",
default=[],
help="Searchable tags. Can be repeated.",
)
args = parser.parse_args()
if not args.item_types:
args.item_types = ["part"]
template_info = {
"template_version": "1.0",
"name": args.name,
"description": args.description,
"item_types": args.item_types,
"categories": args.categories,
"icon": args.icon,
"author": args.author,
"tags": args.tags,
}
if inject_template_json(args.kc_file, template_info):
print(f"Injected silo/template.json into {args.kc_file}")
print(json.dumps(template_info, indent=2))
else:
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<package format="1" xmlns="https://wiki.freecad.org/Package_Metadata">
<name>silo</name>
<description>PLM workbench for Kindred Create</description>
<version>0.1.0</version>
<maintainer email="development@kindred-systems.com">Kindred Systems</maintainer>
<license file="LICENSE">MIT</license>
<url type="repository">https://git.kindred-systems.com/kindred/silo-mod</url>
<content>
<workbench>
<classname>SiloWorkbench</classname>
<subdirectory>freecad</subdirectory>
</workbench>
</content>
<kindred>
<min_create_version>0.1.0</min_create_version>
<load_priority>60</load_priority>
<pure_python>true</pure_python>
<dependencies>
<dependency>sdk</dependency>
</dependencies>
</kindred>
</package>