在Web端复刻AutoCAD多段线命令交互

0 阅读18分钟

前言

多段线能承载直线段与圆弧段,又自带宽度与几何属性。它是真实的工程图纸中使用率最高的图元,如建筑墙体、市政管线、道路边线、地块轮廓。本文作为基础绘图的进阶篇,将带大家深入多段线,并在 Web 端复刻 AutoCAD PLINE 命令交互。 image-20260901105243678.png

一、多段线概念

多段线(Polyline)是由多个顶点依次连接而成的单一实体,相邻顶点之间既可以是直线段,也可以是圆弧段。它与零散直线段最本质的区别在于:无论包含多少段,它在图纸数据库中始终是"一个对象"。

  • 选择与编辑以整体为单位: 一条包含 200 个顶点的管线,点选即整体,移动、删除都是一次操作;
  • 自带几何属性:可直接查询总长度与包围面积,是工程量统计的基础;
  • 支持整体变换: 偏移(画双线墙体)、打碎(还原为基本图元)等操作天然以它为对象。

在 mxcad 中,多段线实体由 McDbPolyline 类表示。绘制一条多段线的最简形式,是依次添加顶点后提交:

import { McDbPolyline, MxCpp } from "mxcad";
const pl = new McDbPolyline();
pl.addVertexAt(pt1);                          // 依次添加顶点
pl.addVertexAt(pt2);
pl.addVertexAt(pt3);
pl.addVertexAt(pt4);
pl.isClosed = true;                           // 首尾闭合
MxCpp.getCurrentMxCAD().drawEntity(pl);       // 提交绘制

addVertexAt 方法

mxcad 提供的 addVertexAt 方法除了写入顶点坐标,还能为每个顶点设置凸度与宽度两类属性:

  • 顶点:多段线的基本组成单元,决定路径走向;
  • 凸度:控制相邻两顶点之间是直线还是圆弧;
  • 宽度:控制线段的粗细,支持渐变。
/**
 * 在指定位置添加顶点
 * @param pt 顶点坐标
 * @param bulge 凸度(默认 0,即直线段)
 * @param startWidth 起始宽度(默认 0)
 * @param endWidth 结束宽度(默认 0)
 * @param index 插入位置(默认追加到末尾)
 */
addVertexAt(pt, bulge?, startWidth?, endWidth?, index?): boolean

二、多段线的三大核心要素

1. 顶点

顶点按添加顺序连接成路径,是多段线最基本的操作单元。围绕顶点的常用 API 如下:

const num = pl.numVerts();               // 顶点总数
const pt = pl.getPointAt(2).val;         // 读取第 2 号顶点坐标
pl.setPointAt(2, newPt);                 // 移动第 2 号顶点
pl.removeVertexAt(2);                    // 删除第 2 号顶点
pl.isClosed = true;                      // 闭合:末顶点自动连回首顶点

需要特别注意的是,顶点是有序的,顶点的顺序决定了多段线的方向。比如,多段线中圆弧的切向计算就是依赖顶点的先后顺序。

2. 凸度

与对象捕捉使用数值编码捕捉模式类似,多段线使用一个数值(凸度)完整描述一段圆弧。

凸度的定义: 凸度等于圆弧包含角(圆心角)四分之一的正切值,即 bulge = tan(θ/4)。

其中 θ 为该弧段所对的圆心角(弧度制)。围绕定义有三条规则:

  1. 符号表方向: bulge > 0 为逆时针弧,bulge < 0 为顺时针弧;
  2. 特征值: bulge = 0 是直线段;|bulge| = 1 恰好是半圆(tan45° = 1);
  3. 存放位置: 凸度挂在弧段的起始顶点上,用于描述当前顶点到下一顶点中间的这一段。

日常开发记住这张速查表即可:

圆弧包含角 θbulge = tan(θ/4)说明
0直线段
90°√2 − 1 ≈ 0.4142四分之一圆,倒圆角常用
180°1半圆
270°√2 + 1 ≈ 2.4142优弧

image-20260901112227125.png

由于手动换算凸度既繁琐又易错,因此 mxcad 提供了现成的工具函数 MxCADUtility.calcBulge(),只需要给出圆弧的起点、弧上任意一点、端点,即可直接得到目标圆弧的凸度值:

