news 2026/9/28 20:12:53

基于Catmull-Rom的曲线编辑器Demo演示

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于Catmull-Rom的曲线编辑器Demo演示

一、整体架构:MVC 式的 Delegate 模式

这套代码的核心思想是数据与逻辑分离:

角色谁职责
数据模型(Model)CurveDelegate里的std::vector<ImVec2> points只存数据,不画、不处理鼠标
视图+控制器(View/Controller)ImCurveEdit::Edit(...)读数据、画曲线、处理鼠标、回调 Delegate
委托(Delegate)CurveDelegate继承ImCurveEdit::Delegate把 ImCurveEdit 的请求翻译成对数据的操作

ImCurveEdit 本身不拥有数据,它每一帧都通过GetPointCount()、GetPoints()、GetMin()、GetMax()从 Delegate 现拉数据,画完就忘。这叫即时模式(Immediate Mode),和你 3D 版本用的"保留模式"(先建树、后遍历、改节点重绘)完全不同。

关键好处:改数据不用通知任何人,下一帧 ImCurveEdit 自动看到新数据。这就是为什么 Delete 逻辑能写在 main.cpp 里、完全不用和 ImCurveEdit 通信。


二、数学层:曲线是怎么画出来的

2.1 数据的两种表达

同一批点,在代码里有两个坐标系:

坐标系存储范围
逻辑坐标points[i]存的值[min, max]由delegate.min/max决定
屏幕坐标每帧实时算出来的像素单位,[0, editSize]

我们在main.cpp里维护了"每逻辑单位 = 500 像素"(kPixelsPerUnit)的映射,所以:

max = (editSize.x / 500 - 1, editSize.y / 500)

这样窗口缩放时,同一个逻辑点的屏幕位置按比例守恒,不会拉伸——这是你之前遇到的"窗口变大点会动"问题的解法。

2.2 逻辑坐标 → 屏幕坐标

ImCurveEdit 内部用的公式(main.cpp里复刻了一份,保证一致):

range.x = max.x - min.x + 1 // 注意 x 多了个 +1 range.y = max.y - min.y // 归一化到 [0, 1] nx = (point.x - min.x) / range.x ny = (point.y - min.y) / range.y // 映射到屏幕像素 offset = (origin.x, origin.y + editSize.y) // 编辑区左下角 viewSize = (editSize.x, -editSize.y) // y 是负的,因为屏幕 y 向下 screen.x = nx * viewSize.x + offset.x screen.y = ny * viewSize.y + offset.y

viewSize.y为负是关键:逻辑 y 向上增长,屏幕 y 向下增长,负号把它们对齐。

2.3 Catmull-Rom 插值

对每相邻两点(p1, p2),看它们的前一个点p0和后一个点p3:

P(t) = 0.5 * [ (2*p1) + (-p0 + p2) * t + (2*p0 - 5*p1 + 4*p2 - p3) * t² + (-p0 + 3*p1 - 3*p2 + p3) * t³ ]

为什么这样就很光滑:

  • 在 t=0 处,P(0) = p1;在 t=1 处,P(1) = p2。曲线经过所有控制点。
  • 在 t=0 处的切线正比于(p2 - p0);在 t=1 处的切线也正比于(p2 - p0)。
  • 由于相邻段共享端点,左边那段"在 p2 处的切线"和右边那段"在 p2 处的切线"用的是同一个p3 - p1,自动连续。这就是 C1 连续。
  • 相比贝塞尔曲线:不需要手柄,点摆哪里曲线就过哪里;代价是无法表达尖角和无法单独调某段的曲率。

首尾段的处理:第一段没有 p0,用p1自己代替;最后一段没有 p3,用p2自己代替。这叫"端点复制"(clamped end)。副作用是两点时曲线会轻微鼓出而不是直线——这是设计取舍,为了保证和 ImCurveEdit 原版视觉一致。


三、交互层:四种鼠标操作

3.1 拖动控制点(Move Point)

ImCurveEdit 内部维护一个selection集合,记录选中的(curveIndex, pointIndex)。鼠标在控制点附近按下 → 加入 selection → 拖动时对每个选中点调用:

delegate.EditPoint(curveIndex,pointIndex,newValue);

