UI 粗折线的长尖角:Miter 限制应改变连接轮廓

一条宽度为 16 的 UI 路径线,在急转弯处伸出了接近 31 的长刺。中心线没有多余控制点,输入也都是有限数;问题来自把相邻线段的两条外侧边一直延长到交点。线段越接近折返,交点就越远。若只在最后把该顶点的位置截短,尖角虽然变短,连接处却不再落在原来的两条边上。

这次把问题压到三个点:局部 XY 平面中,A=(-100,0)、B=(0,0)、C=(-86.602540378,50),Y 向上,路径依次经过 A、B、C。长度统一使用 UI 局部单位,半宽 h=8。入射方向向右,出射方向相对它左转 150°;这里的“转角”指前进方向的变化,因此两条中心线射线围成的窄角是 30°。它不是 150° 的内夹角。

粗折线长尖角与截去尖端的概念对照

这张概念示意只表达长尖端与截角的轮廓差异;精确连接位置以稍后的固定数据图为准。研究实现只求一个连接点的外侧候选,没有把完整 UI Mesh 的渲染结果当作已验证事实。

宽度 16 的线为什么伸出 30.91 的尖端

令单位前进方向为 u、v。路径左转时,外侧位于右手边;右转则相反。因此先由叉积符号选择同一外侧的单位法线 n0、n1,再求平分方向 m=normalize(n0+n1)。候选位移若写为 t*m,它在任一外法线方向上的投影都必须等于半宽:dot(t*m,n0)=h。于是 Miter,即两条偏移直线相交的斜接点,为 M=B+m*h/dot(m,n0)

本例的长度比 r=|M-B|/h1/cos(75°)=3.863703305,距离为 30.909626441。外侧交点坐标约为 (29.856406461,-8)。相比之下,90° 转角的长度比只有 √2,对应距离 11.313708499。线宽相同却长短悬殊,来自分母随折返趋近零,不是线宽参数被重复乘了一次。

限制 L=3 表示允许斜接距离不超过半宽的三倍。本实现以 r<=L 保留 Miter;一旦越界,就返回两个偏移端点 Q0=B+h*n0Q1=B+h*n1,由它们组成 Bevel 的外侧直边。固定输入得到 Q0=(0,-8)、Q1=(4,6.928203230),两点距 B 均为 8。

这里选择的是 Bevel,不是把 Miter 沿平分方向缩到 24,也不是另一种保留部分尖端的裁切算法。沿平分方向截短后,该点到两条中心线的垂距都会小于 h;若原来的偏移边仍被保留,就无法同时维持边界连接。返回顶点数量从一个变为两个,正是调用接口需要显式携带的变化。

外侧偏移直线交点、Bevel 两端点与连接选择的数据图

连接类型必须和顶点一起返回

Join.cs 中的 StrokeJoin.TryBuild 返回 Join 候选,字段 Kind 区分 Straight、Miter 和 Bevel,Outer 只承载外侧点,Ratio 保留未限制斜接的长度比供检查。以下是工程实际使用的完整核心源码;项目启用了隐式 using,目标为 net9.0:

public readonly record struct V2(double X, double Y)
{
    public static V2 operator +(V2 a, V2 b) => new(a.X+b.X, a.Y+b.Y);
    public static V2 operator -(V2 a, V2 b) => new(a.X-b.X, a.Y-b.Y);
    public static V2 operator *(V2 a, double k) => new(a.X*k, a.Y*k);
    public double Length => Math.Sqrt(X*X+Y*Y);
    public bool Finite => double.IsFinite(X) && double.IsFinite(Y);
    public static double Dot(V2 a, V2 b) => a.X*b.X+a.Y*b.Y;
}
public sealed record Join(string Kind, V2[] Outer, double Ratio);
public static class StrokeJoin
{
    public static bool TryBuild(V2 a, V2 b, V2 c, double halfWidth,
        double limit, out Join? candidate)
    {
        candidate = null;
        if (!a.Finite || !b.Finite || !c.Finite ||
            !double.IsFinite(halfWidth) || halfWidth <= 0 ||
            !double.IsFinite(limit) || limit < 1) return false;
        V2 u = b-a, v = c-b;
        double lu = u.Length, lv = v.Length;
        if (!double.IsFinite(lu) || !double.IsFinite(lv) ||
            lu <= 1e-9 || lv <= 1e-9) return false;
        u *= 1/lu; v *= 1/lv;
        double dot = Math.Clamp(V2.Dot(u,v), -1, 1);
        // 近折返没有稳定的单一外侧,交给上游拆段。
        if (1+dot <= 1e-12) return false;
        double cross = u.X*v.Y-u.Y*v.X;
        double side = cross >= 0 ? -1 : 1;
        V2 n0 = new V2(-u.Y,u.X)*side;
        V2 n1 = new V2(-v.Y,v.X)*side;
        V2 sum = n0+n1;
        V2 m = sum*(1/sum.Length);
        double denominator = V2.Dot(m,n0);
        if (!double.IsFinite(denominator) || denominator <= 0) return false;
        double ratio = 1/denominator;
        string kind = cross == 0 ? "Straight" : ratio <= limit ? "Miter" : "Bevel";
        V2[] outer = kind == "Bevel"
            ? [b+n0*halfWidth, b+n1*halfWidth]
            : [b+m*(halfWidth*ratio)];
        if (outer.Any(p => !p.Finite)) return false;
        candidate = new Join(kind, outer, ratio);
        return true;
    }
}

