Some checks failed
Build and Test / build (pull_request) Has been cancelled
- Remove hand-crafted kindred-icons/ in favor of auto-generated themed icons - Add icons/mappings/ with FCAD.csv (Tango palette) and kindred.csv (Catppuccin Mocha) - Add icons/retheme.py script to remap upstream FreeCAD SVG colors - Generate icons/themed/ with 1,595 themed SVGs (45,300 color replacements) - BitmapFactory loads icons/themed/ as highest priority before default icons - 157-color mapping covers the full Tango palette, interpolating between 4 luminance anchors per color family Regenerate: python3 icons/retheme.py
396 lines
12 KiB
Markdown
396 lines
12 KiB
Markdown
# DAG Client Integration Contract
|
|
|
|
**Status:** Draft
|
|
**Last Updated:** 2026-02-13
|
|
|
|
This document describes what silo-mod and Headless Create runners need to implement to integrate with the Silo dependency DAG and worker system.
|
|
|
|
---
|
|
|
|
## 1. Overview
|
|
|
|
The DAG system has two client-side integration points:
|
|
|
|
1. **silo-mod workbench** (desktop) -- pushes DAG data to Silo on save or revision create.
|
|
2. **silorunner + silo-mod** (headless) -- extracts DAGs, validates features, and exports geometry as compute jobs.
|
|
|
|
Both share the same Python codebase in the silo-mod repository. Desktop users call the code interactively; runners call it headlessly via `create --console`.
|
|
|
|
---
|
|
|
|
## 2. DAG Sync Payload
|
|
|
|
Clients push feature trees to Silo via:
|
|
|
|
```
|
|
PUT /api/items/{partNumber}/dag
|
|
Authorization: Bearer <user_token or runner_token>
|
|
Content-Type: application/json
|
|
```
|
|
|
|
### 2.1 Request Body
|
|
|
|
```json
|
|
{
|
|
"revision_number": 3,
|
|
"nodes": [
|
|
{
|
|
"node_key": "Sketch001",
|
|
"node_type": "sketch",
|
|
"properties_hash": "a1b2c3d4e5f6...",
|
|
"metadata": {
|
|
"label": "Base Profile",
|
|
"constraint_count": 12
|
|
}
|
|
},
|
|
{
|
|
"node_key": "Pad001",
|
|
"node_type": "pad",
|
|
"properties_hash": "f6e5d4c3b2a1...",
|
|
"metadata": {
|
|
"label": "Main Extrusion",
|
|
"length": 25.0
|
|
}
|
|
}
|
|
],
|
|
"edges": [
|
|
{
|
|
"source_key": "Sketch001",
|
|
"target_key": "Pad001",
|
|
"edge_type": "depends_on"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
### 2.2 Field Reference
|
|
|
|
**Nodes:**
|
|
|
|
| Field | Type | Required | Description |
|
|
|-------|------|----------|-------------|
|
|
| `node_key` | string | yes | Unique within item+revision. Use Create's internal object name (e.g. `Sketch001`, `Pad003`). |
|
|
| `node_type` | string | yes | One of: `sketch`, `pad`, `pocket`, `fillet`, `chamfer`, `constraint`, `body`, `part`, `datum`. |
|
|
| `properties_hash` | string | no | SHA-256 hex digest of the node's parametric inputs. Used for memoization. |
|
|
| `validation_state` | string | no | One of: `clean`, `dirty`, `validating`, `failed`. Defaults to `clean`. |
|
|
| `metadata` | object | no | Arbitrary key-value pairs for display or debugging. |
|
|
|
|
**Edges:**
|
|
|
|
| Field | Type | Required | Description |
|
|
|-------|------|----------|-------------|
|
|
| `source_key` | string | yes | The node that is depended upon. |
|
|
| `target_key` | string | yes | The node that depends on the source. |
|
|
| `edge_type` | string | no | One of: `depends_on` (default), `references`, `constrains`. |
|
|
|
|
**Direction convention:** Edges point from dependency to dependent. If Pad001 depends on Sketch001, the edge is `source_key: "Sketch001"`, `target_key: "Pad001"`.
|
|
|
|
### 2.3 Response
|
|
|
|
```json
|
|
{
|
|
"synced": true,
|
|
"node_count": 15,
|
|
"edge_count": 14
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 3. Computing properties_hash
|
|
|
|
The `properties_hash` enables memoization -- if a node's inputs haven't changed since the last validation, it can be skipped. Computing it:
|
|
|
|
```python
|
|
import hashlib
|
|
import json
|
|
|
|
def compute_properties_hash(feature_obj):
|
|
"""Hash the parametric inputs of a Create feature."""
|
|
inputs = {}
|
|
|
|
if feature_obj.TypeId == "Sketcher::SketchObject":
|
|
# Hash geometry + constraints
|
|
inputs["geometry_count"] = feature_obj.GeometryCount
|
|
inputs["constraint_count"] = feature_obj.ConstraintCount
|
|
inputs["geometry"] = str(feature_obj.Shape.exportBrep())
|
|
elif feature_obj.TypeId == "PartDesign::Pad":
|
|
inputs["length"] = feature_obj.Length.Value
|
|
inputs["type"] = str(feature_obj.Type)
|
|
inputs["reversed"] = feature_obj.Reversed
|
|
inputs["sketch"] = feature_obj.Profile[0].Name
|
|
# ... other feature types
|
|
|
|
canonical = json.dumps(inputs, sort_keys=True)
|
|
return hashlib.sha256(canonical.encode()).hexdigest()
|
|
```
|
|
|
|
The exact inputs per feature type are determined by what parametric values affect the feature's geometry. Include anything that, if changed, would require recomputation.
|
|
|
|
---
|
|
|
|
## 4. Feature Tree Walking
|
|
|
|
To extract the DAG from a Create document:
|
|
|
|
```python
|
|
import FreeCAD
|
|
|
|
def extract_dag(doc):
|
|
"""Walk a Create document and return nodes + edges."""
|
|
nodes = []
|
|
edges = []
|
|
|
|
for obj in doc.Objects:
|
|
# Skip non-feature objects
|
|
if not hasattr(obj, "TypeId"):
|
|
continue
|
|
|
|
node_type = classify_type(obj.TypeId)
|
|
if node_type is None:
|
|
continue
|
|
|
|
nodes.append({
|
|
"node_key": obj.Name,
|
|
"node_type": node_type,
|
|
"properties_hash": compute_properties_hash(obj),
|
|
"metadata": {
|
|
"label": obj.Label,
|
|
"type_id": obj.TypeId,
|
|
}
|
|
})
|
|
|
|
# Walk dependencies via InList (objects this one depends on)
|
|
for dep in obj.InList:
|
|
if hasattr(dep, "TypeId") and classify_type(dep.TypeId):
|
|
edges.append({
|
|
"source_key": dep.Name,
|
|
"target_key": obj.Name,
|
|
"edge_type": "depends_on",
|
|
})
|
|
|
|
return nodes, edges
|
|
|
|
|
|
def classify_type(type_id):
|
|
"""Map Create TypeIds to DAG node types."""
|
|
mapping = {
|
|
"Sketcher::SketchObject": "sketch",
|
|
"PartDesign::Pad": "pad",
|
|
"PartDesign::Pocket": "pocket",
|
|
"PartDesign::Fillet": "fillet",
|
|
"PartDesign::Chamfer": "chamfer",
|
|
"PartDesign::Body": "body",
|
|
"Part::Feature": "part",
|
|
"Sketcher::SketchConstraint": "constraint",
|
|
}
|
|
return mapping.get(type_id)
|
|
```
|
|
|
|
---
|
|
|
|
## 5. When to Push DAG Data
|
|
|
|
Push the DAG to Silo in these scenarios:
|
|
|
|
| Event | Trigger | Who |
|
|
|-------|---------|-----|
|
|
| User saves in silo-mod | On save callback | Desktop silo-mod workbench |
|
|
| User creates a revision | After `POST /api/items/{pn}/revisions` succeeds | Desktop silo-mod workbench |
|
|
| Runner extracts DAG | After `create-dag-extract` job completes | silorunner via `PUT /api/runner/jobs/{id}/dag` |
|
|
| Runner validates | After `create-validate` job, push updated validation states | silorunner via `PUT /api/runner/jobs/{id}/dag` |
|
|
|
|
---
|
|
|
|
## 6. Runner Entry Points
|
|
|
|
silo-mod must provide these Python entry points for headless invocation:
|
|
|
|
### 6.1 silo.runner.dag_extract
|
|
|
|
Extracts the feature DAG from a Create file and writes it as JSON.
|
|
|
|
```python
|
|
# silo/runner.py
|
|
|
|
def dag_extract(input_path, output_path):
|
|
"""
|
|
Extract feature DAG from a Create file.
|
|
|
|
Args:
|
|
input_path: Path to the .kc (Kindred Create) file.
|
|
output_path: Path to write the JSON output.
|
|
|
|
Output JSON format:
|
|
{
|
|
"nodes": [...], // Same format as DAG sync payload
|
|
"edges": [...]
|
|
}
|
|
"""
|
|
doc = FreeCAD.openDocument(input_path)
|
|
nodes, edges = extract_dag(doc)
|
|
|
|
with open(output_path, 'w') as f:
|
|
json.dump({"nodes": nodes, "edges": edges}, f)
|
|
|
|
FreeCAD.closeDocument(doc.Name)
|
|
```
|
|
|
|
### 6.2 silo.runner.validate
|
|
|
|
Rebuilds all features and reports pass/fail per node.
|
|
|
|
```python
|
|
def validate(input_path, output_path):
|
|
"""
|
|
Validate a Create file by rebuilding all features.
|
|
|
|
Output JSON format:
|
|
{
|
|
"valid": true/false,
|
|
"nodes": [
|
|
{
|
|
"node_key": "Pad001",
|
|
"state": "clean", // or "failed"
|
|
"message": null, // error message if failed
|
|
"properties_hash": "..."
|
|
}
|
|
]
|
|
}
|
|
"""
|
|
doc = FreeCAD.openDocument(input_path)
|
|
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)
|
|
|
|
FreeCAD.closeDocument(doc.Name)
|
|
```
|
|
|
|
### 6.3 silo.runner.export
|
|
|
|
Exports geometry to STEP, IGES, or other formats.
|
|
|
|
```python
|
|
def export(input_path, output_path, format="step"):
|
|
"""
|
|
Export a Create file to an external format.
|
|
|
|
Args:
|
|
input_path: Path to the .kc file.
|
|
output_path: Path to write the exported file.
|
|
format: Export format ("step", "iges", "stl", "obj").
|
|
"""
|
|
doc = FreeCAD.openDocument(input_path)
|
|
|
|
import Part
|
|
shapes = [obj.Shape for obj in doc.Objects if hasattr(obj, "Shape")]
|
|
compound = Part.makeCompound(shapes)
|
|
|
|
format_map = {
|
|
"step": "STEP",
|
|
"iges": "IGES",
|
|
"stl": "STL",
|
|
"obj": "OBJ",
|
|
}
|
|
|
|
Part.export([compound], output_path)
|
|
FreeCAD.closeDocument(doc.Name)
|
|
```
|
|
|
|
---
|
|
|
|
## 7. Headless Invocation
|
|
|
|
The `silorunner` binary shells out to Create (with silo-mod installed):
|
|
|
|
```bash
|
|
# DAG extraction
|
|
create --console -e "from silo.runner import dag_extract; dag_extract('/tmp/job/part.kc', '/tmp/job/dag.json')"
|
|
|
|
# Validation
|
|
create --console -e "from silo.runner import validate; validate('/tmp/job/part.kc', '/tmp/job/result.json')"
|
|
|
|
# Export
|
|
create --console -e "from silo.runner import export; export('/tmp/job/part.kc', '/tmp/job/output.step', 'step')"
|
|
```
|
|
|
|
**Prerequisites:** The runner host must have:
|
|
- Headless Create installed (Kindred's fork of FreeCAD)
|
|
- silo-mod installed as a Create addon (so `from silo.runner import ...` works)
|
|
- No display server required -- `--console` mode is headless
|
|
|
|
---
|
|
|
|
## 8. Validation Result Handling
|
|
|
|
After a runner completes a `create-validate` job, it should:
|
|
|
|
1. Read the result JSON.
|
|
2. Push updated validation states via `PUT /api/runner/jobs/{jobID}/dag`:
|
|
|
|
```json
|
|
{
|
|
"revision_number": 3,
|
|
"nodes": [
|
|
{"node_key": "Sketch001", "node_type": "sketch", "validation_state": "clean", "properties_hash": "abc..."},
|
|
{"node_key": "Pad001", "node_type": "pad", "validation_state": "failed", "properties_hash": "def..."}
|
|
],
|
|
"edges": [
|
|
{"source_key": "Sketch001", "target_key": "Pad001"}
|
|
]
|
|
}
|
|
```
|
|
|
|
3. Complete the job via `POST /api/runner/jobs/{jobID}/complete` with the summary result.
|
|
|
|
---
|
|
|
|
## 9. SSE Events
|
|
|
|
Clients should listen for these events on `GET /api/events`:
|
|
|
|
| Event | Payload | When |
|
|
|-------|---------|------|
|
|
| `dag.updated` | `{item_id, part_number, revision_number, node_count, edge_count}` | After any DAG sync |
|
|
| `dag.validated` | `{item_id, part_number, valid, failed_count}` | After validation completes |
|
|
| `job.created` | `{job_id, definition_name, trigger, item_id}` | Job auto-triggered or manually created |
|
|
| `job.claimed` | `{job_id, runner_id, runner}` | Runner claims a job |
|
|
| `job.progress` | `{job_id, progress, message}` | Runner reports progress |
|
|
| `job.completed` | `{job_id, runner_id}` | Job finishes successfully |
|
|
| `job.failed` | `{job_id, runner_id, error}` | Job fails |
|
|
| `job.cancelled` | `{job_id, cancelled_by}` | Job cancelled by user |
|
|
|
|
---
|
|
|
|
## 10. Cross-Item Edges
|
|
|
|
For assembly constraints that reference geometry in child parts (e.g. a mate constraint between two parts), use the `dag_cross_edges` table. These edges bridge the BOM DAG and the feature DAG.
|
|
|
|
Cross-item edges are **not** included in the standard `PUT /dag` sync. They will be managed through a dedicated endpoint in a future iteration once the assembly constraint model in Create/silo-mod is finalized.
|
|
|
|
For now, the DAG sync covers intra-item dependencies only. Assembly-level interference detection uses the BOM DAG (`relationships` table) combined with per-item feature DAGs.
|