import { MxCADUtility } from "mxcad";
const ret = MxCADUtility.calcBulge(startPt, arcMidPt, endPt);
if (ret.ret) {
    console.log(ret.val);   // 凸度值,可直接作为 addVertexAt 的第 2 参数
}

对已经绘制在图纸上的多段线,还可以按索引读写凸度:

const bulge = pl.getBulgeAt(2);   // 读取第 2 号顶点凸度
pl.setBulgeAt(2, 0);              // 置 0:该段变为直线

3. 宽度

多段线的宽度属性常被忽视,但它是示意图级绘图的关键。宽度分两个层级:

  • 全局宽度(constantWidth): 整条多段线统一粗细,只需设置一个属性:

    pl.constantWidth = 10;   // 整条多段线宽 10
    
  • 逐顶点宽度(addVertexAt):addVertexAt 的第 3、4 个参数分别是该顶点的起始宽度与结束宽度,线段宽度从起始值线性渐变到结束值。利用这一点,几行代码就能画出带箭头的标注线:

    const pl = new McDbPolyline();
    pl.addVertexAt(pt1, 0, 4, 4);      // 等宽段:箭杆
    pl.addVertexAt(pt2, 0, 12, 0);     // 渐变段:宽度从 12 收拢到 0,形成箭头
    pl.addVertexAt(pt3);
    

对已经绘制在图纸上的多段线,用 setWidthsAt / getWidthsAt 按索引读写宽度。

// 设置第 0 号顶点段的宽度:起始宽度 10、结束宽度 10(等宽段)
pl.setWidthsAt(0, 10, 10);

// 读取第 0 号顶点段的宽度信息
const width = pl.getWidthsAt(0);
if (width.ret) {
    // val1 为起始宽度,val2 为结束宽度
    console.log(width.val1, width.val2);
}

三、构建完整的多段线绘制命令

命令的实现分为五步:规划交互选项、构建数据收集与实体生成的基本结构、分别实现直线模式与圆弧模式、完成收尾处理,最后将所有部分整合为一个完整的命令并注册。

image-20260901170711222.png

1. 交互设计

编写代码前,先规划命令的交互形态。MxCAD 对齐 AutoCAD 的操作习惯,因此直接采用 PLINE 的选项体系。其中,PLINED的实现主要分为以下的两种模式:

  • 直线模式:围绕顶点 + 宽度;
  • 圆弧模式:围绕凸度。

根据 AutoCAD 中绘制多段线的操作,可以将命令的实现步骤分为:先收集顶点、构建实体,再处理宽度,最后处理凸度。 image-20260907113854563.png

2. 命令的基本结构

2.1 顶点数据的收集

命令需要用数组记录用户确认的每一个顶点。回看 addVertexAt 的参数——坐标、凸度、起始宽度、结束宽度,数组中每条记录包含这四个字段。这样设计的好处是:生成最终实体时,逐条调用 addVertexAt 即可:

// 顶点数据:坐标 + 凸度 + 起止宽度
interface VertexData {
    pt: McGePoint3d;
    dBulge: number;
    dStartWidth: number;
    dEndWidth: number;
}
const vecData: VertexData[] = [];
// 添加起点:直线段凸度为 0,宽度默认 0
vecData.push({ pt: firstPoint, dBulge: 0, dStartWidth: 0, dEndWidth: 0 });

2.2 临时实体:实时显示与回退

交互过程中,每确认一段需要立刻显示图形,回退时需要能够撤销。因此引入临时实体:每确认一段,用最近两个顶点绘制一条两点多段线临时显示,其 ID 记入 vecTmpObjectId;回退时删除对应的临时实体;命令结束时删除全部临时实体,用完整的顶点数据一次性生成最终多段线:

// 提交一段:用最近两个顶点画临时多段线
function commitSegment() {
    const len = vecData.length;
    if (len < 2) return;
    const seg = new McDbPolyline();
    seg.addVertexAt(vecData[len - 2].pt, vecData[len - 2].dBulge,
        vecData[len - 2].dStartWidth, vecData[len - 2].dEndWidth);
    seg.addVertexAt(vecData[len - 1].pt);
    vecTmpObjectId.push(mxcad.drawEntity(seg));
}
// 回退(U):弹出顶点数据 + 删除最近临时实体
function undoSegment() {
    if (vecData.length <= 1) return;
    vecData.pop();
    vecTmpObjectId[vecTmpObjectId.length - 1].erase();
    vecTmpObjectId.pop();
}

