CadQuery 装配约束求解实战:用 Assembly 与 Constraint 构建参数化铝型材机柜门
CadQuery 装配约束求解实战:用 Assembly 与 Constraint 构建参数化铝型材机柜门
本文基于 CadQuery 官方装配教程 doc/assy.rst,以一套 20x20 V 槽铝型材“机柜门”为贯穿案例,完整演示从 DXF 型材导入、可复用零件定义、Assembly 装配树搭建,到用 constrain 声明几何约束并调用 solve() 自动求解定位的全流程;同时逐类拆解 Point、Axis、Plane、PointInPlane、PointOnLine 及 Fixed 系列约束的代价函数与参数语义,并对照 cadquery/assembly.py 与 cadquery/occ_impl/solver.py 的源码实现说明底层原理。读完本文,你可以独立编写带约束求解的装配脚本,并理解每个约束在优化问题中到底“做了什么”。
定义模型参数:从 DXF 导入型材截面
装配建模的第一步是集中定义模型参数,使后续尺寸修改只需改动一处:
import cadquery as cq
# Parameters
H = 400
W = 200
D = 350
PROFILE = cq.importers.importDXF("vslot-2020_1.dxf").wires()
SLOT_D = 5
PANEL_T = 3
HANDLE_D = 20
HANDLE_L = 50
HANDLE_W = 4
值得注意的设计点是:V 槽型材截面直接由 DXF 文件导入(本仓库的示例文件位于 doc/vslot-2020_1.dxf)。这样想换成其他品牌的铝型材(如 Item 或 Bosch 系列)时,只需替换一份供应商提供的 DXF 文件即可,无需改动任何建模代码。importDXF 返回的是一组 wire,后续用 toPending().extrude(l) 将其拉伸为实体。
定义可复用零件:四个组件函数与配合面标记
接下来把每个零件封装为工厂函数。CadQuery 装配约束依赖“配合几何(mating geometry)”的引用,因此函数中会用 tag() 给关键的面/边打上标签,供后续约束字符串直接引用:
def make_vslot(l):
return PROFILE.toPending().extrude(l)
def make_connector():
rv = (
cq.Workplane()
.box(20, 20, 20)
.faces("<X")
.workplane()
.cboreHole(6, 15, 18)
.faces("<Z")
.workplane(centerOption="CenterOfMass")
.cboreHole(6, 15, 18)
)
# tag mating faces
rv.faces(">X").tag("X").end()
rv.faces(">Z").tag("Z").end()
return rv
def make_panel(w, h, t, cutout):
rv = (
cq.Workplane("XZ")
.rect(w, h)
.extrude(t)
.faces(">Y")
.vertices()
.rect(2 * cutout, 2 * cutout)
.cutThruAll()
.faces("<Y")
.workplane()
.pushPoints([(-w / 3, HANDLE_L / 2), (-w / 3, -HANDLE_L / 2)])
.hole(3)
)
# tag mating edges
rv.faces(">Y").edges("%CIRCLE").edges(">Z").tag("hole1")
rv.faces(">Y").edges("%CIRCLE").edges("<Z").tag("hole2")
return rv
def make_handle(w, h, r):
pts = ((0, 0), (w, 0), (w, h), (0, h))
path = cq.Workplane().polyline(pts)
rv = (
cq.Workplane("YZ")
.rect(r, r)
.sweep(path, transition="round")
.tag("solid")
.faces("<X")
.workplane()
.faces("<X", tag="solid")
.hole(r / 1.5)
)
# tag mating faces
rv.faces("<X").faces(">Y").tag("mate1")
rv.faces("<X").faces("<Y").tag("mate2")
return rv
四个零件各自的配合标记约定是理解后续约束的关键:
| 零件 | 打标对象 | 用途 |
|---|---|---|
make_vslot(l) |
无 | 型材本身以 @faces@<Z、>Z 等方向选择器引用 |
make_connector() |
面 >X → 标签 X;面 >Z → 标签 Z |
四个角的连接块配合面 |
make_panel(w, h, t, cutout) |
>Y 面上两个圆孔的圆边 → hole1 / hole2 |
门板安装孔(上/下) |
make_handle(w, h, r) |
手柄左端面中 >Y / <Y 的面 → mate1 / mate2 |
手柄两个安装耳 |
构建初始装配:Assembly.add 与命名
实例化所有零件并通过 Assembly.add 挂到装配树上。add 会做三件重要的事(见 cadquery/assembly.py):强制名称唯一(重名直接抛 ValueError)、深拷贝子装配、把子树扁平化进 self.objects 字典——约束求解就是按这套命名空间寻址零件的:
# define the elements
door = (
cq.Assembly()
.add(make_vslot(H), name="left")
.add(make_vslot(H), name="right")
.add(make_vslot(W), name="top")
.add(make_vslot(W), name="bottom")
.add(make_connector(), name="con_tl", color=cq.Color("black"))
.add(make_connector(), name="con_tr", color=cq.Color("black"))
.add(make_connector(), name="con_bl", color=cq.Color("black"))
.add(make_connector(), name="con_br", color=cq.Color("black"))
.add(
make_panel(W + SLOT_D, H + SLOT_D, PANEL_T, SLOT_D),
name="panel",
color=cq.Color(0, 0, 1, 0.2),
)
.add(
make_handle(HANDLE_D, HANDLE_L, HANDLE_W),
name="handle",
color=cq.Color("yellow"),
)
)
注意 color 既可以用 cq.Color("black") 这样的 CSS 颜色名,也可以像门板那样用 RGBA 数值 cq.Color(0, 0, 1, 0.2) 表达半透明蓝色。
定义约束:字符串选择器与 Shape 对象两种写法
约束通过 Assembly.constrain 声明。选择器采用 name<a href="https://link.gitcode.com/i/3a8a497a15b3ce3beec6830147994f9f" target="_blank">?tag][@kind@selector] 的语法(该文法定义在 [cadquery/assembly.py 的 _define_grammar,查询执行在 _query 方法 cadquery/assembly.py):
name:零件名(支持/表示嵌套子装配);?tag:引用零件内部tag()打过的标签;@kind@selector:在solids/faces/edges/vertices四种类别上用 CadQuery 选择器语法筛选,如@faces@<Z。
本例的完整约束集:
# define the constraints
(
door
# left profile
.constrain("left@faces@<Z", "con_bl?Z", "Plane")
.constrain("left@faces@<X", "con_bl?X", "Axis")
.constrain("left@faces@>Z", "con_tl?Z", "Plane")
.constrain("left@faces@<X", "con_tl?X", "Axis")
# top
.constrain("top@faces@<Z", "con_tl?X", "Plane")
.constrain("top@faces@<Y", "con_tl@faces@>Y", "Axis")
# bottom
.constrain("bottom@faces@<Y", "con_bl@faces@>Y", "Axis")
.constrain("bottom@faces@>Z", "con_bl?X", "Plane")
# right connectors
.constrain("top@faces@>Z", "con_tr@faces@>X", "Plane")
.constrain("bottom@faces@<Z", "con_br@faces@>X", "Plane")
.constrain("left@faces@>Z", "con_tr?Z", "Axis")
.constrain("left@faces@<Z", "con_br?Z", "Axis")
# right profile
.constrain("right@faces@>Z", "con_tr@faces@>Z", "Plane")
.constrain("right@faces@<X", "left@faces@<X", "Axis")
# panel
.constrain("left@faces@>X[-4]", "panel@faces@<X", "Plane")
.constrain("left@faces@>Z", "panel@faces@>Z", "Axis")
# handle
.constrain("panel?hole1", "handle?mate1", "Plane")
.constrain("panel?hole2", "handle?mate2", "Point")
)
约束语义速读:左型材通过上下两端的 Plane+Axis 约束“咬”进 con_bl 和 con_tl 两个连接块;顶/底型材的 Axis 约束保证它们与连接块端面的轴线共线(即端面对端面对齐);门板用 left@faces@>X[-4] 精确定位到左型材靠内的第 4 个 >X 面(内槽壁);手柄则由门板两个安装孔(圆边)与两个配合面的 Plane / Point 约束固定。
当字符串选择器不够用时,constrain 还接受直接传入 Shape 对象(比如使用 BoxSelector 或自定义选择器类)。从 cadquery/assembly.py 的重载分发看,5/6 个位置参数的形式即 constrain(id1, shape1, id2, shape2, kind[, param])。例如:
.constrain("part1@faces@>Z", "part3@faces@<Z", "Axis")
等价于:
.constrain("part1", part1.faces(">z").val(), "part3", part3.faces("<Z").val(), "Axis")
注意必须用 Workplane.val() 取出单个 Shape 传入,而不是整个 Workplane 对象。
完整脚本与 solve 求解
下面给出包含最终 solve() 步骤的完整可运行代码(注意此完整版中 SLOT_D = 6,门板外廓传参为 W + 2 * SLOT_D, H + 2 * SLOT_D,即相对前文片段做了参数修正):
import cadquery as cq
# Parameters
H = 400
W = 200
D = 350
PROFILE = cq.importers.importDXF("vslot-2020_1.dxf").wires()
SLOT_D = 6
PANEL_T = 3
HANDLE_D = 20
HANDLE_L = 50
HANDLE_W = 4
def make_vslot(l):
return PROFILE.toPending().extrude(l)
def make_connector():
rv = (
cq.Workplane()
.box(20, 20, 20)
.faces("<X")
.workplane()
.cboreHole(6, 15, 18)
.faces("<Z")
.workplane(centerOption="CenterOfMass")
.cboreHole(6, 15, 18)
)
# tag mating faces
rv.faces(">X").tag("X").end()
rv.faces(">Z").tag("Z").end()
return rv
def make_panel(w, h, t, cutout):
rv = (
cq.Workplane("XZ")
.rect(w, h)
.extrude(t)
.faces(">Y")
.vertices()
.rect(2 * cutout, 2 * cutout)
.cutThruAll()
.faces("<Y")
.workplane()
.pushPoints([(-w / 3, HANDLE_L / 2), (-w / 3, -HANDLE_L / 2)])
.hole(3)
)
# tag mating edges
rv.faces(">Y").edges("%CIRCLE").edges(">Z").tag("hole1")
rv.faces(">Y").edges("%CIRCLE").edges("<Z").tag("hole2")
return rv
def make_handle(w, h, r):
pts = ((0, 0), (w, 0), (w, h), (0, h))
path = cq.Workplane().polyline(pts)
rv = (
cq.Workplane("YZ")
.rect(r, r)
.sweep(path, transition="round")
.tag("solid")
.faces("<X")
.workplane()
.faces("<X", tag="solid")
.hole(r / 1.5)
)
# tag mating faces
rv.faces("<X").faces(">Y").tag("mate1")
rv.faces("<X").faces("<Y").tag("mate2")
return rv
# define the elements
door = (
cq.Assembly()
.add(make_vslot(H), name="left")
.add(make_vslot(H), name="right")
.add(make_vslot(W), name="top")
.add(make_vslot(W), name="bottom")
.add(make_connector(), name="con_tl", color=cq.Color("black"))
.add(make_connector(), name="con_tr", color=cq.Color("black"))
.add(make_connector(), name="con_bl", color=cq.Color("black"))
.add(make_connector(), name="con_br", color=cq.Color("black"))
.add(
make_panel(W + 2 * SLOT_D, H + 2 * SLOT_D, PANEL_T, SLOT_D),
name="panel",
color=cq.Color(0, 0, 1, 0.2),
)
.add(
make_handle(HANDLE_D, HANDLE_L, HANDLE_W),
name="handle",
color=cq.Color("yellow"),
)
)
# define the constraints
(
door
# left profile
.constrain("left@faces@<Z", "con_bl?Z", "Plane")
.constrain("left@faces@<X", "con_bl?X", "Axis")
.constrain("left@faces@>Z", "con_tl?Z", "Plane")
.constrain("left@faces@<X", "con_tl?X", "Axis")
# top
.constrain("top@faces@<Z", "con_tl?X", "Plane")
.constrain("top@faces@<Y", "con_tl@faces@>Y", "Axis")
# bottom
.constrain("bottom@faces@<Y", "con_bl@faces@>Y", "Axis")
.constrain("bottom@faces@>Z", "con_bl?X", "Plane")
# right connectors
.constrain("top@faces@>Z", "con_tr@faces@>X", "Plane")
.constrain("bottom@faces@<Z", "con_br@faces@>X", "Plane")
.constrain("left@faces@>Z", "con_tr?Z", "Axis")
.constrain("left@faces@<Z", "con_br?Z", "Axis")
# right profile
.constrain("right@faces@>Z", "con_tr@faces@>Z", "Plane")
.constrain("right@faces@<X", "left@faces@<X", "Axis")
# panel
.constrain("left@faces@>X[-4]", "panel@faces@<X", "Plane")
.constrain("left@faces@>Z", "panel@faces@>Z", "Axis")
# handle
.constrain("panel?hole1", "handle?mate1", "Plane")
.constrain("panel?hole2", "handle?mate2", "Point")
)
# solve
door.solve()
show_object(door, name="door")
(show_object 为文档站点/ Jupyter 环境内置的可视化函数;普通脚本中可直接调用 door.export(...) 或 door.toCompound()。)
从源码看,solve() 的执行过程(cadquery/assembly.py)是:
- 为约束涉及的所有零件编号,
Fixed约束对象与装配根节点会被锁定;没有任何锁定时,自动锁定二元约束中首个出现的实体,保证系统整体刚性; - 用装配包围盒对角线长度
self.toCompound().BoundingBox().DiagonalLength作为全局scale,归一化代价函数,避免大装配中平移动量淹没旋转量; - 把每个约束交给
ConstraintSpec.toPODs()分解为基元约束(Plane会被拆成Axis+Point两条,见下文),再交给基于 CasADi 的非线性规划求解器 IPOPT(见 cadquery/occ_impl/solver.py,tol=1e-14、精确 Hessian、MUMPS 线性求解器); - 求解后各零件的
Location被更新为“相对根节点”的解。
约束验证的完整测试可参考 tests/test_assembly.py。
数据导出:STEP 与 OCCT XML
求解完成后可直接导出:
door.export("door.step")
door.export("door.xml")
STEP 可被 FreeCAD 等几乎所有 CAD 工具加载,XML 则是 OCCT 内部格式,可供其他使用 OCCT 的应用读取。实际导出效果(STEP 加载到 FreeCAD)如下:
从 cadquery/assembly.py 的 ExportLiterals 定义看,当前仓库实际支持的导出格式比文档正文列出的更多:"STEP", "XML", "XBF", "GLTF", "VTKJS", "VRML", "STL",export 方法会依据文件后缀自动推断格式(exportType 缺省时按扩展名推断);反向读取则支持 STEP / XML / XBF(Assembly.load / importStep,见 cadquery/assembly.py)。
物体初始位姿:loc 与 solve 的相互作用
零件也可以不靠约束、直接带初始位姿加入装配:
import cadquery as cq
cone = cq.Solid.makeCone(1, 0, 2)
assy = cq.Assembly()
assy.add(
cone,
loc=cq.Location((0, 0, 0), (1, 0, 0), 180),
name="cone0",
color=cq.Color("green"),
)
assy.add(cone, name="cone1", color=cq.Color("blue"))
show_object(assy)
作为显式计算位姿的替代方案,约束 + Assembly.solve 通常更能表达真实世界的装配关系。两者的交互规律值得注意:
- 当提供了初始位姿又调用
solve()时,求解器会用解覆盖这些初始位姿; - 但在欠约束系统中,初始位姿仍会影响最终解:某零件若不进入代价函数,求解器可能原地不动;当存在多个最优解时,初始位姿会让求解器收敛到其中之一;
- 对非常复杂的装配,给出大致正确的初始位姿还能减少计算时间。
如果用户显式提供位姿而非约束,则零件关系变化时(例如 cone1 移动)需要用户自己负责同步更新所有依赖位姿。而约束(如“两个圆锥底面接触”用一条 Plane 约束表达)天然可维护。
约束类型与代价函数:优化视角下的完整语义
一旦提供至少一条约束并运行 solve(),系统就被组织为一个优化问题:每条约束贡献一个代价函数,该函数依赖于约束创建时指定的两个对象的位姿(Location);求解器变化各子件位姿,使所有代价函数之和最小。 因此读懂代价函数就精确读懂了约束的行为。源码中约束种类、元数与参数类型集中在 cadquery/occ_impl/solver.py 的 UnaryConstraintKind / BinaryConstraintKind / ConstraintInvariants / CompoundConstraints;各代价函数实现见 cadquery/occ_impl/solver.py。
Point 约束
Point 是最常用的约束之一,最小化两个点之间的距离。典型用法:面中心对中、顶点间对齐;配合“虚拟顶点(dummy vertex)”还能在两个零件之间制造偏移。代价函数:
cost = (param - |c1 - c2|)^2
param:约束参数,默认 0;ci:第 i 个对象的重心;|v|:向量模长。
param 指定两个中心之间期望的偏移量。该偏移不携带方向信息;若要指定某个方向上的偏移,应使用虚拟 Vertex。Point 约束通过 Shape.Center() 求重心,因此对一切 Shape 子类都适用:
import cadquery as cq
# Use the Point constraint to position boxes relative to an arc
line = cq.Edge.makeCircle(radius=10, angle1=0, angle2=90)
box = cq.Workplane().box(1, 1, 1)
assy = cq.Assembly()
assy.add(line, name="line")
# position the red box on the center of the arc
assy.add(box, name="box0", color=cq.Color("red"))
assy.constrain("line", "box0", "Point")
# position the green box at a normalized distance of 0.8 along the arc
position0 = line.positionAt(0.8)
assy.add(box, name="box1", color=cq.Color("green"))
assy.constrain(
"line",
cq.Vertex.makeVertex(*position0.toTuple()),
"box1",
box.val(),
"Point",
)
# position the orange box 2 units in any direction from the green box
assy.add(box, name="box2", color=cq.Color("orange"))
assy.constrain(
"line",
cq.Vertex.makeVertex(*position0.toTuple()),
"box2",
box.val(),
"Point",
param=2,
)
# position the blue box offset 2 units in the x direction from the green box
position1 = position0 + cq.Vector(2, 0, 0)
assy.add(box, name="box3", color=cq.Color("blue"))
assy.constrain(
"line",
cq.Vertex.makeVertex(*position1.toTuple()),
"box3",
box.val(),
"Point",
)
assy.solve()
show_object(assy)
这个例子同时展示了 param 偏移(橙色盒:距离为 2、方向任意)与“虚拟顶点 + 精确目标点”(蓝色盒:沿 X 方向偏移 2)两种偏移建模手法。对照源码 point_cost(cadquery/occ_impl/solver.py):param == 0 时最小化两点距离平方;param != 0 时最小化 (距离 - param)^2,即“距离等于 param”的圆环条件。
Axis 约束
Axis 约束最小化两个向量之间的夹角,常用于面与面对齐、控制对象旋转。代价函数:
cost = (param - angle(d1, d2))^2 (一般角情况,源码中写作 (d1·d2 - cos(param))^2)
param:默认 180 度,使两方向互为反向,即所谓 “mate”(贴合)关系——两个对象的外表面相互接触;di:从第 i 个对象参数提取的方向向量;- 方向量在源码层面以
pi(180°)为缺省,约束声明中的角度参数会经radians转换(cadquery/occ_impl/solver.py)。
import cadquery as cq
cone = cq.Solid.makeCone(1, 0, 2)
assy = cq.Assembly()
assy.add(cone, name="cone0", color=cq.Color("green"))
assy.add(cone, name="cone1", color=cq.Color("blue"))
assy.constrain("cone0@faces@<Z", "cone1@faces@<Z", "Axis")
assy.solve()
show_object(assy)
将 param 设为 0,则两个对象指向相同方向——常用于“一个物体穿过另一个物体”的场景,例如销钉插入板孔:
import cadquery as cq
plate = cq.Workplane().box(10, 10, 1).faces(">Z").workplane().hole(2)
cone = cq.Solid.makeCone(0.8, 0, 4)
assy = cq.Assembly()
assy.add(plate, name="plate", color=cq.Color("green"))
assy.add(cone, name="cone", color=cq.Color("blue"))
# place the center of the flat face of the cone in the center of the upper face of the plate
assy.constrain("plate@faces@>Z", "cone@faces@<Z", "Point")
# set both the flat face of the cone and the upper face of the plate to point in the same direction
assy.constrain("plate@faces@>Z", "cone@faces@<Z", "Axis", param=0)
assy.solve()
show_object(assy)
方向向量按对象类型以三种方式提取(源码 _getAxis,cadquery/occ_impl/solver.py):
| 对象类型 | 提取方式 |
|---|---|
Face |
Face.normalAt() |
Edge 且 geomType() == "CIRCLE" |
Mixins.normal()(圆所在平面法向) |
Edge 且非圆 |
Mixins.tangentAt() |
其他类型将抛出 ValueError。最常见用法仍然是由 Face 定义 Axis 约束。还可以把约束对象与一个曲面组合,例如让圆锥尖端法向指向正弦曲面某处:
import cadquery as cq
from math import cos, sin, pi
# Create a sinusoidal surface:
surf = cq.Workplane().parametricSurface(
lambda u, v: (u, v, 5 * sin(pi * u / 10) * cos(pi * v / 10)),
N=40,
start=0,
stop=20,
)
# Create a cone with a small, flat tip:
cone = (
cq.Workplane()
.add(cq.Solid.makeCone(1, 0.1, 2))
# tag the tip for easy reference in the constraint:
.faces(">Z")
.tag("tip")
.end()
)
assy = cq.Assembly()
assy.add(surf, name="surf", color=cq.Color("lightgray"))
assy.add(cone, name="cone", color=cq.Color("green"))
# set the Face on the tip of the cone to point in
# the opposite direction of the center of the surface:
assy.constrain("surf", "cone?tip", "Axis")
# to make the example clearer, move the cone to the center of the face:
assy.constrain("surf", "cone?tip", "Point")
assy.solve()
show_object(assy)
Plane 约束:Axis + Point 的复合体
Plane 约束就是 Point 与 Axis 的组合,是常用组合的便捷捷径。上面“曲面 + 锥尖”的例子可压缩为一条:
assy = cq.Assembly()
assy.add(surf, name="surf", color=cq.Color("lightgray"))
assy.add(cone, name="cone", color=cq.Color("green"))
-# set the Face on the tip of the cone to point in
-# the opposite direction of the center of the surface:
-assy.constrain("surf", "cone?tip", "Axis")
-# to make the example clearer, move the cone to the center of the face:
-assy.constrain("surf", "cone?tip", "Point")
+assy.constrain("surf", "cone?tip", "Plane")
assy.solve()
show_object(assy)
结果与两条约束完全相同。这一点在源码中有直接对应:CompoundConstraints 把 "Plane" 映射为 ("Axis", "Point"),并把 param 同时喂给两者,其中 Point 分量固定取 0(cadquery/occ_impl/solver.py)。因此 param 只作用于 Axis 分量:贴合(mate)关系保持默认值,贯穿(through)关系则设为 0。
PointInPlane 约束
PointInPlane 把第一个对象的重心放到由第二个对象定义的平面内。代价函数:
cost = dist(c, plane_offset)^2
c:第一个参数的重心;plane_offset:由第二个对象生成的平面,沿其法向偏移param得到;dist(a, b):点 a 到平面 b 的距离。
import cadquery as cq
# Create an L-shaped object:
bracket = (
cq.Workplane("YZ")
.hLine(1)
.vLine(0.1)
.hLineTo(0.2)
.vLineTo(1)
.hLineTo(0)
.close()
.extrude(1)
# tag some faces for easy reference:
.faces(">Y[1]")
.tag("inner_vert")
.end()
.faces(">Z[1]")
.tag("inner_horiz")
.end()
)
box = cq.Workplane().box(0.5, 0.5, 0.5)
assy = cq.Assembly()
assy.add(bracket, name="bracket", color=cq.Color("gray"))
assy.add(box, name="box", color=cq.Color("green"))
# lock bracket orientation:
assy.constrain("bracket@faces@>Z", "box@faces@>Z", "Axis", param=0)
assy.constrain("bracket@faces@>X", "box@faces@>X", "Axis", param=0)
# constrain the bottom of the box to be on the plane defined by inner_horiz:
assy.constrain("box@faces@<Z", "bracket?inner_horiz", "PointInPlane")
# constrain the side of the box to be 0.2 units from the plane defined by inner_vert
assy.constrain("box@faces@<Y", "bracket?inner_vert", "PointInPlane", param=0.2)
# constrain the end of the box to be 0.1 units inside the end of the bracket
assy.constrain("box@faces@>X", "bracket@faces@>X", "PointInPlane", param=-0.1)
assy.solve()
show_object(assy)
param 支持负值(如 -0.1 表示“在平面内侧 0.1 单位”)。源码中 _getPln 对 Face 生成 gp_Pln,对 Edge / Wire 用其 normal() 与 Center() 生成平面(cadquery/occ_impl/solver.py)。
PointOnLine 约束
PointOnLine 把第一个对象的重心放到由第二个对象定义的直线上。代价函数:
cost = (param - dist(c, line))^2
c:第一个参数的重心;line:由第二个对象生成的直线;param:默认 0;dist(a, b):点 a 到直线 b 的距离。
import cadquery as cq
b1 = cq.Workplane().box(1, 1, 1)
b2 = cq.Workplane().sphere(0.15)
assy = (
cq.Assembly()
.add(b1, name="b1")
.add(b2, loc=cq.Location((0, 0, 4)), name="b2", color=cq.Color("red"))
)
# fix the position of b1
assy.constrain("b1", "Fixed")
# b2 on one of the edges of b1
assy.constrain("b2", "b1@edges@>>Z and >>Y", "PointOnLine")
# b2 on another of the edges of b1
assy.constrain("b2", "b1@edges@>>Z and >>X", "PointOnLine")
# effectively b2 will be constrained to be on the intersection of the two edges
assy.solve()
show_object(assy)
该例子展示了“两条 PointOnLine 叠加即等效于落在两线交点”的技巧,同时用 Fixed 约束把 b1 锁死,否则整个装配会作为刚体漂移。
Fixed 系列:固定自由度
FixedPoint 把参数指定的位置锁定为目标点,锁住该对象全部平动自由度。代价函数:
cost = |c - param|^2
c:参数对象的重心;param:目标位置三元组。
import cadquery as cq
b1 = cq.Workplane().box(1, 1, 1)
b2 = cq.Workplane().sphere(0.15)
assy = (
cq.Assembly()
.add(b1, name="b1")
.add(b2, loc=cq.Location((0, 0, 4)), name="b2", color=cq.Color("red"))
.add(b1, loc=cq.Location((-2, 0, 0)), name="b3", color=cq.Color("red"))
)
pnt = (0.5, 0.5, 0.5)
# fix the position of b1
assy.constrain("b1", "Fixed")
# fix b2 center at point
assy.constrain("b2", "FixedPoint", pnt)
# fix b3 vertex position at point
assy.constrain("b3@vertices@<X and <Y and <Z", "FixedPoint", pnt)
assy.solve()
show_object(assy)
注意 FixedPoint 锁定的是**被引用对象(可以是面、顶点等子形)**的重心,因此 b3 例中是通过固定其一个顶点来锚定整个方块。
FixedRotation 把对象旋转锁定为目标欧拉角,锁住全部旋转自由度。代价函数:
cost = |R - param|^2
R:施加在对象上的旋转角向量;param:目标旋转三元组(度)。
import cadquery as cq
b1 = cq.Workplane().box(1, 1, 1)
b2 = cq.Workplane().rect(0.1, 0.1).extrude(1, taper=-15)
assy = (
cq.Assembly()
.add(b1, name="b1")
.add(b2, loc=cq.Location((0, 0, 4)), name="b2", color=cq.Color("red"))
)
# fix the position of b1
assy.constrain("b1", "Fixed")
# fix b2 bottom face position (but not rotation)
assy.constrain("b2@faces@<Z", "FixedPoint", (0, 0, 0.5))
# fix b2 rotational degrees of freedom too
assy.constrain("b2", "FixedRotation", (45, 0, 45))
assy.solve()
show_object(assy)
源码实现(cadquery/occ_impl/solver.py)中,目标角先经 radians 换算,再以四元数点积形式 1 - (q·q_target)^2 度量旋转差。
FixedAxis 把对象(面)的法向或(边)的切向锁定为目标方向,锁住两个旋转自由度(绕该轴的自由旋转保留)。代价函数:
cost = (angle(a, param))^2
a:参数对象的法向或切向量;param:目标方向三元组。
import cadquery as cq
b1 = cq.Workplane().box(1, 1, 1)
b2 = cq.Workplane().rect(0.1, 0.1).extrude(1, taper=-15)
assy = (
cq.Assembly()
.add(b1, name="b1")
.add(b2, loc=cq.Location((0, 0, 4)), name="b2", color=cq.Color("red"))
)
# fix the position of b1
assy.constrain("b1", "Fixed")
# fix b2 bottom face position (but not rotation)
assy.constrain("b2@faces@<Z", "FixedPoint", (0, 0, 0.5))
# fix b2 some rotational degrees of freedom too
assy.constrain("b2@faces@>Z", "FixedAxis", (1, 0, 2))
assy.solve()
show_object(assy)
Fixed 是最强的单对象约束:同时锁死平动与旋转。从源码看它并不产生代价函数(fixed_cost 返回 None,注释明确“在变量层面处理”),而是让 solve() 把对应实体标记为 locked——在 CasADi 变量表中将其退化为固定参数(cadquery/assembly.py、cadquery/occ_impl/solver.py)。这也是前例中 assy.constrain("b1", "Fixed") 的写法。
装配颜色:Color 与命名颜色
除 RGBA 数值外,Color 类还支持从文本名称实例化,名称为 CSS 扩展颜色名集合。教程原文给出了完整色样表(数百个条目),常用名称包括:black、white、gray、lightgray、red、orange、yellow、green、cyan、blue、magenta、purple、gold、brown、pink、salmon、coral、khaki、wheat、teal、navyblue、maroon、sienna、plum、thistle 等,且同一颜色普遍带 1–4 的四个明度档位(如 red1–red4、blueviolet、darkslategray、lightsteelblue 等)。完整列表见 doc/assy.rst 的 “Assembly colors” 一节(以 HTML 色块网格形式给出,含每个颜色的 RGBA 采样值);颜色对象本体定义在 cadquery/occ_impl/assembly.py 的 Color 类中,material 参数同理支持字符串到 Material 的自动转换(_ensure_material,cadquery/assembly.py)。
小结与延伸阅读
本篇覆盖了 CadQuery 装配功能的核心工作流:参数化定义 → 零件工厂函数(配合 tag() 标记配合几何)→ Assembly.add 组装 → 字符串选择器/Shape 对象两种方式的 constrain 声明 → solve() 的 IPOPT 优化求解 → STEP/XML 等格式导出。理解“约束 = 代价函数”这一优化视角后,param 的语义(Point 的距离偏移、Axis 的夹角、PointInPlane/PointOnLine 的带符号距离、Fixed 系列的目标位姿)都可以从源码代价函数直接读出,而不必死记。进一步阅读建议:
- 教程原文:doc/assy.rst(含完整颜色样表)
- 装配 API 实现:cadquery/assembly.py
- 约束求解器与代价函数:cadquery/occ_impl/solver.py
- 装配测试用例:tests/test_assembly.py
- 装配导出/导入实现:cadquery/occ_impl/exporters/assembly.py、cadquery/occ_impl/importers/assembly.py
- 教程示例数据:doc/vslot-2020_1.dxf
