Compare commits

...

15 Commits

Author SHA1 Message Date
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
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
91f539a18a Merge pull request 'feat(open): replace modal open dialog with MDI tab' (#20) from feat/open-item-mdi-tab into main
Reviewed-on: #20
2026-02-12 17:47:09 +00:00
2ddfea083a Merge branch 'main' into feat/open-item-mdi-tab 2026-02-12 17:46:57 +00:00
be8783bf0a feat(open): replace modal open dialog with MDI tab
Extract search-and-open UI into OpenItemWidget (open_search.py), a
plain QWidget with item_selected/cancelled signals.  Silo_Open now
adds this widget as an MDI subwindow instead of running a blocking
QDialog, matching the Silo_New tab pattern.

Improvements over the old dialog:
- Non-blocking: multiple search tabs can be open simultaneously
- 500 ms debounce on search input reduces API load
- Filter checkbox changes trigger immediate re-search
2026-02-12 10:22:42 -06:00
972dc07157 feat(silo): replace modal new-item dialog with MDI pre-document tab
Extract SchemaFormWidget from SchemaFormDialog so the creation form
can be embedded as a plain QWidget in an MDI subwindow tab.  Each
Ctrl+N invocation opens a new tab alongside document tabs.  On
successful creation the pre-document tab closes and the real document
opens in its place.

- SchemaFormWidget emits item_created/cancelled signals
- SchemaFormDialog preserved as thin modal wrapper for backward compat
- Inline error display replaces modal QMessageBox
- Live tab title updates from part number preview
2026-02-11 15:14:38 -06:00
069bb7a552 Merge pull request 'fix: pull assembly dependencies recursively before opening' (#19) from fix/pull-assembly-dependencies into main
Reviewed-on: #19
2026-02-11 19:12:22 +00:00
201e0af450 feat: register Silo overlay context for EditingContextResolver
Add 'Silo Origin' toolbar (Commit/Pull/Push/Info/BOM) registered with
Unavailable visibility. Register a Silo overlay via
FreeCADGui.registerEditingOverlay() that appends this toolbar to any
active editing context when the current document is Silo-tracked
(ownsDocument() returns True).

Consolidate PySide.QtCore imports.
2026-02-11 13:11:47 -06:00
8a6e5cdffa fix: pull assembly dependencies recursively before opening
When pulling an assembly from Silo, the linked component files were not
downloaded, causing FreeCAD to report 'Link not restored' errors for
every external reference.

Add _pull_dependencies() that queries the BOM API to discover child
part numbers, then downloads the latest file revision for each child
that doesn't already exist locally. Recurses into sub-assemblies.

Silo_Pull.Activated() now calls _pull_dependencies() after downloading
the assembly file and before opening it, so all PropertyXLink paths
resolve correctly.
2026-02-11 13:09:59 -06:00
95c56fa29a Merge pull request 'feat/schema-driven-new-item-form' (#18) from feat/schema-driven-new-item-form into main
Reviewed-on: #18
2026-02-11 16:19:05 +00:00
7 changed files with 1304 additions and 254 deletions

View File

@@ -35,9 +35,20 @@ class SiloWorkbench(FreeCADGui.Workbench):
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",
]
self.appendToolbar("Silo Origin", self.silo_toolbar_commands, "Unavailable")
# Silo menu provides admin/management commands.
# File operations (New/Open/Save) are handled by the standard File
# toolbar via the origin system -- no separate Silo toolbar needed.
self.menu_commands = [
"Silo_Info",
"Silo_BOM",
@@ -68,6 +79,44 @@ class SiloWorkbench(FreeCADGui.Workbench):
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:
FreeCADGui.registerEditingOverlay(
"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")
from PySide import QtCore as _QtCore
_QtCore.QTimer.singleShot(2500, _register_silo_overlay)
# Override the Start page with Silo-aware version (must happen before
# the C++ StartLauncher fires at ~100ms after GUI init)
try:
@@ -92,6 +141,4 @@ def _handle_startup_urls():
handle_kindred_url(arg)
from PySide import QtCore
QtCore.QTimer.singleShot(500, _handle_startup_urls)
_QtCore.QTimer.singleShot(500, _handle_startup_urls)

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

191
freecad/open_search.py Normal file
View File

@@ -0,0 +1,191 @@
"""Search-and-open widget for Kindred Create.
Provides :class:`OpenItemWidget`, a plain ``QWidget`` that can be
embedded in an MDI sub-window. Searches both the Silo database and
local CAD files, presenting results in a unified table. Emits
``item_selected`` when the user picks an item and ``cancelled`` when
the user clicks Cancel.
"""
import FreeCAD
from PySide import QtCore, QtWidgets
class OpenItemWidget(QtWidgets.QWidget):
"""Search-and-open widget for embedding in an MDI subwindow.
Parameters
----------
client : SiloClient
Authenticated Silo API client instance.
search_local_fn : callable
Function that accepts a search term string and returns an
iterable of dicts with keys ``part_number``, ``description``,
``path``, ``modified``.
parent : QWidget, optional
Parent widget.
Signals
-------
item_selected(dict)
Emitted when the user selects an item. The dict contains
keys: *part_number*, *description*, *item_type*, *source*
(``"database"``, ``"local"``, or ``"both"``), *modified*,
and *path* (str or ``None``).
cancelled()
Emitted when the user clicks Cancel.
"""
item_selected = QtCore.Signal(dict)
cancelled = QtCore.Signal()
def __init__(self, client, search_local_fn, parent=None):
super().__init__(parent)
self._client = client
self._search_local = search_local_fn
self._results_data = []
self.setMinimumWidth(700)
self.setMinimumHeight(500)
self._build_ui()
# Debounced search timer (500 ms)
self._search_timer = QtCore.QTimer(self)
self._search_timer.setSingleShot(True)
self._search_timer.setInterval(500)
self._search_timer.timeout.connect(self._do_search)
# Populate on first display
QtCore.QTimer.singleShot(0, self._do_search)
# ---- UI construction ---------------------------------------------------
def _build_ui(self):
layout = QtWidgets.QVBoxLayout(self)
layout.setSpacing(8)
# Search row
self._search_input = QtWidgets.QLineEdit()
self._search_input.setPlaceholderText("Search by part number or description...")
self._search_input.textChanged.connect(self._on_search_changed)
layout.addWidget(self._search_input)
# Filter checkboxes
filter_layout = QtWidgets.QHBoxLayout()
self._db_checkbox = QtWidgets.QCheckBox("Database")
self._db_checkbox.setChecked(True)
self._local_checkbox = QtWidgets.QCheckBox("Local Files")
self._local_checkbox.setChecked(True)
self._db_checkbox.toggled.connect(self._on_filter_changed)
self._local_checkbox.toggled.connect(self._on_filter_changed)
filter_layout.addWidget(self._db_checkbox)
filter_layout.addWidget(self._local_checkbox)
filter_layout.addStretch()
layout.addLayout(filter_layout)
# Results table
self._results_table = QtWidgets.QTableWidget()
self._results_table.setColumnCount(5)
self._results_table.setHorizontalHeaderLabels(
["Part Number", "Description", "Type", "Source", "Modified"]
)
self._results_table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows)
self._results_table.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection)
self._results_table.horizontalHeader().setStretchLastSection(True)
self._results_table.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers)
self._results_table.doubleClicked.connect(self._open_selected)
layout.addWidget(self._results_table, 1)
# Buttons
btn_layout = QtWidgets.QHBoxLayout()
btn_layout.addStretch()
open_btn = QtWidgets.QPushButton("Open")
open_btn.clicked.connect(self._open_selected)
cancel_btn = QtWidgets.QPushButton("Cancel")
cancel_btn.clicked.connect(self.cancelled.emit)
btn_layout.addWidget(open_btn)
btn_layout.addWidget(cancel_btn)
layout.addLayout(btn_layout)
# ---- Search logic ------------------------------------------------------
def _on_search_changed(self, _text):
"""Restart debounce timer on each keystroke."""
self._search_timer.start()
def _on_filter_changed(self, _checked):
"""Re-run search immediately when filter checkboxes change."""
self._do_search()
def _do_search(self):
"""Execute search against database and/or local files."""
search_term = self._search_input.text().strip()
self._results_data = []
if self._db_checkbox.isChecked():
try:
for item in self._client.list_items(search=search_term):
self._results_data.append(
{
"part_number": item.get("part_number", ""),
"description": item.get("description", ""),
"item_type": item.get("item_type", ""),
"source": "database",
"modified": item.get("updated_at", "")[:10]
if item.get("updated_at")
else "",
"path": None,
}
)
except Exception as e:
FreeCAD.Console.PrintWarning(f"DB search failed: {e}\n")
if self._local_checkbox.isChecked():
try:
for item in self._search_local(search_term):
existing = next(
(r for r in self._results_data if r["part_number"] == item["part_number"]),
None,
)
if existing:
existing["source"] = "both"
existing["path"] = item.get("path")
else:
self._results_data.append(
{
"part_number": item.get("part_number", ""),
"description": item.get("description", ""),
"item_type": "",
"source": "local",
"modified": item.get("modified", "")[:10]
if item.get("modified")
else "",
"path": item.get("path"),
}
)
except Exception as e:
FreeCAD.Console.PrintWarning(f"Local search failed: {e}\n")
self._populate_table()
def _populate_table(self):
"""Refresh the results table from ``_results_data``."""
self._results_table.setRowCount(len(self._results_data))
for row, data in enumerate(self._results_data):
self._results_table.setItem(row, 0, QtWidgets.QTableWidgetItem(data["part_number"]))
self._results_table.setItem(row, 1, QtWidgets.QTableWidgetItem(data["description"]))
self._results_table.setItem(row, 2, QtWidgets.QTableWidgetItem(data["item_type"]))
self._results_table.setItem(row, 3, QtWidgets.QTableWidgetItem(data["source"]))
self._results_table.setItem(row, 4, QtWidgets.QTableWidgetItem(data["modified"]))
self._results_table.resizeColumnsToContents()
# ---- Selection ---------------------------------------------------------
def _open_selected(self):
"""Emit ``item_selected`` with the data from the selected row."""
selected = self._results_table.selectedItems()
if not selected:
return
row = selected[0].row()
self.item_selected.emit(dict(self._results_data[row]))

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