数据收集与实体生成相互分离:顶点数组保存数据,临时实体用于实时显示,最终实体在命令结束时生成。

2.3 主循环与模式切换

命令的主体是一个循环。用布尔变量 isDrawLine 标记当前处于直线模式还是圆弧模式,循环据此切换两套交互分支,关键字 A(圆弧)与 L(直线)用于切换模式:

while (true) {
    if (isDrawLine) {
        // ---- 直线模式(见第 3 小节)----
    } else {
        // ---- 圆弧模式(见第 4 小节)----
    }
}

基本结构确定后,下面分别实现两个分支的逻辑。

3. 直线模式:顶点收集与宽度设置

直线模式的主要流程是:取点、保存顶点数据、提交临时段;关键字分支处理宽度、长度、回退、闭合:

const getNextPoint = new MxCADUiPrPoint();
getNextPoint.setMessage("\n指定下一个点:");
// 关键字随顶点数动态变化:≥3 个顶点才允许闭合(与 AutoCAD 一致)
let sKeyWord = "[圆弧(A)/宽度(W)/长度(L)/半宽(H)]";
if (vecData.length >= 3) sKeyWord = "[回退(U)/圆弧(A)/宽度(W)/长度(L)/半宽(H)/闭合(C)]";
else if (vecData.length >= 2) sKeyWord = "[回退(U)/圆弧(A)/宽度(W)/长度(L)/半宽(H)]";
getNextPoint.setKeyWords(sKeyWord);
// 动态预览:光标移动时实时画出"上一顶点 → 光标"的橡皮筋段
const drawData = vecData[vecData.length - 1];
getNextPoint.setUserDraw((currentPoint, pWorldDraw) => {
    const pl = new McDbPolyline();
    pl.addVertexAt(drawData.pt, drawData.dBulge, drawData.dStartWidth, drawData.dEndWidth);
    pl.addVertexAt(currentPoint);
    pWorldDraw.drawMcDbEntity(pl, true);   // 第二参数 true:仅预览,不入数据库
});
const ptNext = await getNextPoint.go();
if (ptNext !== null) {
    // 取点成功:保存新顶点数据(凸度 0),提交临时段
    vecData.push({ pt: ptNext, dBulge: 0, dStartWidth, dEndWidth });
    commitSegment();
} else if (getNextPoint.getStatus() === MrxDbgUiPrBaseReturn.kKeyWord) {
    if (getNextPoint.isKeyWordPicked("A")) {
        isDrawLine = false;                    // 切换圆弧模式
    } else if (getNextPoint.isKeyWordPicked("W")) {
        // 宽度:起点宽、端点宽两步输入
        const getWidth = new MxCADUiPrDist();
        getWidth.setMessage("指定起点宽度");
        if (await getWidth.go() === null) break;
        dStartWidth = getWidth.value();
        getWidth.setMessage("指定端点宽度");
        if (await getWidth.go() === null) break;
        dEndWidth = getWidth.value();
        // 宽度写回当前顶点,作用于下一段;端点宽延续为后续默认值
        vecData[vecData.length - 1].dStartWidth = dStartWidth;
        vecData[vecData.length - 1].dEndWidth = dEndWidth;
        dStartWidth = dEndWidth;
    } else if (getNextPoint.isKeyWordPicked("C")) {
        isClose = true; break;                 // 闭合
    } else if (getNextPoint.isKeyWordPicked("U")) {
        undoSegment();                         // 回退
    }
    // H 半宽(输入值 ×2)、L 长度(沿上一段方向推算顶点)逻辑类似,此处省略
} else {
    break;   // 右键/回车结束
}

这里有两个关键细节:

  1. 动态预览通过 setUserDraw 回调实现,第二参数传 true 表示仅预览、不写入数据库,因此预览图形不会写入图纸;
  2. 宽度的生效规则:宽度写回的是当前顶点(作用于即将绘制的下一段),且端点宽度会延续为后续段的默认起始宽度,与 AutoCAD PLINE 宽度持续生效的规则一致。

