import { approximately, assert, Box, Editor, exhaustiveSwitchError, Geometry2dFilters, lerp, Mat, TLArrowBinding, TLArrowBindingProps, TLArrowShape, TLShapeId, Vec, VecLike, VecModel, } from '@tldraw/editor' import { STROKE_SIZES } from '../../shared/default-shape-constants' import { ArrowShapeUtil } from '../ArrowShapeUtil' import { BOUND_ARROW_OFFSET, TLArrowBindings } from '../shared' import { ElbowArrowAxes, ElbowArrowBox, ElbowArrowBoxEdges, ElbowArrowEdge, ElbowArrowInfo, ElbowArrowInfoWithoutRoute, ElbowArrowOptions, ElbowArrowRoute, ElbowArrowSide, ElbowArrowSideWithAxis, ElbowArrowTargetBox, ElbowArrowTerminal, } from './definitions' import { createRange, expandRange, isWithinRange, rangeSize, subtractRange } from './range' import { ElbowArrowWorkingInfo } from './routes/ElbowArrowWorkingInfo' import { routeArrowWithAutoEdgePicking, routeArrowWithManualEdgePicking, routeArrowWithPartialEdgePicking, } from './routes/routeArrowWithAutoEdgePicking' export function getElbowArrowInfo( editor: Editor, arrow: TLArrowShape, bindings: TLArrowBindings, arrowStrokeWidth?: number ): ElbowArrowInfo { const shapeOptions = editor.getShapeUtil(arrow.type).options const options: ElbowArrowOptions = { elbowMidpoint: arrow.props.elbowMidPoint, expandElbowLegLength: shapeOptions.expandElbowLegLength[arrow.props.size] * arrow.props.scale, minElbowLegLength: shapeOptions.minElbowLegLength[arrow.props.size] * arrow.props.scale, } // Before we can do anything else, we need to find the start and end terminals of the arrow. // These contain the binding info, geometry, bounds, etc. let startTerminal = getElbowArrowTerminalInfo( editor, arrow, bindings.start, arrow.props.start, arrowStrokeWidth ) let endTerminal = getElbowArrowTerminalInfo( editor, arrow, bindings.end, arrow.props.end, arrowStrokeWidth ) // unclosed paths are weird - we handle them outside of the initial terminal info. startTerminal = adjustTerminalForUnclosedPathIfNeeded(startTerminal, endTerminal, options) endTerminal = adjustTerminalForUnclosedPathIfNeeded(endTerminal, startTerminal, options) // Ther terminal might include a "side" if the user has explicitly indicated what side the arrow // should come from. There are two terminals, and two cases for each terminal (explicit side or // not), for a total for 4 cases to handle. In order to keep things a bit simpler though, we // only handle 3 cases: if start no side and end has a side, we flip them around. From here on // out, we use A and B to refer to the terminals as they may be swapped. const swapOrder = !!(!startTerminal.side && endTerminal.side) let { aTerminal, bTerminal } = swapOrder ? { aTerminal: endTerminal, bTerminal: startTerminal } : { aTerminal: startTerminal, bTerminal: endTerminal } // We model each edge that an arrow might enter/exit from separately. If an edge is blocked, // `getUsableEdge` might return null. let edgesA = { top: getUsableEdge(aTerminal, bTerminal, 'top', options), right: getUsableEdge(aTerminal, bTerminal, 'right', options), bottom: getUsableEdge(aTerminal, bTerminal, 'bottom', options), left: getUsableEdge(aTerminal, bTerminal, 'left', options), } let edgesB = { top: getUsableEdge(bTerminal, aTerminal, 'top', options), right: getUsableEdge(bTerminal, aTerminal, 'right', options), bottom: getUsableEdge(bTerminal, aTerminal, 'bottom', options), left: getUsableEdge(bTerminal, aTerminal, 'left', options), } // We we don't have a usable edge because it's blocked, we can convert some of the terminals to // points. Point terminals have less strict edge routing rules, but don't look as good // generally. For example, the arrow might go through the shape instead of around. const aIsUsable = hasUsableEdge(edgesA, aTerminal.side) const bIsUsable = hasUsableEdge(edgesB, bTerminal.side) let needsNewEdges = false if (!aIsUsable || !bIsUsable) { needsNewEdges = true if (!aIsUsable) { bTerminal = convertTerminalToPoint(bTerminal) } if (!bIsUsable) { aTerminal = convertTerminalToPoint(aTerminal) } if (bTerminal.bounds.containsPoint(aTerminal.target, options.expandElbowLegLength)) { bTerminal = convertTerminalToPoint(bTerminal) } if (aTerminal.bounds.containsPoint(bTerminal.target, options.expandElbowLegLength)) { aTerminal = convertTerminalToPoint(aTerminal) } } if (needsNewEdges) { edgesA = { top: getUsableEdge(aTerminal, bTerminal, 'top', options), right: getUsableEdge(aTerminal, bTerminal, 'right', options), bottom: getUsableEdge(aTerminal, bTerminal, 'bottom', options), left: getUsableEdge(aTerminal, bTerminal, 'left', options), } edgesB = { top: getUsableEdge(bTerminal, aTerminal, 'top', options), right: getUsableEdge(bTerminal, aTerminal, 'right', options), bottom: getUsableEdge(bTerminal, aTerminal, 'bottom', options), left: getUsableEdge(bTerminal, aTerminal, 'left', options), } } // We expand the bounds of the terminals so we can route arrows around them without the arrows // being too close to the shapes. const expandedA = aTerminal.isPoint ? aTerminal.bounds : aTerminal.bounds.clone().expandBy(options.expandElbowLegLength) const expandedB = bTerminal.isPoint ? bTerminal.bounds : bTerminal.bounds.clone().expandBy(options.expandElbowLegLength) const common: ElbowArrowBox = { original: Box.Common([aTerminal.bounds, bTerminal.bounds]), expanded: Box.Common([expandedA, expandedB]), } // Calculate the gaps between the two terminals. If gap is positive, B is to the right of A. If // it's negative, the opposite is true. If it's 0, there's no gap between the shapes in that // dimension. let gapX = bTerminal.bounds.minX - aTerminal.bounds.maxX if (gapX < 0) { gapX = aTerminal.bounds.minX - bTerminal.bounds.maxX if (gapX < 0) { gapX = 0 } gapX = -gapX } let gapY = bTerminal.bounds.minY - aTerminal.bounds.maxY if (gapY < 0) { gapY = aTerminal.bounds.minY - bTerminal.bounds.maxY if (gapY < 0) { gapY = 0 } gapY = -gapY } // The midpoint of the gap is a useful point to route arrows through, but the user can also drag // it to choose a new midpoint. First, we calculate some constraints we'll need to keep in mind // when figuring out the midpoint... const aMinLength = aTerminal.minEndSegmentLength * 3 const bMinLength = bTerminal.minEndSegmentLength * 3 const minLegDistanceNeeded = (aTerminal.isPoint ? aMinLength : options.minElbowLegLength) + (bTerminal.isPoint ? bMinLength : options.minElbowLegLength) // ...then, the possible range of the midpoint. This is also used when dragging the midpoint. let mxRange: null | { a: number; b: number } = null if (gapX > minLegDistanceNeeded) { mxRange = { a: aTerminal.isPoint ? aTerminal.bounds.maxX + aMinLength : expandedA.maxX, b: bTerminal.isPoint ? bTerminal.bounds.minX - bMinLength : expandedB.minX, } } else if (gapX < -minLegDistanceNeeded) { mxRange = { a: aTerminal.isPoint ? aTerminal.bounds.minX - aMinLength : expandedA.minX, b: bTerminal.isPoint ? bTerminal.bounds.maxX + bMinLength : expandedB.maxX, } } let myRange: null | { a: number; b: number } = null if (gapY > minLegDistanceNeeded) { myRange = { a: aTerminal.isPoint ? aTerminal.bounds.maxY + aMinLength : expandedA.maxY, b: bTerminal.isPoint ? bTerminal.bounds.minY - bMinLength : expandedB.minY, } } else if (gapY < -minLegDistanceNeeded) { myRange = { a: aTerminal.isPoint ? aTerminal.bounds.minY - aMinLength : expandedA.minY, b: bTerminal.isPoint ? bTerminal.bounds.maxY + bMinLength : expandedB.maxY, } } // and finally we take the range and the midpoint prop and calculate the actual position of the // midpoint. Note that the midpoint and midpoint range can be null if the gap is too small for a // midpoint line. const midpoint = swapOrder ? 1 - options.elbowMidpoint : options.elbowMidpoint const mx = mxRange ? lerp(mxRange.a, mxRange.b, midpoint) : null const my = myRange ? lerp(myRange.a, myRange.b, midpoint) : null // The info without route is given to the route-finding functions to route between the two // terminals. const info: ElbowArrowInfoWithoutRoute = { options, swapOrder, A: { isPoint: aTerminal.isPoint, target: aTerminal.target, isExact: aTerminal.isExact, arrowheadOffset: aTerminal.arrowheadOffset, minEndSegmentLength: aTerminal.minEndSegmentLength, original: aTerminal.bounds, expanded: expandedA, edges: edgesA, geometry: aTerminal.geometry, }, B: { isPoint: bTerminal.isPoint, target: bTerminal.target, isExact: bTerminal.isExact, arrowheadOffset: bTerminal.arrowheadOffset, minEndSegmentLength: bTerminal.minEndSegmentLength, original: bTerminal.bounds, expanded: expandedB, edges: edgesB, geometry: bTerminal.geometry, }, common, gapX, gapY, midX: mx, midY: my, } // We wrap the info in a working info object that lets us mutate and reset it as needed. const workingInfo = new ElbowArrowWorkingInfo(info) // Figure out the final sides to use for each terminal. const aSide = getSideToUse(aTerminal, bTerminal, info.A.edges) const bSide = getSideToUse(bTerminal, aTerminal, info.B.edges) // try to find a route with the specification we have: let route if (aSide && bSide) { route = routeArrowWithManualEdgePicking(workingInfo, aSide, bSide) } else if (aSide && !bSide) { route = routeArrowWithPartialEdgePicking(workingInfo, aSide) } if (!route) { route = routeArrowWithAutoEdgePicking(workingInfo, aSide || bSide ? 'fallback' : 'auto') } if (route) { // If we found a route, we need to fix it up. The route will only go to the bounding box of // the shape, so we need to cast the final segments into the actual geometry of the shape. castPathSegmentIntoGeometry('first', info.A, info.B, route) castPathSegmentIntoGeometry('last', info.B, info.A, route) // If we have tiny L-shaped arrows, the arrowheads look super janky. We fix those up by just // drawing a straight line instead. fixTinyEndNubs(route, aTerminal, bTerminal) // If we swapped the order way back of the start of things, we need to reverse the route so // it flows start -> end instead of A -> B. if (swapOrder) route.points.reverse() } return { ...info, route, midXRange: mxRange ? swapOrder ? { lo: mxRange.b, hi: mxRange.a } : { lo: mxRange.a, hi: mxRange.b } : null, midYRange: myRange ? swapOrder ? { lo: myRange.b, hi: myRange.a } : { lo: myRange.a, hi: myRange.b } : null, } } /** * Take the route from `getElbowArrowInfo` (which represents the visible body of the arrow) and * convert it into a path we can use to show that paths to the handles, which may extend further * into the target shape geometries. * @returns */ export function getRouteHandlePath(info: ElbowArrowInfo, route: ElbowArrowRoute): ElbowArrowRoute { const startTarget = info.swapOrder ? info.B.target : info.A.target const endTarget = info.swapOrder ? info.A.target : info.B.target const firstSegmentLength = Vec.ManhattanDist(route.points[0], route.points[1]) const lastSegmentLength = Vec.ManhattanDist( route.points[route.points.length - 2], route.points[route.points.length - 1] ) const newFirstSegmentLength = Vec.ManhattanDist(startTarget, route.points[1]) const newLastSegmentLength = Vec.ManhattanDist(route.points[route.points.length - 2], endTarget) const firstSegmentLengthChange = firstSegmentLength - newFirstSegmentLength const lastSegmentLengthChange = lastSegmentLength - newLastSegmentLength const newPoints = [startTarget, ...route.points, endTarget] return { name: route.name, distance: route.distance + firstSegmentLengthChange + lastSegmentLengthChange, points: newPoints.filter((p) => !route.skipPointsWhenDrawing.has(p)), aEdgePicking: route.aEdgePicking, bEdgePicking: route.bEdgePicking, skipPointsWhenDrawing: route.skipPointsWhenDrawing, midpointHandle: route.midpointHandle, } } /** * Take a normalizes anchor and return the side we think it's closest to. */ export function getEdgeFromNormalizedAnchor(normalizedAnchor: VecLike) { if (approximately(normalizedAnchor.x, 0.5) && approximately(normalizedAnchor.y, 0.5)) { return null } if ( Math.abs(normalizedAnchor.x - 0.5) > // slightly bias towards x arrows to prevent flickering when the anchor is right on the line // between the two directions Math.abs(normalizedAnchor.y - 0.5) - 0.0001 ) { return normalizedAnchor.x < 0.5 ? 'left' : 'right' } return normalizedAnchor.y < 0.5 ? 'top' : 'bottom' } function getElbowArrowTerminalInfo( editor: Editor, arrow: TLArrowShape, binding: TLArrowBinding | undefined, point: VecModel, arrowStrokeWidth?: number ): ElbowArrowTerminal { const theme = editor.getCurrentTheme() const arrowSW = arrowStrokeWidth ?? theme.strokeWidth * STROKE_SIZES[arrow.props.size] const arrowStrokeSize = (arrowSW * arrow.props.scale) / 2 const minEndSegmentLength = arrowStrokeSize * 3 if (binding) { const target = editor.getShape(binding.toId) const geometry = getBindingGeometryInArrowSpace(editor, arrow, binding.toId, binding.props) if (geometry && target) { let arrowheadOffset = 0 const arrowheadProp = binding.props.terminal === 'start' ? 'arrowheadStart' : 'arrowheadEnd' if (arrow.props[arrowheadProp] !== 'none') { const targetScale = 'scale' in target.props ? target.props.scale : 1 const targetStrokeSize = 'size' in target.props ? (theme.strokeWidth * (STROKE_SIZES[target.props.size] ?? 0) * targetScale) / 2 : 0 arrowheadOffset = arrowStrokeSize + targetStrokeSize + BOUND_ARROW_OFFSET * arrow.props.scale } let side: ElbowArrowSideWithAxis | null = null const targetPoint = geometry.target if (binding.props.isPrecise) { side = getEdgeFromNormalizedAnchor( Vec.RotWith( binding.props.normalizedAnchor, { x: 0.5, y: 0.5 }, geometry.shapeToArrowTransform.rotation() ) ) } return { targetShapeId: binding.toId, isPoint: false, isExact: binding.props.isExact, bounds: geometry.bounds, geometry: geometry.geometry, target: targetPoint, arrowheadOffset, minEndSegmentLength, side, snap: binding.props.snap, } } } return { targetShapeId: null, bounds: Box.FromCenter(point, { x: 0, y: 0 }), geometry: null, isExact: false, isPoint: true, target: Vec.From(point), arrowheadOffset: 0, minEndSegmentLength, side: null, snap: 'none', } } function getBindingGeometryInArrowSpace( editor: Editor, arrow: TLArrowShape, targetId: TLShapeId, bindingProps: TLArrowBindingProps ) { const hasArrowhead = bindingProps.terminal === 'start' ? arrow.props.arrowheadStart !== 'none' : arrow.props.arrowheadEnd !== 'none' const targetGeometryInTargetSpace = editor.getShapeGeometry( targetId, hasArrowhead ? undefined : { context: '@tldraw/arrow-without-arrowhead' } ) if (!targetGeometryInTargetSpace) { return null } const arrowTransform = editor.getShapePageTransform(arrow.id) const shapeTransform = editor.getShapePageTransform(targetId) const shapeToArrowTransform = arrowTransform.clone().invert().multiply(shapeTransform) const targetGeometryInArrowSpace = targetGeometryInTargetSpace.transform(shapeToArrowTransform) const center = { x: 0.5, y: 0.5 } const normalizedAnchor = bindingProps.isPrecise ? bindingProps.normalizedAnchor : center const targetInShapeSpace = { x: lerp( targetGeometryInTargetSpace.bounds.minX, targetGeometryInTargetSpace.bounds.maxX, normalizedAnchor.x ), y: lerp( targetGeometryInTargetSpace.bounds.minY, targetGeometryInTargetSpace.bounds.maxY, normalizedAnchor.y ), } const centerInShapeSpace = { x: lerp( targetGeometryInTargetSpace.bounds.minX, targetGeometryInTargetSpace.bounds.maxX, center.x ), y: lerp( targetGeometryInTargetSpace.bounds.minY, targetGeometryInTargetSpace.bounds.maxY, center.y ), } const targetInArrowSpace = Mat.applyToPoint(shapeToArrowTransform, targetInShapeSpace) const centerInArrowSpace = Mat.applyToPoint(shapeToArrowTransform, centerInShapeSpace) return { bounds: targetGeometryInArrowSpace.bounds, geometry: targetGeometryInArrowSpace, target: targetInArrowSpace, center: centerInArrowSpace, shapeToArrowTransform, } } const sideProps = { top: { expand: -1, main: 'minY', opposite: 'maxY', crossMid: 'midX', crossMin: 'minX', crossMax: 'maxX', bRangeExpand: 'max', crossAxis: 'x', }, bottom: { expand: 1, main: 'maxY', opposite: 'minY', crossMid: 'midX', crossMin: 'minX', crossMax: 'maxX', bRangeExpand: 'min', crossAxis: 'x', }, left: { expand: -1, main: 'minX', opposite: 'maxX', crossMid: 'midY', crossMin: 'minY', crossMax: 'maxY', bRangeExpand: 'max', crossAxis: 'y', }, right: { expand: 1, main: 'maxX', opposite: 'minX', crossMid: 'midY', crossMin: 'minY', crossMax: 'maxY', bRangeExpand: 'min', crossAxis: 'y', }, } as const export function getUsableEdge( a: ElbowArrowTerminal, b: ElbowArrowTerminal, side: 'top' | 'right' | 'bottom' | 'left', options: ElbowArrowOptions ): ElbowArrowEdge | null { const props = sideProps[side] // if a shape is bound to itself, by default we'd end up routing the arrow _within_ the shape - // as if it were a point-to-point arrow. if one of the bindings is specifically to the edge // though, we route it externally instead. const isSelfBoundAndShouldRouteExternal = a.targetShapeId === b.targetShapeId && a.targetShapeId !== null && (a.snap === 'edge' || a.snap === 'edge-point') && (b.snap === 'edge' || b.snap === 'edge-point') const aValue = a.bounds[props.main] const aExpanded = a.isPoint ? null : aValue + props.expand * options.expandElbowLegLength const originalACrossRange = createRange(a.bounds[props.crossMin], a.bounds[props.crossMax]) let aCrossRange = originalACrossRange // this edge is too small to be useful: if (!aCrossRange) { return null } assert(originalACrossRange) const bRange = createRange(b.bounds[props.main], b.bounds[props.opposite]) if (!b.isPoint) { bRange[props.bRangeExpand] -= options.minElbowLegLength * 2 * props.expand } const bCrossRange = expandRange( createRange(b.bounds[props.crossMin], b.bounds[props.crossMax]), options.expandElbowLegLength ) assert(bRange && bCrossRange) let isPartial = false if ( isWithinRange(aValue, bRange) && !a.isPoint && !b.isPoint && !isSelfBoundAndShouldRouteExternal ) { const subtracted = subtractRange(aCrossRange, bCrossRange) switch (subtracted.length) { case 0: return null case 1: isPartial = subtracted[0] !== aCrossRange aCrossRange = subtracted[0] break case 2: isPartial = true aCrossRange = rangeSize(subtracted[0]) > rangeSize(subtracted[1]) ? subtracted[0] : subtracted[1] break default: exhaustiveSwitchError(subtracted) } } if (!isWithinRange(a.target[props.crossAxis], aCrossRange)) { return null } const crossTarget = a.target[props.crossAxis] return { value: aValue, expanded: aExpanded, cross: aCrossRange, crossTarget, isPartial, } } function hasUsableEdge(edges: ElbowArrowBoxEdges, side: ElbowArrowSideWithAxis | null) { if (side === null) { return !!(edges.bottom || edges.left || edges.right || edges.top) } if (side === 'x') { return !!edges.left || !!edges.right } if (side === 'y') { return !!edges.top || !!edges.bottom } return !!edges[side] } function getSideToUse( binding: ElbowArrowTerminal, other: ElbowArrowTerminal, edges: ElbowArrowBoxEdges | null ): ElbowArrowSide | null { switch (binding.side) { case null: return null case 'x': if (binding.bounds.center.x > other.bounds.center.x && edges?.left) { return 'left' } else if (edges?.right) { return 'right' } return null case 'y': if (binding.bounds.center.y > other.bounds.center.y && edges?.top) { return 'top' } else if (edges?.bottom) { return 'bottom' } return null default: return binding.side } } function convertTerminalToPoint(terminal: ElbowArrowTerminal): ElbowArrowTerminal { if (terminal.isPoint) return terminal let side: ElbowArrowSideWithAxis | null = null let arrowheadOffset = 0 if (terminal.snap === 'edge' || terminal.snap === 'edge-point') { arrowheadOffset = terminal.arrowheadOffset if (terminal.side === 'x' || terminal.side === 'left' || terminal.side === 'right') { side = 'x' } if (terminal.side === 'y' || terminal.side === 'top' || terminal.side === 'bottom') { side = 'y' } } return { targetShapeId: terminal.targetShapeId, side, bounds: new Box(terminal.target.x, terminal.target.y, 0, 0), geometry: terminal.geometry, target: terminal.target, arrowheadOffset, minEndSegmentLength: terminal.minEndSegmentLength, isExact: terminal.isExact, isPoint: true, snap: terminal.snap, } } /** * Make sure the first path segments goes fully into the target, and doesn't just point to its * bounding box. This modifies the route in-place. */ function castPathSegmentIntoGeometry( segment: 'first' | 'last', target: ElbowArrowTargetBox, other: ElbowArrowTargetBox, route: ElbowArrowRoute ) { if (!target.geometry) return const point1 = segment === 'first' ? route.points[0] : route.points[route.points.length - 1] const point2 = segment === 'first' ? route.points[1] : route.points[route.points.length - 2] const pointToFindClosestIntersectionTo = target.geometry.isClosed ? point2 : target.target const initialDistance = Vec.ManhattanDist(point1, pointToFindClosestIntersectionTo) let nearestIntersectionToPoint2: VecLike | null = null let nearestDistanceToPoint2 = Infinity if (target.isExact) { nearestIntersectionToPoint2 = target.target } else if (target.geometry) { const intersections = target.geometry.intersectLineSegment(point2, target.target, { includeLabels: false, includeInternal: false, }) if ( target.geometry.hitTestPoint( target.target, Math.max(1, target.arrowheadOffset), true, Geometry2dFilters.EXCLUDE_NON_STANDARD ) ) { intersections.push(target.target) } for (const intersection of intersections) { const point2Distance = Vec.ManhattanDist(pointToFindClosestIntersectionTo, intersection) if (point2Distance < nearestDistanceToPoint2) { nearestDistanceToPoint2 = point2Distance nearestIntersectionToPoint2 = intersection } } } if (nearestIntersectionToPoint2) { let offset = target.arrowheadOffset const currentFinalSegmentLength = Vec.ManhattanDist(point2, nearestIntersectionToPoint2) const minLength = target.arrowheadOffset * 2 if (currentFinalSegmentLength < minLength) { const targetLength = minLength - target.arrowheadOffset offset = currentFinalSegmentLength - targetLength } if (offset < target.minEndSegmentLength) { if (target.geometry.bounds.containsPoint(other.target)) { offset = Math.max(0, offset) } else { offset = -target.arrowheadOffset } } let nudgedPoint = nearestIntersectionToPoint2 let shouldAddExtraPointForNudge = false if (!target.isExact && offset !== 0) { const nudged = Vec.Nudge(nearestIntersectionToPoint2, point2, offset) nudgedPoint = nudged if ( offset < 0 && !target.geometry.hitTestPoint(nudged, 0, true, Geometry2dFilters.EXCLUDE_NON_STANDARD) ) { // point has been nudged _out_ of the shape so lets not actually apply the nudge nudgedPoint = nearestIntersectionToPoint2 } else { if (offset < 0) { shouldAddExtraPointForNudge = true } nudgedPoint = nudged } } const newDistance = Vec.ManhattanDist(point2, nudgedPoint) route.distance += newDistance - initialDistance point1.x = nudgedPoint.x point1.y = nudgedPoint.y if (shouldAddExtraPointForNudge) { const midPoint = Vec.Lrp(point2, point1, 0.5) route.skipPointsWhenDrawing.add(midPoint) route.points.splice(segment === 'first' ? 1 : route.points.length - 1, 0, midPoint) } } } function fixTinyEndNubs( route: ElbowArrowRoute, aTerminal: ElbowArrowTerminal, bTerminal: ElbowArrowTerminal ) { if (!route) return if (route.points.length >= 3) { const a = route.points[0] const b = route.points[1] const firstSegmentLength = Vec.ManhattanDist(a, b) if (firstSegmentLength < aTerminal.minEndSegmentLength) { route.points.splice(1, 1) if (route.points.length >= 3) { const matchAxis = approximately(a.x, b.x) ? 'y' : 'x' route.points[1][matchAxis] = a[matchAxis] } } } if (route.points.length >= 3) { const a = route.points[route.points.length - 1] const b = route.points[route.points.length - 2] const lastSegmentLength = Vec.ManhattanDist(a, b) if (lastSegmentLength < bTerminal.minEndSegmentLength) { route.points.splice(route.points.length - 2, 1) if (route.points.length >= 3) { const matchAxis = approximately(a.x, b.x) ? 'y' : 'x' route.points[route.points.length - 2][matchAxis] = a[matchAxis] } } } } function adjustTerminalForUnclosedPathIfNeeded( terminal: ElbowArrowTerminal, otherTerminal: ElbowArrowTerminal, options: ElbowArrowOptions ): ElbowArrowTerminal { if (!terminal.geometry || terminal.geometry.isClosed) return terminal const normalizedPointAlongPath = terminal.geometry.uninterpolateAlongEdge( terminal.target, Geometry2dFilters.EXCLUDE_NON_STANDARD ) const prev = terminal.geometry.interpolateAlongEdge( normalizedPointAlongPath - 0.01 / terminal.geometry.length ) const next = terminal.geometry.interpolateAlongEdge( normalizedPointAlongPath + 0.01 / terminal.geometry.length ) const normal = next.sub(prev).per().uni() const axis = Math.abs(normal.x) > Math.abs(normal.y) ? ElbowArrowAxes.x : ElbowArrowAxes.y if (terminal.geometry.bounds.containsPoint(otherTerminal.target, options.expandElbowLegLength)) { terminal.side = axis.self return convertTerminalToPoint(terminal) } const min = axis.v( terminal.target[axis.self] - terminal.bounds[axis.size] * 2, terminal.target[axis.cross] ) const max = axis.v( terminal.target[axis.self] + terminal.bounds[axis.size] * 2, terminal.target[axis.cross] ) let furthestIntersectionTowardsMin: VecLike | null = null let furthestIntersectionTowardsMinDistance = 0 let furthestIntersectionTowardsMax: VecLike | null = null let furthestIntersectionTowardsMaxDistance = 0 let side: ElbowArrowSideWithAxis = axis.self for (const intersection of terminal.geometry.intersectLineSegment( min, max, Geometry2dFilters.EXCLUDE_NON_STANDARD )) { if (Math.abs(intersection[axis.self] - terminal.target[axis.self]) < 1) { continue } if (intersection[axis.self] < terminal.target[axis.self]) { if ( Vec.ManhattanDist(intersection, terminal.target) > furthestIntersectionTowardsMinDistance ) { furthestIntersectionTowardsMinDistance = Vec.ManhattanDist(intersection, terminal.target) furthestIntersectionTowardsMin = intersection } } else { if ( Vec.ManhattanDist(intersection, terminal.target) > furthestIntersectionTowardsMaxDistance ) { furthestIntersectionTowardsMaxDistance = Vec.ManhattanDist(intersection, terminal.target) furthestIntersectionTowardsMax = intersection } } } if (furthestIntersectionTowardsMin && furthestIntersectionTowardsMax) { if (furthestIntersectionTowardsMinDistance > furthestIntersectionTowardsMaxDistance) { side = axis.hiEdge } else { side = axis.loEdge } } else if (furthestIntersectionTowardsMin && !furthestIntersectionTowardsMax) { side = axis.hiEdge } else if (!furthestIntersectionTowardsMin && furthestIntersectionTowardsMax) { side = axis.loEdge } terminal.side = side return terminal }