geometry.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. import { type TBBox, util, type XY } from 'fabric';
  2. const { makeBoundingBoxFromPoints } = util;
  3. /**
  4. * 计算将 `bounds` 平移多少 `{ x, y }` 后,能按选定策略与 `container` 对齐。
  5. *
  6. * - **cover**:在宽、高均不小于容器时,避免出现「容器内侧露空」——保持内容仍盖住整个容器(类似 cover)。
  7. * - **contain**:将 `bounds` 完全限制在容器矩形内,防止越界拖出可视区域。
  8. * - **auto**:根据 `bounds` 与 `container` 的尺寸关系自动选择:若宽高均不小于容器(在 EPS 容差内)则按 cover,否则按 contain。
  9. *
  10. * @param bounds - 待调整的轴对齐矩形(如内容、选区等)。
  11. * @param container - 轴对齐的裁剪或视口矩形。
  12. * @param mode - 夹紧策略。`auto` 为默认,按尺寸推断;`cover` / `contain` 为强制策略。
  13. * @returns 需要施加的平移 `{ x, y }`;若无需移动则返回 `undefined`。
  14. */
  15. export function calcTranslationDelta(bounds: TBBox, container: TBBox, mode: 'auto' | 'cover' | 'contain' = 'auto'): XY | void {
  16. const EPS = 1e-6;
  17. const covers = mode === 'auto' ? bounds.width >= container.width - EPS && bounds.height >= container.height - EPS : mode === 'cover';
  18. const bl = bounds.left;
  19. const bt = bounds.top;
  20. const br = bounds.left + bounds.width;
  21. const bb = bounds.top + bounds.height;
  22. const cl = container.left;
  23. const ct = container.top;
  24. const cr = container.left + container.width;
  25. const cb = container.top + container.height;
  26. const delta = { x: 0, y: 0 };
  27. if (covers) {
  28. if (bl > cl + EPS) delta.x -= bl - cl;
  29. if (bt > ct + EPS) delta.y -= bt - ct;
  30. if (br < cr - EPS) delta.x += cr - br;
  31. if (bb < cb - EPS) delta.y += cb - bb;
  32. } else {
  33. if (bl < cl - EPS) delta.x += cl - bl;
  34. if (bt < ct - EPS) delta.y += ct - bt;
  35. if (br > cr + EPS) delta.x -= br - cr;
  36. if (bb > cb + EPS) delta.y -= bb - cb;
  37. }
  38. return delta.x === 0 && delta.y === 0 ? void 0 : delta;
  39. }
  40. /**
  41. * 计算将主体四边形约束在容器四边形(带 UV 内边)内所需的一系列修正步骤。
  42. *
  43. * 在最多 `refineIters` 次迭代中,依次尝试:按轴对齐包围盒做 contain 平移、将越界角点沿容器四边形 UV 拉回(平移有上限)、仍不满足时相对重心等比缩小。
  44. * 返回的步骤需按顺序应用;`translate` 的 `x/y` 为平移增量,`scale` 的 `x/y` 为相对重心的缩放系数(当前实现中二者相等)。
  45. *
  46. * @param corners - 主体四边形顶点(至少 4 点,顺序与 `container` 一致,按平行四边形处理)。
  47. * @param container - 容器/视口四边形顶点(至少 4 点)。
  48. * @param options - 预留配置(当前未使用)。
  49. * @returns 修正步骤数组;若 `corners` 非法(非数组或点数不足)则返回 `undefined`;已满足约束时可能为空数组。
  50. */
  51. export function calcSubjectContainerClampSteps(corners: XY[], container: XY[], options?: {step?: number}) {
  52. if (!Array.isArray(corners) || corners.length < 4) return;
  53. if (!Array.isArray(container) || container.length < 4) return;
  54. const result: Array<{ kind: 'translate' | 'scale' } & XY> = [];
  55. const containerInset = 0.35;
  56. const containerNudgeCap = 48;
  57. const refineIters = options?.step ?? 16;
  58. const shrink = 0.97;
  59. const { insetU, insetV } = parallelogramWithInset(container, containerInset);
  60. for (let step = 0; step < refineIters; step++) {
  61. if (!subjectViolations(corners, container, insetU, insetV)) break;
  62. const delta = calcTranslationDelta(makeBoundingBoxFromPoints(corners), makeBoundingBoxFromPoints(container), 'contain');
  63. if (delta) {
  64. corners = corners.map((p) => ({ x: p.x + delta.x, y: p.y + delta.y }));
  65. result.push({ kind: 'translate', ...delta });
  66. }
  67. const pull = subjectPullIntoBackdropQuad(corners, container, { cap: containerNudgeCap, inset: containerInset });
  68. if (pull) {
  69. corners = corners.map((p) => ({ x: p.x + pull.x, y: p.y + pull.y }));
  70. result.push({ kind: 'translate', ...pull });
  71. }
  72. if (!subjectViolations(corners, container, insetU, insetV)) break;
  73. const centroid = corners.reduce((c, p) => ({ x: c.x + p.x, y: c.y + p.y }), { x: 0, y: 0 });
  74. const cx = centroid.x / corners.length;
  75. const cy = centroid.y / corners.length;
  76. corners = corners.map((p) => ({ x: cx + shrink * (p.x - cx), y: cy + shrink * (p.y - cy) }));
  77. result.push({ kind: 'scale', x: shrink, y: shrink });
  78. }
  79. return result;
  80. }
  81. function parallelogramWithInset(corners: XY[], inset = 0.35) {
  82. const [tl, tr, br, bl] = corners;
  83. const e1x = tr.x - tl.x;
  84. const e1y = tr.y - tl.y;
  85. const e2x = bl.x - tl.x;
  86. const e2y = bl.y - tl.y;
  87. const len1 = Math.hypot(e1x, e1y) || 1;
  88. const len2 = Math.hypot(e2x, e2y) || 1;
  89. return {
  90. tl,
  91. tr,
  92. br,
  93. bl,
  94. e1x,
  95. e1y,
  96. e2x,
  97. e2y,
  98. insetU: inset / len1,
  99. insetV: inset / len2,
  100. };
  101. }
  102. function subjectPullIntoBackdropQuad(corners: XY[], container: XY[], options?: { cap?: number; inset?: number }) {
  103. const cap = options?.cap ?? 48;
  104. const { e1x, e1y, e2x, e2y, insetV, insetU } = parallelogramWithInset(container, options?.inset);
  105. let sx = 0;
  106. let sy = 0;
  107. let n = 0;
  108. for (const p of corners) {
  109. if (pointInParallelogramInset(container, p, insetU, insetV)) continue;
  110. const { u, v } = parallelogramUV(container, p);
  111. const u0 = insetU;
  112. const u1 = 1 - insetU;
  113. const v0 = insetV;
  114. const v1 = 1 - insetV;
  115. const uc = Math.min(u1, Math.max(u0, u));
  116. const vc = Math.min(v1, Math.max(v0, v));
  117. const du = uc - u;
  118. const dv = vc - v;
  119. if (du === 0 && dv === 0) continue;
  120. sx += du * e1x + dv * e2x;
  121. sy += du * e1y + dv * e2y;
  122. n++;
  123. }
  124. const delta = { x: n === 0 ? 0 : sx / n, y: n === 0 ? 0 : sy / n };
  125. const mag = Math.hypot(delta.x, delta.y);
  126. if (mag > cap) {
  127. const s = cap / mag;
  128. delta.x *= s;
  129. delta.y *= s;
  130. }
  131. return delta.x === 0 && delta.y === 0 ? void 0 : delta;
  132. }
  133. function parallelogramUV(corners: XY[], point: XY): { u: number; v: number } {
  134. const [tl, tr, br, bl] = corners;
  135. const e1x = tr.x - tl.x;
  136. const e1y = tr.y - tl.y;
  137. const e2x = bl.x - tl.x;
  138. const e2y = bl.y - tl.y;
  139. const px = point.x - tl.x;
  140. const py = point.y - tl.y;
  141. const det = e1x * e2y - e1y * e2x;
  142. if (Math.abs(det) < 1e-12) return { u: 0, v: 0 };
  143. const u = (px * e2y - py * e2x) / det;
  144. const v = (e1x * py - e1y * px) / det;
  145. return { u, v };
  146. }
  147. function pointInParallelogramInset(corners: XY[], point: XY, insetU: number, insetV: number): boolean {
  148. const { u, v } = parallelogramUV(corners, point);
  149. const u0 = insetU;
  150. const u1 = 1 - insetU;
  151. const v0 = insetV;
  152. const v1 = 1 - insetV;
  153. if (!(u0 <= u1 && v0 <= v1)) return u >= 0 && u <= 1 && v >= 0 && v <= 1;
  154. return u >= u0 - 1e-9 && u <= u1 + 1e-9 && v >= v0 - 1e-9 && v <= v1 + 1e-9;
  155. }
  156. function subjectViolations(corners: XY[], container: XY[], backdropInsetU: number, backdropInsetV: number): boolean {
  157. for (const p of corners) {
  158. if (!pointInParallelogramInset(container, p, backdropInsetU, backdropInsetV)) return true;
  159. }
  160. return false;
  161. }