Skip to content

Parts and the bill of materials

A cell is more than geometry and behavior: every conveyor, sensor, controller and fence panel in it is a product somebody buys. botrail keeps that identity on the residents themselves — a part is what a thing is commercially — and derives the bill of materials from the scene, the way the I/O list is derived from the sequences. You author what is in the cell and where; the parts list falls out, and it can never disagree with the scene it was read from.

scene.set_part("belt", manufacturer="MISUMI", model="GVL-1200", mass_kg=45)
scene.set_part("eye", catalog="keyence/pz-g61n", model="PZ-G61N")
scene.set_part("fence", category="structure.fence", model="FP-2000", qty=12)

bom = scene.bom()
print(bom.to_markdown())
scene.export_bom("bom.csv")

What a part is

Scene.set_part pins identity to a resident by name — a robot, a device, a sensor, an I/O node, an obstacle, or an obstacle group (everything under name/: an imported USD subtree, a generated assembly). Every field is optional:

field meaning
catalog a catalog reference — "id", "id@revision", or (id, revision)
manufacturer, model maker and model / part number
category the BOM category (conveyor, sensor.photoelectric, structure.fence, ...); overrides the one the resident's kind implies
description free text
qty how many the target stands for — one by default; a fence group generated as twelve panels says 12
any other keyword, or attributes={...} free attributes: numbers (mass_kg=45, power_w=200, a price) are summed by bom().total(key), text (finish="RAL 7035") is carried along

A part carries no geometry and no behavior. The shape stays on the obstacle, the motion on the device, the wiring on the I/O map; the part is only the name and the count. That is deliberate — botrail does not model shapes, and it does not size anything: it counts and checks.

Names live in separate name spaces (a conveyor device and its belt slab may both be belt), so when a name resolves to several things set_part asks for kind= ("robot", "device", "sensor", "io_node", "obstacle", "group"). Re-pinning a target replaces its part; removing the resident removes the pin.

Generated structures come identified

The standard parts — bt.parts.fence, table, pedestal, conveyor, pallet, light_curtain, photoelectric — pin their parts as they build: a fence is one line for the panels (its quantity the panel count), one for the posts, one for the door; a conveyor's identity sits on the device, its body being geometry. Give them model= / manufacturer= / attributes and the BOM line is complete the moment the thing exists.

Catalog robots need no authoring

A robot loaded with Robot.from_catalog already knows what it is: the package's manifest names the maker, the product, the category and the headline specs, and botrail keeps them on the model's provenance record — so a catalog arm is an identified BOM line with its id@revision, payload_kg, reach_mm and mass_kg the moment it enters the scene, and a tool welded on with attach_tool is its own line. A part pinned to the robot's instance name overlays that (a description, a price, a different category); it does not replace it.

Tools in the stack

A tool welded onto the robot with attach_tool is a line of its own, named by its place in the stack — arm/tool for the first, arm/tool2 for the next, arm/tool/tool3 for one riding a tool that is itself a stack. A catalog tool brings its identity; one made from a URDF string (a bt.tools.multi_tool bracket, say) does not, and gets it pinned by that row name:

scene.set_part("arm/tool", kind="tool", category="tool.multi",
               model="Custom bracket", description="In-house design; mounting fit unverified")

An authored reference such as the former MPH-3 is not an orderable commercial product. Leave its catalog identity and mass unset unless they are established; the assembly and machine-tending demos use identified commercial tools.

The pin follows the robot through a rename and rides the project; on a catalog tool's row it is the last word.

The bill of materials

Scene.bom lists every piece of equipment the scene holds — robots and their tools, conveyors, axes and vehicles, sensors, controller boxes — whether or not it has been identified, plus every obstacle or group a part was pinned to. Bare obstacles are geometry (scenery, stock, a workpiece pool) and are not parts unless you say so; a Source/Sink pair models an endless line and is not equipment either.

Identical products merge into one row with the quantity summed and the resident names kept for traceability; a row that names nothing yet — no catalog reference, maker or model — stays under its own resident name, so bom.unidentified() is the purchasing to-do list.

| # | category             | manufacturer | model       | catalog              | qty | names            | mass_kg |
|---|----------------------|--------------|-------------|----------------------|-----|------------------|---------|
| 1 | manipulator          | FANUC        | M-20iD/25   | fanuc/m/m-20id-25@…  | 1   | arm              | 250     |
| 2 | robot_controller     | FANUC        | R-30iB Plus |                      | 1   | arm/controller   |         |
| 3 | conveyor             | MISUMI       | GVL-1200    |                      | 1   | belt             | 45      |
| 4 | sensor.photoelectric | KEYENCE      | PZ-G61N     | keyence/pz-g61n      | 1   | eye              |         |
| 5 | plc                  |              | R04CPU      |                      | 1   | PLC1             |         |
| 6 | part                 |              | HFS8-1200   |                      | 2   | table_a; table_b | 30      |
| 7 | structure.fence      |              | FP-2000     |                      | 12  | fence            | 8       |

Totals: mass_kg = 451

Every arm needs a controller box, so the bill lists one per arm before anybody declares it: the line <robot>/controller (category robot_controller). A catalog arm names it where its maker lists exactly one controller (the manifest's specs.controller); an arm sold as a set with its controller — a UR e-Series, a Doosan M — carries the set's identity, so the line is no second purchase; otherwise it stays unidentified with what is known in its description (the candidate controllers, the set's note), which is what the to-do list should say. Pin identity on it like any other line (scene.set_part("arm/controller", manufacturer="FANUC", model="R-30iB Mate Plus")), or declare the cabinet in the I/O map (scene.add_io_node("UR", kind="robot_controller", robots=["arm"])): the node's own line then stands for it, and a pin on the derived name follows the arm onto it. bt.parts.controller does the whole thing at once — the box on the floor plan, the node and the line are one name. The cables and tubes a cell routes are derived lines too — one per route under cable.<medium> or pneumatic.tube, with its cut and purchase length, routes of one spec merged with their lengths added (as are ducts of one model, and anything else bought by the metre under cable.*), and a line per clamp article. A vehicle carries its controller on board, and a gripper or a hand loaded on its own is driven by someone else's cabinet — neither gets a line.

The Bom renders as CSV, Markdown or JSON (to_csv(), to_markdown(), to_json(), save(path); scene.export_bom(path) picks the format from the extension), and its numbers are pytest material like everything else botrail bakes:

bom = build_cell().bom()
assert bom.unidentified() == []                # everything has a number
assert bom.total("mass_kg") <= 1500            # None, not 0, when nobody says
assert bom.total("power_w") <= 20_000

The generated Python and the .botrail project carry the pins, so a reloaded cell — or the script generate_python() writes — produces the same table. The studio shows the same table over the viewport, wears each line on its row of the scene tree, and answers "what is this" for whatever is clicked.

What it is not

The BOM is a derived table, not a purchasing system: botrail holds the slot for a price and sums it if you fill it in, but it does not know prices, lead times or stock, and it does not validate attributes. Nothing here sizes a frame, a supply or a valve — a part's mass_kg is a number you wrote down.

What the cell asks of each line

A BOM line also carries requirements — the payload, reach, stroke, span, load the cell implies for it — and the check compares them with the part's attributes. See Selecting parts: scene.requirements(), scene.check() and bt.catalog.search.