Skip to content

自定义实体入门

前两天的学习是在“使用”CAD已有的工具(比如画线、创建块),那么今天的学习就是“创造”属于我们自己的、全新的CAD工具。这就像从一个积木玩家,成长为一个可以自己设计和制造积木的设计师。

自定义实体介绍

什么是自定义实体

自定义实体 (Custom Entity),简单来说,就是我们通过编程创建的一种全新的、不存在于标准CAD中的图形对象

标准CAD有直线(AcDbLine)、圆(AcDbCircle)、块引用(AcDbBlockReference)等实体。而自定义实体允许我们定义一个全新的类型,比如一个“智能墙体”(MyDbWall)、一个“参数化门窗”(MyDbWindow)或者一个带有特殊信息的“设备标签”(MyDbTag)。

Details
  • 从ZcDbEntity或其派生类派生出来
  • 可以添加到数据库并随之保存到图纸里
  • 可以自行定义图形显示、夹点、捕捉点等特性和行为
  • CAD的实体(圆、直线…)也是使用自定义实体的接口来实现的,对CAD来说和自定义实体是一样的

这个新实体可以拥有自己独特的外观、数据和行为。

为什么要有自定义实体

用已有的直线、圆、块等组合一下,不也能画出墙体和门窗吗?为什么还要费力去创建新实体呢?

原因在于,自定义实体能做到普通图形组合无法实现的功能:

  1. 封装专业数据:一个“墙体”实体,除了有长度、高度等图形信息,还可以包含“材质”、“耐火等级”、“造价”等专业数据。这些数据被封装在实体内部,管理起来非常方便。

  2. 实现智能行为:我们可以让“门窗”实体在插入“墙体”实体时,自动在墙上“开洞”。当移动门窗时,“洞口”也跟着移动。这是简单的图形组合无法做到的。

  3. 简化操作与识别:在复杂的图纸中,你可以直接选中一个“柱子”实体,而不是一堆表示柱子的线条。程序可以轻松地统计出图纸中所有“柱子”的数量和属性,极大地提高了效率和准确性。

与CAD操作的对应

学习内容对应的CAD概念或操作
创建自定义实体类想象一下,你正在为CAD软件添加一个全新的绘图命令,比如“绘制智能墙体”,这个命令创建的就是我们定义的实体。
显示实体当你在屏幕上能看到这个“墙体”时,就是我们编写的显示代码在工作。
编辑实体当你使用MOVEROTATESCALE命令,或者拖动夹点来改变“墙体”时,就是我们编写的编辑代码在响应。
捕捉实体当你画另一条线,想捕捉到“墙体”的中点或端点时,就是我们编写的捕捉代码在计算捕捉点。
保存与打开当你保存含有“墙体”的DWG文件,并能在另一台电脑上打开它时,就是我们编写的读写代码在发挥作用。

创建自定义实体

要创建一个自定义实体,我们首先需要定义一个C++类。这个类就像一个“蓝图”,描述了我们新实体的所有特征和能力。

cpp
// 这是一个自定义实体的基本框架
// 它继承自 ZcDbEntity,意味着它具备了CAD实体的基本属性
class SampleCustEnt : public ZcDbEntity {
public:
    // [MANDATORY] 这是一个必须有的宏,它为我们的类添加了ZRX运行时需要的一些“魔法”函数
    // 比如 isA(), desc() 等,用于类型识别。记住它,每个自定义实体都要有。
    ZCRX_DECLARE_MEMBERS(SampleCustEnt);

protected:
    // 记录自定义实体的版本号,非常重要,用于后续的图纸兼容。
    static ZSoft::UInt32 kCurrentVersionNumber;

public:
    // 构造函数:当 new SampleCustEnt() 时被调用
    SampleCustEnt();
    // 析构函数:当实体被销毁时被调用
    virtual ~SampleCustEnt();

protected:
    // --- 核心虚函数重载 ---
    // 这些函数是我们必须“重写”的,用来定义实体的具体行为

    // 负责“画出”实体,决定了实体在屏幕上的样子
    virtual ZSoft::Boolean subWorldDraw(ZcGiWorldDraw* mode);

    // (可选) 负责根据视口不同而改变显示,比subWorldDraw更高级
    virtual void subViewportDraw(ZcGiViewportDraw* mode);