长度下限 1e-9 使用同一局部单位;近折返阈值 1+dot<=1e-12 则无量纲。它们是本切片的数值合同,并非适用于所有坐标尺度的常数。重复点、极短段、非有限数和导致中间长度溢出的输入都会失败。输入接近反向时,平分方向对微小扰动敏感,当前入口要求上游拆成独立段,再明确端帽如何处理,避免返回一个方向不稳定的连接。

完全直行采用右侧偏移点作为外侧占位,返回 Straight;此时没有需要填充的转角。Bevel 和 Miter 仍需调用者结合内侧连接生成索引。尤其当线段短到相邻连接彼此侵入时,单个连接的长度有界并不意味着整条轮廓无自交。这也是此处没有直接返回完整三角形列表的原因。

用偏移距离验算,而不再抄一次半角公式

Program.cs 的调用链是 Main → AngleGrid → Read → TryBuildAngleGrid 遍历 -175° 到 175° 的整数转角,排除零,共 350 个样本。它把 L 放宽到 100,使这组范围内的候选都走 Miter,然后独立检查 dot(M-B,n0)=8dot(M-B,n1)=8。测试答案来自两条偏移直线方程,不依赖实现的法线求和步骤。最大残差为 3.33955E-13 个局部单位。

正常样本、150° 尖角回归、镜像、平移以及阈值等号分别约束选择行为。阈值测试把同一次计算得到的 r 作为 L,要求 Miter;把 L 降到相邻的较小 double,要求 Bevel。它证明本实现使用闭区间判定,不声称不同浮点后端会在理想角度边界给出相同末位。

失败路径在 InvalidInputs 中保留先前有效连接。这个回归可以直接复制到同一项目:

Join active = new("Miter", [new V2(8,-8)], Math.Sqrt(2));
Join prior = active;
if (StrokeJoin.TryBuild(new(-100,0), new(0,0), new(-100,0),
    8, 3, out var candidate)) active = candidate!;
if (!ReferenceEquals(active, prior)) throw new Exception("failure-keeps-active");

2026-09-11 使用 .NET SDK 9.0.202、net9.0 Release 执行;$PROBE_DIR 指向包含上述文件的研究目录:

cd "$PROBE_DIR"
dotnet build JoinLab.csproj -c Release
dotnet run --project JoinLab.csproj -c Release --no-build

构建退出码 0,警告 0、错误 0,耗时 15.22 秒;运行退出码 0,决定性输出为:

passed=17 failed=0 skipped=0 samples=350 duration_ms=14.7670
turn=150 half_width=8 limit=3 ratio=3.863703305 uncapped_length=30.909626441 selected=Bevel
normal_length=11.313708499 bevel_radii=8,8 maximum_error=3.33955E-13 failure_keeps_active=true

17 项检查全部通过,其中一项覆盖上述 350 个转角。耗时仅记录 CPU 功能检查,不作为网格吞吐量或 GPU 性能结论。本次也未覆盖 Unity Canvas、抗锯齿、透明混合与实际像素回读。

有界尖角仍然需要完整轮廓合同

对程序化 UI 路径,连接生成阶段应交付“类型加顶点”,让索引生成端知道自己收到一个交点还是一条截角边。后续 Bounds 和命中区域需要基于实际生成的轮廓,而不能只把中心线范围外扩一个半宽:即便 L=3,合法 Miter 仍可能伸出超过 h。

若设计要求的是屏幕像素宽度,应先决定在哪个空间生成或度量连接。这里在局部 XY 中计算,经历非均匀缩放后不能继续声称各方向具有相同屏幕半宽。动态路径在 Miter 与 Bevel 阈值附近往返,也可能产生轮廓跳变;是否加入视觉过渡需要另行定义,当前函数不拥有跨帧状态。

开篇的长刺可以通过明确的轮廓选择消除:150° 样本从距离 30.91 的交点切换为两端半径 8 的 Bevel 边。但内侧交点、短段重叠、端帽和填充绕序仍需整条路径的生成器处理。Miter 限制给出了单个外侧连接的局部长度约束,不能替代完整轮廓的可填充性检查。