|
@@ -0,0 +1,320 @@
|
|
|
|
|
+import type { Canvas, FabricObjectProps, TBrushEventData, XY } from 'fabric';
|
|
|
|
|
+import type { PolygonShape } from '../types';
|
|
|
|
|
+
|
|
|
|
|
+import { Point, Polygon, Shadow } from 'fabric';
|
|
|
|
|
+import { ShapeBrush } from './ShapeBrush';
|
|
|
|
|
+import { mergeObjectOptions } from '@/lib/fabric';
|
|
|
|
|
+
|
|
|
|
|
+const GEOM_EPS = 1e-9;
|
|
|
|
|
+
|
|
|
|
|
+function orient(a: XY, b: XY, c: XY): number {
|
|
|
|
|
+ return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function onSegment(a: XY, b: XY, c: XY): boolean {
|
|
|
|
|
+ return Math.min(a.x, b.x) - GEOM_EPS <= c.x && c.x <= Math.max(a.x, b.x) + GEOM_EPS && Math.min(a.y, b.y) - GEOM_EPS <= c.y && c.y <= Math.max(a.y, b.y) + GEOM_EPS;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function segmentsIntersect(p1: XY, p2: XY, p3: XY, p4: XY): boolean {
|
|
|
|
|
+ const o1 = orient(p1, p2, p3);
|
|
|
|
|
+ const o2 = orient(p1, p2, p4);
|
|
|
|
|
+ const o3 = orient(p3, p4, p1);
|
|
|
|
|
+ const o4 = orient(p3, p4, p2);
|
|
|
|
|
+
|
|
|
|
|
+ if ((o1 > GEOM_EPS && o2 < -GEOM_EPS) || (o1 < -GEOM_EPS && o2 > GEOM_EPS)) {
|
|
|
|
|
+ if ((o3 > GEOM_EPS && o4 < -GEOM_EPS) || (o3 < -GEOM_EPS && o4 > GEOM_EPS)) return true;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (Math.abs(o1) <= GEOM_EPS && onSegment(p1, p2, p3)) return true;
|
|
|
|
|
+ if (Math.abs(o2) <= GEOM_EPS && onSegment(p1, p2, p4)) return true;
|
|
|
|
|
+ if (Math.abs(o3) <= GEOM_EPS && onSegment(p3, p4, p1)) return true;
|
|
|
|
|
+ if (Math.abs(o4) <= GEOM_EPS && onSegment(p3, p4, p2)) return true;
|
|
|
|
|
+
|
|
|
|
|
+ return false;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function newEdgeCrossesExisting(points: XY[], candidate: XY): boolean {
|
|
|
|
|
+ const n = points.length;
|
|
|
|
|
+ if (n < 1) return false;
|
|
|
|
|
+ const last = points[n - 1]!;
|
|
|
|
|
+
|
|
|
|
|
+ for (let i = 0; i < n - 1; i++) {
|
|
|
|
|
+ if (i === n - 2) continue;
|
|
|
|
|
+ const a = points[i]!;
|
|
|
|
|
+ const b = points[i + 1]!;
|
|
|
|
|
+ if (segmentsIntersect(last, candidate, a, b)) return true;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return false;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function closingEdgeCrosses(points: XY[]): boolean {
|
|
|
|
|
+ const n = points.length;
|
|
|
|
|
+ if (n < 3) return false;
|
|
|
|
|
+ const first = points[0]!;
|
|
|
|
|
+ const last = points[n - 1]!;
|
|
|
|
|
+
|
|
|
|
|
+ for (let i = 0; i < n - 1; i++) {
|
|
|
|
|
+ if (i === 0 || i === n - 2) continue;
|
|
|
|
|
+ const a = points[i]!;
|
|
|
|
|
+ const b = points[i + 1]!;
|
|
|
|
|
+ if (segmentsIntersect(last, first, a, b)) return true;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return false;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function polygonSignedArea(pts: XY[]): number {
|
|
|
|
|
+ if (pts.length < 3) return 0;
|
|
|
|
|
+ let s = 0;
|
|
|
|
|
+ for (let i = 0; i < pts.length; i++) {
|
|
|
|
|
+ const j = (i + 1) % pts.length;
|
|
|
|
|
+ s += pts[i]!.x * pts[j]!.y - pts[j]!.x * pts[i]!.y;
|
|
|
|
|
+ }
|
|
|
|
|
+ return s / 2;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function distanceSq(a: XY, b: XY): number {
|
|
|
|
|
+ const dx = a.x - b.x;
|
|
|
|
|
+ const dy = a.y - b.y;
|
|
|
|
|
+ return dx * dx + dy * dy;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function axisAlignedBBoxCenter(pts: XY[]): { ox: number; oy: number } {
|
|
|
|
|
+ let minX = pts[0]!.x;
|
|
|
|
|
+ let minY = pts[0]!.y;
|
|
|
|
|
+ let maxX = minX;
|
|
|
|
|
+ let maxY = minY;
|
|
|
|
|
+ for (let i = 1; i < pts.length; i++) {
|
|
|
|
|
+ const { x, y } = pts[i]!;
|
|
|
|
|
+ minX = Math.min(minX, x);
|
|
|
|
|
+ minY = Math.min(minY, y);
|
|
|
|
|
+ maxX = Math.max(maxX, x);
|
|
|
|
|
+ maxY = Math.max(maxY, y);
|
|
|
|
|
+ }
|
|
|
|
|
+ return { ox: minX + (maxX - minX) / 2, oy: minY + (maxY - minY) / 2 };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function buildPolygonFromScenePoints(scenePts: XY[], style: Partial<FabricObjectProps>): Polygon {
|
|
|
|
|
+ const { ox, oy } = axisAlignedBBoxCenter(scenePts);
|
|
|
|
|
+ const rel = scenePts.map((p) => ({ x: p.x - ox, y: p.y - oy }));
|
|
|
|
|
+ return new Polygon(rel, {
|
|
|
|
|
+ left: ox,
|
|
|
|
|
+ top: oy,
|
|
|
|
|
+ fill: null,
|
|
|
|
|
+ ...style,
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** `decimate === 0` 时点击首点闭合的默认吸附半径(场景像素),与旧 POLYGON_CLOSE_DISTANCE_SCENE 一致。 */
|
|
|
|
|
+const CLOSE_SNAP_WHEN_DECIMATE_OFF = 14;
|
|
|
|
|
+
|
|
|
|
|
+type BrushInvalidReason = 'outOfRegion' | 'selfIntersection' | 'degenerateTriangle' | 'closeWouldIntersect' | 'polygonTooFewPoints' | 'dragTooShort';
|
|
|
|
|
+
|
|
|
|
|
+export class PolygonBrush extends ShapeBrush<PolygonShape> {
|
|
|
|
|
+ /** 有效顶点数达到该值即自动闭合落成;默认 100;三角形工具用 3。 */
|
|
|
|
|
+ vertexTargetCount = 100;
|
|
|
|
|
+
|
|
|
|
|
+ private get vc() {
|
|
|
|
|
+ return this.shape === 'triangle' ? 3 : this.vertexTargetCount;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 是否三角形工具:仅由 `vertexTargetCount === 3` 决定(与 Cropper `triangle` / `polygon` 形状一致)。 */
|
|
|
|
|
+ private isTriangleTool(): boolean {
|
|
|
|
|
+ return this.vc === 3;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 是否多边形工具(非三角形):`vertexTargetCount > 3`。 */
|
|
|
|
|
+ private isPolygonTool(): boolean {
|
|
|
|
|
+ return this.vc > 3;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ decimate = 24;
|
|
|
|
|
+ areaEps = 13;
|
|
|
|
|
+
|
|
|
|
|
+ private vertices: XY[] = [];
|
|
|
|
|
+ private moveHint: XY | null = null;
|
|
|
|
|
+
|
|
|
|
|
+ constructor(canvas: Canvas) {
|
|
|
|
|
+ super(canvas);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ onMouseDoubleClick(pointer: Point, { e }: TBrushEventData): void {
|
|
|
|
|
+ if (!this.canvas._isMainEvent(e)) return;
|
|
|
|
|
+ this.tryCloseFromKeyboardOrDblClick({ x: pointer.x, y: pointer.y });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private closeSnapSq(): number {
|
|
|
|
|
+ if (this.decimate > 0) return this.decimate * this.decimate;
|
|
|
|
|
+ return CLOSE_SNAP_WHEN_DECIMATE_OFF * CLOSE_SNAP_WHEN_DECIMATE_OFF;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private fireInvalid(reason: BrushInvalidReason, point: XY, extra?: { vertexCount?: number; detail?: Record<string, unknown> }) {
|
|
|
|
|
+ this.canvas.fire('brush:invalid', {
|
|
|
|
|
+ reason,
|
|
|
|
|
+ point,
|
|
|
|
|
+ vertexCount: extra?.vertexCount,
|
|
|
|
|
+ detail: extra?.detail,
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private refreshPreview(): void {
|
|
|
|
|
+ this._render();
|
|
|
|
|
+ this.canvas.requestRenderAll();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private finalizePolygon(pointForInvalid: XY): boolean {
|
|
|
|
|
+ if (closingEdgeCrosses(this.vertices)) {
|
|
|
|
|
+ this.fireInvalid('closeWouldIntersect', pointForInvalid, { vertexCount: this.vertices.length });
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ const area = Math.abs(polygonSignedArea(this.vertices));
|
|
|
|
|
+ if (area < this.areaEps) {
|
|
|
|
|
+ this.fireInvalid('degenerateTriangle', pointForInvalid, { vertexCount: this.vertices.length });
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const options = mergeObjectOptions({
|
|
|
|
|
+ fill: null,
|
|
|
|
|
+ stroke: this.color,
|
|
|
|
|
+ strokeWidth: this.width,
|
|
|
|
|
+ strokeLineCap: this.strokeLineCap,
|
|
|
|
|
+ strokeMiterLimit: this.strokeMiterLimit,
|
|
|
|
|
+ strokeLineJoin: this.strokeLineJoin,
|
|
|
|
|
+ strokeDashArray: this.strokeDashArray,
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const poly = buildPolygonFromScenePoints(this.vertices, options);
|
|
|
|
|
+
|
|
|
|
|
+ if (this.shadow) {
|
|
|
|
|
+ this.shadow.affectStroke = true;
|
|
|
|
|
+ poly.shadow = new Shadow(this.shadow);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ this.vertices = [];
|
|
|
|
|
+ this.moveHint = null;
|
|
|
|
|
+ this.canvas.clearContext(this.canvas.contextTop);
|
|
|
|
|
+ this.canvas.fire('before:object:created', { object: poly });
|
|
|
|
|
+ this.canvas.add(poly);
|
|
|
|
|
+ this.canvas.requestRenderAll();
|
|
|
|
|
+ poly.setCoords();
|
|
|
|
|
+ this._resetShadow();
|
|
|
|
|
+ this.canvas.fire('object:created', { object: poly });
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Enter / 双击:在顶点未达上限且 ≥3 点时尝试闭合。
|
|
|
|
|
+ * 三角形工具仅靠点满自动闭合,此处恒 false。
|
|
|
|
|
+ * 多边形工具下闭合不合规(点数不足、自交闭合边、退化面积等)时清空顶点,恢复初始状态。
|
|
|
|
|
+ */
|
|
|
|
|
+ tryCloseFromKeyboardOrDblClick(scenePointForEvent: XY): boolean {
|
|
|
|
|
+ if (this.isTriangleTool()) return false;
|
|
|
|
|
+ if (this.vertices.length < 3) {
|
|
|
|
|
+ this.fireInvalid('polygonTooFewPoints', scenePointForEvent, { vertexCount: this.vertices.length });
|
|
|
|
|
+ this.reset();
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ const ok = this.finalizePolygon(scenePointForEvent);
|
|
|
|
|
+ if (!ok) this.reset();
|
|
|
|
|
+ return ok;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ onMouseDown(pointer: Point, { e }: TBrushEventData): void {
|
|
|
|
|
+ if (!this.canvas._isMainEvent(e)) return;
|
|
|
|
|
+ if (this._isOutSideContainer(pointer)) return;
|
|
|
|
|
+
|
|
|
|
|
+ const r = pointer;
|
|
|
|
|
+ const canCloseByFirst =
|
|
|
|
|
+ this.isPolygonTool() && this.vertices.length >= 3 && this.vertices.length < this.vertexTargetCount && distanceSq(r, this.vertices[0]!) <= this.closeSnapSq();
|
|
|
|
|
+
|
|
|
|
|
+ if (canCloseByFirst) {
|
|
|
|
|
+ if (closingEdgeCrosses(this.vertices)) {
|
|
|
|
|
+ this.fireInvalid('closeWouldIntersect', r, { vertexCount: this.vertices.length });
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ this.finalizePolygon(r);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (this.vertices.length > 0 && this.decimate > 0) {
|
|
|
|
|
+ const last = this.vertices[this.vertices.length - 1]!;
|
|
|
|
|
+ const d = Math.hypot(r.x - last.x, r.y - last.y);
|
|
|
|
|
+ if (d < this.decimate) {
|
|
|
|
|
+ this.fireInvalid('dragTooShort', r, {
|
|
|
|
|
+ detail: { distance: d, minRequired: this.decimate },
|
|
|
|
|
+ });
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (this.vertices.length > 0 && newEdgeCrossesExisting(this.vertices, r)) {
|
|
|
|
|
+ this.fireInvalid('selfIntersection', r, { vertexCount: this.vertices.length });
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ this.vertices.push(r);
|
|
|
|
|
+
|
|
|
|
|
+ if (this.vertices.length === this.vertexTargetCount) {
|
|
|
|
|
+ if (this.isTriangleTool()) {
|
|
|
|
|
+ const area = Math.abs(polygonSignedArea(this.vertices));
|
|
|
|
|
+ if (area < this.areaEps) {
|
|
|
|
|
+ this.fireInvalid('degenerateTriangle', r, { vertexCount: 3 });
|
|
|
|
|
+ this.vertices.pop();
|
|
|
|
|
+ this.refreshPreview();
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ this.finalizePolygon(r);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ this.refreshPreview();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ onMouseMove(pointer: Point, { e }: TBrushEventData): void {
|
|
|
|
|
+ if (!this.canvas._isMainEvent(e)) return;
|
|
|
|
|
+ if (this._isOutSideContainer(pointer)) return;
|
|
|
|
|
+ this.moveHint = pointer;
|
|
|
|
|
+ this.refreshPreview();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ onMouseUp({ e }: TBrushEventData): boolean {
|
|
|
|
|
+ if (!this.canvas._isMainEvent(e)) return false;
|
|
|
|
|
+ if (this.isTriangleTool() && this.vertices.length >= 3) return false;
|
|
|
|
|
+ if (this.isPolygonTool()) return this.vertices.length > 0;
|
|
|
|
|
+ return this.vertices.length > 0 && this.vertices.length < 3;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ _render(ctx: CanvasRenderingContext2D = this.canvas.contextTop): void {
|
|
|
|
|
+ this.canvas.clearContext(ctx);
|
|
|
|
|
+ if (this.vertices.length === 0) return;
|
|
|
|
|
+ this._saveAndTransform(ctx);
|
|
|
|
|
+ this._setBrushStyles(ctx);
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ ctx.moveTo(this.vertices[0]!.x, this.vertices[0]!.y);
|
|
|
|
|
+ for (let i = 1; i < this.vertices.length; i++) {
|
|
|
|
|
+ ctx.lineTo(this.vertices[i]!.x, this.vertices[i]!.y);
|
|
|
|
|
+ }
|
|
|
|
|
+ const tail = this.moveHint ?? this.vertices[this.vertices.length - 1]!;
|
|
|
|
|
+ ctx.lineTo(tail.x, tail.y);
|
|
|
|
|
+ // 仅三角形工具:预览闭合到首点,形成三角形;多边形(vertexTargetCount > 3)保持开放折线,不出现三角形预览
|
|
|
|
|
+ if (this.isTriangleTool() && this.vertices.length >= 2) {
|
|
|
|
|
+ ctx.lineTo(this.vertices[0]!.x, this.vertices[0]!.y);
|
|
|
|
|
+ }
|
|
|
|
|
+ ctx.stroke();
|
|
|
|
|
+ ctx.restore();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ reset(): void {
|
|
|
|
|
+ this.vertices = [];
|
|
|
|
|
+ this.moveHint = null;
|
|
|
|
|
+ this.canvas.clearContext(this.canvas.contextTop);
|
|
|
|
|
+ this.canvas.requestRenderAll();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ protected override cleanupBrushState(): void {
|
|
|
|
|
+ this.vertices = [];
|
|
|
|
|
+ this.moveHint = null;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|