EditPoint是 Delegate 的回调,你在里面做两件事:

  • 函数模式:赋值 + 按 x 排序 + 返回新索引(排序后索引可能变)
  • 自由模式:赋值 + 不排序 + 返回原索引

ImCurveEdit 拿到返回值,如果索引变了就更新 selection——这是 MVC 里典型的"Model 改变通知 View"机制,只不过这里是通过返回值实现的。

3.2 双击曲线加点(Add Point)

ImCurveEdit 检测到鼠标距曲线 8 像素内双击 → 调用:

delegate.AddPoint(curveIndex,logicalPos);

你在里面做的不是简单追加,而是:

  1. 遍历所有相邻点对,对每对算"点到线段的距离平方"
  2. 找距离最小的段
  3. points.insert(begin + bestSegment + 1, value)

为什么不用排序:排序只在函数模式有效(按 x 排恰好等于按段插入);自由模式下 x 不是单调的,排序会破坏点击顺序。用"找最近段 + 中间插入",两种模式行为统一,也不依赖 x 单调性。

3.3 框选(Quad Selection)

ImCurveEdit 内部维护一个selectingQuad状态:

  • 鼠标在空白处按下 → 记下起点
  • 拖动时画红框,把框内所有控制点加入 selection
  • 松手 → 结束

这跟你的 3D 版本应该很像,区别只是从"3D 视锥剔除"退化成了"2D 矩形相交测试"。

3.4 Delete 删除

这块 ImCurveEdit 内部不做,全部在 main.cpp 里实现:

  1. 每帧检查selectedPoints(由ImCurveEdit::Edit的第五个参数返回)
  2. 如果检测到 Delete 键 + 有选中点 → 收集索引 → 从大到小排序去重
  3. 从后往前points.erase(...)(从后往前是因为删前面的会导致后面索引失效)
  4. 清空 selectedPoints

关键坑:ImCurveEdit 内部有一个static std::set<EditPoint> selection,它记录的是索引。你从外部删除点后,这个 set 里的旧索引会在下一帧越界访问。所以必须在ImCurveEdit.cpp的Edit()函数开头加一段越界清理:

foreach(curveIndex,pointIndex)in selection:ifpointIndex>=GetPointCount(curveIndex):remove from selection

这叫防御式编程——不改数据结构,而是在入口处过滤脏数据。


四、渲染层:ImDrawList 的即时绘制

整个窗口的绘制都通过ImDrawList完成,每帧重建:

// 曲线(每两相邻点之间细分成 20 段小线段)foreachsegment(p1,p2):forsub in0..19:r1=CatmullRom(...)r2=CatmullRom(...)dl->AddLine(screen(r1),screen(r2),green,1.3f);// 控制点(菱形)foreach point:dl->AddConvexPolyFilled(diamond,4,black);dl->AddPolyline(diamond,4,blue,2.0f);

为什么细分成 20 段:GPU 不能直接画三次曲线。用折线逼近,段数多了视觉上就光滑。20 段对 500 像素的段长来说已经足够。

为什么用 4 顶点菱形而不是AddCircleFilled:为了和 ImCurveEdit 原版完全一致。原版用的是 4 顶点凸多边形,看起来像小菱形,用圆形会不一致,切模式时会闪一下。


五、状态层:三种模式的状态机

整个程序有一个顶层状态机:

N键 双击 EDIT <--------> DRAW <--------> EDIT ^ | |___________ S 键切换 ___________| (正交于上面的切换)

gMode只管 EDIT/DRAW,delegate.sortPointsByX只管函数/自由。两者正交,可以任意组合:

gModesortPointsByX行为
EDITtrue编辑模式 + 函数排序
EDITfalse编辑模式 + 自由顺序
DRAWtrue绘制模式 + 每点按 x 排序
DRAWfalse绘制模式 + 保持点击顺序

为什么模式切换时不重排点:用户在函数模式下画了个 S 型(其实被排序成了单调曲线),切到自由模式如果重排,曲线会突然跳到"未排序的样子",视觉上很突兀。保持数组顺序不变,用户自己决定要不要拖动整理。

为什么绘制模式下的加点也遵循模式:用户在自由模式下按 S 形点击,点序就是 S 形;在函数模式下点击,点会自动按 x 排好——两种需求都满足。


六、输入层:ImGui 1.87+ 的事件队列