4. 圆弧模式:切向继承与凸度计算

4.1 切向继承:保证圆弧平滑衔接

切换到圆弧模式后,首先需要计算当前弧段的起点切向。AutoCAD 规定,圆弧段默认与上一段相切衔接——上一段是直线,切向就是直线方向;上一段是圆弧,切向就是该弧终点的切线方向。据此实现:

let vecArcTangent = new McGeVector3d();
if (vecData.length < 2) {
    // 第一段就是圆弧:无参照,切向取水平方向
    vecArcTangent.copy(McGeVector3d.kXAxis);
} else {
    const size = vecData.length;
    const pt1 = vecData[size - 2].pt;        // 上一段起点
    const dBulge = vecData[size - 2].dBulge; // 上一段凸度
    const pt2 = vecData[size - 1].pt;        // 上一段终点(当前弧起点)

    if (dBulge === 0) {
        // 上一段是直线:切向 = 直线方向
        vecArcTangent = pt2.sub(pt1);
    } else {
        // 上一段是圆弧:构造临时多段线,求终点一阶导数(切向量)
        const tmpPl = new McDbPolyline();
        tmpPl.addVertexAt(pt1, dBulge);
        tmpPl.addVertexAt(pt2);
        const tmpVec = tmpPl.getFirstDeriv(new McGePoint3d(pt2.x, pt2.y, 0));
        if (tmpVec.ret) vecArcTangent = tmpVec.val;
        else vecArcTangent.copy(McGeVector3d.kXAxis);
    }
}

如上文所述,顶点的顺序决定了多段线的方向。vecData 中顶点的先后次序决定了这里"上一段"的方向;根据该切向,新弧段即可与已有路径平滑衔接。

4.2 默认方式:直接指定端点(切向定弧)

确定切向后,用户只需指定圆弧端点——起点、端点、起点切向三者唯一确定一段圆弧。用辅助函数 CalcArcBulge 由这三者计算凸度,核心思路是:圆心既在弦的垂直平分线上、又在起点切线的垂线上,两线求交得到圆心;选取与切向一致的弧中点;最后三点调用 calcBulge

// ---------- 辅助函数:由起点、端点、起点切向计算凸度 ----------
// 思路:圆心同时在弦的垂直平分线与起点切线的垂线上,两线求交得圆心;
// 选取与切向一致的弧中点后,用三点调用 calcBulge
function CalcArcBulge(firstPoint: McGePoint3d, nextPoint: McGePoint3d,
    vecArcTangent: McGeVector3d): number {
    if (firstPoint.isEqualTo(nextPoint)) return 0;
    // 弦中点,及弦的垂直平分线
    const midPt = firstPoint.c().addvec(nextPoint.c().sub(firstPoint).mult(0.5));
    const vecMid = nextPoint.c().sub(firstPoint);
    vecMid.rotateBy(Math.PI / 2, McGeVector3d.kZAxis);
    const tmpMidLine = new McDbLine(midPt, midPt.c().addvec(vecMid));
    // 起点切线的垂线
    const vecVertical = vecArcTangent.c();
    vecVertical.rotateBy(Math.PI / 2, McGeVector3d.kZAxis);
    const tmpVerticalLine = new McDbLine(firstPoint, firstPoint.c().addvec(vecVertical));
    // 两线求交得圆心
    const aryPoint = tmpMidLine.IntersectWith(tmpVerticalLine, McDb.Intersect.kExtendBoth);
    if (aryPoint.isEmpty()) return 0;
    const arcCenPoint = aryPoint.at(0);
    const dR = arcCenPoint.distanceTo(firstPoint);
    // 从圆心出发有两个候选弧中点,选取与切向夹角更小的一个
    vecMid.normalize();
    vecMid.mult(dR);
    const arcMidPt1 = arcCenPoint.c().addvec(vecMid);
    const arcMidPt2 = arcCenPoint.c().subvec(vecMid);
    const vecArcDir1 = arcMidPt1.c().sub(firstPoint);
    const vecArcDir2 = arcMidPt2.c().sub(firstPoint);
    const arcMidPt = (vecArcDir1.angleTo1(vecArcTangent) > vecArcDir2.angleTo1(vecArcTangent))
        ? arcMidPt2 : arcMidPt1;
    return MxCADUtility.calcBulge(firstPoint, arcMidPt, nextPoint).val;
}
getNextPoint.setUserDraw((currentPoint, pWorldDraw) => {
    // Ctrl 键按下时切向取反 → 弧的凹凸方向实时翻转
    const tangent = isCtrl ? vecArcTangent.clone().negate() : vecArcTangent;
    const dBulge = CalcArcBulge(startPt, currentPoint, tangent);
    const pl = new McDbPolyline();
    pl.addVertexAt(startPt, dBulge, dStartWidth, dEndWidth);
    pl.addVertexAt(currentPoint);
    pWorldDraw.drawMcDbEntity(pl, true);
});
const ptNext = await getNextPoint.go();
if (ptNext !== null) {
    // 确认:凸度写回当前顶点,新端点入数组
    // 凸度写回的是弧段的起始顶点,新端点的凸度保持 0
    vecData[vecData.length - 1].dBulge = CalcArcBulge(
        startPt, ptNext, isCtrl ? vecArcTangent.clone().negate() : vecArcTangent);
    vecData.push({ pt: ptNext, dBulge: 0, dStartWidth, dEndWidth });
    commitSegment();
}