    // 负责设置实体的显示属性,如颜色、线型、图层等
    virtual ZSoft::UInt32 subSetAttributes(ZcGiDrawableTraits* traits);
};

注册与注销

光有类的定义还不够,我们必须在程序加载时,明确地告诉CAD:“我定义了一个新实体,请你认识一下它!” 这个过程就是注册

cpp
// 在 initapp 函数中,程序被加载时执行
void initapp()
{
    // 注册自定义实体类。这是让CAD识别 SampleCustEnt 的关键一步。
    SampleCustEnt::rxInit();
    // 重建ZRX的类继承树,确保我们新注册的类被正确地加入到系统中。
    zcrxBuildClassHierarchy();
}

// 在 unloadapp 函数中,程序被卸载时执行
void unloadapp()
{
    // 从CAD系统中注销我们的自定义实体类,释放资源。
    // SampleCustEnt::desc() 用于获取类的描述符。
    deleteZcRxClass(SampleCustEnt::desc());
}
安全检查:assertReadEnabledassertWriteEnabled

用途:这是两个非常重要的安全检查函数,用来防止对数据库对象的非法操作。

  • assertReadEnabled(): 用在只读取实体数据的函数开头。它会检查实体被打开时是不是至少有“读”权限。

  • assertWriteEnabled(): 用在会修改实体数据的函数开头。它会检查实体被打开时是不是有“写”权限。

如果权限不匹配,程序会立即报错并退出,这能帮助我们及早发现代码中的逻辑错误。这是一个必须遵守的好习惯。

让实体“看得见”:自定义实体的显示

一个实体如果不能被画出来,那就毫无意义。subWorldDraw 函数就是我们实体的“专属画笔”。

subWorldDraw vs subViewportDraw

  • subWorldDraw (视口无关的显示): 这是最常用的显示函数。无论你如何缩放、平移、旋转视图,实体都用同一种方式绘制。例如,一个圆,无论远近,它始终是个圆。

  • subViewportDraw (视口相关的显示): 这是一个更高级的显示函数。它允许实体的外观根据当前视口的状态(如缩放比例)而改变。

    • CAD操作对应:想象一下地图应用,当你放大到一定程度时,才会显示出街道名称。这就是视口相关显示。在CAD中,你可以让一个复杂的设备实体在缩小时只显示一个轮廓框,在放大时才显示内部的详细零件。

如何显示图形

subWorldDraw函数内部,我们使用mode参数提供的“几何图形绘制器”(geometry())来画图。

cpp
// SampleCustEnt 的 subWorldDraw 函数实现
ZSoft::Boolean SampleCustEnt::subWorldDraw(ZcGiWorldDraw* mode) {
    // 安全检查:这个函数只读取实体数据来绘制,所以用 assertReadEnabled。
    assertReadEnabled();

    // 使用 mode->geometry() 来获取绘图工具集。
    // .circle() 就是其中一个工具,用来画圆。
    // 参数分别是:圆心(m_center)、半径(m_radius)、法线向量(ZcGeVector3d::kZAxis)。
    // 这里的 m_center 和 m_radius 是我们为 SampleCustEnt 类定义的成员变量。
    mode->geometry().circle(m_center, m_radius, ZcGeVector3d::kZAxis);

    // 返回 kTrue (或调用父类的方法) 表示绘制成功。
    // 如果你的实体在某些情况下不需要调用 subViewportDraw,可以返回 kFalse。
    return ZcDbEntity::subWorldDraw(mode);
}

