跳转到内容

面板拼版教程

这个进阶教程构建一个真实可用的工具:它用 Option.table() 类型为用户提供一个可编辑的单元放置网格,并据此拼装出面板。

它与 LinkCAD 随附的 Panel Assembly 工具(plugins/python/tools/panelize.py)一致,因此你可以对照阅读随附文件。

目标

创建一个通过放置单元引用把单元排布成面板的工具——不复制也不展平任何几何图形,因此面板保持可编辑,并可导出到任何格式。

完整插件

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)"
),
}

表格选项的工作方式

Option.table() 在工具对话框中创建一个可编辑网格:

  • TableColumn 对象定义
  • 由用户添加和删除
  • 取值是把列键映射到值的 list[dict]——若由对话框保存,则是该列表的 JSON 字符串形式
  • 列类型包括 stringintegerrealchoicecell_choice

TableColumn 属性

属性说明
key该列取值对应的字典键
label列标题文本
col_typestringintegerrealchoicecell_choice
default新行的默认值
choiceschoice 列的可选项
decimalsreal 列的小数位数
min_value数值列的最小值
max_value数值列的最大值

cell_choice 列类型

cell_choice 列会渲染一个下拉列表,其中填充当前图纸的所有单元名。这是让用户选择单元的推荐方式。

关键模式

放置单元引用

引用由将要持有它的单元创建:

assembly_cell.add_ref(ref_cell, transformation)

add_ref() 也能在一次调用中构建阵列,这比每个位置一个引用更划算:

assembly_cell.add_ref(
ref_cell,
transformation,
columns=8,
rows=4,
column_spacing=10_000,
row_spacing=8_000,
)

它会抛出 ValueError,而不是返回一个无法使用的值——循环层次结构(把某个单元放进它自己的后代中)如此,目标单元不存在时也是如此。这个工具仍然在改动图纸之前先自行检查自引用,好让用户得到一条点名输出单元的提示,而不是事务进行到一半时抛出的异常。

使用数据库事务

务必把对图纸的修改包裹在事务中。块正常结束时提交,异常逸出时回滚,因此中途失败不会留下半成品:

from linkcad.v1.db import Transaction
with Transaction(drawing, "Panel Assembly"):
...

描述字符串会作为撤销条目显示在“Edit”菜单中。

单位换算

数据库存储整数,而一个整数代表多大,因图纸而异。drawing.units 表示 1 米等于多少数据库单位,正是它把物理尺寸转换成坐标:

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

在这里猜测会直接决定几何图形落在哪里。当图纸未声明其单位时,请拒绝处理而不是假设。每单位对应多少米,请取自 linkcad.plugin.UNIT_IN_METERS,不要自己写一张表——可接受的名称是 nmummilmmcminchm

从控制台运行

工具选项就是普通的实例属性,因此无需打开对话框,也可以直接从脚本编辑器驱动工具:

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