这块是最容易踩坑的地方,也是你的 3D 版本可能没遇到过的问题:

  • ImGui 1.87 之前,io.MousePos是直接赋值:io.MousePos.x = ...
  • 1.87 之后改为事件队列:io.AddMousePosEvent(x, y),事件在下一帧NewFrame()时统一处理

ImApp.h 原始代码用的是旧 API,需要改。为什么:如果你直接赋值io.MousePos,ImGui 内部会在NewFrame()里用队列里的值覆盖你的赋值——结果就是鼠标坐标永远不动,一切交互失效。

另一个坑:IsAnyMouseButtonDown()在WM_LBUTTONUP里调用,读的是io.MouseDown[],但队列还没处理,读到的是上一帧的值,会误判为"还有键按下",导致ReleaseCapture()不被调用 → 窗口标题栏失效。

解法:用 Win32 的GetAsyncKeyState(VK_LBUTTON)直接查物理按键,绕过 ImGui 队列。


七、和你 3D 版本的核心差异

维度你的 3D 版本这个 2D Demo
数据表示3D 点 + 手柄 + 可能是 NURBS 或 Bezier2D 点 + Catmull-Rom,无手柄
渲染顶点缓冲 + 投影矩阵 + 深度测试ImDrawList 2D 屏幕空间直接画
坐标变换4x4 矩阵,含旋转、投影2x2 缩放 + 平移,无旋转
交互命中射线投射 + 屏幕空间距离屏幕空间欧氏距离
模式通常是"保留模式"UI即时模式(ImGui 哲学)
选中/框选视锥剔除矩形重叠
数据存储常驻内存对象树每帧通过 Delegate 现拉

本质上数学不同(3D 复杂得多),但架构模式相似:都是"数据 + 交互 + 渲染"三层分离。你那边可能是 MFC/Qt 的 MVC,这边是 ImGui 的即时模式 Delegate。


八、一句话总结每个功能

功能实现核心
曲线光滑Catmull-Rom 插值,段间 C1 连续,不需要手柄
经过控制点Catmull-Rom 数学性质:P(0)=p1,P(1)=p2
窗口缩放点不动逻辑范围随编辑区尺寸动态调整,像素/逻辑单位恒定
双击加点找最近线段 + 中间插入(两种模式统一)
函数/自由模式Delegate 里加 bool,EditPoint/AddPoint 分支处理
Delete 删除main.cpp 里监听按键 + ImCurveEdit.cpp 里防御式清理 selection
绘制模式独立状态机 + 自己处理鼠标 + 自己用 ImDrawList 画曲线
窗口缩放/关闭用 Win32 原生样式,去掉冲突的 WS_POPUP
鼠标输入正常改用 ImGui 1.87+ 事件 API
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/28 20:12:48

深度学习情感分析实战:数据预处理与BiLSTM模型部署的坑与细节

简介&#xff1a;一份基于深度学习的电影评论情感分析系统完整项目&#xff0c;面向Python毕业设计、课程设计或希望入门情感分析、Web开发的读者。系统功能完善、界面美观&#xff0c;操作简单&#xff0c;管理便捷&#xff0c;前端采用HTML/CSS/JavaScript&#xff0c;后端为…

作者头像 李华
网站建设 2026/9/28 20:10:29

RAG 检索很准,答案还是烂?把检索块和上下文块拆开

RAG 检索很准&#xff0c;答案还是烂&#xff1f;把检索块和上下文块拆开 最近几篇在写 RAG&#xff1a;上一篇写了分块策略怎么选&#xff0c;但那篇有个默认假设——检索用什么块&#xff0c;就返回什么块。这篇把这个假设拆掉&#xff1a;检索命中了相关内容&#xff0c;LL…

作者头像 李华
网站建设 2026/9/28 20:10:13

湘楚有才单招解析:为什么不建议选择短期单招集训

在湖南高职单招竞争逐年加剧的当下,越来越多中职、往届考生选择参加集训提升应试能力。市场上涌现出大量短期单招集训项目,这类项目大多集中在考前两到三个月开班,依靠低价宣传快速吸引生源,看上去时间短、花费少,对急于升学的考生和家长充满诱惑力。很多家庭简单认为,单招备考…

作者头像 李华