4.3 角度 (A):由夹角解直角三角形

如果用户先输入圆弧的包含角、再指定端点,可以借助直角三角形求解:把半弦长作为对边,结合夹角解出半径与弦心距,进而定位弧中点,最后交给 calcBulge

const getBulge = (pt: McGePoint3d) => {
    // 弦中点与半弦长(直角三角形对边)
    const midPt = new McGePoint3d((pt.x + startPoint.x) / 2, (pt.y + startPoint.y) / 2);
    const oppositeSide = midPt.distanceTo(startPoint);
    // 解直角三角形:半径 = 斜边,弦心距 = 邻边
    const angleA = Math.PI / 2 - angle / 2;
    const radius = oppositeSide / Math.sin(angleA);
    const adjacentEdge = oppositeSide / Math.tan(angleA);
    // 弦中点沿垂线偏移"矢高"(半径 − 弦心距)得弧中点;Ctrl 切换优弧方向
    const vet = midPt.sub(startPoint).rotateBy(Math.PI / 2).normalize()
        .mult(isCtrl ? -radius - adjacentEdge : radius - adjacentEdge);
    const midPoint = midPt.addvec(vet);
    return MxCADUtility.calcBulge(startPoint, midPoint, pt).val;
};

4.4 其余定弧方式

用同样的思路,还可以实现其余几种定弧方式:

  • 第二个点 (S): 起点、弧上点、端点直接定弧,预览阶段用 McDbArc.computeArc 实时拟合,确认后转算凸度;
  • 圆心 (CE): 圆心 + 端点(或角度、弦长)定弧,用向量夹角区分优弧劣弧;弦长超过直径时提示无效;
  • 半径 (R):已知半径反解圆心位置,端点距起点超过 2 倍半径同样判为无效;
  • 方向 (D):让用户手动指定起点切向,覆盖自动继承的切向,用于需要强制指定出弧方向的场景。

这些方式的实现目标相同:计算凸度,并写入当前顶点。

4.5 Ctrl 键:Web 端的方向切换交互

桌面 CAD 中切换弧的凹凸方向需要走命令行子选项,而 Web 端的命令可以直接利用浏览器键盘事件——按住 Ctrl 键即可实时翻转弧方向:切向定弧时对切向取反,角度模式改取优弧,三点模式把弧上点关于圆心对称。光标移动过程中按住/松开 Ctrl,预览图形实时翻转:

let isCtrl = false;
window.addEventListener("keydown", (e) => { if (e.key === "Control") isCtrl = true; });
window.addEventListener("keyup", () => isCtrl = false);

5. 收尾:闭合、清理与最终提交

命令结束时需要完成三项处理:

// 1. 圆弧模式下闭合:闭合段同样遵循切向约束,按当前切向计算凸度
if (getNextPoint.isKeyWordPicked("C")) {
    vecData[vecData.length - 1].dBulge = CalcArcBulge(
        currentPt, firstPoint, isCtrl ? vecArcTangent.clone().negate() : vecArcTangent);
    isClose = true;
    break;
}

