Compare commits
10 Commits
feat/live-
...
feat/dag-a
| Author | SHA1 | Date | |
|---|---|---|---|
| 27ddd6d750 | |||
|
|
e31321ac95 | ||
|
|
dc64a66f0f | ||
|
|
3d38e4b4c3 | ||
|
|
da2a360c56 | ||
|
|
3dd0da3964 | ||
|
|
4921095296 | ||
|
|
3a9fe6aed8 | ||
|
|
9e99b83091 | ||
|
|
fed72676bc |
@@ -45,6 +45,7 @@ class SiloWorkbench(FreeCADGui.Workbench):
|
||||
"Separator",
|
||||
"Silo_Info",
|
||||
"Silo_BOM",
|
||||
"Silo_Jobs",
|
||||
]
|
||||
self.appendToolbar("Silo Origin", self.silo_toolbar_commands, "Unavailable")
|
||||
|
||||
@@ -52,12 +53,14 @@ class SiloWorkbench(FreeCADGui.Workbench):
|
||||
self.menu_commands = [
|
||||
"Silo_Info",
|
||||
"Silo_BOM",
|
||||
"Silo_Jobs",
|
||||
"Silo_TagProjects",
|
||||
"Silo_SetStatus",
|
||||
"Silo_Rollback",
|
||||
"Separator",
|
||||
"Silo_Settings",
|
||||
"Silo_Auth",
|
||||
"Silo_Runners",
|
||||
"Silo_StartPanel",
|
||||
"Silo_Diag",
|
||||
]
|
||||
|
||||
463
freecad/dag.py
Normal file
463
freecad/dag.py
Normal 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
|
||||
156
freecad/runner.py
Normal file
156
freecad/runner.py
Normal 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)
|
||||
@@ -7,7 +7,6 @@ import socket
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
@@ -32,25 +31,6 @@ SILO_PROJECTS_DIR = os.environ.get(
|
||||
)
|
||||
|
||||
|
||||
def _relative_time(dt):
|
||||
"""Format a datetime as a human-friendly relative string."""
|
||||
now = datetime.now()
|
||||
diff = now - dt
|
||||
seconds = int(diff.total_seconds())
|
||||
if seconds < 60:
|
||||
return "just now"
|
||||
minutes = seconds // 60
|
||||
if minutes < 60:
|
||||
return f"{minutes}m ago"
|
||||
hours = minutes // 60
|
||||
if hours < 24:
|
||||
return f"{hours}h ago"
|
||||
days = hours // 24
|
||||
if days < 30:
|
||||
return f"{days}d ago"
|
||||
return dt.strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FreeCAD settings adapter
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -234,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 = []
|
||||
@@ -288,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]
|
||||
@@ -731,6 +717,28 @@ class Silo_New:
|
||||
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."""
|
||||
|
||||
@@ -801,6 +809,8 @@ class Silo_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")
|
||||
@@ -855,6 +865,8 @@ class Silo_Commit:
|
||||
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")
|
||||
|
||||
@@ -1777,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:
|
||||
@@ -2326,6 +2338,18 @@ class SiloEventListener(QtCore.QThread):
|
||||
) # (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
|
||||
@@ -2442,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
|
||||
@@ -2451,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):
|
||||
@@ -2470,8 +2535,6 @@ class SiloAuthDockWidget:
|
||||
|
||||
self.widget = QtGui.QWidget()
|
||||
self._event_listener = None
|
||||
self._activity_events = [] # list of (datetime, text, part_number)
|
||||
self._activity_seeded = False
|
||||
self._build_ui()
|
||||
self._refresh_status()
|
||||
|
||||
@@ -2479,11 +2542,6 @@ class SiloAuthDockWidget:
|
||||
self._timer.timeout.connect(self._refresh_status)
|
||||
self._timer.start(30000)
|
||||
|
||||
# Refresh relative timestamps every 60s
|
||||
self._ts_timer = QtCore.QTimer(self.widget)
|
||||
self._ts_timer.timeout.connect(self._rebuild_activity_feed)
|
||||
self._ts_timer.start(60000)
|
||||
|
||||
# -- UI construction ----------------------------------------------------
|
||||
|
||||
def _build_ui(self):
|
||||
@@ -2674,6 +2732,14 @@ class SiloAuthDockWidget:
|
||||
self._event_listener.revision_created.connect(self._on_remote_revision)
|
||||
self._event_listener.connection_status.connect(self._on_sse_status)
|
||||
self._event_listener.server_mode_changed.connect(self._on_server_mode)
|
||||
self._event_listener.dag_updated.connect(self._on_dag_updated)
|
||||
self._event_listener.dag_validated.connect(self._on_dag_validated)
|
||||
self._event_listener.job_created.connect(self._on_job_created)
|
||||
self._event_listener.job_claimed.connect(self._on_job_claimed)
|
||||
self._event_listener.job_progress.connect(self._on_job_progress)
|
||||
self._event_listener.job_completed.connect(self._on_job_completed)
|
||||
self._event_listener.job_failed.connect(self._on_job_failed)
|
||||
self._event_listener.job_cancelled.connect(self._on_job_cancelled)
|
||||
self._event_listener.start()
|
||||
else:
|
||||
if self._event_listener is not None and self._event_listener.isRunning():
|
||||
@@ -2686,7 +2752,6 @@ class SiloAuthDockWidget:
|
||||
self._sse_label.setStyleSheet("font-size: 11px; color: #4CAF50;")
|
||||
self._sse_label.setToolTip("")
|
||||
FreeCAD.Console.PrintMessage("Silo: SSE connected\n")
|
||||
self._seed_activity_feed()
|
||||
elif status == "disconnected":
|
||||
self._sse_label.setText(
|
||||
f"Reconnecting ({retry}/{SiloEventListener._MAX_RETRIES})..."
|
||||
@@ -2711,8 +2776,6 @@ class SiloAuthDockWidget:
|
||||
global _server_mode
|
||||
_server_mode = mode
|
||||
self._update_mode_banner()
|
||||
if mode != "normal":
|
||||
self._append_activity_event(f"Server mode: {mode}")
|
||||
|
||||
def _update_mode_banner(self):
|
||||
_MODE_BANNERS = {
|
||||
@@ -2743,7 +2806,7 @@ class SiloAuthDockWidget:
|
||||
mw = FreeCADGui.getMainWindow()
|
||||
if mw is not None:
|
||||
mw.statusBar().showMessage(f"Silo: {part_number} updated on server", 5000)
|
||||
self._append_activity_event(f"{part_number} updated", part_number)
|
||||
self._refresh_activity_panel()
|
||||
|
||||
def _on_remote_revision(self, part_number, revision):
|
||||
FreeCAD.Console.PrintMessage(
|
||||
@@ -2754,48 +2817,89 @@ class SiloAuthDockWidget:
|
||||
mw.statusBar().showMessage(
|
||||
f"Silo: {part_number} rev {revision} available", 5000
|
||||
)
|
||||
self._append_activity_event(
|
||||
f"{part_number} Rev {revision} created", part_number
|
||||
self._refresh_activity_panel()
|
||||
|
||||
def _on_dag_updated(self, part_number, node_count, edge_count):
|
||||
FreeCAD.Console.PrintMessage(
|
||||
f"Silo: DAG updated for {part_number}"
|
||||
f" ({node_count} nodes, {edge_count} edges)\n"
|
||||
)
|
||||
self._add_activity_entry(
|
||||
f"\u25b6 {part_number} \u2013 DAG synced"
|
||||
f" ({node_count} nodes, {edge_count} edges)",
|
||||
part_number,
|
||||
)
|
||||
|
||||
def _append_activity_event(self, text, pn=""):
|
||||
"""Prepend an event to the activity feed and rebuild the display."""
|
||||
self._activity_events.insert(0, (datetime.now(), text, pn))
|
||||
self._activity_events = self._activity_events[:50]
|
||||
self._rebuild_activity_feed()
|
||||
def _on_dag_validated(self, part_number, valid, failed_count):
|
||||
if valid:
|
||||
status = "\u2713 PASS"
|
||||
FreeCAD.Console.PrintMessage(f"Silo: Validation passed for {part_number}\n")
|
||||
else:
|
||||
status = f"\u2717 FAIL ({failed_count} failed)"
|
||||
FreeCAD.Console.PrintWarning(
|
||||
f"Silo: Validation failed for {part_number}"
|
||||
f" ({failed_count} features failed)\n"
|
||||
)
|
||||
self._add_activity_entry(f"{status} \u2013 {part_number}", part_number)
|
||||
|
||||
def _seed_activity_feed(self):
|
||||
"""One-time: populate the feed with recent items from the database."""
|
||||
if self._activity_seeded:
|
||||
def _on_job_created(self, job_id, definition_name, part_number):
|
||||
FreeCAD.Console.PrintMessage(
|
||||
f"Silo: Job {definition_name} created for {part_number}\n"
|
||||
)
|
||||
self._add_activity_entry(
|
||||
f"\u23f3 {part_number} \u2013 {definition_name} queued",
|
||||
part_number,
|
||||
)
|
||||
|
||||
def _on_job_completed(self, job_id):
|
||||
FreeCAD.Console.PrintMessage(f"Silo: Job {job_id} completed\n")
|
||||
self._refresh_activity_panel()
|
||||
|
||||
def _on_job_failed(self, job_id, error):
|
||||
FreeCAD.Console.PrintError(f"Silo: Job {job_id} failed: {error}\n")
|
||||
self._add_activity_entry(f"\u2717 Job {job_id[:8]} failed: {error}", None)
|
||||
|
||||
def _on_job_claimed(self, job_id, runner_id):
|
||||
FreeCAD.Console.PrintMessage(
|
||||
f"Silo: Job {job_id[:8]} claimed by runner {runner_id}\n"
|
||||
)
|
||||
|
||||
def _on_job_progress(self, job_id, progress, message):
|
||||
FreeCAD.Console.PrintMessage(
|
||||
f"Silo: Job {job_id[:8]} progress {progress}%: {message}\n"
|
||||
)
|
||||
|
||||
def _on_job_cancelled(self, job_id):
|
||||
FreeCAD.Console.PrintMessage(f"Silo: Job {job_id[:8]} cancelled\n")
|
||||
self._add_activity_entry(f"\u2718 Job {job_id[:8]} cancelled", None)
|
||||
|
||||
def _add_activity_entry(self, text, part_number):
|
||||
"""Insert a live event entry at the top of the Activity panel."""
|
||||
from PySide import QtCore, QtGui, QtWidgets
|
||||
|
||||
mw = FreeCADGui.getMainWindow()
|
||||
if mw is None:
|
||||
return
|
||||
panel = mw.findChild(QtWidgets.QDockWidget, "SiloDatabaseActivity")
|
||||
if panel is None:
|
||||
return
|
||||
activity_list = panel.findChild(QtWidgets.QListWidget)
|
||||
if activity_list is None:
|
||||
return
|
||||
self._activity_seeded = True
|
||||
try:
|
||||
items = _client.list_items()
|
||||
if isinstance(items, list):
|
||||
for item in reversed(items[:10]):
|
||||
pn = item.get("part_number", "")
|
||||
desc = item.get("description", "")
|
||||
if desc and len(desc) > 40:
|
||||
desc = desc[:37] + "..."
|
||||
text = f"{pn} \u2013 {desc}" if desc else pn
|
||||
updated = item.get("updated_at", "")
|
||||
ts = datetime.now()
|
||||
if updated:
|
||||
try:
|
||||
ts = datetime.fromisoformat(
|
||||
updated.replace("Z", "+00:00")
|
||||
).replace(tzinfo=None)
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
self._activity_events.insert(0, (ts, text, pn))
|
||||
self._activity_events = self._activity_events[:50]
|
||||
except Exception:
|
||||
pass
|
||||
self._rebuild_activity_feed()
|
||||
|
||||
def _rebuild_activity_feed(self):
|
||||
"""Render _activity_events into the Database Activity QListWidget."""
|
||||
from PySide import QtCore, QtWidgets
|
||||
item = QtWidgets.QListWidgetItem(text)
|
||||
if part_number:
|
||||
item.setData(QtCore.Qt.UserRole, part_number)
|
||||
item.setForeground(QtGui.QColor("#89b4fa"))
|
||||
activity_list.insertItem(0, item)
|
||||
|
||||
# Cap the list at 50 entries
|
||||
while activity_list.count() > 50:
|
||||
activity_list.takeItem(activity_list.count() - 1)
|
||||
|
||||
def _refresh_activity_panel(self):
|
||||
"""Refresh the Database Activity panel if it exists."""
|
||||
from PySide import QtCore, QtGui, QtWidgets
|
||||
|
||||
mw = FreeCADGui.getMainWindow()
|
||||
if mw is None:
|
||||
@@ -2817,18 +2921,66 @@ class SiloAuthDockWidget:
|
||||
)
|
||||
activity_list._silo_connected = True
|
||||
|
||||
if not self._activity_events:
|
||||
item = QtWidgets.QListWidgetItem("(No activity yet)")
|
||||
item.setFlags(QtCore.Qt.NoItemFlags)
|
||||
activity_list.addItem(item)
|
||||
return
|
||||
# Collect local part numbers for badge
|
||||
local_pns = set()
|
||||
try:
|
||||
for lf in search_local_files():
|
||||
local_pns.add(lf.get("part_number", ""))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for ts, text, pn in self._activity_events:
|
||||
label = f"{text} \u00b7 {_relative_time(ts)}"
|
||||
list_item = QtWidgets.QListWidgetItem(label)
|
||||
if pn:
|
||||
list_item.setData(QtCore.Qt.UserRole, pn)
|
||||
activity_list.addItem(list_item)
|
||||
try:
|
||||
items = _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]
|
||||
|
||||
# Fetch latest revision info
|
||||
rev_num = ""
|
||||
comment = ""
|
||||
try:
|
||||
revs = _client.get_revisions(pn)
|
||||
if revs:
|
||||
latest = revs[0] if isinstance(revs, list) else revs
|
||||
rev_num = str(latest.get("revision_number", ""))
|
||||
comment = latest.get("comment", "") or ""
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Truncate long descriptions
|
||||
desc_display = desc
|
||||
if len(desc_display) > 40:
|
||||
desc_display = desc_display[:37] + "..."
|
||||
|
||||
# Build display text
|
||||
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}"
|
||||
)
|
||||
|
||||
if comment:
|
||||
line1 += f'\n "{comment}"'
|
||||
else:
|
||||
line1 += "\n (no comment)"
|
||||
|
||||
list_item = QtWidgets.QListWidgetItem(line1)
|
||||
list_item.setData(QtCore.Qt.UserRole, pn)
|
||||
if desc and len(desc) > 40:
|
||||
list_item.setToolTip(desc)
|
||||
if pn in local_pns:
|
||||
list_item.setForeground(QtGui.QColor("#4CAF50"))
|
||||
activity_list.addItem(list_item)
|
||||
|
||||
if activity_list.count() == 0:
|
||||
activity_list.addItem("(No items in database)")
|
||||
except Exception:
|
||||
activity_list.addItem("(Unable to refresh activity)")
|
||||
|
||||
def _on_activity_double_click(self, item):
|
||||
"""Open/checkout item from activity pane."""
|
||||
@@ -3024,6 +3176,471 @@ class Silo_Auth:
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Jobs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_STATUS_ICONS = {
|
||||
"pending": "\u23f3", # hourglass
|
||||
"claimed": "\u2699", # gear
|
||||
"running": "\u25b6", # play
|
||||
"completed": "\u2714", # check
|
||||
"failed": "\u2717", # cross
|
||||
"cancelled": "\u2013", # dash
|
||||
}
|
||||
|
||||
|
||||
class JobMonitorDialog:
|
||||
"""Dialog showing job status, logs, and actions."""
|
||||
|
||||
def __init__(self, parent=None, part_number=None):
|
||||
from PySide import QtCore, QtGui
|
||||
|
||||
self._part_number = part_number
|
||||
self._jobs = []
|
||||
|
||||
self.dialog = QtGui.QDialog(parent)
|
||||
self.dialog.setWindowTitle("Jobs")
|
||||
self.dialog.setMinimumWidth(850)
|
||||
self.dialog.setMinimumHeight(500)
|
||||
layout = QtGui.QVBoxLayout(self.dialog)
|
||||
|
||||
# -- Filter bar --
|
||||
filter_layout = QtGui.QHBoxLayout()
|
||||
self._status_combo = QtGui.QComboBox()
|
||||
self._status_combo.addItems(
|
||||
["All", "pending", "claimed", "running", "completed", "failed", "cancelled"]
|
||||
)
|
||||
self._status_combo.currentIndexChanged.connect(self._refresh)
|
||||
filter_layout.addWidget(QtGui.QLabel("Status:"))
|
||||
filter_layout.addWidget(self._status_combo)
|
||||
|
||||
self._search_edit = QtGui.QLineEdit()
|
||||
self._search_edit.setPlaceholderText("Filter by item or definition...")
|
||||
self._search_edit.returnPressed.connect(self._refresh)
|
||||
filter_layout.addWidget(self._search_edit)
|
||||
filter_layout.addStretch()
|
||||
|
||||
trigger_btn = QtGui.QPushButton("Trigger Job...")
|
||||
trigger_btn.clicked.connect(self._trigger_job)
|
||||
filter_layout.addWidget(trigger_btn)
|
||||
|
||||
layout.addLayout(filter_layout)
|
||||
|
||||
# -- Splitter: table + detail --
|
||||
splitter = QtGui.QSplitter(QtCore.Qt.Vertical)
|
||||
layout.addWidget(splitter)
|
||||
|
||||
# Job table
|
||||
self._table = QtGui.QTableWidget()
|
||||
self._table.setColumnCount(7)
|
||||
self._table.setHorizontalHeaderLabels(
|
||||
[
|
||||
"Status",
|
||||
"Definition",
|
||||
"Item",
|
||||
"Runner",
|
||||
"Progress",
|
||||
"Created",
|
||||
"Duration",
|
||||
]
|
||||
)
|
||||
self._table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows)
|
||||
self._table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection)
|
||||
self._table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers)
|
||||
self._table.horizontalHeader().setStretchLastSection(True)
|
||||
self._table.currentCellChanged.connect(self._on_selection_changed)
|
||||
splitter.addWidget(self._table)
|
||||
|
||||
# Detail panel
|
||||
detail_widget = QtGui.QWidget()
|
||||
detail_layout = QtGui.QVBoxLayout(detail_widget)
|
||||
detail_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
detail_header = QtGui.QHBoxLayout()
|
||||
self._detail_label = QtGui.QLabel("Select a job to view details")
|
||||
detail_header.addWidget(self._detail_label)
|
||||
detail_header.addStretch()
|
||||
self._cancel_btn = QtGui.QPushButton("Cancel Job")
|
||||
self._cancel_btn.setEnabled(False)
|
||||
self._cancel_btn.clicked.connect(self._cancel_job)
|
||||
detail_header.addWidget(self._cancel_btn)
|
||||
detail_layout.addLayout(detail_header)
|
||||
|
||||
self._log_view = QtGui.QTextEdit()
|
||||
self._log_view.setReadOnly(True)
|
||||
self._log_view.setFontFamily("monospace")
|
||||
detail_layout.addWidget(self._log_view)
|
||||
|
||||
splitter.addWidget(detail_widget)
|
||||
splitter.setSizes([300, 200])
|
||||
|
||||
self._refresh()
|
||||
|
||||
def _refresh(self):
|
||||
from PySide import QtGui
|
||||
|
||||
status_filter = self._status_combo.currentText()
|
||||
if status_filter == "All":
|
||||
status_filter = ""
|
||||
|
||||
try:
|
||||
self._jobs = _client.list_jobs(
|
||||
status=status_filter,
|
||||
definition=self._search_edit.text(),
|
||||
)
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintError(f"Silo: Failed to list jobs: {e}\n")
|
||||
self._jobs = []
|
||||
|
||||
# Filter by part_number client-side if scoped
|
||||
if self._part_number:
|
||||
self._jobs = [
|
||||
j
|
||||
for j in self._jobs
|
||||
if j.get("part_number") == self._part_number
|
||||
or j.get("item_id") == self._part_number
|
||||
]
|
||||
|
||||
self._table.setRowCount(len(self._jobs))
|
||||
for row, job in enumerate(self._jobs):
|
||||
status = job.get("status", "")
|
||||
icon = _STATUS_ICONS.get(status, "?")
|
||||
self._table.setItem(row, 0, QtGui.QTableWidgetItem(f"{icon} {status}"))
|
||||
self._table.setItem(
|
||||
row, 1, QtGui.QTableWidgetItem(job.get("definition_name", ""))
|
||||
)
|
||||
self._table.setItem(
|
||||
row, 2, QtGui.QTableWidgetItem(job.get("part_number", ""))
|
||||
)
|
||||
self._table.setItem(
|
||||
row, 3, QtGui.QTableWidgetItem(job.get("runner_name", ""))
|
||||
)
|
||||
|
||||
progress = job.get("progress", 0)
|
||||
progress_msg = job.get("progress_message", "")
|
||||
progress_text = f"{progress}%" if progress else ""
|
||||
if progress_msg:
|
||||
progress_text += f" {progress_msg}"
|
||||
self._table.setItem(row, 4, QtGui.QTableWidgetItem(progress_text))
|
||||
|
||||
created = job.get("created_at", "")
|
||||
if created and len(created) > 16:
|
||||
created = created[:16].replace("T", " ")
|
||||
self._table.setItem(row, 5, QtGui.QTableWidgetItem(created))
|
||||
|
||||
duration = job.get("duration_seconds")
|
||||
dur_text = f"{duration}s" if duration else ""
|
||||
self._table.setItem(row, 6, QtGui.QTableWidgetItem(dur_text))
|
||||
|
||||
self._table.resizeColumnsToContents()
|
||||
|
||||
def _on_selection_changed(self, row, _col, _prev_row, _prev_col):
|
||||
from PySide import QtGui
|
||||
|
||||
if row < 0 or row >= len(self._jobs):
|
||||
self._detail_label.setText("Select a job to view details")
|
||||
self._log_view.clear()
|
||||
self._cancel_btn.setEnabled(False)
|
||||
return
|
||||
|
||||
job = self._jobs[row]
|
||||
job_id = job.get("id", "")
|
||||
status = job.get("status", "")
|
||||
defn = job.get("definition_name", "")
|
||||
pn = job.get("part_number", "")
|
||||
error = job.get("error_message", "")
|
||||
|
||||
self._detail_label.setText(f"<b>{defn}</b> \u2014 {pn} \u2014 {status}")
|
||||
self._cancel_btn.setEnabled(status in ("pending", "claimed", "running"))
|
||||
|
||||
# Load logs
|
||||
self._log_view.clear()
|
||||
if error:
|
||||
self._log_view.append(f"ERROR: {error}\n")
|
||||
try:
|
||||
logs = _client.get_job_logs(job_id)
|
||||
for entry in logs:
|
||||
level = entry.get("level", "info").upper()
|
||||
msg = entry.get("message", "")
|
||||
self._log_view.append(f"[{level}] {msg}")
|
||||
except Exception as e:
|
||||
self._log_view.append(f"(failed to load logs: {e})")
|
||||
|
||||
def _cancel_job(self):
|
||||
from PySide import QtGui
|
||||
|
||||
row = self._table.currentRow()
|
||||
if row < 0 or row >= len(self._jobs):
|
||||
return
|
||||
job = self._jobs[row]
|
||||
job_id = job.get("id", "")
|
||||
reply = QtGui.QMessageBox.question(
|
||||
self.dialog,
|
||||
"Cancel Job",
|
||||
f"Cancel job {job.get('definition_name', '')} for "
|
||||
f"{job.get('part_number', '')}?",
|
||||
QtGui.QMessageBox.Yes | QtGui.QMessageBox.No,
|
||||
)
|
||||
if reply != QtGui.QMessageBox.Yes:
|
||||
return
|
||||
try:
|
||||
_client.cancel_job(job_id)
|
||||
FreeCAD.Console.PrintMessage(f"Silo: Job {job_id} cancelled\n")
|
||||
except Exception as e:
|
||||
QtGui.QMessageBox.warning(
|
||||
self.dialog, "Cancel Failed", f"Failed to cancel job:\n{e}"
|
||||
)
|
||||
self._refresh()
|
||||
|
||||
def _trigger_job(self):
|
||||
from PySide import QtGui
|
||||
|
||||
try:
|
||||
definitions = _client.list_job_definitions()
|
||||
except Exception as e:
|
||||
QtGui.QMessageBox.warning(
|
||||
self.dialog, "Error", f"Failed to load job definitions:\n{e}"
|
||||
)
|
||||
return
|
||||
|
||||
if not definitions:
|
||||
QtGui.QMessageBox.information(
|
||||
self.dialog,
|
||||
"No Definitions",
|
||||
"No job definitions are loaded on the server.",
|
||||
)
|
||||
return
|
||||
|
||||
names = [d.get("name", "") for d in definitions]
|
||||
name, ok = QtGui.QInputDialog.getItem(
|
||||
self.dialog, "Trigger Job", "Job definition:", names, editable=False
|
||||
)
|
||||
if not ok or not name:
|
||||
return
|
||||
|
||||
pn = self._part_number or ""
|
||||
if not pn:
|
||||
pn, ok = QtGui.QInputDialog.getText(
|
||||
self.dialog, "Trigger Job", "Part number (optional):"
|
||||
)
|
||||
if not ok:
|
||||
return
|
||||
|
||||
try:
|
||||
result = _client.trigger_job(name, part_number=pn)
|
||||
FreeCAD.Console.PrintMessage(
|
||||
f"Silo: Job triggered: {result.get('id', '')}\n"
|
||||
)
|
||||
except Exception as e:
|
||||
QtGui.QMessageBox.warning(
|
||||
self.dialog, "Trigger Failed", f"Failed to trigger job:\n{e}"
|
||||
)
|
||||
self._refresh()
|
||||
|
||||
def on_job_event(self):
|
||||
"""Called from SSE handlers to refresh the table."""
|
||||
if self.dialog.isVisible():
|
||||
self._refresh()
|
||||
|
||||
def exec_(self):
|
||||
self.dialog.exec_()
|
||||
|
||||
|
||||
class Silo_Jobs:
|
||||
"""View and manage compute jobs."""
|
||||
|
||||
def GetResources(self):
|
||||
return {
|
||||
"MenuText": "Jobs",
|
||||
"ToolTip": "View and manage compute jobs",
|
||||
"Pixmap": _icon("info"),
|
||||
}
|
||||
|
||||
def Activated(self):
|
||||
doc = FreeCAD.ActiveDocument
|
||||
part_number = None
|
||||
if doc:
|
||||
obj = get_tracked_object(doc)
|
||||
if obj and hasattr(obj, "SiloPartNumber"):
|
||||
part_number = obj.SiloPartNumber
|
||||
|
||||
monitor = JobMonitorDialog(
|
||||
parent=FreeCADGui.getMainWindow(),
|
||||
part_number=part_number,
|
||||
)
|
||||
monitor.exec_()
|
||||
|
||||
def IsActive(self):
|
||||
return _client.is_authenticated()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runners (admin)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RunnerAdminDialog:
|
||||
"""Dialog for managing runner registrations."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
from PySide import QtCore, QtGui
|
||||
|
||||
self._runners = []
|
||||
|
||||
self.dialog = QtGui.QDialog(parent)
|
||||
self.dialog.setWindowTitle("Runners")
|
||||
self.dialog.setMinimumWidth(650)
|
||||
self.dialog.setMinimumHeight(350)
|
||||
layout = QtGui.QVBoxLayout(self.dialog)
|
||||
|
||||
# Runner table
|
||||
self._table = QtGui.QTableWidget()
|
||||
self._table.setColumnCount(5)
|
||||
self._table.setHorizontalHeaderLabels(
|
||||
["Name", "Tags", "Status", "Last Heartbeat", "Jobs"]
|
||||
)
|
||||
self._table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows)
|
||||
self._table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection)
|
||||
self._table.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers)
|
||||
self._table.horizontalHeader().setStretchLastSection(True)
|
||||
layout.addWidget(self._table)
|
||||
|
||||
# Buttons
|
||||
btn_layout = QtGui.QHBoxLayout()
|
||||
register_btn = QtGui.QPushButton("Register Runner...")
|
||||
register_btn.clicked.connect(self._register_runner)
|
||||
btn_layout.addWidget(register_btn)
|
||||
|
||||
delete_btn = QtGui.QPushButton("Delete Runner")
|
||||
delete_btn.clicked.connect(self._delete_runner)
|
||||
btn_layout.addWidget(delete_btn)
|
||||
|
||||
btn_layout.addStretch()
|
||||
|
||||
refresh_btn = QtGui.QPushButton("Refresh")
|
||||
refresh_btn.clicked.connect(self._refresh)
|
||||
btn_layout.addWidget(refresh_btn)
|
||||
|
||||
layout.addLayout(btn_layout)
|
||||
|
||||
self._refresh()
|
||||
|
||||
def _refresh(self):
|
||||
from PySide import QtGui
|
||||
|
||||
try:
|
||||
self._runners = _client.list_runners()
|
||||
except Exception as e:
|
||||
FreeCAD.Console.PrintError(f"Silo: Failed to list runners: {e}\n")
|
||||
self._runners = []
|
||||
|
||||
self._table.setRowCount(len(self._runners))
|
||||
for row, runner in enumerate(self._runners):
|
||||
self._table.setItem(row, 0, QtGui.QTableWidgetItem(runner.get("name", "")))
|
||||
tags = ", ".join(runner.get("tags", []))
|
||||
self._table.setItem(row, 1, QtGui.QTableWidgetItem(tags))
|
||||
status = runner.get("status", "unknown")
|
||||
icon = "\u2705" if status == "online" else "\u26aa"
|
||||
self._table.setItem(row, 2, QtGui.QTableWidgetItem(f"{icon} {status}"))
|
||||
heartbeat = runner.get("last_heartbeat", "")
|
||||
if heartbeat and len(heartbeat) > 16:
|
||||
heartbeat = heartbeat[:16].replace("T", " ")
|
||||
self._table.setItem(row, 3, QtGui.QTableWidgetItem(heartbeat))
|
||||
jobs = runner.get("jobs_completed", 0)
|
||||
self._table.setItem(row, 4, QtGui.QTableWidgetItem(str(jobs)))
|
||||
|
||||
self._table.resizeColumnsToContents()
|
||||
|
||||
def _register_runner(self):
|
||||
from PySide import QtGui
|
||||
|
||||
name, ok = QtGui.QInputDialog.getText(
|
||||
self.dialog, "Register Runner", "Runner name:"
|
||||
)
|
||||
if not ok or not name:
|
||||
return
|
||||
|
||||
tags_str, ok = QtGui.QInputDialog.getText(
|
||||
self.dialog,
|
||||
"Register Runner",
|
||||
"Tags (comma-separated, e.g. create,linux):",
|
||||
)
|
||||
if not ok:
|
||||
return
|
||||
|
||||
tags = [t.strip() for t in tags_str.split(",") if t.strip()]
|
||||
|
||||
try:
|
||||
result = _client.register_runner(name, tags)
|
||||
token = result.get("token", "")
|
||||
QtGui.QMessageBox.information(
|
||||
self.dialog,
|
||||
"Runner Registered",
|
||||
f"Runner <b>{name}</b> registered.\n\n"
|
||||
f"Token (copy now — shown only once):\n\n"
|
||||
f"<code>{token}</code>",
|
||||
)
|
||||
except Exception as e:
|
||||
QtGui.QMessageBox.warning(
|
||||
self.dialog,
|
||||
"Registration Failed",
|
||||
f"Failed to register runner:\n{e}",
|
||||
)
|
||||
self._refresh()
|
||||
|
||||
def _delete_runner(self):
|
||||
from PySide import QtGui
|
||||
|
||||
row = self._table.currentRow()
|
||||
if row < 0 or row >= len(self._runners):
|
||||
return
|
||||
runner = self._runners[row]
|
||||
runner_name = runner.get("name", "")
|
||||
runner_id = runner.get("id", "")
|
||||
|
||||
reply = QtGui.QMessageBox.question(
|
||||
self.dialog,
|
||||
"Delete Runner",
|
||||
f"Delete runner <b>{runner_name}</b>?\n\nThis will invalidate its token.",
|
||||
QtGui.QMessageBox.Yes | QtGui.QMessageBox.No,
|
||||
)
|
||||
if reply != QtGui.QMessageBox.Yes:
|
||||
return
|
||||
|
||||
try:
|
||||
_client.delete_runner(runner_id)
|
||||
FreeCAD.Console.PrintMessage(f"Silo: Runner {runner_name} deleted\n")
|
||||
except Exception as e:
|
||||
QtGui.QMessageBox.warning(
|
||||
self.dialog,
|
||||
"Delete Failed",
|
||||
f"Failed to delete runner:\n{e}",
|
||||
)
|
||||
self._refresh()
|
||||
|
||||
def exec_(self):
|
||||
self.dialog.exec_()
|
||||
|
||||
|
||||
class Silo_Runners:
|
||||
"""Manage compute runners (admin)."""
|
||||
|
||||
def GetResources(self):
|
||||
return {
|
||||
"MenuText": "Runners",
|
||||
"ToolTip": "Manage compute runners (admin)",
|
||||
"Pixmap": _icon("info"),
|
||||
}
|
||||
|
||||
def Activated(self):
|
||||
admin = RunnerAdminDialog(parent=FreeCADGui.getMainWindow())
|
||||
admin.exec_()
|
||||
|
||||
def IsActive(self):
|
||||
return _client.is_authenticated()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Start panel
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -3305,6 +3922,8 @@ class _DiagWorker(QtCore.QThread):
|
||||
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:
|
||||
self.result.emit("DNS", False, f"{hostname}: {e}")
|
||||
except Exception as e:
|
||||
@@ -3423,3 +4042,5 @@ FreeCADGui.addCommand("Silo_Settings", Silo_Settings())
|
||||
FreeCADGui.addCommand("Silo_Auth", Silo_Auth())
|
||||
FreeCADGui.addCommand("Silo_StartPanel", Silo_StartPanel())
|
||||
FreeCADGui.addCommand("Silo_Diag", Silo_Diag())
|
||||
FreeCADGui.addCommand("Silo_Jobs", Silo_Jobs())
|
||||
FreeCADGui.addCommand("Silo_Runners", Silo_Runners())
|
||||
|
||||
Submodule silo-client updated: 68a4139251...9b71cf0375
Reference in New Issue
Block a user