@@ -1,8 +1,12 @@
"""Schema-driven new-item dialog for Kindred Create.
"""Schema-driven new-item form for Kindred Create.
Fetches schema data from the Silo REST API and builds a dynamic Qt form
that mirrors the React ``CreateItemPane`` — category picker, property
fields grouped by domain, live part number preview, and project tagging.
The primary widget is :class:`SchemaFormWidget` (a plain ``QWidget``)
which can be embedded in an MDI tab, dock panel, or wrapped in the
backward-compatible :class:`SchemaFormDialog` modal.
"""
import json
@@ -211,30 +215,29 @@ def _read_field(widget: QtWidgets.QWidget, prop_def: dict):
# ---------------------------------------------------------------------------
# Main form dialog
# Embeddable form widget
# ---------------------------------------------------------------------------
class SchemaFormDialog(QtWidgets.QDialog):
"""Schema-driven new-item dialog.
class SchemaFormWidget(QtWidgets.QWidget):
"""Schema-driven new-item form widget.
Fetches schema and property data from the Silo API, builds the form
dynamically, and returns the creation result on accept.
A plain ``QWidget`` that can be embedded in an MDI subwindow, dock
panel, or dialog. Emits :pyqt:`item_created` on successful creation
and :pyqt:`cancelled` when the user clicks Cancel.
"""
item_created = QtCore.Signal(dict)
cancelled = QtCore.Signal()
def __init__(self, client, parent=None):
super().__init__(parent)
self._client = client
self._result = None
self._prop_widgets = {} # {key: (widget, prop_def)}
self._prop_groups = [] # list of _CollapsibleGroup to clear on category change
self._categories = {}
self._projects = []
self.setWindowTitle("New Item")
self.setMinimumSize(600, 500)
self.resize(680, 700)
self._load_schema_data()
self._build_ui()
@@ -307,8 +310,18 @@ class SchemaFormDialog(QtWidgets.QDialog):
root = QtWidgets.QVBoxLayout(self)
root.setSpacing(8)
# Inline error label (hidden by default)
self._error_label = QtWidgets.QLabel()
self._error_label.setStyleSheet(
"background-color: #f38ba8; color: #1e1e2e; "
"padding: 8px; border-radius: 4px; font-weight: bold;"
)
self._error_label.setAlignment(QtCore.Qt.AlignCenter)
self._error_label.hide()
root.addWidget(self._error_label)
# Part number preview banner
self._pn_label = QtWidgets.QLabel("Part Number: ")
self._pn_label = QtWidgets.QLabel("Part Number: \u2014")
self._pn_label.setStyleSheet("font-size: 16px; font-weight: bold; padding: 8px;")
self._pn_label.setAlignment(QtCore.Qt.AlignCenter)
root.addWidget(self._pn_label)
@@ -401,7 +414,7 @@ class SchemaFormDialog(QtWidgets.QDialog):
btn_layout.addStretch()
self._cancel_btn = QtWidgets.QPushButton("Cancel")
self._cancel_btn.clicked.connect(self.reject)
self._cancel_btn.clicked.connect(self.cancelled.emit)
btn_layout.addWidget(self._cancel_btn)
self._create_btn = QtWidgets.QPushButton("Create")
@@ -506,8 +519,10 @@ class SchemaFormDialog(QtWidgets.QDialog):
pn = self._generate_pn_preview(category)
if pn:
self._pn_label.setText(f"Part Number: {pn}")
self.setWindowTitle(f"New: {pn}")
else:
self._pn_label.setText(f"Part Number: {category}-????")
self.setWindowTitle(f"New: {category}-????")
# -- submission ---------------------------------------------------------
@@ -550,10 +565,12 @@ class SchemaFormDialog(QtWidgets.QDialog):
def _on_create(self):
"""Validate and submit the form."""
self._error_label.hide()
data = self._collect_form_data()
if not data["category"]:
QtWidgets.QMessageBox.warning(self, "Validation", "Category is required.")
self._error_label.setText("Category is required.")
self._error_label.show()
return
try:
@@ -563,11 +580,47 @@ class SchemaFormDialog(QtWidgets.QDialog):
data["description"],
projects=data["projects"],
)
self._result = result
self._result["_form_data"] = data
self.accept()
result["_form_data"] = data
self.item_created.emit(result)
except Exception as e:
QtWidgets.QMessageBox.critical(self, "Error", f"Failed to create item:\n{e}")
self._error_label.setText(f"Failed to create item: {e}")
self._error_label.show()
# ---------------------------------------------------------------------------
# Modal dialog wrapper (backward compatibility)
# ---------------------------------------------------------------------------
class SchemaFormDialog(QtWidgets.QDialog):
"""Modal dialog wrapper around :class:`SchemaFormWidget`.
Provides the same ``exec_and_create()`` API as the original
implementation for callers that still need blocking modal behavior.
"""
def __init__(self, client, parent=None):
super().__init__(parent)
self.setWindowTitle("New Item")
self.setMinimumSize(600, 500)
self.resize(680, 700)
self._result = None
layout = QtWidgets.QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
self._form = SchemaFormWidget(client, parent=self)
self._form.item_created.connect(self._on_created)
self._form.cancelled.connect(self.reject)
layout.addWidget(self._form)
@property
def _desc_edit(self):
"""Expose description field for pre-fill by callers."""
return self._form._desc_edit
def _on_created(self, result):
self._result = result
self.accept()
def exec_and_create(self):
"""Show dialog and return the creation result, or None if cancelled."""