mode->geometry()` 提供了丰富的绘图工具:

  • circle(): 绘制圆或圆弧。
  • polyline(): 绘制多段线。
  • text(): 绘制文字。
  • shell(): 通过顶点和面列表绘制复杂的三维壳体。
  • mesh(): 通过网格点绘制三维网格。
  • pline(): 直接绘制一个已经构造好的 ZcDbPolyline 对象,非常灵活。

让实体“可编辑”:变换、夹点与捕捉

移动、旋转、缩放 (subTransformBy)

作用:当用户对我们的实体使用MOVE, ROTATE, SCALE等编辑命令时,CAD会调用这个函数。

CAD会把用户的操作计算成一个变换矩阵 (ZcGeMatrix3d),然后传递给我们的函数。我们的任务就是用这个矩阵去更新实体自身的几何数据。

cpp
// ZcGeMatrix3d xform: 这是CAD计算好的变换矩阵。
Zcad::ErrorStatus SampleCustEnt::subTransformBy(const ZcGeMatrix3d& xform) {
    // 安全检查:这个函数要修改实体数据(中心点),所以用 assertWriteEnabled。
    assertWriteEnabled();

    // 用矩阵 xform 来变换我们的圆心 m_center。
    // .transformBy() 是 ZcGePoint3d 类的方法。
    m_center = m_center.transformBy(xform);
    
    // 对于一个圆来说,半径在移动、旋转时不变。
    // 如果是缩放,我们还需要在这里添加代码来缩放半径 m_radius。

    return Zcad::eOk; // 返回成功
}

夹点(subGetGripPoints & subMoveGripPointsAt)

作用:定义实体被选中时出现的蓝色小方块(夹点),以及拖动它们时的行为。

CAD操作对应:你选中一条直线,会看到三个夹点(两端点、一中点)。拖动端点夹点可以改变长度和方向,拖动中点夹点可以移动整条直线。这就是夹点功能。

这个功能分两步实现:

第一步:告诉CAD在哪里显示夹点 (subGetGripPoints)

cpp
Zcad::ErrorStatus SampleCustEnt::subGetGripPoints(
    ZcGePoint3dArray& gripPoints, // 输出参数:我们需要把夹点的位置添加到这个数组里
    ZcDbIntArray& osnapModes,   // (可选) 与夹点关联的捕捉模式
    ZcDbIntArray& geomIds) const // (可选) 与夹点关联的几何ID
{
    assertReadEnabled();
    // 对于我们的圆形实体,我们只在圆心添加一个夹点。
    gripPoints.append(m_center);
    return Zcad::eOk;
}

第二步:定义拖动夹点时的行为 (subMoveGripPointsAt)

cpp
Zcad::ErrorStatus SampleCustEnt::subMoveGripPointsAt(
    const ZcDbIntArray& indices, // 输入参数:被拖动的夹点的索引号数组
    const ZcGeVector3d& offset)  // 输入参数:夹点被拖动的位移向量
{
    assertWriteEnabled();
    // indices[0] 是第一个被拖动夹点的索引。
    // 因为我们只定义了1个夹点,所以它的索引是0。
    if (indices[0] == 0) {
        // 如果是第0号夹点(圆心夹点)被拖动,
        // 我们就让圆心 m_center 加上这个位移向量。
        m_center += offset;
    }
    return Zcad::eOk;
}

对象捕捉

  • 当CAD可以进行对象捕捉的情况下,鼠标靠近自定义实体会触发subGetOsnapPoints函数,由自定义实体来定义如何计算捕捉点。

  • 尽可能利用CAD实体本身的功能来计算,能不自己算就不要自己算。

作用:让其他绘图操作可以捕捉到我们自定义实体的关键点(如中心、端点、交点等)。

CAD操作对应:当你开启对象捕捉(OSNAP),并将鼠标靠近一个圆时,光标会自动吸附到它的圆心或象限点上。这个函数就是用来实现这个功能的。

cpp
Zcad::ErrorStatus SampleCustEnt::subGetOsnapPoints(
    ZcDb::OsnapMode osnapMode,      // 输入:CAD当前请求的捕捉模式(如圆心、中点)
    ZSoft::GsMarker gsSelectionMark, // 输入:子实体选择标记
    const ZcGePoint3d& pickPoint,    // 输入:鼠标拾取点
    const ZcGePoint3d& lastPoint,    // 输入:上一个点
    const ZcGeMatrix3d& viewXform,   // 输入:视图变换矩阵
    ZcGePoint3dArray& snapPoints,  // 输出:我们计算出的捕捉点要放在这里
    ZcDbIntArray& geomIds) const
{
    assertReadEnabled();
    
    // --- 这是一个非常聪明的技巧 ---
    // 我们不需要自己费力去计算圆的各种捕捉点。
    // 我们可以创建一个临时的、标准的 ZcDbCircle 对象。
    ZcDbCircle circle;
    // 让这个临时圆的几何属性和我们的自定义实体完全一样。
    circle.setCenter(m_center);
    circle.setRadius(m_radius);
    
    // 然后,直接调用标准圆的 getOsnapPoints 方法,让它帮我们完成所有计算!
    // 这就是“尽可能利用CAD实体本身的功能”,省时省力还不容易出错。
    return circle.getOsnapPoints(osnapMode, gsSelectionMark, pickPoint, lastPoint, viewXform, snapPoints, geomIds);
}

自定义实体的保存和读取

如何实现

如果自定义实体不能随DWG文件保存,那么它只能存在于当前的编辑会话中,一关闭图纸就没了。为了让它能被永久保存和读取,我们需要实现序列化。

序列化 (Serialization):将内存中的对象数据(比如 m_center 的坐标值)转换成一串二进制数据流,以便写入文件。这个过程由 dwgOutFields 函数负责。

反序列化 (Deserialization):从文件中读取二进制数据流,并将其恢复成内存中的对象数据。这个过程由 dwgInFields 函数负责。

保存实体数据 (dwgOutFields)

cpp
Zcad::ErrorStatus SampleCustEnt::dwgOutFields(ZcDbDwgFiler* pFiler) const {
    assertReadEnabled();
    // 1. 必须先调用父类的 dwgOutFields,这是一个规定。
    Zcad::ErrorStatus es = ZcDbEntity::dwgOutFields(pFiler);
    if (es != Zcad::eOk) return es;

    // 2. 写入版本号,这是为了未来的兼容性。
    pFiler->writeUInt32(SampleCustEnt::kCurrentVersionNumber);

    // 3. 按照特定顺序写入我们自己的成员变量。
    pFiler->writePoint3d(m_center);
    pFiler->writeDouble(m_radius);

    return pFiler->filerStatus();
}

5.2 读取实体数据 (dwgInFields)

cpp
Zcad::ErrorStatus SampleCustEnt::dwgInFields(ZcDbDwgFiler* pFiler) {
    assertWriteEnabled();
    // 1. 同样,必须先调用父类的 dwgInFields。
    Zcad::ErrorStatus es = ZcDbEntity::dwgInFields(pFiler);
    if (es != Zcad::eOk) return es;

    // 2. 读取版本号,并进行检查。
    ZSoft::UInt32 version = 0;
    pFiler->readUInt32(&version);
    // 如果文件的版本比当前程序的版本还新,说明我们无法解析它,
    // 此时应返回 eMakeMeProxy,让它显示为代理对象。
    if (version > SampleCustEnt::kCurrentVersionNumber) {
        return Zcad::eMakeMeProxy;
    }
    // 这里还可以添加对旧版本的兼容处理代码。

    // 3. 必须以和写入时完全相同的顺序和类型来读取数据!
    pFiler->readPoint3d(&m_center);
    pFiler->readDouble(&m_radius);

    return pFiler->filerStatus();
}

关键点dwgInFieldsdwgOutFields 中的读写顺序、类型和数量必须严格一一对应,否则会导致文件损坏或程序崩溃。

代理对象与解释器

6.1 代理对象 (Proxy Object)

场景:你用你的ZRX程序创建了一个包含“智能墙体”的DWG文件,然后把这个文件发给了没有安装你程序的同事。

结果:当你的同事打开这个DWG文件时,他看不到漂亮的墙体,而是会看到一个方框,上面可能写着“Proxy Entity”。这个方框就是代理对象

定义:当CAD打开一个DWG文件,发现里面有一种它不认识的自定义实体时,就会将该实体显示为一个代理对象。CAD知道那里有个东西,但不知道它长什么样,也不能编辑它。

Details
  • 打开包含自定义实体的图纸,如果没有加载定义的程序,自定义实体会显示为代理对象
  • 合理的做法是把自定义实体的定义代码放到独立的工程里,单独部署到有需要查看而不需要创建的环境里。
  • 这种文件被称为自定义实体的解释器(Enabler)

解释器 (Enabler)

为了解决代理问题,我们可以创建一个解释器

定义:解释器是一个轻量级的ZRX程序,它只包含自定义实体的定义代码(显示、捕捉、读写等),但不包含创建这些实体的命令。

用途:你可以把这个解释器程序发给你的同事。他安装后,虽然不能创建新的“智能墙体”,但是当他打开你发给他的DWG文件时,就能正确地看到、捕捉和打印这些墙体了,它们不再是代理对象。

这在团队协作中非常有用,可以确保图纸在不同成员之间正确流转。