From f67d9a04229515e769a210223b191f369ff5cfcd Mon Sep 17 00:00:00 2001 From: forbes-0023 Date: Thu, 19 Feb 2026 12:37:14 -0600 Subject: [PATCH] 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 --- freecad/bom_sync.py | 317 +++++++++++++++++++++++++++++++++++++++ freecad/silo_commands.py | 86 +++++++++++ silo-client | 2 +- 3 files changed, 404 insertions(+), 1 deletion(-) create mode 100644 freecad/bom_sync.py diff --git a/freecad/bom_sync.py b/freecad/bom_sync.py new file mode 100644 index 0000000..eaab445 --- /dev/null +++ b/freecad/bom_sync.py @@ -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 diff --git a/freecad/silo_commands.py b/freecad/silo_commands.py index 656a5cb..f819354 100644 --- a/freecad/silo_commands.py +++ b/freecad/silo_commands.py @@ -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" diff --git a/silo-client b/silo-client index 285bd1f..9d07de1 160000 --- a/silo-client +++ b/silo-client @@ -1 +1 @@ -Subproject commit 285bd1fa118668add145e04e1842d41ba37f7d23 +Subproject commit 9d07de1bca62a14b1d7717b940dbd89749f6f5ac -- 2.49.1