View File

@@ -26,7 +26,9 @@ from silo_client import (
_PREF_GROUP = "User parameter:BaseApp/Preferences/Mod/KindredSilo"
# Configuration - preferences take priority over env vars
SILO_PROJECTS_DIR = os.environ.get("SILO_PROJECTS_DIR", os.path.expanduser("~/projects"))
SILO_PROJECTS_DIR = os.environ.get(
"SILO_PROJECTS_DIR", os.path.expanduser("~/projects")
)
# ---------------------------------------------------------------------------
@@ -64,7 +66,9 @@ class FreeCADSiloSettings(SiloSettings):
param = FreeCAD.ParamGet(_PREF_GROUP)
return param.GetString("SslCertPath", "")
def save_auth(self, username: str, role: str = "", source: str = "", token: str = ""):
def save_auth(
self, username: str, role: str = "", source: str = "", token: str = ""
):
param = FreeCAD.ParamGet(_PREF_GROUP)
param.SetString("AuthUsername", username)
param.SetString("AuthRole", role)
@@ -122,7 +126,9 @@ def _get_ssl_verify() -> bool:
def _get_ssl_context():
from silo_client._ssl import build_ssl_context
return build_ssl_context(_fc_settings.get_ssl_verify(), _fc_settings.get_ssl_cert_path())
return build_ssl_context(
_fc_settings.get_ssl_verify(), _fc_settings.get_ssl_cert_path()
)
def _get_auth_headers() -> Dict[str, str]:
@@ -179,7 +185,9 @@ def _fetch_server_mode() -> str:
# Icon helper
# ---------------------------------------------------------------------------
_ICON_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources", "icons")
_ICON_DIR = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "resources", "icons"
)
def _icon(name):
@@ -206,41 +214,45 @@ def get_projects_dir() -> Path:
def get_cad_file_path(part_number: str, description: str = "") -> Path:
"""Generate canonical file path for a CAD file.
Path format: ~/projects/cad/{category_code}_{category_name}/{part_number}_{description}.FCStd
Path format: ~/projects/cad/{category_code}_{category_name}/{part_number}_{description}.kc
"""
category, _ = parse_part_number(part_number)
folder_name = get_category_folder_name(category)
if description:
filename = f"{part_number}_{sanitize_filename(description)}.FCStd"
filename = f"{part_number}_{sanitize_filename(description)}.kc"
else:
filename = f"{part_number}.FCStd"
filename = f"{part_number}.kc"
return get_projects_dir() / "cad" / folder_name / filename
def find_file_by_part_number(part_number: str) -> Optional[Path]:
"""Find existing CAD file for a part number."""
"""Find existing CAD file for a part number. Prefers .kc over .FCStd."""
category, _ = parse_part_number(part_number)
folder_name = get_category_folder_name(category)
cad_dir = get_projects_dir() / "cad" / folder_name
if cad_dir.exists():
matches = list(cad_dir.glob(f"{part_number}*.FCStd"))
if matches:
return matches[0]
base_cad_dir = get_projects_dir() / "cad"
if base_cad_dir.exists():
for subdir in base_cad_dir.iterdir():
if subdir.is_dir():
matches = list(subdir.glob(f"{part_number}*.FCStd"))
if matches:
return matches[0]
for search_dir in _search_dirs(cad_dir):
for ext in ("*.kc", "*.FCStd"):
matches = list(search_dir.glob(f"{part_number}{ext[1:]}"))
if matches:
return matches[0]
return None
def _search_dirs(category_dir: Path):
"""Yield the category dir, then all sibling dirs under cad/."""
if category_dir.exists():
yield category_dir
base_cad_dir = category_dir.parent
if base_cad_dir.exists():
for subdir in base_cad_dir.iterdir():
if subdir.is_dir() and subdir != category_dir:
yield subdir
def search_local_files(search_term: str = "", category_filter: str = "") -> list:
"""Search for CAD files in local cad directory."""
results = []
@@ -260,7 +272,9 @@ def search_local_files(search_term: str = "", category_filter: str = "") -> list
if category_filter and category_code.upper() != category_filter.upper():
continue
for fcstd_file in category_dir.glob("*.FCStd"):
for fcstd_file in sorted(
list(category_dir.glob("*.kc")) + list(category_dir.glob("*.FCStd"))
):
filename = fcstd_file.stem
parts = filename.split("_", 1)
part_number = parts[0]
@@ -574,7 +588,9 @@ def handle_kindred_url(url: str):
parts = [parsed.netloc] + [p for p in parsed.path.split("/") if p]
if len(parts) >= 2 and parts[0] == "item":
part_number = parts[1]
FreeCAD.Console.PrintMessage(f"Silo: Opening item {part_number} from kindred:// URL\n")
FreeCAD.Console.PrintMessage(
f"Silo: Opening item {part_number} from kindred:// URL\n"
)
_sync.open_item(part_number)
@@ -594,149 +610,44 @@ class Silo_Open:
}
def Activated(self):
from PySide import QtCore, QtGui
from open_search import OpenItemWidget
from PySide import QtGui, QtWidgets
dialog = QtGui.QDialog()
dialog.setWindowTitle("Silo - Open Item")
dialog.setMinimumWidth(700)
dialog.setMinimumHeight(500)
mw = FreeCADGui.getMainWindow()
mdi = mw.findChild(QtWidgets.QMdiArea)
if not mdi:
return
layout = QtGui.QVBoxLayout(dialog)
widget = OpenItemWidget(_client, search_local_files)
# Search row
search_layout = QtGui.QHBoxLayout()
search_input = QtGui.QLineEdit()
search_input.setPlaceholderText("Search by part number or description...")
search_layout.addWidget(search_input)
layout.addLayout(search_layout)
sw = mdi.addSubWindow(widget)
sw.setWindowTitle("Open Item")
sw.setWindowIcon(QtGui.QIcon(_icon("open")))
sw.show()
mdi.setActiveSubWindow(sw)
# Filters
filter_layout = QtGui.QHBoxLayout()
db_checkbox = QtGui.QCheckBox("Database")
db_checkbox.setChecked(True)
local_checkbox = QtGui.QCheckBox("Local Files")
local_checkbox.setChecked(True)
filter_layout.addWidget(db_checkbox)
filter_layout.addWidget(local_checkbox)
filter_layout.addStretch()
layout.addLayout(filter_layout)
# Results table
results_table = QtGui.QTableWidget()
results_table.setColumnCount(5)
results_table.setHorizontalHeaderLabels(
["Part Number", "Description", "Type", "Source", "Modified"]
)
results_table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows)
results_table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection)
results_table.horizontalHeader().setStretchLastSection(True)
results_table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers)
layout.addWidget(results_table)
results_data = []
def do_search():
nonlocal results_data
search_term = search_input.text().strip()
results_data = []
results_table.setRowCount(0)
if db_checkbox.isChecked():
try:
for item in _client.list_items(search=search_term):
results_data.append(
{
"part_number": item.get("part_number", ""),
"description": item.get("description", ""),
"item_type": item.get("item_type", ""),
"source": "database",
"modified": item.get("updated_at", "")[:10]
if item.get("updated_at")
else "",
"path": None,
}
)
except Exception as e:
FreeCAD.Console.PrintWarning(f"DB search failed: {e}\n")
if local_checkbox.isChecked():
try:
for item in search_local_files(search_term):
existing = next(
(r for r in results_data if r["part_number"] == item["part_number"]),
None,
)
if existing:
existing["source"] = "both"
existing["path"] = item.get("path")
else:
results_data.append(
{
"part_number": item.get("part_number", ""),
"description": item.get("description", ""),
"item_type": "",
"source": "local",
"modified": item.get("modified", "")[:10]
if item.get("modified")
else "",
"path": item.get("path"),
}
)
except Exception as e:
FreeCAD.Console.PrintWarning(f"Local search failed: {e}\n")
results_table.setRowCount(len(results_data))
for row, data in enumerate(results_data):
results_table.setItem(row, 0, QtGui.QTableWidgetItem(data["part_number"]))
results_table.setItem(row, 1, QtGui.QTableWidgetItem(data["description"]))
results_table.setItem(row, 2, QtGui.QTableWidgetItem(data["item_type"]))
results_table.setItem(row, 3, QtGui.QTableWidgetItem(data["source"]))
results_table.setItem(row, 4, QtGui.QTableWidgetItem(data["modified"]))
results_table.resizeColumnsToContents()
_open_after_close = [None]
def open_selected():
selected = results_table.selectedItems()
if not selected:
return
row = selected[0].row()
_open_after_close[0] = dict(results_data[row])
dialog.accept()
search_input.textChanged.connect(lambda: do_search())
results_table.doubleClicked.connect(open_selected)
# Buttons
btn_layout = QtGui.QHBoxLayout()
open_btn = QtGui.QPushButton("Open")
open_btn.clicked.connect(open_selected)
cancel_btn = QtGui.QPushButton("Cancel")
cancel_btn.clicked.connect(dialog.reject)
btn_layout.addStretch()
btn_layout.addWidget(open_btn)
btn_layout.addWidget(cancel_btn)
layout.addLayout(btn_layout)
do_search()
dialog.exec_()
# Open the document AFTER the dialog has fully closed so that
# heavy document loads (especially Assembly files) don't run
# inside the dialog's nested event loop, which can cause crashes.
data = _open_after_close[0]
if data is not None:
def _on_selected(data):
sw.close()
if data.get("path"):
FreeCAD.openDocument(data["path"])
else:
_sync.open_item(data["part_number"])
widget.item_selected.connect(_on_selected)
widget.cancelled.connect(sw.close)
def IsActive(self):
return True
class Silo_New:
"""Create new item with part number."""
"""Create new item with part number.
Opens a pre-document MDI tab containing the schema-driven creation
form. Each invocation opens a new tab so multiple items can be
prepared in parallel. On successful creation the tab closes and
the real document opens in its place.
"""
def GetResources(self):
return {
@@ -746,58 +657,88 @@ class Silo_New:
}
def Activated(self):
from PySide import QtGui
from PySide import QtGui, QtWidgets
from schema_form import SchemaFormWidget
from schema_form import SchemaFormDialog
sel = FreeCADGui.Selection.getSelection()
dlg = SchemaFormDialog(_client, parent=FreeCADGui.getMainWindow())
# Pre-fill description from selected object
if sel:
dlg._desc_edit.setText(sel[0].Label)
result = dlg.exec_and_create()
if result is None:
mw = FreeCADGui.getMainWindow()
mdi = mw.findChild(QtWidgets.QMdiArea)
if not mdi:
return
part_number = result["part_number"]
form_data = result.get("_form_data", {})
selected_projects = form_data.get("projects") or []
# Each invocation creates a new pre-document tab
form = SchemaFormWidget(_client)
try:
if sel:
# Tag selected object
obj = sel[0]
set_silo_properties(
obj,
{
"SiloItemId": result.get("id", ""),
"SiloPartNumber": part_number,
"SiloRevision": 1,
},
)
obj.Label = part_number
_sync.save_to_canonical_path(FreeCAD.ActiveDocument, force_rename=True)
else:
# Create new document
_sync.create_document_for_item(result, save=True)
# Pre-fill description from current selection
sel = FreeCADGui.Selection.getSelection()
if sel:
form._desc_edit.setText(sel[0].Label)
msg = f"Part number: {part_number}"
if selected_projects:
msg += f"\nTagged with projects: {', '.join(selected_projects)}"
# Add as MDI subwindow (appears as a tab alongside documents)
sw = mdi.addSubWindow(form)
sw.setWindowTitle("New Item")
sw.setWindowIcon(QtGui.QIcon(_icon("new")))
sw.show()
mdi.setActiveSubWindow(sw)
FreeCAD.Console.PrintMessage(f"Created: {part_number}\n")
QtGui.QMessageBox.information(None, "Item Created", msg)
# On creation: process result, close tab, open real document
def _on_created(result):
part_number = result["part_number"]
except Exception as e:
QtGui.QMessageBox.critical(None, "Error", str(e))
try:
sel_now = FreeCADGui.Selection.getSelection()
if sel_now:
obj = sel_now[0]
set_silo_properties(
obj,
{
"SiloItemId": result.get("id", ""),
"SiloPartNumber": part_number,
"SiloRevision": 1,
},
)
obj.Label = part_number
_sync.save_to_canonical_path(
FreeCAD.ActiveDocument, force_rename=True
)
else:
_sync.create_document_for_item(result, save=True)
FreeCAD.Console.PrintMessage(f"Created: {part_number}\n")
except Exception as e:
FreeCAD.Console.PrintError(f"Failed to process created item: {e}\n")
# Close the pre-document tab
sw.close()
form.item_created.connect(_on_created)
form.cancelled.connect(sw.close)
def IsActive(self):
return _server_mode == "normal"
def _push_dag_after_upload(doc, part_number, revision_number):
"""Extract and push the feature DAG after a successful upload.
Failures are logged as warnings -- DAG sync must never block save.
"""
try:
from dag import extract_dag
nodes, edges = extract_dag(doc)
if not nodes:
return
result = _client.push_dag(part_number, revision_number, nodes, edges)
node_count = result.get("node_count", len(nodes))
edge_count = result.get("edge_count", len(edges))
FreeCAD.Console.PrintMessage(
f"DAG synced: {node_count} nodes, {edge_count} edges\n"
)
except Exception as e:
FreeCAD.Console.PrintWarning(f"DAG sync failed: {e}\n")
class Silo_Save:
"""Save locally and upload to MinIO."""
@@ -861,11 +802,15 @@ class Silo_Save:
# Try to upload to MinIO
try:
result = _client._upload_file(part_number, str(file_path), properties, "Auto-save")
result = _client._upload_file(
part_number, str(file_path), properties, "Auto-save"
)
new_rev = result["revision_number"]
FreeCAD.Console.PrintMessage(f"Uploaded as revision {new_rev}\n")
_push_dag_after_upload(doc, part_number, new_rev)
except Exception as e:
FreeCAD.Console.PrintWarning(f"Upload failed: {e}\n")
FreeCAD.Console.PrintMessage("File saved locally but not uploaded.\n")
@@ -894,7 +839,9 @@ class Silo_Commit:
obj = get_tracked_object(doc)
if not obj:
FreeCAD.Console.PrintError("No tracked object. Use 'New' to register first.\n")
FreeCAD.Console.PrintError(
"No tracked object. Use 'New' to register first.\n"
)
return
part_number = obj.SiloPartNumber
@@ -911,11 +858,15 @@ class Silo_Commit:
if not file_path:
return
result = _client._upload_file(part_number, str(file_path), properties, comment)
result = _client._upload_file(
part_number, str(file_path), properties, comment
)
new_rev = result["revision_number"]
FreeCAD.Console.PrintMessage(f"Committed revision {new_rev}: {comment}\n")
_push_dag_after_upload(doc, part_number, new_rev)
except Exception as e:
FreeCAD.Console.PrintError(f"Commit failed: {e}\n")
@@ -960,7 +911,9 @@ def _check_pull_conflicts(part_number, local_path, doc=None):
server_updated = item.get("updated_at", "")
if server_updated:
# Parse ISO format timestamp
server_dt = datetime.datetime.fromisoformat(server_updated.replace("Z", "+00:00"))
server_dt = datetime.datetime.fromisoformat(
server_updated.replace("Z", "+00:00")
)
if server_dt > local_mtime:
conflicts.append("Server version is newer than local file.")
except Exception:
@@ -990,7 +943,9 @@ class SiloPullDialog:
# Revision table
self._table = QtGui.QTableWidget()
self._table.setColumnCount(5)
self._table.setHorizontalHeaderLabels(["Rev", "Date", "Comment", "Status", "File"])
self._table.setHorizontalHeaderLabels(
["Rev", "Date", "Comment", "Status", "File"]
)
self._table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows)
self._table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection)
self._table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers)
@@ -1062,6 +1017,67 @@ class SiloPullDialog:
return None
def _pull_dependencies(part_number, progress_callback=None):
"""Recursively pull all BOM children that have files on the server.
Returns list of (part_number, dest_path) tuples for successfully pulled files.
Skips children that already exist locally.
"""
pulled = []
try:
bom = _client.get_bom(part_number)
except Exception as e:
FreeCAD.Console.PrintWarning(f"Could not fetch BOM for {part_number}: {e}\n")
return pulled
for entry in bom:
child_pn = entry.get("child_part_number")
if not child_pn:
continue
# Skip if already exists locally
existing = find_file_by_part_number(child_pn)
if existing and existing.exists():
FreeCAD.Console.PrintMessage(
f" {child_pn}: already exists at {existing}\n"
)
# Still recurse — this child may itself be an assembly with missing deps
_pull_dependencies(child_pn, progress_callback)
continue
# Check if this child has a file on the server
try:
latest = _client.latest_file_revision(child_pn)
except Exception:
latest = None
if not latest or not latest.get("file_key"):
FreeCAD.Console.PrintMessage(f" {child_pn}: no file on server, skipping\n")
continue
# Determine destination path
child_desc = entry.get("child_description", "")
dest_path = get_cad_file_path(child_pn, child_desc)
dest_path.parent.mkdir(parents=True, exist_ok=True)
rev_num = latest["revision_number"]
FreeCAD.Console.PrintMessage(f" Pulling {child_pn} rev {rev_num}...\n")
try:
ok = _client._download_file(
child_pn, rev_num, str(dest_path), progress_callback=progress_callback
)
if ok:
pulled.append((child_pn, dest_path))
except Exception as e:
FreeCAD.Console.PrintWarning(f" Failed to pull {child_pn}: {e}\n")
# Recurse into child (it may be a sub-assembly)
_pull_dependencies(child_pn, progress_callback)
return pulled
class Silo_Pull:
"""Download from MinIO / sync from database."""
@@ -1104,14 +1120,18 @@ class Silo_Pull:
if not has_any_file:
if existing_local:
FreeCAD.Console.PrintMessage(f"Opening existing local file: {existing_local}\n")
FreeCAD.Console.PrintMessage(
f"Opening existing local file: {existing_local}\n"
)
FreeCAD.openDocument(str(existing_local))
else:
try:
item = _client.get_item(part_number)
new_doc = _sync.create_document_for_item(item, save=True)
if new_doc:
FreeCAD.Console.PrintMessage(f"Created local file for {part_number}\n")
FreeCAD.Console.PrintMessage(
f"Created local file for {part_number}\n"
)
else:
QtGui.QMessageBox.warning(
None,
@@ -1189,6 +1209,19 @@ class Silo_Pull:
FreeCAD.Console.PrintMessage(f"Pulled revision {rev_num} of {part_number}\n")
# Pull assembly dependencies before opening so links resolve
if item.get("item_type") == "assembly":
progress.setLabelText(f"Pulling dependencies for {part_number}...")
progress.setValue(0)
progress.show()
dep_pulled = _pull_dependencies(part_number, progress_callback=on_progress)
progress.setValue(100)
progress.close()
if dep_pulled:
FreeCAD.Console.PrintMessage(
f"Pulled {len(dep_pulled)} dependency file(s)\n"
)
# Close existing document if open, then reopen
if doc and doc.FileName == str(dest_path):
FreeCAD.closeDocument(doc.Name)
@@ -1242,7 +1275,9 @@ class Silo_Push:
server_dt = datetime.fromisoformat(
server_time_str.replace("Z", "+00:00")
)
local_dt = datetime.fromtimestamp(local_mtime, tz=timezone.utc)
local_dt = datetime.fromtimestamp(
local_mtime, tz=timezone.utc
)
if local_dt > server_dt:
unuploaded.append(lf)
else:
@@ -1255,7 +1290,9 @@ class Silo_Push:
pass # Not in DB, skip
if not unuploaded:
QtGui.QMessageBox.information(None, "Push", "All local files are already uploaded.")
QtGui.QMessageBox.information(
None, "Push", "All local files are already uploaded."
)
return
msg = f"Found {len(unuploaded)} files to upload:\n\n"
@@ -1273,7 +1310,9 @@ class Silo_Push:
uploaded = 0
for item in unuploaded:
result = _sync.upload_file(item["part_number"], item["path"], "Synced from local")
result = _sync.upload_file(
item["part_number"], item["path"], "Synced from local"
)
if result:
uploaded += 1
@@ -1322,9 +1361,7 @@ class Silo_Info:
msg = f"<h3>{part_number}</h3>"
msg += f"<p><b>Type:</b> {item.get('item_type', '-')}</p>"
msg += f"<p><b>Description:</b> {item.get('description', '-')}</p>"
msg += (
f"<p><b>Projects:</b> {', '.join(project_codes) if project_codes else 'None'}</p>"
)
msg += f"<p><b>Projects:</b> {', '.join(project_codes) if project_codes else 'None'}</p>"
msg += f"<p><b>Current Revision:</b> {item.get('current_revision', 1)}</p>"
msg += f"<p><b>Local Revision:</b> {getattr(obj, 'SiloRevision', '-')}</p>"
@@ -1390,7 +1427,9 @@ class Silo_TagProjects:
try:
# Get current projects for item
current_projects = _client.get_item_projects(part_number)
current_codes = {p.get("code", "") for p in current_projects if p.get("code")}
current_codes = {
p.get("code", "") for p in current_projects if p.get("code")
}
# Get all available projects
all_projects = _client.get_projects()
@@ -1501,7 +1540,9 @@ class Silo_Rollback:
dialog.setMinimumHeight(300)
layout = QtGui.QVBoxLayout(dialog)
label = QtGui.QLabel(f"Select a revision to rollback to (current: Rev {current_rev}):")
label = QtGui.QLabel(
f"Select a revision to rollback to (current: Rev {current_rev}):"
)
layout.addWidget(label)
# Revision table
@@ -1516,8 +1557,12 @@ class Silo_Rollback:
for i, rev in enumerate(prev_revisions):
table.setItem(i, 0, QtGui.QTableWidgetItem(str(rev["revision_number"])))
table.setItem(i, 1, QtGui.QTableWidgetItem(rev.get("status", "draft")))
table.setItem(i, 2, QtGui.QTableWidgetItem(rev.get("created_at", "")[:10]))
table.setItem(i, 3, QtGui.QTableWidgetItem(rev.get("comment", "") or ""))
table.setItem(
i, 2, QtGui.QTableWidgetItem(rev.get("created_at", "")[:10])
)
table.setItem(
i, 3, QtGui.QTableWidgetItem(rev.get("comment", "") or "")
)
table.resizeColumnsToContents()
layout.addWidget(table)
@@ -1543,7 +1588,9 @@ class Silo_Rollback:
def on_rollback():
selected = table.selectedItems()
if not selected:
QtGui.QMessageBox.warning(dialog, "Rollback", "Please select a revision")
QtGui.QMessageBox.warning(
dialog, "Rollback", "Please select a revision"
)
return
selected_rev[0] = int(table.item(selected[0].row(), 0).text())
dialog.accept()
@@ -1641,7 +1688,9 @@ class Silo_SetStatus:
# Update status
_client.update_revision(part_number, rev_num, status=status)
FreeCAD.Console.PrintMessage(f"Updated Rev {rev_num} status to '{status}'\n")
FreeCAD.Console.PrintMessage(
f"Updated Rev {rev_num} status to '{status}'\n"
)
QtGui.QMessageBox.information(
None, "Status Updated", f"Revision {rev_num} status set to '{status}'"
)
@@ -1705,7 +1754,9 @@ class Silo_Settings:
ssl_checkbox.setChecked(param.GetBool("SslVerify", True))
layout.addWidget(ssl_checkbox)
ssl_hint = QtGui.QLabel("Disable only for internal servers with self-signed certificates.")
ssl_hint = QtGui.QLabel(
"Disable only for internal servers with self-signed certificates."
)
ssl_hint.setWordWrap(True)
ssl_hint.setStyleSheet("color: #888; font-size: 11px;")
layout.addWidget(ssl_hint)
@@ -1738,7 +1789,7 @@ class Silo_Settings:
path, _ = QtGui.QFileDialog.getOpenFileName(
dialog,
"Select CA Certificate",
os.path.dirname(cert_input.text()) or "/etc/ssl/certs",
os.path.dirname(cert_input.text()) or os.path.expanduser("~"),
"Certificates (*.pem *.crt *.cer);;All Files (*)",
)
if path:
@@ -1982,7 +2033,9 @@ class Silo_BOM:
wu_table = QtGui.QTableWidget()
wu_table.setColumnCount(5)
wu_table.setHorizontalHeaderLabels(["Parent Part Number", "Type", "Qty", "Unit", "Ref Des"])
wu_table.setHorizontalHeaderLabels(
["Parent Part Number", "Type", "Qty", "Unit", "Ref Des"]
)
wu_table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows)
wu_table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection)
wu_table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers)
@@ -2011,12 +2064,16 @@ class Silo_BOM:
bom_table.setItem(
row, 1, QtGui.QTableWidgetItem(entry.get("child_description", ""))
)
bom_table.setItem(row, 2, QtGui.QTableWidgetItem(entry.get("rel_type", "")))
bom_table.setItem(
row, 2, QtGui.QTableWidgetItem(entry.get("rel_type", ""))
)
qty = entry.get("quantity")
bom_table.setItem(
row, 3, QtGui.QTableWidgetItem(str(qty) if qty is not None else "")
)
bom_table.setItem(row, 4, QtGui.QTableWidgetItem(entry.get("unit") or ""))
bom_table.setItem(
row, 4, QtGui.QTableWidgetItem(entry.get("unit") or "")
)
ref_des = entry.get("reference_designators") or []
bom_table.setItem(row, 5, QtGui.QTableWidgetItem(", ".join(ref_des)))
bom_table.setItem(
@@ -2038,12 +2095,16 @@ class Silo_BOM:
wu_table.setItem(
row, 0, QtGui.QTableWidgetItem(entry.get("parent_part_number", ""))
)
wu_table.setItem(row, 1, QtGui.QTableWidgetItem(entry.get("rel_type", "")))
wu_table.setItem(
row, 1, QtGui.QTableWidgetItem(entry.get("rel_type", ""))
)
qty = entry.get("quantity")
wu_table.setItem(
row, 2, QtGui.QTableWidgetItem(str(qty) if qty is not None else "")
)
wu_table.setItem(row, 3, QtGui.QTableWidgetItem(entry.get("unit") or ""))
wu_table.setItem(
row, 3, QtGui.QTableWidgetItem(entry.get("unit") or "")
)
ref_des = entry.get("reference_designators") or []
wu_table.setItem(row, 4, QtGui.QTableWidgetItem(", ".join(ref_des)))
wu_table.resizeColumnsToContents()
@@ -2096,7 +2157,9 @@ class Silo_BOM:
try:
qty = float(qty_text)
except ValueError:
QtGui.QMessageBox.warning(dialog, "BOM", "Quantity must be a number.")
QtGui.QMessageBox.warning(
dialog, "BOM", "Quantity must be a number."
)
return
unit = unit_input.text().strip() or None
@@ -2175,7 +2238,9 @@ class Silo_BOM:
try:
new_qty = float(qty_text)
except ValueError:
QtGui.QMessageBox.warning(dialog, "BOM", "Quantity must be a number.")
QtGui.QMessageBox.warning(
dialog, "BOM", "Quantity must be a number."
)
return
new_unit = unit_input.text().strip() or None
@@ -2199,7 +2264,9 @@ class Silo_BOM:
)
load_bom()
except Exception as exc:
QtGui.QMessageBox.warning(dialog, "BOM", f"Failed to update entry:\n{exc}")
QtGui.QMessageBox.warning(
dialog, "BOM", f"Failed to update entry:\n{exc}"
)
def on_remove():
selected = bom_table.selectedItems()
@@ -2225,7 +2292,9 @@ class Silo_BOM:
_client.delete_bom_entry(part_number, child_pn)
load_bom()
except Exception as exc:
QtGui.QMessageBox.warning(dialog, "BOM", f"Failed to remove entry:\n{exc}")
QtGui.QMessageBox.warning(
dialog, "BOM", f"Failed to remove entry:\n{exc}"
)
add_btn.clicked.connect(on_add)
edit_btn.clicked.connect(on_edit)
@@ -2264,9 +2333,23 @@ class SiloEventListener(QtCore.QThread):
item_updated = QtCore.Signal(str) # part_number
revision_created = QtCore.Signal(str, int) # part_number, revision
connection_status = QtCore.Signal(str, int, str) # (status, retry_count, error_message)
connection_status = QtCore.Signal(
str, int, str
) # (status, retry_count, error_message)
server_mode_changed = QtCore.Signal(str) # "normal" / "read-only" / "degraded"
# DAG events
dag_updated = QtCore.Signal(str, int, int) # part_number, node_count, edge_count
dag_validated = QtCore.Signal(str, bool, int) # part_number, valid, failed_count
# Job lifecycle events
job_created = QtCore.Signal(str, str, str) # job_id, definition_name, part_number
job_claimed = QtCore.Signal(str, str) # job_id, runner_id
job_progress = QtCore.Signal(str, int, str) # job_id, progress, message
job_completed = QtCore.Signal(str) # job_id
job_failed = QtCore.Signal(str, str) # job_id, error
job_cancelled = QtCore.Signal(str) # job_id
_MAX_RETRIES = 10
_BASE_DELAY = 1 # seconds, doubles each retry
_MAX_DELAY = 60 # seconds, backoff cap
@@ -2338,7 +2421,9 @@ class SiloEventListener(QtCore.QThread):
req = urllib.request.Request(url, headers=headers, method="GET")
try:
self._response = urllib.request.urlopen(req, context=_get_ssl_context(), timeout=90)
self._response = urllib.request.urlopen(
req, context=_get_ssl_context(), timeout=90
)
except urllib.error.HTTPError as e:
if e.code in (404, 501):
raise _SSEUnsupported()
@@ -2381,6 +2466,35 @@ class SiloEventListener(QtCore.QThread):
self.server_mode_changed.emit(mode)
return
# Job lifecycle events (keyed by job_id, not part_number)
job_id = payload.get("job_id", "")
if event_type == "job.created":
self.job_created.emit(
job_id,
payload.get("definition_name", ""),
payload.get("part_number", ""),
)
return
if event_type == "job.claimed":
self.job_claimed.emit(job_id, payload.get("runner_id", ""))
return
if event_type == "job.progress":
self.job_progress.emit(
job_id,
int(payload.get("progress", 0)),
payload.get("message", ""),
)
return
if event_type == "job.completed":
self.job_completed.emit(job_id)
return
if event_type == "job.failed":
self.job_failed.emit(job_id, payload.get("error", ""))
return
if event_type == "job.cancelled":
self.job_cancelled.emit(job_id)
return
pn = payload.get("part_number", "")
if not pn:
return
@@ -2390,6 +2504,18 @@ class SiloEventListener(QtCore.QThread):
elif event_type == "revision_created":
rev = payload.get("revision", 0)
self.revision_created.emit(pn, int(rev))
elif event_type == "dag.updated":
self.dag_updated.emit(
pn,
int(payload.get("node_count", 0)),
int(payload.get("edge_count", 0)),
)
elif event_type == "dag.validated":
self.dag_validated.emit(
pn,
bool(payload.get("valid", False)),
int(payload.get("failed_count", 0)),
)
class _SSEUnsupported(Exception):
@@ -2619,7 +2745,9 @@ class SiloAuthDockWidget:
self._sse_label.setToolTip("")
FreeCAD.Console.PrintMessage("Silo: SSE connected\n")
elif status == "disconnected":
self._sse_label.setText(f"Reconnecting ({retry}/{SiloEventListener._MAX_RETRIES})...")
self._sse_label.setText(
f"Reconnecting ({retry}/{SiloEventListener._MAX_RETRIES})..."
)
self._sse_label.setStyleSheet("font-size: 11px; color: #FF9800;")
self._sse_label.setToolTip(error or "Connection lost")
FreeCAD.Console.PrintWarning(
@@ -2629,7 +2757,9 @@ class SiloAuthDockWidget:
self._sse_label.setText("Disconnected")
self._sse_label.setStyleSheet("font-size: 11px; color: #F44336;")
self._sse_label.setToolTip(error or "Max retries reached")
FreeCAD.Console.PrintError(f"Silo: SSE gave up after {retry} retries: {error}\n")
FreeCAD.Console.PrintError(
f"Silo: SSE gave up after {retry} retries: {error}\n"
)
elif status == "unsupported":
self._sse_label.setText("Not available")
self._sse_label.setStyleSheet("font-size: 11px; color: #888;")
@@ -2671,10 +2801,14 @@ class SiloAuthDockWidget:
self._refresh_activity_panel()
def _on_remote_revision(self, part_number, revision):
FreeCAD.Console.PrintMessage(f"Silo: New revision {revision} for {part_number}\n")
FreeCAD.Console.PrintMessage(
f"Silo: New revision {revision} for {part_number}\n"
)
mw = FreeCADGui.getMainWindow()
if mw is not None:
mw.statusBar().showMessage(f"Silo: {part_number} rev {revision} available", 5000)
mw.statusBar().showMessage(
f"Silo: {part_number} rev {revision} available", 5000
)
self._refresh_activity_panel()
def _refresh_activity_panel(self):
@@ -2740,7 +2874,9 @@ class SiloAuthDockWidget:
rev_part = f" \u2013 Rev {rev_num}" if rev_num else ""
date_part = f" \u2013 {updated}" if updated else ""
local_badge = " \u25cf local" if pn in local_pns else ""
line1 = f"{pn} \u2013 {desc_display}{rev_part}{date_part}{local_badge}"
line1 = (
f"{pn} \u2013 {desc_display}{rev_part}{date_part}{local_badge}"
)
if comment:
line1 += f'\n "{comment}"'
@@ -3196,7 +3332,9 @@ class Silo_StartPanel:
dock = QtGui.QDockWidget("Silo", mw)
dock.setObjectName("SiloStartPanel")
dock.setWidget(content.widget)
dock.setAllowedAreas(QtCore.Qt.LeftDockWidgetArea | QtCore.Qt.RightDockWidgetArea)
dock.setAllowedAreas(
QtCore.Qt.LeftDockWidgetArea | QtCore.Qt.RightDockWidgetArea
)
mw.addDockWidget(QtCore.Qt.LeftDockWidgetArea, dock)
def IsActive(self):
@@ -3230,7 +3368,9 @@ class _DiagWorker(QtCore.QThread):
self.result.emit("DNS", False, "no hostname in URL")
return
try:
addrs = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
addrs = socket.getaddrinfo(
hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM
)
first_ip = addrs[0][4][0] if addrs else "?"
self.result.emit("DNS", True, f"{hostname} -> {first_ip}")
except socket.gaierror as e: