自定义实体入门
前两天的学习是在“使用”CAD已有的工具(比如画线、创建块),那么今天的学习就是“创造”属于我们自己的、全新的CAD工具。这就像从一个积木玩家,成长为一个可以自己设计和制造积木的设计师。
自定义实体介绍
什么是自定义实体
自定义实体 (Custom Entity),简单来说,就是我们通过编程创建的一种全新的、不存在于标准CAD中的图形对象。
标准CAD有直线(AcDbLine
)、圆(AcDbCircle
)、块引用(AcDbBlockReference
)等实体。而自定义实体允许我们定义一个全新的类型,比如一个“智能墙体”(MyDbWall
)、一个“参数化门窗”(MyDbWindow
)或者一个带有特殊信息的“设备标签”(MyDbTag
)。
Details
- 从ZcDbEntity或其派生类派生出来
- 可以添加到数据库并随之保存到图纸里
- 可以自行定义图形显示、夹点、捕捉点等特性和行为
- CAD的实体(圆、直线…)也是使用自定义实体的接口来实现的,对CAD来说和自定义实体是一样的
这个新实体可以拥有自己独特的外观、数据和行为。
为什么要有自定义实体
用已有的直线、圆、块等组合一下,不也能画出墙体和门窗吗?为什么还要费力去创建新实体呢?
原因在于,自定义实体能做到普通图形组合无法实现的功能:
封装专业数据:一个“墙体”实体,除了有长度、高度等图形信息,还可以包含“材质”、“耐火等级”、“造价”等专业数据。这些数据被封装在实体内部,管理起来非常方便。
实现智能行为:我们可以让“门窗”实体在插入“墙体”实体时,自动在墙上“开洞”。当移动门窗时,“洞口”也跟着移动。这是简单的图形组合无法做到的。
简化操作与识别:在复杂的图纸中,你可以直接选中一个“柱子”实体,而不是一堆表示柱子的线条。程序可以轻松地统计出图纸中所有“柱子”的数量和属性,极大地提高了效率和准确性。
与CAD操作的对应
学习内容 | 对应的CAD概念或操作 |
---|---|
创建自定义实体类 | 想象一下,你正在为CAD软件添加一个全新的绘图命令,比如“绘制智能墙体”,这个命令创建的就是我们定义的实体。 |
显示实体 | 当你在屏幕上能看到这个“墙体”时,就是我们编写的显示代码在工作。 |
编辑实体 | 当你使用MOVE 、ROTATE 、SCALE 命令,或者拖动夹点来改变“墙体”时,就是我们编写的编辑代码在响应。 |
捕捉实体 | 当你画另一条线,想捕捉到“墙体”的中点或端点时,就是我们编写的捕捉代码在计算捕捉点。 |
保存与打开 | 当你保存含有“墙体”的DWG文件,并能在另一台电脑上打开它时,就是我们编写的读写代码在发挥作用。 |
创建自定义实体
要创建一个自定义实体,我们首先需要定义一个C++类。这个类就像一个“蓝图”,描述了我们新实体的所有特征和能力。
// 这是一个自定义实体的基本框架
// 它继承自 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:“我定义了一个新实体,请你认识一下它!” 这个过程就是注册。
// 在 initapp 函数中,程序被加载时执行
void initapp()
{
// 注册自定义实体类。这是让CAD识别 SampleCustEnt 的关键一步。
SampleCustEnt::rxInit();
// 重建ZRX的类继承树,确保我们新注册的类被正确地加入到系统中。
zcrxBuildClassHierarchy();
}
// 在 unloadapp 函数中,程序被卸载时执行
void unloadapp()
{
// 从CAD系统中注销我们的自定义实体类,释放资源。
// SampleCustEnt::desc() 用于获取类的描述符。
deleteZcRxClass(SampleCustEnt::desc());
}
安全检查:assertReadEnabled
和 assertWriteEnabled
用途:这是两个非常重要的安全检查函数,用来防止对数据库对象的非法操作。
assertReadEnabled()
: 用在只读取实体数据的函数开头。它会检查实体被打开时是不是至少有“读”权限。assertWriteEnabled()
: 用在会修改实体数据的函数开头。它会检查实体被打开时是不是有“写”权限。
如果权限不匹配,程序会立即报错并退出,这能帮助我们及早发现代码中的逻辑错误。这是一个必须遵守的好习惯。
让实体“看得见”:自定义实体的显示
一个实体如果不能被画出来,那就毫无意义。subWorldDraw
函数就是我们实体的“专属画笔”。
subWorldDraw
vs subViewportDraw
subWorldDraw
(视口无关的显示): 这是最常用的显示函数。无论你如何缩放、平移、旋转视图,实体都用同一种方式绘制。例如,一个圆,无论远近,它始终是个圆。subViewportDraw
(视口相关的显示): 这是一个更高级的显示函数。它允许实体的外观根据当前视口的状态(如缩放比例)而改变。- CAD操作对应:想象一下地图应用,当你放大到一定程度时,才会显示出街道名称。这就是视口相关显示。在CAD中,你可以让一个复杂的设备实体在缩小时只显示一个轮廓框,在放大时才显示内部的详细零件。
如何显示图形
在subWorldDraw
函数内部,我们使用mode
参数提供的“几何图形绘制器”(geometry()
)来画图。
// 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
),然后传递给我们的函数。我们的任务就是用这个矩阵去更新实体自身的几何数据。
// 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
)
Zcad::ErrorStatus SampleCustEnt::subGetGripPoints(
ZcGePoint3dArray& gripPoints, // 输出参数:我们需要把夹点的位置添加到这个数组里
ZcDbIntArray& osnapModes, // (可选) 与夹点关联的捕捉模式
ZcDbIntArray& geomIds) const // (可选) 与夹点关联的几何ID
{
assertReadEnabled();
// 对于我们的圆形实体,我们只在圆心添加一个夹点。
gripPoints.append(m_center);
return Zcad::eOk;
}
第二步:定义拖动夹点时的行为 (subMoveGripPointsAt
)
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),并将鼠标靠近一个圆时,光标会自动吸附到它的圆心或象限点上。这个函数就是用来实现这个功能的。
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
)
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
)
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();
}
关键点:dwgInFields
和 dwgOutFields
中的读写顺序、类型和数量必须严格一一对应,否则会导致文件损坏或程序崩溃。
代理对象与解释器
6.1 代理对象 (Proxy Object)
场景:你用你的ZRX程序创建了一个包含“智能墙体”的DWG文件,然后把这个文件发给了没有安装你程序的同事。
结果:当你的同事打开这个DWG文件时,他看不到漂亮的墙体,而是会看到一个方框,上面可能写着“Proxy Entity”。这个方框就是代理对象。
定义:当CAD打开一个DWG文件,发现里面有一种它不认识的自定义实体时,就会将该实体显示为一个代理对象。CAD知道那里有个东西,但不知道它长什么样,也不能编辑它。
Details
- 打开包含自定义实体的图纸,如果没有加载定义的程序,自定义实体会显示为代理对象
- 合理的做法是把自定义实体的定义代码放到独立的工程里,单独部署到有需要查看而不需要创建的环境里。
- 这种文件被称为自定义实体的解释器(Enabler)
解释器 (Enabler)
为了解决代理问题,我们可以创建一个解释器。
定义:解释器是一个轻量级的ZRX程序,它只包含自定义实体的定义代码(显示、捕捉、读写等),但不包含创建这些实体的命令。
用途:你可以把这个解释器程序发给你的同事。他安装后,虽然不能创建新的“智能墙体”,但是当他打开你发给他的DWG文件时,就能正确地看到、捕捉和打印这些墙体了,它们不再是代理对象。
这在团队协作中非常有用,可以确保图纸在不同成员之间正确流转。