Skip to content

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 json
import logging
from 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 TableColumn objects
  • 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, and cell_choice

TableColumn Properties

PropertyDescription
keyDict key for this column’s value
labelColumn header text
col_typestring, integer, real, choice, cell_choice
defaultDefault value for new rows
choicesOptions for choice columns
decimalsDecimal places for real columns
min_valueMinimum for numeric columns
max_valueMaximum 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_meter

Guessing 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"])