Compare commits

...

35 Commits

Author SHA1 Message Date
forbes-0023
9187622239 chore: add root package.xml, Init.py, and InitGui.py (#373)
Move package.xml from freecad/ to mod root following the standard
addon layout used by sdk, gears, datums, and solver.

Add root Init.py (sys.path setup for silo-client) and InitGui.py
that consolidates all Silo GUI initialization:
- SiloWorkbench class and registration
- Silo overlay context registration
- Document observer for .kc tree building
- Auth and activity dock panels via kindred_sdk
- First-start settings check
- Start page override
- kindred:// URL handler

This decouples Silo initialization from src/Mod/Create/InitGui.py,
which previously held five Silo-specific deferred setup functions.
2026-03-03 08:19:42 -06:00
63a6da8b6c Merge pull request 'chore(deps): update silo-client to 5dfb567b' (#50) from auto/update-silo-client-5dfb567b into main
Reviewed-on: #50
2026-02-26 19:54:26 +00:00
forbes-0023
a88e104d94 feat(templates): document templating system with Save as Template command
Add a template system that lets users create new items from pre-configured
.kc template files and save existing documents as reusable templates.

Template use (New Item form):
- templates.py: discovery, filtering, TemplateInfo dataclass
- schema_form.py: template combo picker filtered by type/category
- silo_commands.py: SiloSync.create_document_from_template() copies
  template .kc, strips identity, stamps Silo properties

Template creation (Save as Template):
- SaveAsTemplateDialog: captures name, description, item types,
  categories, author, and tags
- Silo_SaveAsTemplate command: copies doc, strips Silo identity,
  injects silo/template.json, optionally uploads to Silo
- Registered in Silo menu via InitGui.py

Template search paths (3-tier, later shadows earlier by name):
1. mods/silo/freecad/templates/ (system)
2. ~/.local/share/FreeCAD/Templates/ (personal, sister to Macro/)
3. ~/projects/templates/ (org-shared)
2026-02-21 09:06:26 -06:00
kindred-bot
7dc157894f chore(deps): update silo-client to 5dfb567b
Upstream: 5dfb567bac
2026-02-19 20:49:11 +00:00
43e905c00a Merge pull request 'feat(silo): BOM auto-extraction from Assembly links (#276)' (#49) from feat/bom-sync-and-manifest into main
Reviewed-on: #49
2026-02-19 20:27:14 +00:00
forbes-0023
f67d9a0422 feat(silo): BOM auto-extraction from Assembly links (#276)
Phase 1 implementation:
- New bom_sync.py: extract cross-document App::Link components from
  Assembly, resolve SiloItemId UUIDs to part numbers, diff against
  server BOM, apply adds/qty updates via individual CRUD calls.
- Hook _push_bom_after_upload into Silo_Save and Silo_Commit
  (same non-blocking pattern as DAG sync).
- Hook _update_manifest_revision to write revision_hash into .kc
  manifest after successful upload (#277).
- Add bom_merged SSE signal + dispatch + Activity pane handler.
- Add merge_bom_json to SiloClient (forward-looking for Phase 2).

Merge rules: auto-add, auto-update qty, NEVER auto-delete removed
entries (warn only).

Refs: #276, #277
2026-02-19 12:37:14 -06:00
af98994a53 Merge pull request 'chore: update silo-client pointer to main merge commit' (#48) from fix/silo-client-pointer into main
Reviewed-on: #48
2026-02-19 01:58:00 +00:00
Zoe Forbes
d266bfb653 chore: update silo-client pointer to main merge commit
Points to silo-client main (285bd1f) which includes the merged
kc-metadata-api methods from PR #19.
2026-02-18 19:56:58 -06:00
a92174e0b9 Merge pull request 'chore: bump silo-client to feat/kc-metadata-api' (#47) from feat/kc-metadata-api into main
Reviewed-on: #47
2026-02-19 01:43:01 +00:00
Zoe Forbes
edbaf65923 chore: bump silo-client to feat/kc-metadata-api
Tracks new .kc metadata and dependency resolution API methods
needed by Create module server integration (silo-mod#43).
2026-02-18 19:35:41 -06:00
80f8ec27a0 Merge pull request 'chore(deps): update silo-client to c5c8288e' (#36) from auto/update-silo-client-c5c8288e into main
Reviewed-on: #36
2026-02-18 22:34:25 +00:00
kindred-bot
6b3e8b7518 chore(deps): update silo-client to c5c8288e
Upstream: c5c8288eeb
2026-02-18 21:06:32 +00:00
b3fe98c696 Merge pull request 'fix: remove MinIO references and degraded mode' (#35) from fix/remove-minio-references into main
Reviewed-on: #35
2026-02-18 20:56:18 +00:00
Zoe Forbes
c537e2f08f fix: remove MinIO references and degraded mode
The silo server now uses filesystem storage instead of MinIO.

- Remove all MinIO references from docstrings, tooltips, and UI text
- Remove obsolete 'degraded' server mode (no separate storage service)
- Update Silo_Info display: 'File in MinIO' → 'File on Server'
- Update SiloOrigin class docstring
2026-02-18 14:55:57 -06:00
29b1f32fd9 Merge pull request 'refactor: migrate to kindred-addon-sdk for overlay, origin, and theme' (#34) from feat/migrate-to-sdk into main
Reviewed-on: #34
2026-02-17 17:05:34 +00:00
dca6380199 refactor: migrate to kindred-addon-sdk for overlay, origin, and theme (#250)
Replace FreeCADGui.registerEditingOverlay() with kindred_sdk.register_overlay().
Replace FreeCADGui.addOrigin()/removeOrigin() with kindred_sdk wrappers.
Replace hardcoded _MOCHA dict with kindred_sdk.get_theme_tokens().
Add sdk dependency to package.xml <kindred> element.
2026-02-17 11:03:21 -06:00
27f0cc0f34 feat: add <kindred> element to package.xml
Declares min_create_version=0.1.0, load_priority=60, pure_python=true,
and documents universal overlay context.
2026-02-17 11:03:20 -06:00
a5eff534b5 Merge pull request 'chore(deps): update silo-client to 5e6f2cb9' (#33) from auto/update-silo-client-5e6f2cb9 into main
Reviewed-on: #33
2026-02-17 14:48:26 +00:00
kindred-bot
1001424b16 chore(deps): update silo-client to 5e6f2cb9
Upstream: 5e6f2cb963
2026-02-17 14:48:03 +00:00
7e3127498a Merge pull request 'feat(schema): make schema name configurable (closes #28)' (#32) from feat/configurable-schema-name into main
Reviewed-on: #32
2026-02-16 19:20:02 +00:00
82d8741059 feat(schema): make schema name configurable, update silo-client submodule
Replace all hardcoded 'kindred-rd' schema references with the new
configurable get_schema_name() setting. Priority: FreeCAD preference
SchemaName > SILO_SCHEMA env var > default 'kindred-rd'.

- Add get_schema_name() to FreeCADSiloSettings and _get_schema_name() helper
- Add Schema Name field to Settings dialog
- Replace raw urllib calls in schema_form.py with SiloClient methods
  (get_property_schema, generate_part_number)
- Inline parse_part_number/sanitize_filename (removed from silo-client)
- Simplify category folder naming to use category code directly
- Update silo-client submodule to origin/main + configurable schema branch

Closes #28
2026-02-16 13:17:50 -06:00
Zoe Forbes
3fe43710fa fix(ui): remove addStretch from auth panel, use compact size policy
Replace layout.addStretch() with QSizePolicy.Maximum so the
Database Auth dock panel only takes the height its content needs,
leaving more vertical space for the Database Activity panel below.

Closes #190
2026-02-15 09:43:15 -06:00
Zoe Forbes
8cbd872e5c Merge branch 'feat/worker-client-ui' into main
Resolve conflicts in silo_commands.py:
- Keep event-based activity feed (_append_activity_event, _rebuild_activity_feed)
- Adapt DAG/job SSE handlers to use _append_activity_event
- Keep _relative_time formatting for activity entries
- Include DNS diagnostic IP display from feature branch
2026-02-15 08:32:55 -06:00
Zoe Forbes
e31321ac95 feat: add Jobs and Runners commands with SSE event wiring
- Add JobMonitorDialog (Silo_Jobs): filter, view, trigger, cancel jobs
- Add RunnerAdminDialog (Silo_Runners): list, register, delete runners
- Wire job_claimed, job_progress, job_cancelled SSE signals to handlers
- Add activity panel entries for job lifecycle events
- Register Silo_Jobs in toolbar and menu, Silo_Runners in menu
- Update silo-client submodule with worker API methods
2026-02-15 05:07:33 -06:00
Zoe Forbes
dc64a66f0f feat: show DAG status and job events in Activity panel
Connects dag_updated, dag_validated, and job lifecycle signals
from SiloEventListener to the Database Activity dock widget.

- dag.updated: inserts DAG sync status (node/edge count)
- dag.validated: inserts pass/fail badge with failed count
- job.created: inserts queued job entry
- job.completed: refreshes the full activity list
- job.failed: inserts error entry

Live entries are inserted at the top of the activity list,
styled in Catppuccin Blue, capped at 50 entries.

Closes kindred/create#219
2026-02-14 15:28:40 -06:00
Zoe Forbes
3d38e4b4c3 feat: handle DAG and job SSE events in SiloEventListener
New signals:
- dag_updated(part_number, node_count, edge_count)
- dag_validated(part_number, valid, failed_count)
- job_created/claimed/progress/completed/failed/cancelled

Dispatch logic parses payloads and emits typed signals for
downstream UI and logging consumers.

Closes kindred/create#218
2026-02-14 15:22:29 -06:00
Zoe Forbes
da2a360c56 feat: add headless runner entry points
New file: runner.py with three entry points for silorunner:
- dag_extract(input_path, output_path): extract feature DAG as JSON
- validate(input_path, output_path): rebuild features, report pass/fail
- export(input_path, output_path, format): export to STEP/IGES/STL/OBJ

Invoked via: create --console -e 'from runner import dag_extract; ...'

Closes kindred/create#217
2026-02-14 15:17:30 -06:00
Zoe Forbes
3dd0da3964 feat: push DAG on save and commit
Adds _push_dag_after_upload() helper that extracts the feature DAG
and pushes it to Silo after a successful file upload.

Hooked into both Silo_Save and Silo_Commit commands. DAG sync
failures are logged as warnings and never block the save/commit.

Closes kindred/create#216
2026-02-14 15:10:50 -06:00
Zoe Forbes
4921095296 feat: update silo-client — add DAG API methods
Points silo-client to feat/dag-api-methods with push_dag/get_dag.

Closes kindred/create#215
2026-02-14 15:06:31 -06:00
Zoe Forbes
3a9fe6aed8 feat: add DAG extraction engine
Implements extract_dag(), classify_type(), and compute_properties_hash()
for extracting feature trees from FreeCAD documents.

- classify_type: maps ~50 FreeCAD TypeIds to 8 DAG node types
- compute_properties_hash: SHA-256 of per-feature parametric inputs
- extract_dag: two-pass walk of doc.Objects producing nodes + edges

No GUI dependencies -- works in both desktop and headless mode.

Closes kindred/create#214
2026-02-14 14:41:35 -06:00
Zoe Forbes
9e99b83091 fix: default cert browser to home dir instead of /etc/ssl/certs (#203)
The CA certificate file browser hardcoded /etc/ssl/certs as fallback,
which confused users when the dialog opened to a system directory.
Default to the user's home directory instead.
2026-02-14 12:50:01 -06:00
0f407360ed Merge pull request 'feat: use .kc extension for new files, find both .kc and .FCStd' (#23) from feat/kc-file-format-layer1 into main
Reviewed-on: #23
2026-02-13 19:42:03 +00:00
fa4f3145c6 Merge branch 'main' into feat/kc-file-format-layer1 2026-02-13 19:41:55 +00:00
Zoe Forbes
fed72676bc feat: use .kc extension for new files, find both .kc and .FCStd
- get_cad_file_path() now generates .kc paths instead of .FCStd
- find_file_by_part_number() searches .kc first, falls back to .FCStd
- search_local_files() lists both .kc and .FCStd files
2026-02-13 13:39:22 -06:00
d3e27010d8 Merge pull request 'feat: live SSE-based activity feed for Database Activity panel' (#22) from feat/live-activity-panel into main
Reviewed-on: #22
2026-02-13 01:57:03 +00:00
18 changed files with 2662 additions and 116 deletions

13
Init.py Normal file
View File

@@ -0,0 +1,13 @@
"""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)

265
InitGui.py Normal file
View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

317
freecad/bom_sync.py Normal file
View File

@@ -0,0 +1,317 @@
"""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

463
freecad/dag.py Normal file
View File

@@ -0,0 +1,463 @@
"""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,4 +12,17 @@
<subdirectory>./</subdirectory> <subdirectory>./</subdirectory>
</workbench> </workbench>
</content> </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> </package>

156
freecad/runner.py Normal file
View File

@@ -0,0 +1,156 @@
"""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,9 +10,6 @@ backward-compatible :class:`SchemaFormDialog` modal.
""" """
import json import json
import urllib.error
import urllib.parse
import urllib.request
import FreeCAD import FreeCAD
from PySide import QtCore, QtGui, QtWidgets from PySide import QtCore, QtGui, QtWidgets
@@ -237,9 +234,11 @@ class SchemaFormWidget(QtWidgets.QWidget):
self._prop_groups = [] # list of _CollapsibleGroup to clear on category change self._prop_groups = [] # list of _CollapsibleGroup to clear on category change
self._categories = {} self._categories = {}
self._projects = [] self._projects = []
self._templates = [] # List[TemplateInfo]
self._load_schema_data() self._load_schema_data()
self._build_ui() self._build_ui()
self._update_template_combo()
# Part number preview debounce timer # Part number preview debounce timer
self._pn_timer = QtCore.QTimer(self) self._pn_timer = QtCore.QTimer(self)
@@ -254,7 +253,9 @@ class SchemaFormWidget(QtWidgets.QWidget):
try: try:
schema = self._client.get_schema() schema = self._client.get_schema()
segments = schema.get("segments", []) 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"): if cat_segment and cat_segment.get("values"):
self._categories = cat_segment["values"] self._categories = cat_segment["values"]
except Exception as e: except Exception as e:
@@ -265,19 +266,20 @@ class SchemaFormWidget(QtWidgets.QWidget):
except Exception: except Exception:
self._projects = [] 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: def _fetch_properties(self, category: str) -> dict:
"""Fetch merged property definitions for a category.""" """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: try:
resp = urllib.request.urlopen(req, context=_get_ssl_context(), timeout=5) data = self._client.get_property_schema(category=category)
data = json.loads(resp.read().decode("utf-8"))
return data.get("properties", data) return data.get("properties", data)
except Exception as e: except Exception as e:
FreeCAD.Console.PrintWarning( FreeCAD.Console.PrintWarning(
@@ -287,19 +289,10 @@ class SchemaFormWidget(QtWidgets.QWidget):
def _generate_pn_preview(self, category: str) -> str: def _generate_pn_preview(self, category: str) -> str:
"""Call the server to preview the next part number.""" """Call the server to preview the next part number."""
from silo_commands import _get_api_url, _get_auth_headers, _get_ssl_context from silo_commands import _get_schema_name
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: try:
resp = urllib.request.urlopen(req, context=_get_ssl_context(), timeout=5) data = self._client.generate_part_number(_get_schema_name(), category)
data = json.loads(resp.read().decode("utf-8"))
return data.get("part_number", "") return data.get("part_number", "")
except Exception: except Exception:
return "" return ""
@@ -322,7 +315,9 @@ class SchemaFormWidget(QtWidgets.QWidget):
# Part number preview banner # Part number preview banner
self._pn_label = QtWidgets.QLabel("Part Number: \u2014") 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) self._pn_label.setAlignment(QtCore.Qt.AlignCenter)
root.addWidget(self._pn_label) root.addWidget(self._pn_label)
@@ -344,8 +339,15 @@ class SchemaFormWidget(QtWidgets.QWidget):
self._type_combo = QtWidgets.QComboBox() self._type_combo = QtWidgets.QComboBox()
for t in _ITEM_TYPES: for t in _ITEM_TYPES:
self._type_combo.addItem(t.capitalize(), t) self._type_combo.addItem(t.capitalize(), t)
self._type_combo.currentIndexChanged.connect(
lambda _: self._update_template_combo()
)
fl.addRow("Type:", self._type_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 = QtWidgets.QLineEdit()
self._desc_edit.setPlaceholderText("Item description") self._desc_edit.setPlaceholderText("Item description")
fl.addRow("Description:", self._desc_edit) fl.addRow("Description:", self._desc_edit)
@@ -425,11 +427,25 @@ class SchemaFormWidget(QtWidgets.QWidget):
root.addLayout(btn_layout) 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 ---------------------------------------------------- # -- category change ----------------------------------------------------
def _on_category_changed(self, category: str): def _on_category_changed(self, category: str):
"""Rebuild property groups when category selection changes.""" """Rebuild property groups when category selection changes."""
self._create_btn.setEnabled(bool(category)) self._create_btn.setEnabled(bool(category))
self._update_template_combo()
# Remove old property groups # Remove old property groups
for group in self._prop_groups: for group in self._prop_groups:
@@ -561,6 +577,7 @@ class SchemaFormWidget(QtWidgets.QWidget):
"long_description": long_description, "long_description": long_description,
"projects": selected_projects if selected_projects else None, "projects": selected_projects if selected_projects else None,
"properties": properties if properties else None, "properties": properties if properties else None,
"template_path": self._template_combo.currentData(),
} }
def _on_create(self): def _on_create(self):
@@ -574,8 +591,10 @@ class SchemaFormWidget(QtWidgets.QWidget):
return return
try: try:
from silo_commands import _get_schema_name
result = self._client.create_item( result = self._client.create_item(
"kindred-rd", _get_schema_name(),
data["category"], data["category"],
data["description"], data["description"],
projects=data["projects"], projects=data["projects"],

File diff suppressed because it is too large Load Diff

View File

@@ -30,7 +30,7 @@ class SiloOrigin:
Key behaviors: Key behaviors:
- Documents are always stored locally (hybrid local-remote model) - Documents are always stored locally (hybrid local-remote model)
- Database tracks metadata, part numbers, and revision history - Database tracks metadata, part numbers, and revision history
- MinIO stores revision snapshots for sync/backup - Server stores revision files for sync/backup
- Identity is tracked by UUID (SiloItemId), displayed as part number - Identity is tracked by UUID (SiloItemId), displayed as part number
""" """
@@ -388,9 +388,7 @@ class SiloOrigin:
# Upload to Silo # Upload to Silo
properties = collect_document_properties(doc) properties = collect_document_properties(doc)
_client._upload_file( _client._upload_file(obj.SiloPartNumber, str(file_path), properties, comment="")
obj.SiloPartNumber, str(file_path), properties, comment=""
)
# Clear modified flag (Modified is on Gui.Document, not App.Document) # Clear modified flag (Modified is on Gui.Document, not App.Document)
gui_doc = FreeCADGui.getDocument(doc.Name) gui_doc = FreeCADGui.getDocument(doc.Name)
@@ -567,12 +565,9 @@ def register_silo_origin():
This should be called during workbench initialization to make This should be called during workbench initialization to make
Silo available as a file origin. Silo available as a file origin.
""" """
origin = get_silo_origin() from kindred_sdk import register_origin
try:
FreeCADGui.addOrigin(origin) register_origin(get_silo_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(): def unregister_silo_origin():
@@ -582,9 +577,7 @@ def unregister_silo_origin():
""" """
global _silo_origin global _silo_origin
if _silo_origin: if _silo_origin:
try: from kindred_sdk import unregister_origin
FreeCADGui.removeOrigin(_silo_origin)
FreeCAD.Console.PrintLog("Unregistered Silo origin\n") unregister_origin(_silo_origin)
except Exception as e:
FreeCAD.Console.PrintWarning(f"Could not unregister Silo origin: {e}\n")
_silo_origin = None _silo_origin = None

View File

@@ -19,23 +19,10 @@ from PySide import QtCore, QtGui, QtWidgets
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Catppuccin Mocha palette # Catppuccin Mocha palette
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_MOCHA = { # Catppuccin Mocha palette — sourced from kindred-addon-sdk
"base": "#1e1e2e", from kindred_sdk.theme import get_theme_tokens
"mantle": "#181825",
"crust": "#11111b", _MOCHA = get_theme_tokens()
"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" _PREF_GROUP = "User parameter:BaseApp/Preferences/Mod/KindredSilo"

167
freecad/templates.py Normal file
View File

@@ -0,0 +1,167 @@
"""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

@@ -0,0 +1,78 @@
#!/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()

25
package.xml Normal file
View File

@@ -0,0 +1,25 @@
<?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>