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..df373fc 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,11 +115,106 @@ 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; + +// 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(); @@ -140,12 +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. - const route = elbow ? elbowRoute(start, end) : [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). @@ -216,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/arrowEndpoints.test.js b/src/utils/arrowEndpoints.test.js index e6da97d..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 @@ -74,6 +122,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) => diff --git a/src/utils/binding.js b/src/utils/binding.js index 98abae3..c48a471 100644 --- a/src/utils/binding.js +++ b/src/utils/binding.js @@ -1,6 +1,11 @@ import { fabric } from "fabric"; -import { isArrow } from "./shapeLabel"; +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 @@ -177,6 +182,45 @@ const anchorTarget = (shape, anchor) => { }; }; +// 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); + 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; @@ -211,9 +255,55 @@ 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) + : 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; + // 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; + } + + // 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 @@ -230,10 +320,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; - + arrow.startDir = undefined; + arrow.endDir = undefined; setArrowEndpoints(arrow, tail, tip, refit); return true; }; @@ -264,10 +354,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 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(); 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) + }); +});