搞CAD二次开发的人都知道,一个插件写到最后,最难伺候的不是算法、不是图层管理,而是"autoCAD 提示用户输入"这一步。你辛辛苦苦把逻辑写好了,结果用户在手型光标下乱点两下、按个ESC、或者在命令行里输了个负数,程序直接崩了,连错在哪都看不出来。这篇文章就来聊聊,在AutoLISP里怎么把"提示用户输入"这件事做扎实——从最常用的getpoint、getint、getstring,到initget、error这些防御手段,再配合一个完整案例和实测坑位,帮你把这块短板补上。适合刚开始写LISP的人,也给写过一阵子但总觉得交互不顺手的人做参考。
1. 先说清楚:用户输入提示在CAD开发里到底扮演什么角色
1.1 从一次"翻车"说起
去年我做了一个批量布置图块的小工具,功能不复杂:用户先选一个基点,输入行数、列数和间距,程序就把选中的图块复制成阵列。逻辑写起来一小时搞定,结果测试的时候直接傻眼。图纸里全是道路标记块,我请一个同事帮忙试,他在命令行弹出来之后,对着动态输入框输了一个"500,500",然后程序自作主张地把这个点当成了相对上一基点的坐标,阵列的位置全乱了,几百个块铺得整个图纸外面都是。
说实话,那会儿我第一反应是"CAD真难用",后来冷静下来才明白,问题不在CAD,而在我的程序没有把"用户到底会被什么样的输入方式影响"这件事想清楚。所谓"提示用户输入",并不是在命令行里打一句话那么简单,它是一整套交互通道的设计:提示语的写法、输入函数的选型、返回值的含义、用户的习惯性操作,哪一环掉链子都会出问题。
1.2 输入交互的底层机制:命令行是唯一中立语言
AutoCAD里有好几条交互路线:点功能区按钮、点菜单、点工具栏、在命令行打字、在动态输入框里填数。但对一个AutoLISP程序来说,它能主动控制、能稳定捕获的,只有命令行和动态输入这一路。你写的getpoint、getint之类的函数,本质上是往命令行缓冲区里写一段提示字符串,然后程序挂起,等待用户用鼠标点一下、或者用键盘敲一个值、或者干脆按个空格和回车。
理解这个机制很重要。它决定了三件事:
- 提示语里最好直接告诉用户"能输入什么、默认是什么",否则用户在命令行前面愣住,体验很差。
- getxxx系列函数接受的是"命令行的值",它不负责校验业务逻辑,只负责把输入转成LISP能用的数据。
- 用户的按ESC、敲错键、输一半又取消,这些行为最终都会让getxxx返回nil,而不是抛异常。
所以,与其说"提示用户输入"是等待答案,不如说它是"程序与用户之间一次有防御的对话"。下面我就把常用的输入函数一个一个拆开讲。
2. 输入函数选型:不同场景该用哪个
2.1 点类输入:getpoint与getcorner的取舍
最常用的是getpoint,它的作用就是等待用户点一个点或者输入一个坐标,返回一个三维点表,格式是"(x y z)",如果用户有Z轴标高,会带上z值。
基本用法:
(setq pt (getpoint "\n请指定插入点:"))如果用户在命令行输入了"100,200",返回的就是(100.0 200.0 0.0)。如果用户用鼠标在屏幕上点,返回的就是当前UCS下的坐标。
getpoint还支持第二参数作为基点,这时候屏幕上会出现一条从基点连到光标的橡皮线,配合提示语,用户能很直观地看到"我要从这个点拉到那个点"。比如:
(setq base (getpoint "\n指定第一个角点:")) (setq other (getpoint base "\n指定对角点:"))getcorner则是"框选"专用,它需要一个基准点作为第一角,然后用户拖动鼠标拉出矩形,返回的是矩形对角点。亮点是拖动过程中能看到实时的矩形框,选范围的时候比getpoint直观得多:
(setq base (getpoint "\n指定基点:")) (setq corner (getcorner base "\n指定对角点:"))选型逻辑很简单:只要是想让用户"拉一个范围",优先getcorner;只是单纯取一个点,用getpoint。但有个细节我踩过坑:getcorner的两个角点,第二个角点的x和y不一定比第一个大,它完全取决于用户怎么拖,所以后面计算宽度高度时,别偷懒直接相减,记得用abs处理。
2.2 数值与距离输入:getint、getreal与getdist
getint返回整数,getreal返回实数,这两兄弟一个管整数一个管小数。要说的是,如果你让用户输入行数、列数、数量这种"只能数整"的东西,用getint;如果是比例系数、面积、材料厚度这种可能带小数,用getreal。
但真正干活的时候,我更推荐getdist。这函数表面上是"获取距离",但它有个很人性化的特性:既允许用户直接敲数字,也允许用户在屏幕上点两个点,让程序量取实际距离。举个例子,你要让用户输入一个"排距",用getreal的话,用户只能老老实实敲"3200";但用getdist,用户可以直接在图纸上点两点,程序自动算出距离,省去查图的时间。
(setq dist_val (getdist "\n指定间距:"))这里有个经典坑:getdist输入"3,4"的时候,AutoCAD会把它当成一个二维坐标点,然后返回的是"从原点到这个点的距离",而不是3或者4。如果你压根没想让用户用坐标形式输入距离,最好在提示语里写一句"请输入数值或点取两点"。别嫌啰嗦,一句话能省掉大量售后。
同样的逻辑也适用于getangle,它是获取角度,但用户既可以输入一个数值,也可以在屏幕上点两个点,程序会返回第一个点到第二个点的方向夹角。跟getdist是同一个套路。
2.3 字符串与角度输入:getstring、getangle的细节
getstring是获取文字输入的,但它有个极其容易踩坑的签名:
(getstring "\n请输入名称:") ; 不支持空格 (getstring T "\n请输入名称:") ; 支持空格第一个参数如果不传或传nil,用户输入的文字遇到空格就截断了,比如用户明明输入的是"道路 中线",得到的却是"道路"。我之前写标注程序时,想让用户输入图框编号,结果所有编号里带空格的都被砍掉一半,查了半天才想起来是getstring没加T。规则记死:允许空格,就传T。
getangle和getorient的区别也值得一提。getangle返回的是相对当前角度基准(ANGBASE系统变量)的角度,getorient返回的是相对正东方向的角度。如果你的程序要计算"零件旋转了多少度",用getorient更靠谱,因为它不受绘图环境的ANGBASE影响,结果恒定。别问我怎么知道的,有一版程序在某个图纸里突然全部旋转量差了90度,查完系统变量才发现是图档的ANGBASE被改过。
下面这张表可以对应着记:
| 函数 | 返回类型 | 适用场景 | 一句话提醒 |
|---|---|---|---|
| getpoint | 点表 | 取一个点 | 返回的是UCS坐标 |
| getcorner | 点表 | 框选范围 | 配合基点使用 |
| getint | 整数 | 行数、列数、数量 | 不接受小数 |
| getreal | 实数 | 系数、厚度、比例 | 可以带小数 |
| getdist | 实数 | 距离、间距、长度 | 支持点两点量距离 |
| getangle | 弧度 | 旋转角度 | 受ANGBASE影响 |
| getorient | 弧度 | 绝对旋转方向 | 固定正东为0度 |
| getstring | 字符串 | 名称、编号 | 允许空格必须传T |
| getkword | 关键字 | 选项分支 | 配合initget使用 |
3. 从提示到校验:让程序扛得住乱输入
3.1 initget:给用户输入提前戴上"紧箍咒"
getxxx系列函数看起来是一个人畜无害的"等待输入",实际上它把所有校验压力都丢给了后面的代码。你不拦截,用户按个回车、输个0、输个负数,程序照样往下跑。这时候就轮到initget出场了。
initget的作用,是在下一个输入函数执行之前,给输入提前设好"规定",比如不允许空输入、不允许0、不允许负数。位码规则常用这几个:
- 1:不允许直接回车(空输入)
- 2:不允许输入0
- 4:不允许输入负数
- 8:不允许外部边界
- 32:画虚线拖引线
这几个位码可以相加组合。比如想禁止空、0、负数,就写(initget 7),原理是1+2+4=7。看起来像魔法数字,但这是AutoLISP的老传统,记习惯就好。
一个实际例子:
(initget 7) (setq rows (getint "\n请输入行数:"))用户要是敢直接回车、输0、输-3,系统直接报"无效选项",并且重新提示,根本走不到你的业务逻辑。但要注意,initget只对它后面的那一个输入函数有效,用完即焚,别指望设一次管三个输入。每次输入前都得重新initget。
initget还有一个压箱底的能力:指定关键字。配合getkword使用,可以实现"多选题"式的交互:
(initget "A B") (setq mode (getkword "\n选择布置方式 [自动(A)/手动(B)] <A>:"))用户输入A或B,getkword返回大写关键字字符串;如果用户直接回车,返回nil,你再自己给默认值。这种"带选项的提示"比单纯让用户输入数字舒服得多,因为用户不用记代码,看提示就知道该怎么选。
3.2error错误陷阱:程序不会因为用户按了ESC就崩
这是很多LISP教程不爱讲、但实际开发必须面对的残酷现实:用户从来不会按你设计的剧本来操作。你以为他会老老实实输入,结果他可能中途觉得这个工具没意义,直接按ESC。然后呢?你的程序可能已经改了一堆系统变量,没恢复,后面所有命令在环境异常的情况下运行,画出来全是错的。
AutoLISP提供error这个系统函数作为错误入口,当程序出问题或者用户按ESC的时候,它会自动被调用。你可以重新定义error,在里面做收尾工作。
我在写工具时有个固定习惯:程序开头先保存系统变量,然后重定义error,在error里恢复系统变量、把捕捉关闭、退出UNDO标记。大概长这样:
(defun c:TT (/ old_os old_echo) (setq old_os (getvar "OSMODE") old_echo (getvar "CMDECHO")) (defun *error* (msg) (setvar "OSMODE" old_os) (setvar "CMDECHO" old_echo) (princ "\nTT 命令已退出") (princ) ) (setvar "CMDECHO" 0) ;; 业务代码 (princ) )这句话值五毛:error里面千万不能再调用可能出错的函数,也别再做复杂业务逻辑,否则会在错误处理里递归报错。老实的做法就是恢复环境变量、打印一句提示,然后退出。
关于用户的"取消"还有一个细节,getxxx在用户按ESC时会返回nil,但AutoLISP会把这种取消当成一个错误抛出,进入error。所以你在error里不要写"用户一定犯了什么错"这种话,它就是一次正常取消。
3.3 完整示例:做一个安全的批量布置工具
光讲函数不清不痒,我把前面这些串成一个能直接跑的示例:批量复制选中的对象,按用户输入的基地点、行数、列数、间距,生成矩形阵列。这个工具我用来布置设备块,代码不长但五脏俱全。
(defun c:BLKARR ( / old_os old_echo base rows cols gap_x gap_y pt i j ename) (setq old_os (getvar "OSMODE") old_echo (getvar "CMDECHO")) (defun *error* (msg) (setvar "OSMODE" old_os) (setvar "CMDECHO" old_echo) (princ "\nBLKARR 已取消") (princ) ) (setvar "CMDECHO" 0) (setq ename (car (entsel "\n选择要阵列的对象:"))) (if (null ename) (progn (princ "\n未选择对象,程序退出") (exit)) ) (setq base (getpoint "\n指定阵列基点:")) (if (null base) (exit)) (initget 7) (setq rows (getint "\n输入行数:")) (if (null rows) (exit)) (initget 7) (setq cols (getint "\n输入列数:")) (if (null cols) (exit)) (initget 6) (setq gap_x (getdist base "\n输入列间距:")) (if (null gap_x) (exit)) (initget 6) (setq gap_y (getdist base "\n输入行间距:")) (if (null gap_y) (exit)) (setq i 0) (while (< i rows) (setq j 0) (while (< j cols) (if (not (and (= i 0) (= j 0))) (command "_.COPY" ename "" base (list (+ (car base) (* j gap_x)) (+ (cadr base) (* i gap_y)) 0.0)) ) (setq j (1+ j)) ) (setq i (1+ i)) ) (setvar "OSMODE" old_os) (setvar "CMDECHO" old_echo) (princ "\n阵列完成") (princ) )注意中间gap_x和gap_y我用了(initget 6),意思是禁止0和负数。间距为0的阵列没有任何意义,这个拦截帮我在源头上杜绝了"用户输了个0,然后几千个对象叠在一起"的惨案。
还有一点是entsel的返回:它是"(图元名 . 点)"的关联表,所以要加个(car ...)取图元名。数组起点是(0,0),循环的时候跳过第一个对象,不然会在原位置多复制一份。
4. 实测中最容易踩的坑
4.1 动态输入与getdist的"相对距离"幻觉
动态输入(DYNMODE)是CAD给新手准备的便利功能,但对LISP程序来说,它有时会变成坑。拿getdist举例,你提示"输入列间距",用户在屏幕上看到动态输入框,直接输入"3000",看起来一切正常。但动态输入开启时,这个数值可以被解释为相对上一点的位移,也可能被解释为绝对距离,取决于用户的输入习惯和当前的输入模式。
我自己实测过一遍:开着动态输入,调用getdist时输入"3000",有些情况下程序返回的是3000,有些情况下会带出偏移导致结果异常。这跟版本、变量设置都有关系,不展开讲太深,但结论很明确——凡是涉及距离、坐标的插件,在提示语里明确写一句"请使用命令行输入数字"是有必要的。别怕这句提示影响体验,真出错了更影响信任。
4.2 拿到UCS坐标就用?trans函数不能忘
getpoint返回的是当前UCS下的坐标,但很多内置命令(比如CIRCLE、INSERT)期望的是WCS坐标。当你把UCS旋转过之后,getpoint拿到的点直接传给command,画出来的东西就会原地跑偏。
我建议所有从getpoint拿到的点,在进入业务计算之前统一做一次转换:
(setq wcs_pt (trans pt 1 0))trans的三个参数:第一个是要转换的点,第二个是原坐标系代码(1代表UCS,0代表WCS),第三个是目标坐标系代码。这样无论用户当前UCS怎么转,程序拿到的始终是统一坐标系下的点。这个习惯养成后,各种"点不对位"的问题会少很多。
4.3 空输入与负值的处理:别相信任何一个用户
我把用户行为分成了四类,每一类都需要防御:
- 直接回车:getxxx返回nil,必须判断。
- 输入负数或0:getint、getreal照单全收,必须initget拦截。
- 输入非数字字符:getint会自动报错并重新提示,这个倒不用太操心。
- 中途按ESC:触发error,必须在error里恢复环境。
有一种情况特别隐蔽:用户先输入了几个字符,又删掉,最后回车。getstring拿到的是空字符串"",这个不是nil,你用(if (null ...))判断不出来,得用(if (= str "") ...)再拦一道。
4.4 连续输入时的缓冲残留
连续调用两个getstring时,偶尔会遇到"上一次的回车被下一次读取直接消费"的现象。有一次我让用户先输入图号再输入说明文字,测试时发现第二个提示刚弹出来,还没等用户敲键盘,程序就像被按了回车一样跳过去了。排查了一下,是上一个getstring(没加T)截断了输入之后,把剩余字符和换行留在了输入缓冲区,下一个getstring读到就当作回车处理了。
解决方案有两个:要么所有字符串都用(getstring T ...),确保整个逻辑清晰;要么在每次getstring之前加一个(princ "\n")刷新提示行。我更推荐前者,因为T模式行为稳定,不容易被半截输入干扰。
5. 进阶组合玩法
5.1 用关键字实现"带默认值"的提示
很多用户希望提示里带默认值,直接回车就采用。getxxx本身不提供默认值参数,但可以自己实现:先用initget给一个关键字,然后getkword判断,如果返回nil(说明用户直接回车了),就设置成默认值。
(initget "10 20 50") (setq val (getkword "\n输入间距 [10/20/50] <10>:")) (if (null val) (setq val "10")) (setq val (atoi val))这样提示语直接列出了合法的几个选项,用户既可以直接回车选默认值,也可以输入关键字,完全不依赖记忆。我做过的小工具里,凡是参数可选项少于等于4个的,都用这个模式。
5.2 与命令前顶点选联动
有时候程序要先让用户"选一批对象",再进入参数输入。这种情况下,ssget比entsel更适合,而且ssget可以在提示里呼出"栏选""窗选""多边形选"等选项。配合前面说的getpoint,整个流程可以做到"选对象、点基点、输参数"一气呵成。
有一点值得注意:ssget执行时用户按ESC同样会返回nil,而且它不会触发error(至少表现不同),所以判断返回值时不能偷懒。
(setq ss (ssget '((0 . "INSERT")))) (if (null ss) (progn (princ "\n未选择对象,程序退出") (exit)) )过滤器((0 . "INSERT"))表示只允许选图块,其他对象点不中。这样用户的误操作会在选择阶段就被挡住,从源头上减少后面流程的异常。
5.3 用grread做拖拽预览
grread是AutoLISP里读取用户原始设备输入的函数,比getxxx底层得多。它可以实时获取光标位置和键盘按键,配合它可以实现"鼠标移动时预览矩形大小"这种动态效果。虽然写起来比getxxx复杂,但效果是真的唬人,展示给你的同事看,他们会觉得这工具是"专业级"的。
思路很简单:循环调用(grread T 1 0),判断返回值的第一个元素是5(表示鼠标移动)还是3(表示点击),是5就把光标点转成坐标点,重画预览图形;是3就该干嘛干嘛。代码不展开了,但有一个原则:预览图形的更新别直接用command画实体,那样会产生大量垃圾实体,用grvecs或者redraw临时显示更干净。
结尾再分享一个小技巧
写LISP这些年,我最深的体会是:一个工具的可靠性,不取决于它功能多花哨,而取决于它怎么处理那些"用户不按套路出牌"的时刻。尤其是提示用户输入这一步,你花在initget、error和坐标转换上的时间,永远比花在业务算法上的时间值钱得多。
最后送大家一个我一直在用的小习惯:所有命令的提示语里,都把合法的输入范围或者默认值写进去,比如"请输入行数(正整数,默认3):"。这既是写给用户看的,也是写给自己以后维护看的。等哪天真有个插件半年没人动,你重新翻代码看提示语,一眼就能想起当初的设计意图。就冲这一点,提示语写清楚,值了。