16 KiB
Feature Spec: Auto-Populate Silo BOM from Assembly Links
Summary
On Origin_Commit, extract cross-document App::Link components from the active Assembly, resolve each to a Silo part number, diff against the existing server-side BOM, and submit a merge job. The client receives merge status via SSE and surfaces it in the Database Activity pane with a link to the web UI for conflict resolution.
Scope
In scope:
- Cross-document
App::Linkextraction with quantity counting - Silo UUID → part number resolution
- Multi-level BOM construction (levels enumerated per nesting depth)
- Diff/merge against existing server BOM
- SSE merge status events in Activity pane
- Web UI link for merge conflict resolution
- Workflow for components without Silo part numbers
- Consolidation warnings for duplicate individual links vs arrays
Out of scope:
- In-document links (treated as construction/layout geometry, not BOM items)
- Flat BOM generation (existing server endpoint handles this from the multi-level data)
- Recursive commit of sub-assemblies (each assembly commits its own BOM)
Architecture
Trigger
Hook into Origin_Commit in the Silo workbench. After the file commit succeeds, the BOM sync runs as a post-commit step. This separates "save my work" from "publish BOM to server."
Data Flow
Origin_Commit
│
├─ 1. Commit file to Silo (existing flow)
│
└─ 2. BOM sync (new)
│
├─ a. Walk Assembly tree, collect cross-doc App::Links
├─ b. Count quantities (individual links + ElementCount arrays)
├─ c. Resolve Silo UUIDs → part numbers via API
├─ d. Build multi-level BOM structure
├─ e. GET existing BOM from server
├─ f. Diff local vs server BOM
└─ g. POST merge job to server
│
└─ Server emits SSE event with merge status
│
└─ Activity pane displays result + web UI link
1. Assembly Link Extraction
Walk Strategy
def extract_bom_links(assembly_obj, level=1):
"""
Recursively walk an Assembly container, collecting cross-document
App::Link objects. In-document links are skipped.
Returns list of BomEntry(part_number, quantity, level, children=[])
"""
entries = {} # linked_doc_path -> BomEntry
for obj in assembly_obj.Group:
if not obj.isDerivedFrom("App::Link"):
continue
linked = obj.LinkedObject
if linked is None:
continue
# Skip in-document links (construction/layout geometry)
linked_doc = linked.Document
if linked_doc == assembly_obj.Document:
continue
doc_path = linked_doc.FileName
if doc_path in entries:
# Multiple individual links to same source - count and warn
entries[doc_path].quantity += _link_count(obj)
entries[doc_path].consolidation_warning = True
else:
entries[doc_path] = BomEntry(
doc_path=doc_path,
silo_uuid=_get_silo_uuid(linked_doc),
quantity=_link_count(obj),
level=level,
consolidation_warning=False,
children=[],
)
# Recurse into sub-assemblies
if _is_assembly(linked):
entries[doc_path].children = extract_bom_links(linked, level + 1)
return list(entries.values())
Quantity Counting
def _link_count(link_obj):
"""
Count instances from a single App::Link.
ElementCount > 0 means it's a link array.
"""
element_count = getattr(link_obj, "ElementCount", 0)
return element_count if element_count > 0 else 1
Both patterns are counted:
- Multiple
App::Linkinstances to the same external file → summed - Single
App::LinkwithElementCount > 0→ use ElementCount value
When both patterns exist for the same source, emit a consolidation warning suggesting the user convert individual links to a single link array.
In-Document Link Filtering
A link is in-document if linked.Document == assembly.Document. These are construction references (layout sketches, datum planes used for mate positioning, etc.) and are excluded from BOM extraction.
2. Silo Part Number Resolution
UUID Lookup
Each document committed via Silo origin has a Silo UUID stored as a custom property. Resolve to part number:
def _get_silo_uuid(doc):
"""Read the Silo UUID property from a FreeCAD document."""
try:
return doc.getPropertyByName("SiloUUID")
except AttributeError:
return None
def resolve_part_numbers(entries, api_client):
"""
Resolve Silo UUIDs to part numbers.
Returns (resolved_entries, unresolved_entries).
"""
resolved = []
unresolved = []
for entry in entries:
if entry.silo_uuid is None:
unresolved.append(entry)
continue
item = api_client.get_item_by_uuid(entry.silo_uuid)
if item is None:
unresolved.append(entry)
continue
entry.part_number = item["part_number"]
entry.name = item["name"]
resolved.append(entry)
# Recurse children
if entry.children:
child_resolved, child_unresolved = resolve_part_numbers(
entry.children, api_client
)
entry.children = child_resolved
unresolved.extend(child_unresolved)
return resolved, unresolved
Components Without Part Numbers
Unresolved components trigger the part registration workflow:
- After BOM sync, display unresolved components in a dialog
- For each, offer to create a new Silo item:
- Auto-detect whether it's an Assembly or Part from the document type
- Pre-fill name from the document label
- Apply naming convention for the schema (e.g.,
kindred-rdschema with appropriate category) - Open the part number registration form pre-populated
- User confirms or skips each
- Skipped items are excluded from the BOM (not silently dropped — they remain visible as "unregistered" in the Activity pane)
- On next commit, re-check and prompt again
3. Multi-Level BOM Construction
The BOM preserves assembly hierarchy with enumerated levels:
@dataclass
class BomEntry:
doc_path: str
silo_uuid: Optional[str]
part_number: Optional[str]
name: Optional[str]
quantity: int
level: int
children: List["BomEntry"]
consolidation_warning: bool = False
Example structure:
Level 1: ASM-001 Top Assembly
Level 2: PRT-100 Base Plate qty: 1
Level 2: ASM-200 Gearbox Sub-Assy qty: 2
Level 3: PRT-201 Housing qty: 1
Level 3: PRT-202 Gear qty: 4
Level 3: PRT-203 Shaft qty: 1
Level 2: PRT-300 Cover qty: 1
Level 2: F01-001 M8x20 Bolt qty: 12
Each assembly commits only its direct children (first-level BOM). The server's existing GET /api/items/{partNumber}/bom/expanded endpoint handles multi-level expansion, and GET /api/items/{partNumber}/bom/flat handles flattened roll-up.
4. Diff/Merge Logic
Diff Computation
Compare the assembly-derived BOM (local) against the existing server BOM (remote):
def diff_bom(local_entries, remote_entries):
"""
Diff local assembly BOM against server BOM.
Returns BomDiff with categorized changes.
"""
local_map = {e.part_number: e.quantity for e in local_entries}
remote_map = {e["child_part_number"]: e["quantity"] for e in remote_entries}
diff = BomDiff(
added=[], # In local, not in remote
removed=[], # In remote, not in local
quantity_changed=[], # In both, different quantity
unchanged=[], # In both, same quantity
)
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
Merge Rules
| Scenario | Action |
|---|---|
| Added (in assembly, not on server) | Auto-add to server BOM |
| Quantity changed | Auto-update to assembly quantity |
| Removed (on server, not in assembly) | Do NOT delete. Warn user that unreferenced part numbers exist in last server BOM version. |
| Unchanged | No action |
Critical rule: The assembly is authoritative for what exists in the CAD model, but items on the server that aren't in the assembly are never auto-deleted. This handles:
- Purchased parts tracked in Silo but not modeled in CAD
- Parts added via web UI for BOM completeness
- Items from other engineering disciplines (electrical, software BOMs)
Removed items trigger a warning on next BOM sync: "N items in the Silo BOM are not referenced by components in this assembly." This surfaces in the Activity pane with a link to the web UI where the user can review and explicitly remove them if intended.
5. Server-Side Merge Job
New Endpoint
POST /api/items/{partNumber}/bom/merge
Request body:
{
"source": "assembly",
"entries": [
{"child_part_number": "PRT-100", "quantity": 1, "source": "assembly"},
{"child_part_number": "ASM-200", "quantity": 2, "source": "assembly"},
{"child_part_number": "PRT-300", "quantity": 1, "source": "assembly"},
{"child_part_number": "F01-001", "quantity": 12, "source": "assembly"}
]
}
Response body:
{
"status": "merged",
"diff": {
"added": [
{"part_number": "PRT-300", "quantity": 1}
],
"removed": [
{"part_number": "PRT-400", "quantity": 2}
],
"quantity_changed": [
{
"part_number": "F01-001",
"old_quantity": 8,
"new_quantity": 12
}
],
"unchanged": [
{"part_number": "PRT-100", "quantity": 1},
{"part_number": "ASM-200", "quantity": 2}
]
},
"warnings": [
{
"type": "unreferenced",
"part_number": "PRT-400",
"message": "Present in server BOM but not in assembly"
}
],
"resolve_url": "/items/ASM-001/bom"
}
The server:
- Applies adds and quantity changes immediately
- Flags removed items as unreferenced (does not delete)
- Emits an SSE event on the activity stream
- Returns the diff and a
resolve_urlfor the web UI
SSE Event
{
"type": "bom_merge",
"item": "ASM-001",
"user": "joseph",
"timestamp": "2026-02-08T22:15:00Z",
"summary": "BOM updated: 1 added, 1 quantity changed, 1 unreferenced",
"diff": { ... },
"resolve_url": "/items/ASM-001/bom"
}
6. Client-Side UX
Activity Pane Display
The Database Activity pane (already connected via SSE) renders BOM merge events:
┌─────────────────────────────────────────────┐
│ 📋 BOM Updated: ASM-001 │
│ +1 added ~1 qty changed ⚠1 unreferenced │
│ [View in Silo →] │
├─────────────────────────────────────────────┤
│ ⚠ 2 components have no Silo part number │
│ [Register Parts...] │
└─────────────────────────────────────────────┘
Consolidation Warnings
When multiple individual App::Link objects reference the same external document (instead of a single link array), show a non-blocking console warning:
[Silo BOM] PRT-100 has 4 individual links. Consider using a link array
(ElementCount) for cleaner assembly management.
Part Registration Workflow
For unresolved components, the dialog presents:
┌─ Register Components ──────────────────────┐
│ │
│ The following components have no Silo │
│ part number and will be excluded from │
│ the BOM until registered: │
│ │
│ ☑ Bracket.FCStd [Part ▾] [Register] │
│ ☑ MotorMount.FCStd [Assembly ▾] [Reg.] │
│ ☐ construction_ref.FCStd (skip) │
│ │
│ [Register Selected] [Skip All] │
└─────────────────────────────────────────────┘
Registration uses the existing POST /api/items and POST /api/generate-part-number endpoints. Schema and category are pre-selected based on document type (Assembly → assembly category, Part → appropriate category). Name is pre-filled from the document label following the project naming convention.
7. Implementation Plan
Phase 1: Link Extraction + Resolution
silo-mod workbench (mods/silo/):
bom_sync.py— Assembly walker, quantity counter, UUID resolver- Hook into
Origin_Commitpost-commit
Silo server (internal/api/):
GET /api/items/by-uuid/{uuid}— New endpoint for UUID → item lookup (or extend existingGET /api/items/{partNumber}to accept UUID query param)
Phase 2: Diff/Merge + Server Endpoint
Silo server:
POST /api/items/{partNumber}/bom/merge— New merge endpoint- SSE event emission for
bom_mergetype internal/db/bom.go— Merge logic with unreferenced flagging
silo-mod workbench:
- Diff computation (can be client-side or server-side; server preferred for consistency)
- Activity pane rendering for merge events
Phase 3: Part Registration Workflow
silo-mod workbench:
- Registration dialog for unresolved components
- Part/Assembly type detection
- Schema + category pre-selection
- Name convention pre-fill
Phase 4: Web UI Merge Resolution
Silo web UI (web/src/):
- BOM page shows unreferenced items with warning banner
- Action to remove unreferenced items or mark as intentional (non-CAD BOM entries)
- Distinguish between "assembly-sourced" and "manually-added" BOM entries
API Surface (New/Modified)
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/items/by-uuid/{uuid} |
Resolve Silo UUID to item (new) |
POST |
/api/items/{partNumber}/bom/merge |
Submit assembly BOM merge (new) |
| SSE | Activity stream | bom_merge event type (new) |
All existing BOM endpoints (GET .../bom, GET .../bom/expanded, GET .../bom/flat, etc.) remain unchanged.
Design Decisions
- Merge jobs are synchronous. The server processes the merge inline and returns the diff in the response. Revisit if assembly sizes cause timeout issues in practice.
- BOM entries carry a
sourcefield. Each entry includes"source": "assembly"or"source": "manual"so the web UI can distinguish assembly-derived lines from manually-added ones. Manual entries are never overwritten by assembly merges. - Sub-assembly commits walk bottom-up automatically. When a top-level assembly and its sub-assemblies are all dirty, commit traverses depth-first, committing leaf assemblies before parents. This ensures each assembly's BOM merge references already-committed children.