// 2. 清空全部临时实体
for (let i = 0; i < vecTmpObjectId.length; i++) {
    vecTmpObjectId[i].erase();
}

// 3. 用完整的顶点数据一次性生成最终多段线
if (vecData.length > 1) {
    const pNew = new McDbPolyline();
    for (let i = 0; i < vecData.length; i++) {
        pNew.addVertexAt(vecData[i].pt, vecData[i].dBulge,
            vecData[i].dStartWidth, vecData[i].dEndWidth);
    }
    pNew.isClosed = isClose;
    return mxcad.drawEntity(pNew);
}

最后,把命令注册进 MxCAD,使其与内置命令一样可以通过命令行调用。

6. 整合:完整的 Mx_Pline 命令

前五个小节分别实现了命令的各个组成部分。最后一步是把它们按执行顺序组装起来,形成完整的 drawPolyLine 函数。各部分的对应关系如下:

代码位置对应小节内容
导入与辅助函数4.2CalcArcBulge:由起点、端点、切向计算凸度
状态变量2.1 / 4.5顶点数组 vecData、临时实体数组、宽度变量、模式开关、Ctrl 监听
主循环 - 直线分支3取点、宽度设置、回退、闭合、切换圆弧模式
主循环 - 圆弧分支4切向继承、按切向定弧、凸度写回起始顶点
循环结束后5删除临时实体、生成最终多段线

完整代码如下:

import {
    McDb, McDbLine, McDbPolyline, McGePoint3d, McGeVector3d, McObjectId,
    MxCADUiPrPoint, MxCADUiPrDist, MxCADUtility, MxCpp, MxFun
} from "mxcad";
export async function drawPolyLine() {
    const mxcad = MxCpp.getCurrentMxCAD();

    // 【2.1】顶点数组;【2.2】临时实体数组
    const vecData: VertexData[] = [];
    const vecTmpObjectId: McObjectId[] = [];

    // 宽度变量:当前段宽度,同时延续为后续段的默认值
    let dStartWidth = 0;
    let dEndWidth = 0;

    // 【2.3】模式开关与闭合标志
    let isDrawLine = true;
    let isClose = false;

    // 【4.5】Ctrl 键监听:按住时实时翻转弧方向
    let isCtrl = false;
    const onKeydown = (e: KeyboardEvent) => { if (e.key === "Control") isCtrl = true; };
    const onKeyup = () => isCtrl = false;
    window.addEventListener("keydown", onKeydown);
    window.addEventListener("keyup", onKeyup);

    // 【2.2】提交一段:用最近两个顶点画临时多段线
    function commitSegment() {
        const len = vecData.length;
        if (len < 2) return;
        const seg = new McDbPolyline();
        seg.addVertexAt(vecData[len - 2].pt, vecData[len - 2].dBulge,
            vecData[len - 2].dStartWidth, vecData[len - 2].dEndWidth);
        seg.addVertexAt(vecData[len - 1].pt);
        vecTmpObjectId.push(mxcad.drawEntity(seg));
    }

    // 【2.2】回退(U):弹出顶点数据 + 删除最近临时实体
    function undoSegment(getNextPoint: MxCADUiPrPoint) {
        if (vecData.length <= 1) return;
        vecData.pop();
        vecTmpObjectId[vecTmpObjectId.length - 1].erase();
        vecTmpObjectId.pop();
        getNextPoint.setLastInputPoint(vecData[vecData.length - 1].pt);
    }

    // 取起点,加入顶点数组
    const getFirstPoint = new MxCADUiPrPoint();
    getFirstPoint.setMessage("\n指定起点:");
    const firstPoint = await getFirstPoint.go();
    if (!firstPoint) return;
    vecData.push({ pt: firstPoint, dBulge: 0, dStartWidth: 0, dEndWidth: 0 });

    // 【2.3】主循环:根据模式切换交互分支
    while (true) {
        const getNextPoint = new MxCADUiPrPoint();

        if (isDrawLine) {
            // 【3】直线模式
            getNextPoint.setMessage("\n指定下一个点:");
            // 关键字随顶点数动态变化:≥3 个顶点才允许闭合(与 AutoCAD 一致)
            let sKeyWord = "[圆弧(A)/宽度(W)/长度(L)/半宽(H)]";
            if (vecData.length >= 3) sKeyWord = "[回退(U)/圆弧(A)/宽度(W)/长度(L)/半宽(H)/闭合(C)]";
            else if (vecData.length >= 2) sKeyWord = "[回退(U)/圆弧(A)/宽度(W)/长度(L)/半宽(H)]";
            getNextPoint.setKeyWords(sKeyWord);

            // 动态预览:光标移动时实时画出"上一顶点 → 光标"的橡皮筋段
            const drawData = vecData[vecData.length - 1];
            getNextPoint.setUserDraw((currentPoint, pWorldDraw) => {
                const pl = new McDbPolyline();
                pl.addVertexAt(drawData.pt, drawData.dBulge, drawData.dStartWidth, drawData.dEndWidth);
                pl.addVertexAt(currentPoint);
                pWorldDraw.drawMcDbEntity(pl, true);
            });

            const ptNext = await getNextPoint.go();
            if (ptNext !== null) {
                // 取点成功:保存新顶点数据(凸度 0),提交临时段
                vecData.push({ pt: ptNext, dBulge: 0, dStartWidth, dEndWidth });
                commitSegment();
            } else if (getNextPoint.isKeyWordPicked("A")) {
                isDrawLine = false;                    // 切换圆弧模式
            } else if (getNextPoint.isKeyWordPicked("W")) {
                // 宽度:起点宽、端点宽两步输入
                const getWidth = new MxCADUiPrDist();
                getWidth.setMessage("指定起点宽度");
                if (await getWidth.go() === null) break;
                dStartWidth = getWidth.value();
                getWidth.setMessage("指定端点宽度");
                if (await getWidth.go() === null) break;
                dEndWidth = getWidth.value();
                // 宽度写回当前顶点,作用于下一段;端点宽延续为后续默认值
                vecData[vecData.length - 1].dStartWidth = dStartWidth;
                vecData[vecData.length - 1].dEndWidth = dEndWidth;
                dStartWidth = dEndWidth;
            } else if (getNextPoint.isKeyWordPicked("H")) {
                // 半宽:与宽度相同流程,输入值 ×2(实现省略)
            } else if (getNextPoint.isKeyWordPicked("L")) {
                // 长度:沿上一段方向推算新顶点(实现省略)
            } else if (getNextPoint.isKeyWordPicked("C")) {
                isClose = true; break;                 // 闭合
            } else if (getNextPoint.isKeyWordPicked("U")) {
                undoSegment(getNextPoint);             // 回退
            } else {
                break;   // 右键/回车结束
            }
        } else {
            // 【4】圆弧模式

            // 【4.1】切向继承:上一段为直线取直线方向,为圆弧取终点切线方向
            let vecArcTangent = new McGeVector3d();
            if (vecData.length < 2) {
                vecArcTangent.copy(McGeVector3d.kXAxis);
            } else {
                const size = vecData.length;
                const pt1 = vecData[size - 2].pt;
                const dBulge = vecData[size - 2].dBulge;
                const pt2 = vecData[size - 1].pt;
                if (dBulge === 0) {
                    vecArcTangent = pt2.sub(pt1);
                } else {
                    const tmpPl = new McDbPolyline();
                    tmpPl.addVertexAt(pt1, dBulge);
                    tmpPl.addVertexAt(pt2);
                    const tmpVec = tmpPl.getFirstDeriv(new McGePoint3d(pt2.x, pt2.y, 0));
                    if (tmpVec.ret) vecArcTangent = tmpVec.val;
                    else vecArcTangent.copy(McGeVector3d.kXAxis);
                }
            }

            // 本节仅实现默认方式(切向定弧);
            // 角度(A)、第二个点(S)、圆心(CE)、半径(R)、方向(D) 见 4.3 ~ 4.4 小节
            getNextPoint.setMessage("\n指定圆弧的端点(按住 Ctrl 键以切换方向)");
            getNextPoint.setKeyWords("[直线(L)/宽度(W)/半宽(H)]");

            const startPt = vecData[vecData.length - 1].pt;
            // 【4.2】动态预览:光标位置即端点,实时计算凸度并预览
            getNextPoint.setUserDraw((currentPoint, pWorldDraw) => {
                const tangent = isCtrl ? vecArcTangent.clone().negate() : vecArcTangent;
                const dBulge = CalcArcBulge(startPt, currentPoint, tangent);
                const pl = new McDbPolyline();
                pl.addVertexAt(startPt, dBulge, dStartWidth, dEndWidth);
                pl.addVertexAt(currentPoint);
                pWorldDraw.drawMcDbEntity(pl, true);
            });

            const ptNext = await getNextPoint.go();
            if (ptNext !== null) {
                // 【4.2】凸度写回弧段起始顶点,新端点凸度保持 0
                vecData[vecData.length - 1].dBulge = CalcArcBulge(
                    startPt, ptNext, isCtrl ? vecArcTangent.clone().negate() : vecArcTangent);
                vecData.push({ pt: ptNext, dBulge: 0, dStartWidth, dEndWidth });
                commitSegment();
            } else if (getNextPoint.isKeyWordPicked("L")) {
                isDrawLine = true;                     // 切回直线模式
            } else if (getNextPoint.isKeyWordPicked("W")) {
                // 宽度设置,与直线模式相同(实现省略)
            } else if (getNextPoint.isKeyWordPicked("H")) {
                // 半宽设置,与直线模式相同(实现省略)
            } else if (getNextPoint.isKeyWordPicked("C")) {
                // 【5】圆弧模式闭合:闭合段按当前切向计算凸度
                vecData[vecData.length - 1].dBulge = CalcArcBulge(
                    startPt, firstPoint, isCtrl ? vecArcTangent.clone().negate() : vecArcTangent);
                isClose = true;
                break;
            } else if (getNextPoint.isKeyWordPicked("U")) {
                undoSegment(getNextPoint);             // 回退
            } else {
                break;   // 右键/回车结束
            }
        }
    }

    //【5】收尾处理 
    // 1. 删除全部临时实体
    for (let i = 0; i < vecTmpObjectId.length; i++) {
        vecTmpObjectId[i].erase();
    }
    // 2. 用完整的顶点数据一次性生成最终多段线
    if (vecData.length > 1) {
        const pNew = new McDbPolyline();
        for (let i = 0; i < vecData.length; i++) {
            pNew.addVertexAt(vecData[i].pt, vecData[i].dBulge,
                vecData[i].dStartWidth, vecData[i].dEndWidth);
        }
        pNew.isClosed = isClose;
        mxcad.drawEntity(pNew);
    }
    // 3. 移除键盘监听
    window.removeEventListener("keydown", onKeydown);
    window.removeEventListener("keyup", onKeyup);
}

