fix(island,server): 连续圆角四角修正 + 叫人分紧急/普通两级
灵动岛圆角(用户反馈"左上角和右下角缺一块 / 圆角边缘割裂"): - squircle.ts 改为"每个角独立圆心 + 角度参数"的超椭圆采样(原本四角共用同一个偏移向量, 左上/右下被切掉一块);四角几何与 WinIsland 的连续超椭圆一致,r 与 h/2、w/2 自动收敛。 - verify:desktop 新增像素级断言「连续圆角四角一致」:从四角沿对角线找岛体边界, 四角步进必须两两一致(展开 15/15/15/15、紧急 14/14/14/14、胶囊 5/5/5/5), 并保留路径绕行 |winding|≈2π 断言(抓"角上往回折")。该断言即本次回归的守卫。 - 截图留档同时写入裁剪图与整窗图,便于人工复核角形。 叫人分紧急 / 普通两级(用户要求"叫人也分为紧急和普通"): - shared:CreateCallRequest 新增 urgent;紧急叫人 → URGENT(无视上课时段立即展开), 普通叫人(默认)→ HIGH(按普通通知排队)。 - desktop:isImmediate 只认 priority=URGENT,不再把 kind=call 一律视为立即消息; 普通叫人回到与普通通知相同的排队 / 隐藏 / 下课弹出路径。 - web:通知发布 → 叫人、学生管理 → 叫人 两个弹窗新增"级别"单选(默认普通)+ 联动提示文案。 验证:verify:e2e 135/135(新增普通 / 紧急叫人级别断言)、verify:web 20/20(新增级别单选断言)、 verify:desktop 59/59 + 离线复跑 53/53、typecheck / lint / format 全绿。
This commit is contained in:
1 parent
1b4dd14e4a
commit
0690546307
13 files changed
+553
-205
No files matched your search
@@ -1,16 +1,20 @@
|
||||
/**
|
||||
* 连续圆角(squircle / Apple 连续曲率圆角)路径生成。
|
||||
*
|
||||
* 参考实现:WinIsland `src/utils/shape.rs` —— 它用八分体(Octant)+ 超椭圆参数表
|
||||
* 构造 `continuous_rounded_rect_path`,而不是普通圆弧圆角;展开卡的半径取
|
||||
* 参考实现:WinIsland `src/utils/shape.rs` —— 它用八分体(Octant)+ 超椭圆参数表构造
|
||||
* `continuous_rounded_rect_path`,而不是普通圆弧圆角;展开卡半径取
|
||||
* `expanded_island_radius = min(48 * scale, w/2, h/2)`。
|
||||
*
|
||||
* 这里用等价的超椭圆角公式生成 SVG path:
|
||||
* 角点区域按 x = r * (1 - cos^p(t)), y = r * (1 - sin^p(t)) 采样,
|
||||
* 其中 p = 2/n、n 取 4.2 时最接近 Apple 的"连续曲率"观感(普通圆角相当于 n = 2)。
|
||||
* 直边部分保持完全笔直,因此与 WinIsland 的"直边 + 连续圆角"一致。
|
||||
* 做法:把路径拆成「四条直边 + 四段圆角弧」,每段弧都用**自己那个角的圆心 + 角度**参数化:
|
||||
* `point = center + r·(sign(cos a)·|cos a|^p, sign(sin a)·|sin a|^p)`
|
||||
* 四个角的圆心依次是右上 `(w-r, r)` → 右下 `(w-r, h-r)` → 左下 `(r, h-r)` → 左上 `(r, r)`,
|
||||
* 起始角依次是 `-π/2, 0, π/2, π`(每个角扫 π/2),因此天然是顺时针一圈、没有方向歧义。
|
||||
*
|
||||
* 生成的是**像素坐标**路径(w/h 为卡片实际像素尺寸),所以圆角半径恒定、不被拉伸。
|
||||
* 早期版本用「相对角起点的偏移向量」拼角,而四个角需要的偏移向量**并不相同**
|
||||
* (右上/左下是 `(sin^p, 1-cos^p)`,左上/右下是 `(1-cos^p, sin^p)`)——
|
||||
* 于是左上与右下被画成了向内凹的形状,视觉上就是这两个角**缺一块**。
|
||||
*
|
||||
* 生成的是**像素坐标**路径(w/h 为卡片实际像素尺寸),因此圆角半径恒定、不被拉伸。
|
||||
*/
|
||||
|
||||
/** 超椭圆指数:2 = 标准圆弧圆角,越大越"方"但曲率越连续(Apple 观感约 4~5) */
|
||||
@@ -19,27 +23,20 @@ const SUPERELLIPSE_EXPONENT = 4.2;
|
||||
/** 每个角的采样段数(越多越平滑;4 个角共 4x 段) */
|
||||
const CORNER_SEGMENTS = 14;
|
||||
|
||||
function cornerPoints(radius: number): { x: number; y: number }[] {
|
||||
const p = 2 / SUPERELLIPSE_EXPONENT;
|
||||
const points: { x: number; y: number }[] = [];
|
||||
for (let index = 0; index <= CORNER_SEGMENTS; index += 1) {
|
||||
const t = (index / CORNER_SEGMENTS) * (Math.PI / 2);
|
||||
const cos = Math.max(0, Math.cos(t));
|
||||
const sin = Math.max(0, Math.sin(t));
|
||||
// t=0 → 角外侧(贴直边),t=π/2 → 角内侧
|
||||
points.push({
|
||||
x: radius * (1 - Math.pow(cos, p)),
|
||||
y: radius * (1 - Math.pow(sin, p)),
|
||||
});
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
const round = (value: number): number => Math.round(value * 100) / 100;
|
||||
|
||||
/**
|
||||
* 生成连续圆角矩形路径。
|
||||
*
|
||||
* 做法:把路径拆成「四条直边 + 四段圆角弧」,每段弧都用**自己那个角的圆心 + 角度**参数化:
|
||||
* `point = center + r·(sign(cos a)·|cos a|^p, sign(sin a)·|sin a|^p)`
|
||||
* 四个角的圆心依次是右上 `(w-r, r)` → 右下 `(w-r, h-r)` → 左下 `(r, h-r)` → 左上 `(r, r)`,
|
||||
* 起始角依次是 `-π/2, 0, π/2, π`(每个角扫 π/2),因此天然是顺时针一圈、不会出现方向歧义。
|
||||
*
|
||||
* 早期版本用「相对起点的偏移向量」拼角,而四个角需要的偏移向量并不相同
|
||||
* (右上/左下是 `(sin^p, 1-cos^p)`,左上/右下是 `(1-cos^p, sin^p)`),
|
||||
* 于是左上与右下被画成了"向内凹"的形状——视觉上就是这两个角**缺一块**。
|
||||
*
|
||||
* @param width 卡片宽度(px)
|
||||
* @param height 卡片高度(px)
|
||||
* @param radius 四角基准半径(px,会被裁剪到 min(w/2, h/2))
|
||||
@@ -51,23 +48,28 @@ export function squirclePath(width: number, height: number, radius: number): str
|
||||
|
||||
if (r <= 0.5) return `M0,0 H${round(w)} V${round(h)} H0 Z`;
|
||||
|
||||
const corner = cornerPoints(r);
|
||||
const parts: string[] = [];
|
||||
const p = 2 / SUPERELLIPSE_EXPONENT;
|
||||
const centers = [
|
||||
{ cx: w - r, cy: r }, // 右上
|
||||
{ cx: w - r, cy: h - r }, // 右下
|
||||
{ cx: r, cy: h - r }, // 左下
|
||||
{ cx: r, cy: r }, // 左上
|
||||
];
|
||||
const startAngles = [-Math.PI / 2, 0, Math.PI / 2, Math.PI];
|
||||
|
||||
// 从左上角的"上方直边起点"开始,顺时针绘制
|
||||
parts.push(`M${round(r)},0`);
|
||||
// 上边 → 右上角
|
||||
parts.push(`H${round(w - r)}`);
|
||||
for (const point of corner) parts.push(`L${round(w - r + point.x)},${round(point.y)}`);
|
||||
// 右边 → 右下角
|
||||
parts.push(`V${round(h - r)}`);
|
||||
for (const point of corner) parts.push(`L${round(w - point.y)},${round(h - r + point.x)}`);
|
||||
// 下边 → 左下角
|
||||
parts.push(`H${round(r)}`);
|
||||
for (const point of corner) parts.push(`L${round(r - point.x)},${round(h - point.y)}`);
|
||||
// 左边 → 左上角
|
||||
parts.push(`V${round(r)}`);
|
||||
for (const point of corner) parts.push(`L${round(point.y)},${round(r - point.x)}`);
|
||||
const parts: string[] = [];
|
||||
for (let corner = 0; corner < 4; corner += 1) {
|
||||
const center = centers[corner] as { cx: number; cy: number };
|
||||
const startAngle = startAngles[corner] as number;
|
||||
for (let index = 0; index <= CORNER_SEGMENTS; index += 1) {
|
||||
const angle = startAngle + (index / CORNER_SEGMENTS) * (Math.PI / 2);
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
const px = center.cx + r * Math.sign(cos) * Math.pow(Math.abs(cos), p);
|
||||
const py = center.cy + r * Math.sign(sin) * Math.pow(Math.abs(sin), p);
|
||||
parts.push(`${index === 0 && corner === 0 ? 'M' : 'L'}${round(px)},${round(py)}`);
|
||||
}
|
||||
}
|
||||
parts.push('Z');
|
||||
|
||||
return parts.join(' ');
|
||||
|
||||
Reference in new issue
Block a user