From 252df4557506fbc9c9b6d94dc2b2505fa8875588 Mon Sep 17 00:00:00 2001 From: Amark19 Date: Mon, 24 Aug 2026 00:11:54 +0530 Subject: [PATCH 1/4] fix: elbow arrows take style filters + get rounded corners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two elbow-arrow gaps vs eraser.io/Excalidraw: - Style filters (width/style/colour) never applied to an elbow arrow. The panel's applyToActive only styled a `line` connector, skipping the elbow's `polyline` — so changing width/dash/colour did nothing. Now it styles both connector types. - The elbow connector had sharp corners. It now renders with rounded corners (a short quadratic fillet at each bend, capped to the leg length) plus round line joins/caps, matching the eraser/Excalidraw look. The route's first/last points stay exact, so endpoints/binding/hit-testing are unchanged. Co-Authored-By: Claude Opus 4.8 --- src/Handlers/ToolsHandler/tools/arrow.js | 8 ++- .../PropertiesPanel/PropertiesPanel.jsx | 7 +-- src/utils/arrowEndpoints.js | 50 +++++++++++++++++-- src/utils/arrowEndpoints.test.js | 12 +++++ 4 files changed, 70 insertions(+), 7 deletions(-) diff --git a/src/Handlers/ToolsHandler/tools/arrow.js b/src/Handlers/ToolsHandler/tools/arrow.js index 04cbc00..010b867 100644 --- a/src/Handlers/ToolsHandler/tools/arrow.js +++ b/src/Handlers/ToolsHandler/tools/arrow.js @@ -56,8 +56,14 @@ export const buildArrowGroup = (start, end, style) => { selectable: false, }; // The connector: a straight line, or an orthogonally-routed polyline (elbow). + // The elbow uses round joins/caps so its (rounded) corners render smoothly. const line = elbow - ? new fabric.Polyline(elbowRoute(start, end), { ...connStyle, fill: "" }) + ? new fabric.Polyline(elbowRoute(start, end), { + ...connStyle, + fill: "", + strokeLineJoin: "round", + strokeLineCap: "round", + }) : new fabric.Line([start.x, start.y, end.x, end.y], connStyle); // Head aims along the last route segment (= start->end for a straight arrow). // Its centre is backed off so the tip vertex sits exactly on the endpoint. diff --git a/src/components/CanvasEditor/PropertiesPanel/PropertiesPanel.jsx b/src/components/CanvasEditor/PropertiesPanel/PropertiesPanel.jsx index 109006c..d278f54 100644 --- a/src/components/CanvasEditor/PropertiesPanel/PropertiesPanel.jsx +++ b/src/components/CanvasEditor/PropertiesPanel/PropertiesPanel.jsx @@ -160,10 +160,11 @@ const PropertiesPanel = () => { }); } else if (isArrowObject(obj)) { // An arrow group doesn't propagate style to its children, so apply to - // each: line-like children take the stroke/width/dash; the filled - // head(s) take the stroke colour as their fill. + // each: the connector (a straight `line` OR an elbow `polyline`) takes + // the stroke/width/dash; the filled head(s) take the stroke colour as + // their fill. obj._objects.forEach((child) => { - if (child.type === "line") { + if (child.type === "line" || child.type === "polyline") { child.set({ stroke: fab.stroke, strokeWidth: fab.strokeWidth, diff --git a/src/utils/arrowEndpoints.js b/src/utils/arrowEndpoints.js index 9d1c67a..6041484 100644 --- a/src/utils/arrowEndpoints.js +++ b/src/utils/arrowEndpoints.js @@ -83,11 +83,55 @@ export const elbowRoute = (s, e) => { ]; }; +// Radius of the rounded corners on an elbow arrow (eraser.io/Excalidraw style). +export const ELBOW_CORNER_RADIUS = 12; + +// Expand a sharp orthogonal route into one with ROUNDED corners: each interior +// vertex becomes a short quadratic-bezier fillet (the corner is the control +// point), approximated by a few points so the plain polyline renders as a smooth +// rounded elbow. The FIRST and LAST points are left exactly on the endpoints, so +// localEndpoints (which reads points[0]/points[last]) is unaffected. The fillet +// radius is capped to half the shorter adjacent segment so short legs don't kink. +const roundRoute = (route, radius) => { + if (route.length <= 2) return route.map((p) => ({ x: p.x, y: p.y })); + const out = [{ x: route[0].x, y: route[0].y }]; + for (let i = 1; i < route.length - 1; i += 1) { + const a = route[i - 1]; + const b = route[i]; + const c = route[i + 1]; + const v1 = { x: a.x - b.x, y: a.y - b.y }; + const v2 = { x: c.x - b.x, y: c.y - b.y }; + const l1 = Math.hypot(v1.x, v1.y) || 1; + const l2 = Math.hypot(v2.x, v2.y) || 1; + const r = Math.min(radius, l1 / 2, l2 / 2); + if (r < 0.5) { + out.push({ x: b.x, y: b.y }); + continue; + } + const p1 = { x: b.x + (v1.x / l1) * r, y: b.y + (v1.y / l1) * r }; + const p2 = { x: b.x + (v2.x / l2) * r, y: b.y + (v2.y / l2) * r }; + const steps = 4; + out.push(p1); + for (let s = 1; s < steps; s += 1) { + const t = s / steps; + const mt = 1 - t; + out.push({ + x: mt * mt * p1.x + 2 * mt * t * b.x + t * t * p2.x, + y: mt * mt * p1.y + 2 * mt * t * b.y + t * t * p2.y, + }); + } + out.push(p2); + } + out.push({ x: route[route.length - 1].x, y: route[route.length - 1].y }); + return out; +}; + // Position an elbow polyline so its points render at their exact group-local -// coords (fabric otherwise offsets a polyline by its pathOffset). Set the route, -// recompute dimensions, then pin left/top to the new pathOffset. +// coords (fabric otherwise offsets a polyline by its pathOffset). Round the +// route's corners, set the points, recompute dimensions, then pin left/top to +// the new pathOffset. export const layoutElbowPolyline = (poly, route) => { - poly.set({ points: route.map((p) => ({ x: p.x, y: p.y })) }); + poly.set({ points: roundRoute(route, ELBOW_CORNER_RADIUS) }); poly._setPositionDimensions({}); poly.set({ left: poly.pathOffset.x, top: poly.pathOffset.y }); poly.setCoords(); diff --git a/src/utils/arrowEndpoints.test.js b/src/utils/arrowEndpoints.test.js index e6da97d..93c49a6 100644 --- a/src/utils/arrowEndpoints.test.js +++ b/src/utils/arrowEndpoints.test.js @@ -74,6 +74,18 @@ describe("isElbowArrow", () => { expect(isElbowArrow(elbow)).toBe(true); expect(isArrow(elbow)).toBe(true); // an elbow is still an arrow }); + + test("the elbow connector has rounded corners (fillet points beyond the sharp route)", () => { + const elbow = buildArrowGroup( + { x: 0, y: 0 }, + { x: 200, y: 120 }, + { stroke: "#000", strokeWidth: 2, arrowType: "elbow" }, + ); + const { line } = getArrowParts(elbow); + expect(line.type).toBe("polyline"); + // a sharp Z is 4 points; rounding each interior bend adds fillet points + expect(line.points.length).toBeGreaterThan(4); + }); }); const arrow = (label) => From c988a6ccfa17ddd3f61ac04e23d7278b70f975af Mon Sep 17 00:00:00 2001 From: Amark19 Date: Mon, 24 Aug 2026 00:45:32 +0530 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20smart=20elbow=20routing=20=E2=80=94?= =?UTF-8?q?=20perpendicular=20exits=20from=20bound=20shape=20edges?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Elbow arrows routed as a plain dominant-axis mid-bend that ignored which side of each shape the endpoint sat on, so they cut diagonally toward a midpoint instead of leaving the shape square-on. Now a bound elbow records each end's exit direction (the outward edge normal, via binding's rerouteArrow) and the router stubs out perpendicular to each edge, then connects the stubs with a clean right-angled path (facing/perpendicular/opposed cases handled, collinear points collapsed). Free-drawn elbows and live endpoint drags still use the plain mid-bend. Combined with the rounded corners this matches the eraser.io/Excalidraw elbow look. Co-Authored-By: Claude Opus 4.8 --- src/utils/arrowEndpoints.js | 98 ++++++++++++++++++++++++++++++-- src/utils/arrowEndpoints.test.js | 48 ++++++++++++++++ src/utils/binding.js | 24 +++++++- 3 files changed, 164 insertions(+), 6 deletions(-) diff --git a/src/utils/arrowEndpoints.js b/src/utils/arrowEndpoints.js index 6041484..20fff50 100644 --- a/src/utils/arrowEndpoints.js +++ b/src/utils/arrowEndpoints.js @@ -54,10 +54,42 @@ export const headTipOf = (head) => { }; }; -// Orthogonal (elbow) route between two points: a right-angled path. Routes along -// the dominant axis first, bending at the midpoint (a clean Z). Collapses to a -// straight segment when the points share a row/column. -export const elbowRoute = (s, e) => { +// Drop duplicate and collinear points so a route is the minimal set of corners +// (keeps roundRoute from filleting non-corners, and elbows from kinking). +const cleanRoute = (pts) => { + const dedup = []; + pts.forEach((p) => { + const last = dedup[dedup.length - 1]; + if (last && Math.abs(last.x - p.x) < 0.5 && Math.abs(last.y - p.y) < 0.5) + return; + dedup.push({ x: p.x, y: p.y }); + }); + if (dedup.length <= 2) return dedup; + const out = [dedup[0]]; + for (let i = 1; i < dedup.length - 1; i += 1) { + const a = dedup[i - 1]; + const b = dedup[i]; + const c = dedup[i + 1]; + const cross = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x); + if (Math.abs(cross) < 1e-6) continue; // collinear -> drop the middle point + out.push(b); + } + out.push(dedup[dedup.length - 1]); + return out; +}; + +// The dominant axis direction from `from` toward `to`, as an axis unit vector. +const axisToward = (from, to) => { + const dx = to.x - from.x; + const dy = to.y - from.y; + return Math.abs(dx) >= Math.abs(dy) + ? { x: Math.sign(dx) || 1, y: 0 } + : { x: 0, y: Math.sign(dy) || 1 }; +}; + +// The plain dominant-axis mid-bend Z — used when no port directions are known +// (a free-drawn elbow, or a live endpoint drag). +const simpleElbow = (s, e) => { const dx = e.x - s.x; const dy = e.y - s.y; if (Math.abs(dx) < 1 || Math.abs(dy) < 1) @@ -83,6 +115,57 @@ export const elbowRoute = (s, e) => { ]; }; +// Smart orthogonal route between two PORTS — a point plus the axis direction the +// path must leave it by (the outward normal of the shape edge it's bound to). It +// stubs out perpendicular to each edge, then connects the stubs with a clean +// right-angled path, so the arrow leaves/enters each shape square-on (the +// eraser.io/Excalidraw look) instead of cutting diagonally to a mid-point. +const STUB = 22; +const smartElbow = (s, ds, e, de) => { + const dist = Math.hypot(e.x - s.x, e.y - s.y); + const m = Math.min(STUB, Math.max(6, dist * 0.4)); + const a = { x: s.x + ds.x * m, y: s.y + ds.y * m }; + const b = { x: e.x + de.x * m, y: e.y + de.y * m }; + const aH = ds.x !== 0; + const bH = de.x !== 0; + const mid = []; + if (aH && bH) { + const facing = + Math.sign(b.x - a.x) === ds.x && Math.sign(a.x - b.x) === de.x; + if (facing) { + const mx = (a.x + b.x) / 2; + mid.push({ x: mx, y: a.y }, { x: mx, y: b.y }); + } else { + const my = (a.y + b.y) / 2; + mid.push({ x: a.x, y: my }, { x: b.x, y: my }); + } + } else if (!aH && !bH) { + const facing = + Math.sign(b.y - a.y) === ds.y && Math.sign(a.y - b.y) === de.y; + if (facing) { + const my = (a.y + b.y) / 2; + mid.push({ x: a.x, y: my }, { x: b.x, y: my }); + } else { + const mx = (a.x + b.x) / 2; + mid.push({ x: mx, y: a.y }, { x: mx, y: b.y }); + } + } else if (aH) { + mid.push({ x: b.x, y: a.y }); // A horizontal, B vertical -> one corner + } else { + mid.push({ x: a.x, y: b.y }); // A vertical, B horizontal -> one corner + } + return cleanRoute([s, a, ...mid, b, e]); +}; + +// Orthogonal (elbow) route between two points. With port directions (ds/de — the +// outward edge normals of bound shapes) it routes smartly with perpendicular +// exits; without them it falls back to the plain dominant-axis mid-bend. A +// missing single direction is derived from the geometry. +export const elbowRoute = (s, e, ds, de) => { + if (!ds && !de) return simpleElbow(s, e); + return smartElbow(s, ds || axisToward(s, e), e, de || axisToward(e, s)); +}; + // Radius of the rounded corners on an elbow arrow (eraser.io/Excalidraw style). export const ELBOW_CORNER_RADIUS = 12; @@ -189,7 +272,12 @@ const applyEndpointsLocal = (group, start, end) => { const elbow = line.type === "polyline"; // The route the head/label follow: a straight [start,end] or the elbow path. - const route = elbow ? elbowRoute(start, end) : [start, end]; + // A bound elbow carries its ports' exit directions (startDir/endDir, set by + // binding's rerouteArrow) so it leaves each shape square-on; a free elbow has + // none and falls back to the plain mid-bend. + const route = elbow + ? elbowRoute(start, end, group.startDir, group.endDir) + : [start, end]; // heads[0] sits at the tip, aimed along the LAST segment; a second head // (double-ended) sits at the tail, aimed along the FIRST segment (reversed). diff --git a/src/utils/arrowEndpoints.test.js b/src/utils/arrowEndpoints.test.js index 93c49a6..61f5516 100644 --- a/src/utils/arrowEndpoints.test.js +++ b/src/utils/arrowEndpoints.test.js @@ -35,6 +35,54 @@ describe("elbowRoute (orthogonal routing)", () => { }); }); +describe("elbowRoute with port directions (smart perpendicular exits)", () => { + const axisAligned = (r) => { + for (let i = 1; i < r.length; i += 1) + expect(r[i].x === r[i - 1].x || r[i].y === r[i - 1].y).toBe(true); + }; + + test("right-edge port -> left-edge port: leaves +x, keeps the exact ends", () => { + // start exits +x, end exits -x (facing) -> H-V-H via a mid column + const r = elbowRoute( + { x: 0, y: 0 }, + { x: 200, y: 80 }, + { x: 1, y: 0 }, + { + x: -1, + y: 0, + }, + ); + expect(r[0]).toEqual({ x: 0, y: 0 }); + expect(r[r.length - 1]).toEqual({ x: 200, y: 80 }); + // first move is a horizontal stub (perpendicular to the right edge) + expect(r[1].y).toBe(0); + expect(r[1].x).toBeGreaterThan(0); + axisAligned(r); + }); + + test("bottom-edge port -> left-edge port: leaves +y (vertical stub first)", () => { + const r = elbowRoute( + { x: 0, y: 0 }, + { x: 120, y: 120 }, + { x: 0, y: 1 }, + { + x: -1, + y: 0, + }, + ); + expect(r[1].x).toBe(0); // vertical stub down from the bottom edge + expect(r[1].y).toBeGreaterThan(0); + axisAligned(r); + }); + + test("a single provided direction is enough to route smartly", () => { + const r = elbowRoute({ x: 0, y: 0 }, { x: 200, y: 60 }, { x: 1, y: 0 }); + expect(r[0]).toEqual({ x: 0, y: 0 }); + expect(r[r.length - 1]).toEqual({ x: 200, y: 60 }); + axisAligned(r); + }); +}); + describe("headCenterFor (arrowhead sits ON the endpoint, not past it)", () => { test("backs the centre off the tip by the head half-length, along the segment", () => { // horizontal segment (0,0)->(100,0): centre pulled 10 left of the tip diff --git a/src/utils/binding.js b/src/utils/binding.js index 98abae3..be8ffd3 100644 --- a/src/utils/binding.js +++ b/src/utils/binding.js @@ -177,6 +177,20 @@ const anchorTarget = (shape, anchor) => { }; }; +// The outward edge normal at a border point, as an axis unit vector — which side +// of the shape the arrow leaves by. Feeds the smart elbow router so the arrow +// exits perpendicular to that edge (eraser.io/Excalidraw). Picks the dominant +// axis of the point's position within the bbox. +const exitDir = (shape, borderPt) => { + const c = sceneCenter(shape); + const b = sceneBBox(shape); + const rx = (borderPt.x - c.x) / (b.width / 2 || 1); + const ry = (borderPt.y - c.y) / (b.height / 2 || 1); + return Math.abs(rx) >= Math.abs(ry) + ? { x: Math.sign(rx) || 1, y: 0 } + : { x: 0, y: Math.sign(ry) || 1 }; +}; + // --- lookups -------------------------------------------------------------- const shapeById = (canvas, id) => id ? canvas.getObjects().find((o) => o.id === id) || null : null; @@ -234,6 +248,11 @@ export const rerouteArrow = (canvas, arrow, refit = true) => { const tail = startShape ? borderPoint(startShape, startAim) : ends.tail; const tip = endShape ? borderPoint(endShape, endAim) : ends.tip; + // Record each bound end's exit direction so the elbow router leaves the shape + // square-on. An unbound end has none (the router derives it from geometry). + arrow.startDir = startShape ? exitDir(startShape, tail) : undefined; + arrow.endDir = endShape ? exitDir(endShape, tip) : undefined; + setArrowEndpoints(arrow, tail, tip, refit); return true; }; @@ -264,10 +283,13 @@ export const bindEnd = (arrow, end, shape, scenePoint) => { ensureId(arrow); }; -// Unbind one end (e.g. its endpoint was dragged into empty space). +// Unbind one end (e.g. its endpoint was dragged into empty space). Also clears +// that end's cached exit direction so the router doesn't keep steering by a +// stale edge normal. export const unbindEnd = (arrow, end) => { arrow[bindingField(end)] = undefined; arrow[anchorField(end)] = undefined; + arrow[end === "start" ? "startDir" : "endDir"] = undefined; }; // After an arrow is drawn, bind whichever end landed on a shape (anchored at the From 954e8b552aa9c156fb1ab5a3e314e4ac715863ca Mon Sep 17 00:00:00 2001 From: Amark19 Date: Mon, 24 Aug 2026 11:50:05 +0530 Subject: [PATCH 3/4] fix: elbow arrows auto-pick the facing side (no more wrap-around) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bound elbow froze its exit side to wherever it was first dropped, so moving a shape to the opposite side forced an ugly wrap-around (the arrow looped out and the tip could land off-canvas). Elbow re-routing now chooses each end's exit side dynamically from the shapes' relative positions — attaching at the midpoint of the edge that FACES the other end and exiting perpendicular — like eraser.io/Excalidraw. Straight arrows still keep their dropped attach point. Co-Authored-By: Claude Opus 4.8 --- src/utils/binding.js | 61 +++++++++++++++++++++++++-------------- src/utils/binding.test.js | 27 +++++++++++++++++ 2 files changed, 66 insertions(+), 22 deletions(-) diff --git a/src/utils/binding.js b/src/utils/binding.js index be8ffd3..621b93c 100644 --- a/src/utils/binding.js +++ b/src/utils/binding.js @@ -1,5 +1,5 @@ import { fabric } from "fabric"; -import { isArrow } from "./shapeLabel"; +import { isArrow, isElbowArrow } from "./shapeLabel"; import { setArrowEndpoints, sceneEndpoints } from "./arrowEndpoints"; // Arrow <-> shape binding (eraser.io style). An arrow endpoint can be "bound" to @@ -177,18 +177,21 @@ const anchorTarget = (shape, anchor) => { }; }; -// The outward edge normal at a border point, as an axis unit vector — which side -// of the shape the arrow leaves by. Feeds the smart elbow router so the arrow -// exits perpendicular to that edge (eraser.io/Excalidraw). Picks the dominant -// axis of the point's position within the bbox. -const exitDir = (shape, borderPt) => { +// The axis unit vector pointing from `from` toward `to` (dominant axis) — which +// side of a shape faces the other end. +const facingDir = (from, to) => { + const dx = to.x - from.x; + const dy = to.y - from.y; + return Math.abs(dx) >= Math.abs(dy) + ? { x: Math.sign(dx) || 1, y: 0 } + : { x: 0, y: Math.sign(dy) || 1 }; +}; + +// The midpoint of the shape's edge on side `dir` — an axis-aligned ray from the +// centre hits that edge at its middle (the natural elbow attach point). +const edgeMidpoint = (shape, dir) => { const c = sceneCenter(shape); - const b = sceneBBox(shape); - const rx = (borderPt.x - c.x) / (b.width / 2 || 1); - const ry = (borderPt.y - c.y) / (b.height / 2 || 1); - return Math.abs(rx) >= Math.abs(ry) - ? { x: Math.sign(rx) || 1, y: 0 } - : { x: 0, y: Math.sign(ry) || 1 }; + return borderPoint(shape, { x: c.x + dir.x * 1e4, y: c.y + dir.y * 1e4 }); }; // --- lookups -------------------------------------------------------------- @@ -225,9 +228,28 @@ export const rerouteArrow = (canvas, arrow, refit = true) => { if (!startShape && !endShape) return false; const ends = arrowEndpointsScene(arrow); - // Aim each bound end at its stored anchor point (so it keeps its attach - // side/corner). A near-centre anchor is ambiguous, so fall back to facing the - // other end — which snaps to a clean edge instead of burying it in the middle. + + // Elbow arrows auto-pick the side of each shape that FACES the other end and + // attach at that edge's midpoint (eraser.io/Excalidraw). This is dynamic — it + // ignores where the arrow was first dropped — so moving a shape to the far + // side just flips the exit side instead of forcing an ugly wrap-around. Each + // end then exits perpendicular via the recorded startDir/endDir. + if (isElbowArrow(arrow)) { + const sc = startShape ? sceneCenter(startShape) : null; + const ec = endShape ? sceneCenter(endShape) : null; + const sDir = startShape ? facingDir(sc, ec || ends.tip) : undefined; + const eDir = endShape ? facingDir(ec, sc || ends.tail) : undefined; + const tail = startShape ? edgeMidpoint(startShape, sDir) : ends.tail; + const tip = endShape ? edgeMidpoint(endShape, eDir) : ends.tip; + arrow.startDir = sDir; + arrow.endDir = eDir; + setArrowEndpoints(arrow, tail, tip, refit); + return true; + } + + // Straight arrows keep the attach point where they were dropped. Aim each bound + // end at its stored anchor; a near-centre anchor is ambiguous, so fall back to + // facing the other end (a clean edge instead of the middle). const meaningful = (a) => a && (Math.abs(a.fx) > 0.05 || Math.abs(a.fy) > 0.05); const startAim = startShape @@ -244,15 +266,10 @@ export const rerouteArrow = (canvas, arrow, refit = true) => { ? sceneCenter(startShape) : ends.tail : null; - const tail = startShape ? borderPoint(startShape, startAim) : ends.tail; const tip = endShape ? borderPoint(endShape, endAim) : ends.tip; - - // Record each bound end's exit direction so the elbow router leaves the shape - // square-on. An unbound end has none (the router derives it from geometry). - arrow.startDir = startShape ? exitDir(startShape, tail) : undefined; - arrow.endDir = endShape ? exitDir(endShape, tip) : undefined; - + arrow.startDir = undefined; + arrow.endDir = undefined; setArrowEndpoints(arrow, tail, tip, refit); return true; }; diff --git a/src/utils/binding.test.js b/src/utils/binding.test.js index a83572b..de22014 100644 --- a/src/utils/binding.test.js +++ b/src/utils/binding.test.js @@ -148,6 +148,33 @@ describe("shapeUnderPoint", () => { }); }); +describe("rerouteArrow — elbow picks the facing side dynamically", () => { + test("exit sides flip when a bound shape moves to the other side", () => { + const c = makeCanvas(); + const A = rect({ left: 100, top: 100, width: 100, height: 60 }); // centre 150,130 + const B = rect({ left: 400, top: 100, width: 100, height: 60 }); // centre 450,130 + c.add(A, B); + const el = buildArrowGroup( + { x: 180, y: 130 }, + { x: 420, y: 130 }, + { stroke: "#111", strokeWidth: 2, arrowType: "elbow" }, + ); + c.add(el); + bindEnd(el, "start", A, { x: 200, y: 130 }); + bindEnd(el, "end", B, { x: 400, y: 130 }); + + rerouteArrow(c, el); // B is to the RIGHT of A + expect(el.startDir).toEqual({ x: 1, y: 0 }); // A exits right + expect(el.endDir).toEqual({ x: -1, y: 0 }); // B enters from its left + + B.set({ left: 100, top: 400 }); // move B directly BELOW A + B.setCoords(); + rerouteArrow(c, el); + expect(el.startDir).toEqual({ x: 0, y: 1 }); // A now exits DOWN (no wrap-around) + expect(el.endDir).toEqual({ x: 0, y: -1 }); // B now enters from its top + }); +}); + describe("boundArrows", () => { test("finds arrows bound to a shape by id on either end", () => { const c = makeCanvas(); From 178d82df94834ae6d21613b81aac71a11135808c Mon Sep 17 00:00:00 2001 From: Amark19 Date: Mon, 24 Aug 2026 12:32:36 +0530 Subject: [PATCH 4/4] feat: obstacle-aware elbow routing (route around boxes, no coils, no jog) Elbow arrows now route with the shapes themselves as obstacles (grid + A*): - an arrow between distant nodes goes AROUND intervening boxes instead of cutting through them; - treating the arrow's own two endpoints as obstacles means it can no longer coil back inside them when shapes are dragged close/overlapping; - nearly-aligned facing ports snap to a straight run, killing the tiny jog. Pathfinding runs only over shapes near the arrow (kept cheap) and falls back to the built-in perpendicular mid-bend when it can't connect the ports. New utils/orthRoute.js owns the router; binding feeds it the obstacle set and a pre-computed scene route flows through setArrowEndpoints -> applyEndpointsLocal. Co-Authored-By: Claude Opus 4.8 --- src/utils/arrowEndpoints.js | 34 ++++--- src/utils/binding.js | 64 ++++++++++++-- src/utils/orthRoute.js | 172 ++++++++++++++++++++++++++++++++++++ src/utils/orthRoute.test.js | 73 +++++++++++++++ 4 files changed, 328 insertions(+), 15 deletions(-) create mode 100644 src/utils/orthRoute.js create mode 100644 src/utils/orthRoute.test.js diff --git a/src/utils/arrowEndpoints.js b/src/utils/arrowEndpoints.js index 20fff50..df373fc 100644 --- a/src/utils/arrowEndpoints.js +++ b/src/utils/arrowEndpoints.js @@ -267,17 +267,20 @@ export const sceneEndpoints = (group) => { // label to a straight segment between `start` and `end`, both in GROUP-LOCAL // coords (relative to the group centre, which is left unchanged so children // keep rendering). All endpoint mutations funnel through here. -const applyEndpointsLocal = (group, start, end) => { +const applyEndpointsLocal = (group, start, end, presetRoute) => { const { line, heads, text } = getArrowParts(group); const elbow = line.type === "polyline"; - // The route the head/label follow: a straight [start,end] or the elbow path. - // A bound elbow carries its ports' exit directions (startDir/endDir, set by - // binding's rerouteArrow) so it leaves each shape square-on; a free elbow has - // none and falls back to the plain mid-bend. - const route = elbow - ? elbowRoute(start, end, group.startDir, group.endDir) - : [start, end]; + // The route the head/label follow. A caller (binding's obstacle-aware router) + // may hand in a ready LOCAL route; otherwise a bound elbow uses its ports' exit + // directions (startDir/endDir) for a perpendicular mid-bend, and a straight + // arrow is just [start,end]. + const route = + presetRoute && presetRoute.length >= 2 + ? presetRoute + : elbow + ? elbowRoute(start, end, group.startDir, group.endDir) + : [start, end]; // heads[0] sits at the tip, aimed along the LAST segment; a second head // (double-ended) sits at the tail, aimed along the FIRST segment (reversed). @@ -348,11 +351,22 @@ export const reshapeArrow = (group, key, local) => { // translate each frame — leaving the geometry fighting the drag. Skipping the // refit re-positions only the children (keeping a bound end glued to its border // as the group translates); the bounds are re-fitted once on drop. -export const setArrowEndpoints = (group, tailScene, tipScene, refit = true) => { +// sceneRoute (optional) is a full pre-computed orthogonal path in SCENE coords +// (from binding's obstacle-aware router); it's converted to local and used +// verbatim for the connector instead of the built-in mid-bend. +export const setArrowEndpoints = ( + group, + tailScene, + tipScene, + refit = true, + sceneRoute = null, +) => { const inv = fabric.util.invertTransform(group.calcTransformMatrix()); const toLocal = (p) => fabric.util.transformPoint(new fabric.Point(p.x, p.y), inv); - applyEndpointsLocal(group, toLocal(tailScene), toLocal(tipScene)); + const localRoute = + sceneRoute && sceneRoute.length >= 2 ? sceneRoute.map(toLocal) : null; + applyEndpointsLocal(group, toLocal(tailScene), toLocal(tipScene), localRoute); if (refit) refitArrowBounds(group); }; diff --git a/src/utils/binding.js b/src/utils/binding.js index 621b93c..c48a471 100644 --- a/src/utils/binding.js +++ b/src/utils/binding.js @@ -1,6 +1,11 @@ import { fabric } from "fabric"; import { isArrow, isElbowArrow } from "./shapeLabel"; import { setArrowEndpoints, sceneEndpoints } from "./arrowEndpoints"; +import { routeWithObstacles } from "./orthRoute"; + +// How close two facing ports must be (on the perpendicular axis) to snap into a +// straight run instead of showing a tiny jog. +const ALIGN_TOL = 20; // Arrow <-> shape binding (eraser.io style). An arrow endpoint can be "bound" to // a shape by that shape's stable id; when the shape moves or resizes we re-route @@ -194,6 +199,28 @@ const edgeMidpoint = (shape, dir) => { return borderPoint(shape, { x: c.x + dir.x * 1e4, y: c.y + dir.y * 1e4 }); }; +// Scene bounding boxes of the shapes an elbow must route AROUND — every bindable +// shape (including the arrow's own two ends, so it can't coil back inside them), +// limited to those near the tail→tip region so pathfinding stays cheap. +const obstacleRects = (canvas, tail, tip) => { + const pad = 220; + const rx1 = Math.min(tail.x, tip.x) - pad; + const ry1 = Math.min(tail.y, tip.y) - pad; + const rx2 = Math.max(tail.x, tip.x) + pad; + const ry2 = Math.max(tail.y, tip.y) + pad; + return canvas + .getObjects() + .filter((o) => isBindable(o)) + .map((o) => sceneBBox(o)) + .filter( + (b) => + b.left < rx2 && + b.left + b.width > rx1 && + b.top < ry2 && + b.top + b.height > ry1, + ); +}; + // --- lookups -------------------------------------------------------------- const shapeById = (canvas, id) => id ? canvas.getObjects().find((o) => o.id === id) || null : null; @@ -237,13 +264,40 @@ export const rerouteArrow = (canvas, arrow, refit = true) => { if (isElbowArrow(arrow)) { const sc = startShape ? sceneCenter(startShape) : null; const ec = endShape ? sceneCenter(endShape) : null; - const sDir = startShape ? facingDir(sc, ec || ends.tip) : undefined; - const eDir = endShape ? facingDir(ec, sc || ends.tail) : undefined; - const tail = startShape ? edgeMidpoint(startShape, sDir) : ends.tail; - const tip = endShape ? edgeMidpoint(endShape, eDir) : ends.tip; + const sDir = startShape + ? facingDir(sc, ec || ends.tip) + : facingDir(ends.tail, ends.tip); + const eDir = endShape + ? facingDir(ec, sc || ends.tail) + : facingDir(ends.tip, ends.tail); + const tail = startShape ? edgeMidpoint(startShape, sDir) : { ...ends.tail }; + const tip = endShape ? edgeMidpoint(endShape, eDir) : { ...ends.tip }; + // Opposite-facing ports that are nearly aligned: snap the perpendicular + // coord equal so a few-px offset doesn't produce a tiny jog. + if (sDir.x === -eDir.x && sDir.y === -eDir.y) { + if (sDir.x !== 0 && Math.abs(tail.y - tip.y) <= ALIGN_TOL) { + const y = (tail.y + tip.y) / 2; + tail.y = y; + tip.y = y; + } else if (sDir.y !== 0 && Math.abs(tail.x - tip.x) <= ALIGN_TOL) { + const x = (tail.x + tip.x) / 2; + tail.x = x; + tip.x = x; + } + } arrow.startDir = sDir; arrow.endDir = eDir; - setArrowEndpoints(arrow, tail, tip, refit); + // Route around every nearby shape (incl. both endpoints) so the arrow never + // cuts through a box or coils back inside its own ends. Falls back to the + // built-in mid-bend when pathfinding can't connect the ports. + const route = routeWithObstacles( + tail, + sDir, + tip, + eDir, + obstacleRects(canvas, tail, tip), + ); + setArrowEndpoints(arrow, tail, tip, refit, route); return true; } diff --git a/src/utils/orthRoute.js b/src/utils/orthRoute.js new file mode 100644 index 0000000..454980e --- /dev/null +++ b/src/utils/orthRoute.js @@ -0,0 +1,172 @@ +// Obstacle-aware orthogonal router (eraser.io/Excalidraw-style elbow paths). +// +// Given two PORTS — a point plus the axis direction the path must leave it by +// (the outward normal of the shape edge it's bound to) — and a set of obstacle +// rectangles (scene bounding boxes of shapes to avoid, INCLUDING the two shapes +// the arrow connects), it finds a right-angled path from `s` to `e` that: +// - leaves `s` / enters `e` perpendicular to their edges (via short stubs), +// - never crosses a shape's interior (so it can't cut through a box or coil +// back inside its own endpoints), and +// - is as short as possible, with a penalty per 90° turn. +// +// It's a grid + A*: candidate grid lines run through the ports/stubs and each +// (margin-expanded) obstacle edge; nodes are the intersections; a node connects +// to an axis-neighbour when the segment between them is clear. Returns the point +// array, or null when the grid can't connect the ports (caller falls back). + +export const OBSTACLE_MARGIN = 16; // keep the path this far off the shapes +export const STUB = 22; // perpendicular exit length (> margin, so stubs clear) +const BEND_COST = 60; // extra cost per 90° turn (prefer straighter paths) + +const uniqSorted = (arr) => { + const out = []; + arr + .slice() + .sort((a, b) => a - b) + .forEach((v) => { + if (!out.length || Math.abs(out[out.length - 1] - v) > 0.5) out.push(v); + }); + return out; +}; + +const nearestIndex = (sorted, v) => { + let best = 0; + let bestD = Infinity; + for (let i = 0; i < sorted.length; i += 1) { + const d = Math.abs(sorted[i] - v); + if (d < bestD) { + bestD = d; + best = i; + } + } + return best; +}; + +// A horizontal/vertical segment is blocked if it passes through any obstacle's +// interior (touching an expanded edge is allowed — that's how the path hugs it). +const segBlocked = (ax, ay, bx, by, obs) => { + const x1 = Math.min(ax, bx); + const x2 = Math.max(ax, bx); + const y1 = Math.min(ay, by); + const y2 = Math.max(ay, by); + const eps = 0.5; + return obs.some( + (o) => + x1 < o.x2 - eps && x2 > o.x1 + eps && y1 < o.y2 - eps && y2 > o.y1 + eps, + ); +}; + +const cleanColinear = (pts) => { + const dedup = []; + pts.forEach((p) => { + const last = dedup[dedup.length - 1]; + if (last && Math.abs(last.x - p.x) < 0.5 && Math.abs(last.y - p.y) < 0.5) + return; + dedup.push({ x: p.x, y: p.y }); + }); + if (dedup.length <= 2) return dedup; + const out = [dedup[0]]; + for (let i = 1; i < dedup.length - 1; i += 1) { + const a = dedup[i - 1]; + const b = dedup[i]; + const c = dedup[i + 1]; + const cross = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x); + if (Math.abs(cross) < 1e-6) continue; + out.push(b); + } + out.push(dedup[dedup.length - 1]); + return out; +}; + +export const routeWithObstacles = (s, ds, e, de, obstacles) => { + if (!obstacles || !obstacles.length) return null; + const stubS = { x: s.x + ds.x * STUB, y: s.y + ds.y * STUB }; + const stubE = { x: e.x + de.x * STUB, y: e.y + de.y * STUB }; + const obs = obstacles.map((o) => ({ + x1: o.left - OBSTACLE_MARGIN, + y1: o.top - OBSTACLE_MARGIN, + x2: o.left + o.width + OBSTACLE_MARGIN, + y2: o.top + o.height + OBSTACLE_MARGIN, + })); + + // Candidate grid lines: through the ports/stubs and each obstacle's expanded + // edges, plus a frame so the path can route all the way around. + const pad = OBSTACLE_MARGIN + STUB; + const allX = [stubS.x, stubE.x, s.x, e.x]; + const allY = [stubS.y, stubE.y, s.y, e.y]; + obs.forEach((o) => { + allX.push(o.x1, o.x2); + allY.push(o.y1, o.y2); + }); + const xs = uniqSorted([ + ...allX, + Math.min(...allX) - pad, + Math.max(...allX) + pad, + ]); + const ys = uniqSorted([ + ...allY, + Math.min(...allY) - pad, + Math.max(...allY) + pad, + ]); + + const si = nearestIndex(xs, stubS.x); + const sj = nearestIndex(ys, stubS.y); + const ei = nearestIndex(xs, stubE.x); + const ej = nearestIndex(ys, stubE.y); + const key = (i, j) => i * ys.length + j; + + const startK = key(si, sj); + const goalK = key(ei, ej); + const gScore = new Map([[startK, 0]]); + const cameFrom = new Map(); + const cameDir = new Map(); + const heur = (i, j) => Math.abs(xs[i] - xs[ei]) + Math.abs(ys[j] - ys[ej]); + const open = [{ k: startK, i: si, j: sj, f: heur(si, sj) }]; + + let reached = false; + while (open.length) { + let bi = 0; + for (let n = 1; n < open.length; n += 1) if (open[n].f < open[bi].f) bi = n; + const cur = open.splice(bi, 1)[0]; + if (cur.k === goalK) { + reached = true; + break; + } + if (cur.f - heur(cur.i, cur.j) > (gScore.get(cur.k) ?? Infinity)) continue; + const { i, j } = cur; + const dirIn = cameDir.get(cur.k); + [ + [i + 1, j, 1, 0], + [i - 1, j, -1, 0], + [i, j + 1, 0, 1], + [i, j - 1, 0, -1], + ].forEach(([ni, nj, dx, dy]) => { + if (ni < 0 || nj < 0 || ni >= xs.length || nj >= ys.length) return; + if (segBlocked(xs[i], ys[j], xs[ni], ys[nj], obs)) return; + const step = Math.abs(xs[ni] - xs[i]) + Math.abs(ys[nj] - ys[j]); + const turn = dirIn && (dirIn.x !== dx || dirIn.y !== dy) ? BEND_COST : 0; + const nk = key(ni, nj); + const tentative = gScore.get(cur.k) + step + turn; + if (tentative < (gScore.get(nk) ?? Infinity)) { + gScore.set(nk, tentative); + cameFrom.set(nk, cur.k); + cameDir.set(nk, { x: dx, y: dy }); + open.push({ k: nk, i: ni, j: nj, f: tentative + heur(ni, nj) }); + } + }); + } + + if (!reached) return null; + + const path = []; + let k = goalK; + while (k !== undefined) { + const i = Math.floor(k / ys.length); + const j = k % ys.length; + path.push({ x: xs[i], y: ys[j] }); + k = cameFrom.get(k); + } + path.reverse(); + // [s, stubS(=path[0]), ...grid path..., stubE(=path[last]), e] — all orthogonal. + return cleanColinear([s, ...path, e]); +}; diff --git a/src/utils/orthRoute.test.js b/src/utils/orthRoute.test.js new file mode 100644 index 0000000..60fcd20 --- /dev/null +++ b/src/utils/orthRoute.test.js @@ -0,0 +1,73 @@ +import { routeWithObstacles } from "./orthRoute"; + +// Does any segment of the route pass through the rect's interior? +const crossesRect = (route, o) => { + for (let i = 1; i < route.length; i += 1) { + const a = route[i - 1]; + const b = route[i]; + const x1 = Math.min(a.x, b.x); + const x2 = Math.max(a.x, b.x); + const y1 = Math.min(a.y, b.y); + const y2 = Math.max(a.y, b.y); + if (x1 < o.x2 - 1 && x2 > o.x1 + 1 && y1 < o.y2 - 1 && y2 > o.y1 + 1) + return true; + } + return false; +}; + +const axisAligned = (r) => { + for (let i = 1; i < r.length; i += 1) + expect(r[i].x === r[i - 1].x || r[i].y === r[i - 1].y).toBe(true); +}; + +describe("routeWithObstacles", () => { + test("returns null with no obstacles (caller falls back)", () => { + expect( + routeWithObstacles( + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 100, y: 0 }, + { + x: -1, + y: 0, + }, + [], + ), + ).toBeNull(); + }); + + test("routes AROUND a box sitting on the straight line, staying orthogonal", () => { + const s = { x: 0, y: 0 }; + const e = { x: 400, y: 0 }; + const box = { left: 150, top: -60, width: 100, height: 120 }; // blocks y=0 + const r = routeWithObstacles(s, { x: 1, y: 0 }, e, { x: -1, y: 0 }, [box]); + expect(r).not.toBeNull(); + expect(r[0]).toEqual({ x: 0, y: 0 }); + expect(r[r.length - 1]).toEqual({ x: 400, y: 0 }); + axisAligned(r); + // the drawn path must not cut through the box + expect( + crossesRect(r, { + x1: box.left, + y1: box.top, + x2: box.left + box.width, + y2: box.top + box.height, + }), + ).toBe(false); + }); + + test("does not route through the endpoints' own shapes (no coil-back)", () => { + // two boxes overlapping horizontally; ports face each other + const A = { left: 0, top: 0, width: 120, height: 80 }; // s on its right edge + const B = { left: 90, top: 0, width: 120, height: 80 }; // overlaps A in x + const s = { x: 120, y: 40 }; + const e = { x: 90, y: 40 }; + const r = routeWithObstacles(s, { x: 1, y: 0 }, e, { x: -1, y: 0 }, [A, B]); + if (r) { + axisAligned(r); + expect(crossesRect(r, { x1: 0, y1: 0, x2: 120, y2: 80 })).toBe(false); + expect(crossesRect(r, { x1: 90, y1: 0, x2: 210, y2: 80 })).toBe(false); + } + // (null is acceptable here — the caller then falls back to a mid-bend) + }); +});