// 注册命令 
MxFun.on("init", () => {
    MxFun.addCommand("Mx_Pline", drawPolyLine);
});

几点说明:

  1. 完整度边界:上方代码是可直接运行的完整框架,覆盖取点、宽度、回退、闭合、切向定弧与动态预览;半宽与长度选项标记为"实现省略",其余定弧方式(角度、第二个点、圆心、半径、方向)可参照 4.3、4.4 小节的思路逐一补齐;
  2. 组装顺序: 辅助函数 → 状态变量 → 两个局部函数(commitSegment / undoSegment)→ 取起点 → 主循环 → 收尾,即"先定义、后使用"的自然顺序;
  3. 局部函数的好处: commitSegmentundoSegment 定义为 drawPolyLine 内部的函数,可以直接访问 vecDatavecTmpObjectId,不需要额外传参。

至此,一个与 AutoCAD PLINE 交互一致、支持直线/圆弧混合绘制、宽度可变、可回退、可闭合、带实时动态预览的多段线命令就完成了。 image-20260901171421915.png

四、总结

我们从零构建了一个完整的多段线绘制命令,整个命令的实现遵循一套可复用的思路:规划交互选项 → 数据收集与实体生成分离 → 按模式分别实现 → 收尾处理 → 整合注册。主循环管理交互流程,顶点数组与临时实体支持实时绘制与回退,凸度把圆弧几何简化为一个数值。掌握这套方法后,实现其他复合图元的绘制命令也有据可循。