Compare commits

..

1 Commits

Author SHA1 Message Date
forbes-0023
f67d9a0422 feat(silo): BOM auto-extraction from Assembly links (#276)
Phase 1 implementation:
- New bom_sync.py: extract cross-document App::Link components from
  Assembly, resolve SiloItemId UUIDs to part numbers, diff against
  server BOM, apply adds/qty updates via individual CRUD calls.
- Hook _push_bom_after_upload into Silo_Save and Silo_Commit
  (same non-blocking pattern as DAG sync).
- Hook _update_manifest_revision to write revision_hash into .kc
  manifest after successful upload (#277).
- Add bom_merged SSE signal + dispatch + Activity pane handler.
- Add merge_bom_json to SiloClient (forward-looking for Phase 2).

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

Refs: #276, #277
2026-02-19 12:37:14 -06:00
3 changed files with 404 additions and 1 deletions

317
freecad/bom_sync.py Normal file
View File

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

View File

@@ -780,6 +780,60 @@ def _push_dag_after_upload(doc, part_number, revision_number):
FreeCAD.Console.PrintWarning(f"DAG sync failed: {e}\n")
def _update_manifest_revision(file_path, revision_number):
"""Write revision_hash into the .kc manifest after a successful upload.
Failures are logged as warnings -- must never block save.
"""
try:
from kc_format import update_manifest_fields
update_manifest_fields(
file_path,
{
"revision_hash": str(revision_number),
},
)
except Exception as e:
FreeCAD.Console.PrintWarning(f"Manifest revision update failed: {e}\n")
def _push_bom_after_upload(doc, part_number, revision_number):
"""Extract and sync Assembly BOM after a successful upload.
Only runs for Assembly documents with cross-document links.
Failures are logged as warnings -- BOM sync must never block save.
"""
try:
from bom_sync import sync_bom_after_upload
result = sync_bom_after_upload(doc, part_number, _client)
if result is None:
return # Not an assembly or no cross-doc links
parts = []
if result.added_count:
parts.append(f"+{result.added_count} added")
if result.updated_count:
parts.append(f"~{result.updated_count} qty updated")
if result.unreferenced_count:
parts.append(f"!{result.unreferenced_count} unreferenced")
if result.unresolved_count:
FreeCAD.Console.PrintWarning(
f"BOM sync: {result.unresolved_count} components "
f"have no Silo part number\n"
)
if parts:
FreeCAD.Console.PrintMessage(f"BOM synced: {', '.join(parts)}\n")
else:
FreeCAD.Console.PrintMessage("BOM synced: no changes\n")
for err in result.errors:
FreeCAD.Console.PrintWarning(f"BOM sync error: {err}\n")
except Exception as e:
FreeCAD.Console.PrintWarning(f"BOM sync failed: {e}\n")
class Silo_Save:
"""Save locally and upload to the server."""
@@ -851,6 +905,8 @@ class Silo_Save:
FreeCAD.Console.PrintMessage(f"Uploaded as revision {new_rev}\n")
_push_dag_after_upload(doc, part_number, new_rev)
_push_bom_after_upload(doc, part_number, new_rev)
_update_manifest_revision(str(file_path), new_rev)
except Exception as e:
FreeCAD.Console.PrintWarning(f"Upload failed: {e}\n")
@@ -907,6 +963,8 @@ class Silo_Commit:
FreeCAD.Console.PrintMessage(f"Committed revision {new_rev}: {comment}\n")
_push_dag_after_upload(doc, part_number, new_rev)
_push_bom_after_upload(doc, part_number, new_rev)
_update_manifest_revision(str(file_path), new_rev)
except Exception as e:
FreeCAD.Console.PrintError(f"Commit failed: {e}\n")
@@ -2410,6 +2468,9 @@ class SiloEventListener(QtCore.QThread):
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
# BOM events
bom_merged = QtCore.Signal(str, int, int, int) # pn, added, updated, unreferenced
# 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
@@ -2584,6 +2645,13 @@ class SiloEventListener(QtCore.QThread):
bool(payload.get("valid", False)),
int(payload.get("failed_count", 0)),
)
elif event_type == "bom.merged":
self.bom_merged.emit(
pn,
int(payload.get("added", 0)),
int(payload.get("updated", 0)),
int(payload.get("unreferenced", 0)),
)
class _SSEUnsupported(Exception):
@@ -2813,6 +2881,7 @@ class SiloAuthDockWidget:
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.bom_merged.connect(self._on_bom_merged)
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)
@@ -2990,6 +3059,23 @@ class SiloAuthDockWidget:
)
self._append_activity_event(f"{status} \u2013 {part_number}", part_number)
def _on_bom_merged(self, part_number, added, updated, unreferenced):
parts = []
if added:
parts.append(f"+{added} added")
if updated:
parts.append(f"~{updated} qty changed")
if unreferenced:
parts.append(f"!{unreferenced} unreferenced")
summary = ", ".join(parts) if parts else "no changes"
FreeCAD.Console.PrintMessage(
f"Silo: BOM merged for {part_number} ({summary})\n"
)
self._append_activity_event(
f"\u2630 {part_number} \u2013 BOM synced ({summary})",
part_number,
)
def _on_job_created(self, job_id, definition_name, part_number):
FreeCAD.Console.PrintMessage(
f"Silo: Job {definition_name} created for {part_number}\n"