Panel Assembly Tutorial
This advanced tutorial builds a real-world tool that uses the Option.table() type to give the user an editable grid of cell placements, then assembles them into a panel.
It mirrors the Panel Assembly tool that ships with LinkCAD (plugins/python/tools/panelize.py), so you can read the shipped file alongside this page.
The Goal
Create a tool that arranges cells into a panel by placing cell references — no geometry is copied or flattened, so the panel stays editable and exports to any format.
Complete Plugin
import jsonimport loggingfrom typing import Any, Dict, List
from linkcad.v1.plugin import UNIT_IN_METERS, Option, TableColumn, Tool, tool
logger = logging.getLogger(__name__)
# Placement coordinates are entered in whichever unit the user picked, while the# database stores integers whose size comes from `drawing.units` (database units# per metre), so the two have to be reconciled before anything is placed. The# conversion table is linkcad.plugin's: a second table that can silently# disagree is how a thousand-fold placement error once got into this file.
@tool( name="Panel Assembly", menu="Tools/Drawing", tooltip="Build a panel by placing cell references at specified positions", requires_drawing=True,)class PanelizeTool(Tool): placements = Option.table( "Placements", columns=[ TableColumn(key="cell_name", label="Cell", col_type="cell_choice"), TableColumn(key="x", label="X", col_type="real", default=0.0, decimals=3), TableColumn(key="y", label="Y", col_type="real", default=0.0, decimals=3), TableColumn( key="rotation", label="Rotation (°)", col_type="real", default=0.0, decimals=1, min_value=-360.0, max_value=360.0, ), ], default=[], tooltip="Cell placement list: select a cell and enter coordinates", )
units = Option.choice( "Placement units", choices=["nm", "um", "mil", "mm", "cm", "inch", "m"], default="mm", tooltip="Units the X and Y columns are given in", )
output_cell_name = Option.string( "Output Cell Name", default="PANEL_ASSEMBLY", tooltip="Name of the new panel assembly cell", )
set_as_main = Option.boolean( "Set as Main Cell", default=True, tooltip="Make the panel assembly cell the main (top) cell", )
def run(self, drawing: Any) -> Dict[str, Any]: from linkcad.v1.db import Transaction from linkcad.v1.geom import Transformation
# The dialog stores table rows as a JSON string; a script may pass a list. raw = self.placements if isinstance(raw, str): rows: List[Dict[str, Any]] = json.loads(raw) if raw else [] elif isinstance(raw, list): rows = raw else: rows = []
if not rows: raise ValueError( "No placements defined. Add at least one row to the placement table." )
# Validate every referenced cell before changing anything. cell_names_needed = {r["cell_name"] for r in rows if r.get("cell_name")} missing = sorted(n for n in cell_names_needed if drawing.cell(n) is None) if missing: raise ValueError( "The following cells are not in the drawing: " + ", ".join(missing) + ". Import the required designs first." )
output_name = self.output_cell_name or "PANEL_ASSEMBLY"
# A cell placed inside itself corrupts the cell tree, so refuse it up front. if output_name in cell_names_needed: raise ValueError( f"Output cell '{output_name}' cannot also be used as a placement " f"cell. This would create a circular cell reference." )
db_units_per_meter = float(drawing.units) if db_units_per_meter <= 0.0: raise ValueError( "The drawing does not declare its database units, so placement " "coordinates cannot be converted." ) to_db_units = UNIT_IN_METERS[self.units] * db_units_per_meter
placed_count = 0
with Transaction(drawing, "Panel Assembly"): assembly_cell = drawing.add_cell(output_name)
# If the cell already existed, clear it so re-running the tool does # not duplicate every placement. for obj in assembly_cell.cell_objects: obj.destroy()
for row in rows: cell_name = row.get("cell_name", "") if not cell_name: continue
x = float(row.get("x", 0.0)) y = float(row.get("y", 0.0)) rotation = float(row.get("rotation", 0.0))
ref_cell = drawing.cell(cell_name)
# Rotate before translating. Transformation.rotate() also rotates # whatever offset is already set, so the other order would swing # each cell away from its intended spot. xform = Transformation() if rotation != 0.0: xform.rotate(rotation) xform.translate(x * to_db_units, y * to_db_units)
assembly_cell.add_ref(ref_cell, transformation=xform) placed_count += 1
if self.set_as_main: drawing.main_cell = assembly_cell
return { "title": "Panel Assembly", "cell_name": output_name, "placements": placed_count, "unique_cells": len(cell_names_needed), "summary": ( f"Created '{output_name}' with {placed_count} cell references " f"from {len(cell_names_needed)} unique cell(s)" ), }How the Table Option Works
Option.table() creates an editable grid in the tool dialog:
- Columns are defined by
TableColumnobjects - Rows are added and removed by the user
- The value is a
list[dict]mapping column keys to values — or the JSON string form of that list, when the dialog stored it - Column types include
string,integer,real,choice, andcell_choice
TableColumn Properties
| Property | Description |
|---|---|
key | Dict key for this column’s value |
label | Column header text |
col_type | string, integer, real, choice, cell_choice |
default | Default value for new rows |
choices | Options for choice columns |
decimals | Decimal places for real columns |
min_value | Minimum for numeric columns |
max_value | Maximum for numeric columns |
The cell_choice Column Type
A cell_choice column renders a dropdown populated with all cell names from the current drawing. This is the recommended way to let users select cells.
Key Patterns
Placing a Cell Reference
A reference is created by the cell that will hold it:
assembly_cell.add_ref(ref_cell, transformation)add_ref() also builds arrays in one call, which is cheaper than one reference per position:
assembly_cell.add_ref( ref_cell, transformation, columns=8, rows=4, column_spacing=10_000, row_spacing=8_000,)It raises ValueError rather than returning something unusable — for a circular hierarchy (placing a cell inside one of its own descendants) and for a missing target cell. The tool still checks for the self-reference itself, before touching the drawing, so the user gets a message naming the output cell instead of a mid-transaction exception.
Using Database Transactions
Always wrap drawing modifications in a transaction. It commits when the block exits normally and rolls back if an exception escapes, so a failure part-way through leaves nothing half-built:
from linkcad.v1.db import Transaction
with Transaction(drawing, "Panel Assembly"): ...The description string appears in the Edit menu as the undo entry.
Unit Conversion
The database stores integers, and how large one of those integers is varies from drawing to drawing. drawing.units reports how many database units make up a metre, which is what turns a physical size into a coordinate:
from linkcad.v1.plugin import UNIT_IN_METERS
db_units_per_meter = float(drawing.units)if db_units_per_meter <= 0.0: raise ValueError("This drawing does not declare its database units")
x_db = x_mm * UNIT_IN_METERS["mm"] * db_units_per_meterGuessing a scale here would decide where geometry lands, so refuse rather than assume when the drawing does not declare its units. Take the metres-per-unit factor from linkcad.plugin.UNIT_IN_METERS rather than writing your own table — the accepted names are nm, um, mil, mm, cm, inch and m.
Running It from the Console
Tool options are ordinary instance attributes, so a tool can be driven directly from the script editor without opening its dialog:
from tools.panelize import PanelizeTool
t = PanelizeTool()t.placements = [{"cell_name": "UNIT", "x": 5.0, "y": 2.5, "rotation": 0.0}]t.units = "mm"t.output_cell_name = "PANEL"print(t.run(drawing)["summary"])