- 桌面应用
【免费下载链接】diy-layout-creator
multi platform circuit layout and schematic drawing tool
导读
本文基于 orthogonal-connector.md 设计文档,深入剖析 DIY Layout Creator(DIYLC)中第三种连线元件——正交连接器(直角走线)的设计思路与源码落地。文章完整还原其"手柄(控制点)数量与自由度"的推演、H-V交替模板的参数化方案、退化与圆角几何,并结合 AbstractOrthogonalComponent.java、OrthogonalWire.java、OrthogonalLine.java 及单元测试逐行印证。读完你将理解:为什么一个正交路由恰好需要"每个内部手柄两个坐标"的参数化方式、为什么Start Direction不该成为可编辑属性、以及旋转与镜像变换如何被安全地委托给 transformer。
1. 为什么需要第三种连接器
DIYLC 中已有两类连线元件:
- HookupWire(Hook Up Wire):基于 AbstractCurvedComponent.java 的自由 Bezier 曲线,在两个粘性端点之间绘制平滑曲线;
- Line:位于
Shapes分类下的单一直线段(见 Line.java)。
两者都无法表达原理图与框图中最常见的走线风格:连接从端子沿某一坐标轴离开、以直角转弯、再沿坐标轴到达。设计文档的目标正是补上第三种连接器——一条只沿水平与垂直方向延伸、在圆角 90 度转弯处转向的线。其必须具备的最小形状集合是水平-垂直-水平(HVH)与垂直-水平-垂直(VHV)两类;简单的 L 形弯折和更长的"阶梯形"走线应能由同一套机制自然派生,而不是再做一个独立元件。
2. 手柄到底需要多少:自由度与"一个比特位"的困境
正交路由本质上是一条段与段之间水平、垂直交替的折线(polyline)。由于两端都被固定,可自由调整的数量很容易数清:一条s段的路由有s - 1个转角,以及s - 2个连续自由度,外加1 个离散比特位——它决定首段是从起点水平离开还是垂直离开。
| 段数 | 形状 | 转角数 | 连续自由度(DOF) | 比特位 |
|---|---|---|---|---|
| 2 | HV/VH(一个 L) | 1 | 0 | 有 |
| 3 | HVH/VHV | 2 | 1 | 有 |
| 4 | HVHV/VHVH | 3 | 2 | 有 |
| 6 | HVHVHV/ … | 5 | 4 | 有 |
这张表正是整个问题的核心难点:单个内部控制点携带两个数,但HVH/VHV这种情况只需要一个数加一个比特位。也就是说,一个点"同时多了一个坐标、又少了一个比特位"——任何只读取该点坐标的方案,都无法唯一确定布线形态,除非引入"手柄拖过对角线时形态翻转"这类启发式规则。表中唯一能被精确参数化的行是 4 段的那一行:3 个转角、2 个自由度,且中间那个转角是平面上真正的自由点。这一观察驱动了整个设计。
3. 选定的参数化方案:每个手柄都是一个真实转角顶点
核心约定:每个内部控制点都是一个真实的拐角顶点,相邻顶点之间的路由固定为"先H后V"的一对段。
对于单个内部点h,路由为:
A ────────────┐ A = start (粘性) │ c1 = (h.x, A.y) (派生) h ─────────┐ h = the handle (可拖拽) │ c2 = (B.x, h.y) (派生) B B = end (粘性)即A → (h.x, A.y) → h → (B.x, h.y) → B:四段、交替、水平离开、垂直到达。手柄的两个坐标全部被使用,没有任何丢弃,也不存在隐藏状态。
这一模板在 AbstractOrthogonalComponent.buildVertices() 中体现得淋漓尽致——整条路由规则就是一段循环:
protected List<Point2D> buildVertices() { Point2D[] p = getControlPoints(); boolean horizontalFirst = getStartDirection() == OrientationHV.HORIZONTAL; List<Point2D> vertices = new ArrayList<Point2D>(); addVertex(vertices, p[0]); for (int i = 1; i < p.length; i++) { Point2D previous = p[i - 1]; Point2D next = p[i]; if (horizontalFirst) { addVertex(vertices, new Point2D.Double(next.getX(), previous.getY())); // 先水平 } else { addVertex(vertices, new Point2D.Double(previous.getX(), next.getY())); // 先垂直 } addVertex(vertices, next); } return vertices; }3.1 退化即全部形状:零长度段自动坍缩
文档要求的最小形状集合通过让某些段坍缩为零长度实现,而 snap-to-grid(吸附网格)让这一操作变得非常自然:
| 手柄放置位置 | 路由退化为 |
|---|---|
| 任意位置 | HVHV,三个可见弯折 |
h.y == B.y | HVH,两个弯折,垂直段位于h.x |
h.x == A.x | VHV,两个弯折,水平段位于h.y |
h == (A.x, B.y) | VH,一个弯折 |
h == (B.x, A.y) | HV,一个弯折 |
零长度段由addVertex()通过EPSILON = 1e-6阈值去重实现(源码 L203-L208);连整个路由坍缩到单点时,buildVertices()仍能返回单一顶点,单元测试buildVertices_collapsedRoute_keepsASingleVertex专门覆盖了这一边界(见 AbstractOrthogonalComponentTest.java)。
此外,手柄完全可以放在端点所围成的包围盒之外,从而自然形成绕开障碍物的绕行路线,无需任何特殊处理。对应测试buildVertices_bendOutsideTheBox_detoursAround(测试 L80-L83)验证了(0,0)→(100,50)且手柄在(-40,90)时产生(0,0) → (-40,0) → (-40,90) → (100,90) → (100,50)的绕行路由。
3.2 推广:k 个手柄链式套用同一模板
k个内部点按相同模板串联:
A → (h1.x, A.y) → h1 → (h2.x, h1.y) → h2 → … → (B.x, hk.y) → B这产生2k + 2段和2k个自由度,与上文s - 2规则精确吻合。每个新增手柄买来两个弯折,每个被存储的数都有意义,且归一化代码对任意k都是同一个循环。手柄数量是一个枚举属性,刻意对齐 AbstractCurvedComponent.PointCount 的命名方式——正交基类将取值范围收窄为THREE(3)、FOUR(4)、FIVE(5)(源码 L410-L423)。
当用户修改Point Count时,setPointCount()会调用getBendPoint()将各转角点均匀铺成一条阶梯:它们沿路由最后经过的坐标轴等距推进,在另一轴上则落在步距的中间位置(getBendPoint 源码 L264-L272)。测试setPointCount_keepsTheEndsAndStaysAxisAligned、setPointCount_backAndForth_restoresTheEnds验证了端点保持不动且路由始终轴向对齐(测试 L113-L131)。
3.3 "比特位"不需要成为属性
上述模板固定从起点水平离开,因此原则上存在一个需要暴露的比特位。但实践中不需要:上文的退化表已经覆盖了路由与端点相遇的所有方式:
| 离开起点 | 到达终点 | 达成方式 |
|---|---|---|
| 水平 | 垂直 | 转角点放在任意位置 |
| 水平 | 水平 | 转角点放在终点所在行 |
| 垂直 | 垂直 | 转角点放在起点所在列 |
| 垂直 | 水平 | 转角点放在包围盒的对角顶点 |
因此,Start Direction属性只会成为"拖拽已经能做之事"的第二种、且更不易被发现的途径,故不提供。它唯一能增加的形状是"过给定转角点的镜像阶梯",而多放一个转角点同样能实现。
这个比特位以普通字段的形式存活下来:因为四分之一圈旋转会交换两个坐标轴,一条H V H V路由会变成V H V H,固定的H先模板无法表达这一变化。OrthogonalComponentTransformer 正是为此记录该交换,使旋转组件后得到恰好旋转后的路由;除此之外没有任何代码读写它,它也永远不会出现在属性编辑器中。源码中的注释明确说明:"Not an editable property on purpose… It is kept as state because a quarter-turn rotation swaps the two axes and the transformer has to record that"(getStartDirection 注释)。
3.4 为什么拒绝三种替代方案
| 方案 | 判定 |
|---|---|
| 单个手柄、只采信一个坐标、形态由枚举决定 | 手柄的一半运动肉眼可见地无效且无法解释,且无法在不止两个弯折时推广,除非再加第二个属性。 |
| 单个手柄、按它位于对角线哪一侧推断形态 | 拖拽中途路由会在HVH与VHV之间瞬跳。实现可预测,但体验糟糕。 |
| 原样存储转角、每次读取时投影到合法路径 | 过参数化:用2k个存储数表达k - 1个自由度,投影必须知道"最后移动的是哪个点";整体拖动组件会重写原始值并丢失形态。 |
4. 类布局与两个具体元件
| 类 | 模块 | 角色 |
|---|---|---|
AbstractOrthogonalComponent<T> | diylc-core,org.diylc.components | AbstractCurvedComponent的兄弟类。持有控制点、点计数与起始方向字段、拐角半径、路径构建、长度与缓存边界。声明protected abstract void drawCurve(Path2D, Graphics2D, ComponentState, IDrawingObserver)——与AbstractCurvedComponent使用的钩子完全一致,使具体子类保持轻薄。 |
OrthogonalComponentTransformer | diylc-core,org.diylc.components.transform | 将点旋转委托给SimpleComponentTransformer,随后翻转Start Direction。镜像不需要翻转,直接透传。 |
OrthogonalWire | diylc-library,org.diylc.components.connectivity | 布局元件,在Connectivity分类下显示为Right Angle Wire。带AWG线规、条纹支持、IContinuity。其主体近乎是 HookupWire.java 的拷贝——这正是复用drawCurve钩子的意义。 |
OrthogonalLine | diylc-library,org.diylc.components.connectivity | 原理图元件,在Schematic Symbols分类下显示为Right Angle Line。以给定线宽(默认 1 像素)绘制同一路由,可选Line提供的箭头。 |
两个具体元件只差drawCurve与各自追加的属性:
Right Angle Line的属性集与Line完全镜像:线宽(thickness)、颜色(color)、线型(style)、箭头大小(arrow size)与两个箭头开关(Start/End Arrow)。但它额外实现了IContinuity——Line并不具备——因为它位于Schematic Symbols而非Shapes,画在两个符号之间的线应当出现在网络表(netlist)中。这一点由 OrthogonalLine 的 @ComponentDescriptor 与implements IContinuity声明印证。- 箭头的方向取自首段与末段路由,而不是两端点之间的直线方向;箭头也不会像
Line那样从描边中"修剪"掉。箭头以线色填充、尖端落在端点上,因此其下的内容已被遮挡,无需修剪(OrthogonalLine.drawArrow 实现 L122-L128)。注意实现还做了防御:当路由坍缩为单点时没有方向可指,直接跳过箭头绘制(源码 L105-L107)。
OrthogonalWire与HookupWire的属性差异也正如文档所述:只有Corner Radius对应后者的Smooth。其余AWG、Stripe、Stripe Color、Style、Color、Length一一对应,甚至描边渲染中"虚线/点线使用简化实线参与连续性追踪、以AlphaComposite.DST透明填充"的性能优化技巧都原样保留(OrthogonalWire.drawCurve L69-L123 与 HookupWire.drawCurve L65-L119 可逐行对照)。
关于旋转/镜像的委托机制:SimpleComponentTransformer.rotate() 用AffineTransform.getRotateInstance(Math.PI / 2 * direction, ...)旋转所有控制点;OrthogonalComponentTransformer.rotate()在调用super.rotate()之后把startDirection在HORIZONTAL/VERTICAL之间翻转(源码 L39-L46)。测试rotate_producesTheRotatedRoute以旋转矩阵逐顶点比对路由,mirror_keepsTheStartDirection则确认镜像不改方向(测试 L161-L185)。
5. 几何与圆角:buildVertices→buildPath
buildVertices()返回完整顶点列表(包括零长度坍缩),随后连续重复点被丢弃;buildPath()遍历去重后的列表并为每个转角倒圆角(源码 L178-L201):
r_i = min(cornerRadius, len(v[i-1], v[i]) / 2, len(v[i], v[i+1]) / 2)每个转角被绘制为以转角自身为控制点的二次 Bezier 曲线,从它前方r_i处的点画到它后方r_i处的点。将半径钳制到相邻两段各自长度的一半,意味着短段与坍缩段会优雅地退化为尖角,而不是过冲;在大半径下圆弧理论上会更好看,但在该元件面向的半径尺度上差异肉眼不可见。
实现细节上还暗藏两处工程考量:
MIN_CORNER_RADIUS = 1d像素阈值:当半径低于 1 像素时直接lineTo画尖角——此时二次曲线与尖角无法区分,省掉一次路径构造(源码 L189-L192)。Corner Radius是一个Size属性,默认值0.05in,设为0即得到硬直角(源码 L293-L303,常量定义见 L71)。
轴向对齐不变式由测试强约束:buildVertices_everySegmentIsAxisAligned对两种起始方向、x/y各 9 个采样位置共 162 种组合断言"相邻顶点的 x 或 y 之差趋近于零"(测试 L98-L109)。
6. 组件契约(Component Contract)
设计文档给出的契约表如下,其中多数条目可以直接在 AbstractOrthogonalComponent.java 中找到实现:
| 成员 | 行为 |
|---|---|
creationMethod | 文档契约表标注SINGLE_CLICK(同HookupWire),理由是POINT_BY_POINT只给控制点 0 和 1 赋值,无法放置"第二端点不是索引 1"的元件。注意:从当前源码看,两个具体元件与HookupWire一样实际注册为POINT_BY_POINT(OrthogonalWire L46-L49、OrthogonalLine L49-L53、HookupWire L42-L45),可见这是"计划 vs 落地"中的一处修正,最终以与兄弟元件一致的方式实现。 |
isControlPointSticky | 仅第一个与最后一个索引为true(源码 L367-L369)。 |
canControlPointOverlap | 内部索引为true,允许转角点彼此重合(源码 L372-L374)。 |
getControlPointVisibilityPolicy | 每个索引均为WHEN_SELECTED(源码 L377-L379)。 |
getControlPointNodeName | 内部索引返回null。 |
arePointsConnected | 仅端点对{0, count - 1}为true,与HookupWire一致;两个具体元件都以Math.abs(index1 - index2) == getControlPointCount() - 1实现(OrthogonalWire L156-L158)。 |
| 选中态引导线 | 文档契约要求像AbstractCurvedComponent.draw那样绘制经过派生顶点的虚线折线;从当前基类draw()看,实现直接调用drawCurve(buildPath(), ...),选中/拖拽的视觉反馈由子类drawCurve内的SELECTION_COLOR描边承担(OrthogonalWire L71-L73)。 |
| 命中区域 | 由"绘制观察者在追踪状态下填充描边路径"自然得出。 |
getLength | 各段长度精确求和,IHaveLength在此处比 Bezier 近似更诚实(calculateLength L331-L349)。 |
enableCache | false,与HookupWire一致。 |
7. 默认值:落盘即HVH
将元件拖到空白画布上时,它沿DEFAULT_SIZE(1in)对角线展开,唯一手柄位于((A.x + B.x) / 2, B.y),渲染为一条朴素的HVH路由——这正是用户对直角连接器最常见的预期形状,只需拖动一次手柄即可到达所有其他形态。默认控制点数组在源码中直接可见(L79-L81):
protected Point2D[] controlPoints = new Point2D[] {new Point2D.Double(0, 0), new Point2D.Double(DEFAULT_SIZE.convertToPixels() / 2, DEFAULT_SIZE.convertToPixels()), new Point2D.Double(DEFAULT_SIZE.convertToPixels(), DEFAULT_SIZE.convertToPixels())};即起点(0,0)、终点(1in, 1in)、手柄(0.5in, 1in)——与文档描述完全一致。此外,getStretchedPoint(组件整体拉伸时的控制点重定位)复用了getBendPoint的均匀阶梯铺法,测试stretchBetween_placesTheBendsWhereSetPointCountWould验证了"拉伸后的弯折位置恰好等于setPointCount铺出的位置"这一不变式(测试 L144-L157)。
8. 构建产物与三个悬而未决问题的答案
最终落地的文件如下:
| 文件 | 模块 |
|---|---|
org/diylc/components/AbstractOrthogonalComponent.java | diylc-core |
org/diylc/components/transform/OrthogonalComponentTransformer.java | diylc-core |
org/diylc/components/connectivity/OrthogonalWire.java | diylc-library |
org/diylc/components/connectivity/OrthogonalLine.java | diylc-library |
org/diylc/components/AbstractOrthogonalComponentTest.java | diylc-core(测试) |
文档收尾回答了三个设计悬案,值得逐一展开:
- 命名与分类:元件定名
Right Angle Wire,位于Connectivity、紧挨Hookup Wire,通过drawCurve钩子共享其绘制代码;属性表与Hookup Wire唯一差异是前者携带Corner Radius、后者携带Smooth。 - 手柄数量从发布起就可配置:作为
Point Count属性提供Three、Four、Five三档,与AbstractCurvedComponent同名属性对齐;切换时把弯折点铺成两端之间的均匀阶梯。 - 没有添加吸附(snapping):整洁的形状本就只差一个网格步长,而"把手柄吸到起点列或终点行"的吸附会与 snap-to-grid 相互打架而非互补。这是最值得 revisit 的决定——因为既然没有
Start Direction属性,这两类对齐如今是通往"两弯折形状"的唯一途径。
9. 测试如何锁定设计不变量
AbstractOrthogonalComponentTest.java 以假想OrthogonalTestComponent(drawCurve为空实现的最小子类)覆盖了文档承诺的全部不变量:
- 路由模板的两种起始方向:
buildVertices_bendInsideTheBox_producesThreeBends(水平先)与buildVertices_verticalStart_swapsTheAxes(垂直先); - 全部退化形状:
bendOnEndRow→HVH、bendOnStartColumn→VHV、bendOnBoxCorner→ 单弯折、bendOnOppositeBoxCorner→ 垂直离开水平到达(L49-L77); - 轴向对齐不变式:162 种组合逐一断言(L98-L109);
- 点计数切换:切换前后端点保持、路由仍轴向对齐、往返切换可还原端点(L113-L131);
- 变换器契约:旋转后逐顶点等于矩阵旋转结果、镜像保持起始方向(L161-L185)。
总结:一个"恰好参数化"的走线模型
正交连接器的核心设计智慧在于先数清自由度,再选参数化方式:因为HVH/VHV需要"一个数 + 一个比特位"而单个手柄坐标恰好"多一个数、少一个比特位",所以把每个手柄都当作真实转角顶点、用H-先模板消费它的两个坐标,让 4 段情形成为精确参数化的基线,其余所有形状(L 弯、HVH、VHV、绕行)都通过零长度段坍缩自然涌现。Start Direction被刻意藏为仅供旋转变换使用的字段,而非可编辑属性。从 设计文档 到 基类实现、两个具体元件、变换器 与单元测试,文档与代码互相印证,构成了 DIYLC 中"用最少的存储表达最多合法形态"的一堂范本课。
- 桌面应用
【免费下载链接】diy-layout-creator
multi platform circuit layout and schematic drawing tool
相关推荐
7个实用技巧掌握MySQL连接查询:INNER JOIN、LEFT JOIN、RIGHT JOIN实战解析
7个实用技巧掌握MySQL连接查询:INNER JOIN、LEFT JOIN、RIGHT JOIN实战解析 MySQL连接查询是数据库操作中的核心技能,掌握不同
文档教程pandas 数据合并实战:用 DataFrame.merge 实现 Inner / Left / Right / Outer 四种连接
pandas 数据合并实战:用 DataFrame.merge 实现 Inner / Left / Right / Outer 四种连接 导读 在 pandas
数据分析数据科学数据处理Google ANGLE项目编码规范深度解析
Google ANGLE项目编码规范深度解析 引言:为什么ANGLE的编码规范如此重要? 在图形编程领域,Google ANGLE(Almost Native
图形学3D渲染
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考