CadQuery 装配约束求解实战:用 Assembly 与 Constraint 构建参数化铝型材机柜门

原创2026-10-09 10:57:051,038 阅读
文章标签:3D建模

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)是:

  1. 为约束涉及的所有零件编号,Fixed 约束对象与装配根节点会被锁定;没有任何锁定时,自动锁定二元约束中首个出现的实体,保证系统整体刚性;
  2. 用装配包围盒对角线长度 self.toCompound().BoundingBox().DiagonalLength 作为全局 scale,归一化代价函数,避免大装配中平移动量淹没旋转量;
  3. 把每个约束交给 ConstraintSpec.toPODs() 分解为基元约束(Plane 会被拆成 Axis + Point 两条,见下文),再交给基于 CasADi 的非线性规划求解器 IPOPT(见 cadquery/occ_impl/solver.py,tol=1e-14、精确 Hessian、MUMPS 线性求解器);
  4. 求解后各零件的 Location 被更新为“相对根节点”的解。

约束验证的完整测试可参考 tests/test_assembly.py。

数据导出:STEP 与 OCCT XML

求解完成后可直接导出:

door.export("door.step")
door.export("door.xml")

STEP 可被 FreeCAD 等几乎所有 CAD 工具加载,XML 则是 OCCT 内部格式,可供其他使用 OCCT 的应用读取。实际导出效果(STEP 加载到 FreeCAD)如下:

装配导出为 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 系列的目标位姿)都可以从源码代价函数直接读出,而不必死记。进一步阅读建议:

登录后查看全文
cadquery