diff --git a/d2renderers/d2sketch/sketch.go b/d2renderers/d2sketch/sketch.go index 3ab9e8c27..edb8f547c 100644 --- a/d2renderers/d2sketch/sketch.go +++ b/d2renderers/d2sketch/sketch.go @@ -321,29 +321,70 @@ func Paths(r *Runner, shape d2target.Shape, paths []string) (string, error) { } func Connection(r *Runner, connection d2target.Connection, path, attrs string) (string, error) { - roughness := 0.5 - js := fmt.Sprintf(`node = rc.path("%s", {roughness: %f, seed: 1});`, path, roughness) - paths, err := computeRoughPathData(r, js) - if err != nil { - return "", err - } - output := "" animatedClass := "" if connection.Animated { animatedClass = " animated-connection" } - pathEl := d2themes.NewThemableElement("path") - pathEl.Fill = color.None - pathEl.Stroke = connection.Stroke - pathEl.ClassName = fmt.Sprintf("connection%s", animatedClass) - pathEl.Style = connection.CSSStyle() - pathEl.Attributes = attrs - for _, p := range paths { - pathEl.D = p - output += pathEl.Render() + if connection.Animated { + // If connection is animated and bidirectional + if (connection.DstArrow == d2target.NoArrowhead && connection.SrcArrow == d2target.NoArrowhead) || (connection.DstArrow != d2target.NoArrowhead && connection.SrcArrow != d2target.NoArrowhead) { + // There is no pure CSS way to animate bidirectional connections in two directions, so we split it up + path1, path2, err := svg.SplitPath(path, 0.5) + + if err != nil { + return "", err + } + + pathEl1 := d2themes.NewThemableElement("path") + pathEl1.D = path1 + pathEl1.Fill = color.None + pathEl1.Stroke = connection.Stroke + pathEl1.ClassName = fmt.Sprintf("connection%s", animatedClass) + pathEl1.Style = connection.CSSStyle() + pathEl1.Style += "animation-direction: reverse;" + pathEl1.Attributes = attrs + + pathEl2 := d2themes.NewThemableElement("path") + pathEl2.D = path2 + pathEl2.Fill = color.None + pathEl2.Stroke = connection.Stroke + pathEl2.ClassName = fmt.Sprintf("connection%s", animatedClass) + pathEl2.Style = connection.CSSStyle() + pathEl2.Attributes = attrs + return pathEl1.Render() + " " + pathEl2.Render(), nil + } else { + pathEl := d2themes.NewThemableElement("path") + pathEl.D = path + pathEl.Fill = color.None + pathEl.Stroke = connection.Stroke + pathEl.ClassName = fmt.Sprintf("connection%s", animatedClass) + pathEl.Style = connection.CSSStyle() + pathEl.Attributes = attrs + return pathEl.Render(), nil + } + } else { + roughness := 0.5 + js := fmt.Sprintf(`node = rc.path("%s", {roughness: %f, seed: 1});`, path, roughness) + paths, err := computeRoughPathData(r, js) + if err != nil { + return "", err + } + + output := "" + + pathEl := d2themes.NewThemableElement("path") + pathEl.Fill = color.None + pathEl.Stroke = connection.Stroke + pathEl.ClassName = fmt.Sprintf("connection%s", animatedClass) + pathEl.Style = connection.CSSStyle() + pathEl.Attributes = attrs + for _, p := range paths { + pathEl.D = p + output += pathEl.Render() + } + return output, nil } - return output, nil } // TODO cleanup diff --git a/d2renderers/d2sketch/testdata/animated/sketch.exp.svg b/d2renderers/d2sketch/testdata/animated/sketch.exp.svg index fecf1bfd8..819d95394 100644 --- a/d2renderers/d2sketch/testdata/animated/sketch.exp.svg +++ b/d2renderers/d2sketch/testdata/animated/sketch.exp.svg @@ -110,7 +110,7 @@ -wintersummertreessnowsun +wintersummertreessnowsun diff --git a/d2renderers/d2sketch/testdata/animated_dark/sketch.exp.svg b/d2renderers/d2sketch/testdata/animated_dark/sketch.exp.svg index 1178e671f..4b24c1970 100644 --- a/d2renderers/d2sketch/testdata/animated_dark/sketch.exp.svg +++ b/d2renderers/d2sketch/testdata/animated_dark/sketch.exp.svg @@ -108,7 +108,7 @@ -wintersummertreessnowsun +wintersummertreessnowsun diff --git a/d2renderers/d2svg/d2svg.go b/d2renderers/d2svg/d2svg.go index 9faf2a361..9360c3eed 100644 --- a/d2renderers/d2svg/d2svg.go +++ b/d2renderers/d2svg/d2svg.go @@ -11,7 +11,6 @@ import ( "html" "io" "sort" - "strconv" "strings" "math" @@ -495,178 +494,6 @@ func makeLabelMask(labelTL *geo.Point, width, height int, opacity float64) strin ) } -// Gets a certain line/curve's SVG path string. offsetIdx and pathData provides the points needed -func getSVGPathString(pathType string, offsetIdx int, pathData []string) (string, error) { - switch pathType { - case "M": - return fmt.Sprintf("M %s %s ", pathData[offsetIdx+1], pathData[offsetIdx+2]), nil - case "L": - return fmt.Sprintf("L %s %s ", pathData[offsetIdx+1], pathData[offsetIdx+2]), nil - case "C": - return fmt.Sprintf("C %s %s %s %s %s %s ", pathData[offsetIdx+1], pathData[offsetIdx+2], pathData[offsetIdx+3], pathData[offsetIdx+4], pathData[offsetIdx+5], pathData[offsetIdx+6]), nil - case "S": - return fmt.Sprintf("S %s %s %s %s ", pathData[offsetIdx+1], pathData[offsetIdx+2], pathData[offsetIdx+3], pathData[offsetIdx+4]), nil - default: - return "", fmt.Errorf("unknown svg path command \"%s\"", pathData[offsetIdx]) - } -} - -// Gets how much to increment by on an SVG string to get to the next path command -func getPathStringIncrement(pathType string) (int, error) { - switch pathType { - case "M": - return 3, nil - case "L": - return 3, nil - case "C": - return 7, nil - case "S": - return 5, nil - default: - return 0, fmt.Errorf("unknown svg path command \"%s\"", pathType) - } -} - -// This function finds the length of a path in SVG notation -func pathLength(pathData []string) (float64, error) { - var x, y, pathLength float64 - var prevPosition geo.Point - var increment int - - for i := 0; i < len(pathData); i += increment { - switch pathData[i] { - case "M": - x, _ = strconv.ParseFloat(pathData[i+1], 64) - y, _ = strconv.ParseFloat(pathData[i+2], 64) - case "L": - x, _ = strconv.ParseFloat(pathData[i+1], 64) - y, _ = strconv.ParseFloat(pathData[i+2], 64) - - pathLength += geo.EuclideanDistance(prevPosition.X, prevPosition.Y, x, y) - case "C": - x, _ = strconv.ParseFloat(pathData[i+5], 64) - y, _ = strconv.ParseFloat(pathData[i+6], 64) - - pathLength += geo.EuclideanDistance(prevPosition.X, prevPosition.Y, x, y) - case "S": - x, _ = strconv.ParseFloat(pathData[i+3], 64) - y, _ = strconv.ParseFloat(pathData[i+4], 64) - - pathLength += geo.EuclideanDistance(prevPosition.X, prevPosition.Y, x, y) - default: - return 0, fmt.Errorf("unknown svg path command \"%s\"", pathData[i]) - } - - prevPosition = geo.Point{X: x, Y: y} - - incr, err := getPathStringIncrement(pathData[i]) - - if err != nil { - return 0, err - } - - increment = incr - } - - return pathLength, nil -} - -// Splits an SVG path into two SVG paths, with the first path being ~{percentage}% of the path -func splitPath(path string, percentage float64) (string, string, error) { - var sumPathLens, curPathLen, x, y float64 - var prevPosition geo.Point - var path1, path2 string - var increment int - - pastHalf := false - pathData := strings.Split(path, " ") - pathLen, err := pathLength(pathData) - - if err != nil { - return "", "", err - } - - for i := 0; i < len(pathData); i += increment { - switch pathData[i] { - case "M": - x, _ = strconv.ParseFloat(pathData[i+1], 64) - y, _ = strconv.ParseFloat(pathData[i+2], 64) - case "L": - x, _ = strconv.ParseFloat(pathData[i+1], 64) - y, _ = strconv.ParseFloat(pathData[i+2], 64) - - curPathLen = geo.EuclideanDistance(prevPosition.X, prevPosition.Y, x, y) - case "C": - x, _ = strconv.ParseFloat(pathData[i+5], 64) - y, _ = strconv.ParseFloat(pathData[i+6], 64) - - curPathLen = geo.EuclideanDistance(prevPosition.X, prevPosition.Y, x, y) - case "S": - x, _ = strconv.ParseFloat(pathData[i+3], 64) - y, _ = strconv.ParseFloat(pathData[i+4], 64) - - curPathLen = geo.EuclideanDistance(prevPosition.X, prevPosition.Y, x, y) - default: - return "", "", fmt.Errorf("unknown svg path command \"%s\"", pathData[i]) - } - - curPath, err := getSVGPathString(pathData[i], i, pathData) - if err != nil { - return "", "", err - } - - sumPathLens += curPathLen - - if pastHalf { // add to path2 - path2 += curPath - } else if sumPathLens < pathLen*percentage { // add to path1 - path1 += curPath - } else { // transition from path1 -> path2 - t := (pathLen*percentage - sumPathLens + curPathLen) / curPathLen - - switch pathData[i] { - case "L": - path1 += fmt.Sprintf("L %f %f ", (x-prevPosition.X)*t+prevPosition.X, (y-prevPosition.Y)*t+prevPosition.Y) - path2 += fmt.Sprintf("M %f %f L %f %f ", (x-prevPosition.X)*t+prevPosition.X, (y-prevPosition.Y)*t+prevPosition.Y, x, y) - case "C": - h1x, _ := strconv.ParseFloat(pathData[i+1], 64) - h1y, _ := strconv.ParseFloat(pathData[i+2], 64) - h2x, _ := strconv.ParseFloat(pathData[i+3], 64) - h2y, _ := strconv.ParseFloat(pathData[i+4], 64) - - heading1 := geo.Point{X: h1x, Y: h1y} - heading2 := geo.Point{X: h2x, Y: h2y} - nextPoint := geo.Point{X: x, Y: y} - - q1, q2, q3, q4 := svg.BezierCurveSegment(&prevPosition, &heading1, &heading2, &nextPoint, 0, 0.5) - path1 += fmt.Sprintf("C %f %f %f %f %f %f ", q2.X, q2.Y, q3.X, q3.Y, q4.X, q4.Y) - - q1, q2, q3, q4 = svg.BezierCurveSegment(&prevPosition, &heading1, &heading2, &nextPoint, 0.5, 1) - path2 += fmt.Sprintf("M %f %f C %f %f %f %f %f %f ", q1.X, q1.Y, q2.X, q2.Y, q3.X, q3.Y, q4.X, q4.Y) - case "S": - // Skip S curves because they are shorter and we can split along the connection to the next path instead - path1 += fmt.Sprintf("S %s %s %s %s ", pathData[i+1], pathData[i+2], pathData[i+3], pathData[i+4]) - path2 += fmt.Sprintf("M %s %s ", pathData[i+3], pathData[i+4]) - default: - return "", "", fmt.Errorf("unknown svg path command \"%s\"", pathData[i]) - } - - pastHalf = true - } - - incr, err := getPathStringIncrement(pathData[i]) - - if err != nil { - return "", "", err - } - - increment = incr - prevPosition = geo.Point{X: x, Y: y} - } - - return path1, path2, nil -} - func drawConnection(writer io.Writer, labelMaskID string, connection d2target.Connection, markers map[string]struct{}, idToShape map[string]d2target.Shape, sketchRunner *d2sketch.Runner) (labelMask string, _ error) { opacityStyle := "" if connection.Opacity != 1.0 { @@ -745,7 +572,7 @@ func drawConnection(writer io.Writer, labelMaskID string, connection d2target.Co // If connection is animated and bidirectional if connection.Animated && ((connection.DstArrow == d2target.NoArrowhead && connection.SrcArrow == d2target.NoArrowhead) || (connection.DstArrow != d2target.NoArrowhead && connection.SrcArrow != d2target.NoArrowhead)) { // There is no pure CSS way to animate bidirectional connections in two directions, so we split it up - path1, path2, err := splitPath(path, 0.5) + path1, path2, err := svg.SplitPath(path, 0.5) if err != nil { return "", err diff --git a/e2etests/testdata/txtar.txt b/e2etests/testdata/txtar.txt index 19692fcd8..263bfa561 100644 --- a/e2etests/testdata/txtar.txt +++ b/e2etests/testdata/txtar.txt @@ -214,7 +214,7 @@ ok: { dog1 -> dog3 } --- bidirectional_connection_animation -- +-- bidirectional-connection-animation -- a <-> b: {style.animated: true} a <-> c: {style.animated: true} a <-> d: {style.animated: true} @@ -266,3 +266,17 @@ x <-> y <-> z: { } direction: right } + +-- sketch-bidirectional-connection-animation -- +vars: { + d2-config: { + sketch: true + } +} + +a <-> b: {style.animated: true} +a <-> c: {style.animated: true} +a <-> d: {style.animated: true} +a <-> e +f <-> g: {style.animated: true} +x -- x: {style.animated: true} \ No newline at end of file diff --git a/e2etests/testdata/txtar/bidirectional_connection_animation/dagre/board.exp.json b/e2etests/testdata/txtar/bidirectional-connection-animation/dagre/board.exp.json similarity index 100% rename from e2etests/testdata/txtar/bidirectional_connection_animation/dagre/board.exp.json rename to e2etests/testdata/txtar/bidirectional-connection-animation/dagre/board.exp.json diff --git a/e2etests/testdata/txtar/bidirectional_connection_animation/dagre/sketch.exp.svg b/e2etests/testdata/txtar/bidirectional-connection-animation/dagre/sketch.exp.svg similarity index 100% rename from e2etests/testdata/txtar/bidirectional_connection_animation/dagre/sketch.exp.svg rename to e2etests/testdata/txtar/bidirectional-connection-animation/dagre/sketch.exp.svg diff --git a/e2etests/testdata/txtar/bidirectional_connection_animation/elk/board.exp.json b/e2etests/testdata/txtar/bidirectional-connection-animation/elk/board.exp.json similarity index 100% rename from e2etests/testdata/txtar/bidirectional_connection_animation/elk/board.exp.json rename to e2etests/testdata/txtar/bidirectional-connection-animation/elk/board.exp.json diff --git a/e2etests/testdata/txtar/bidirectional_connection_animation/elk/sketch.exp.svg b/e2etests/testdata/txtar/bidirectional-connection-animation/elk/sketch.exp.svg similarity index 100% rename from e2etests/testdata/txtar/bidirectional_connection_animation/elk/sketch.exp.svg rename to e2etests/testdata/txtar/bidirectional-connection-animation/elk/sketch.exp.svg diff --git a/e2etests/testdata/txtar/sketch-bidirectional-connection-animation/dagre/board.exp.json b/e2etests/testdata/txtar/sketch-bidirectional-connection-animation/dagre/board.exp.json new file mode 100644 index 000000000..d4be34b71 --- /dev/null +++ b/e2etests/testdata/txtar/sketch-bidirectional-connection-animation/dagre/board.exp.json @@ -0,0 +1,703 @@ +{ + "name": "", + "config": { + "sketch": true, + "themeID": null, + "darkThemeID": null, + "pad": null, + "center": null, + "layoutEngine": null + }, + "isFolderOnly": false, + "fontFamily": "HandDrawn", + "shapes": [ + { + "id": "a", + "type": "rectangle", + "pos": { + "x": 172, + "y": 0 + }, + "width": 54, + "height": 66, + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "borderRadius": 0, + "fill": "B6", + "stroke": "B1", + "shadow": false, + "3d": false, + "multiple": false, + "double-border": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "blend": false, + "fields": null, + "methods": null, + "columns": null, + "label": "a", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N1", + "italic": false, + "bold": true, + "underline": false, + "labelWidth": 9, + "labelHeight": 21, + "labelPosition": "INSIDE_MIDDLE_CENTER", + "zIndex": 0, + "level": 1 + }, + { + "id": "b", + "type": "rectangle", + "pos": { + "x": 0, + "y": 166 + }, + "width": 55, + "height": 66, + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "borderRadius": 0, + "fill": "B6", + "stroke": "B1", + "shadow": false, + "3d": false, + "multiple": false, + "double-border": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "blend": false, + "fields": null, + "methods": null, + "columns": null, + "label": "b", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N1", + "italic": false, + "bold": true, + "underline": false, + "labelWidth": 10, + "labelHeight": 21, + "labelPosition": "INSIDE_MIDDLE_CENTER", + "zIndex": 0, + "level": 1 + }, + { + "id": "c", + "type": "rectangle", + "pos": { + "x": 115, + "y": 166 + }, + "width": 54, + "height": 66, + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "borderRadius": 0, + "fill": "B6", + "stroke": "B1", + "shadow": false, + "3d": false, + "multiple": false, + "double-border": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "blend": false, + "fields": null, + "methods": null, + "columns": null, + "label": "c", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N1", + "italic": false, + "bold": true, + "underline": false, + "labelWidth": 9, + "labelHeight": 21, + "labelPosition": "INSIDE_MIDDLE_CENTER", + "zIndex": 0, + "level": 1 + }, + { + "id": "d", + "type": "rectangle", + "pos": { + "x": 229, + "y": 166 + }, + "width": 55, + "height": 66, + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "borderRadius": 0, + "fill": "B6", + "stroke": "B1", + "shadow": false, + "3d": false, + "multiple": false, + "double-border": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "blend": false, + "fields": null, + "methods": null, + "columns": null, + "label": "d", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N1", + "italic": false, + "bold": true, + "underline": false, + "labelWidth": 10, + "labelHeight": 21, + "labelPosition": "INSIDE_MIDDLE_CENTER", + "zIndex": 0, + "level": 1 + }, + { + "id": "e", + "type": "rectangle", + "pos": { + "x": 344, + "y": 166 + }, + "width": 53, + "height": 66, + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "borderRadius": 0, + "fill": "B6", + "stroke": "B1", + "shadow": false, + "3d": false, + "multiple": false, + "double-border": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "blend": false, + "fields": null, + "methods": null, + "columns": null, + "label": "e", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N1", + "italic": false, + "bold": true, + "underline": false, + "labelWidth": 8, + "labelHeight": 21, + "labelPosition": "INSIDE_MIDDLE_CENTER", + "zIndex": 0, + "level": 1 + }, + { + "id": "f", + "type": "rectangle", + "pos": { + "x": 457, + "y": 0 + }, + "width": 54, + "height": 66, + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "borderRadius": 0, + "fill": "B6", + "stroke": "B1", + "shadow": false, + "3d": false, + "multiple": false, + "double-border": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "blend": false, + "fields": null, + "methods": null, + "columns": null, + "label": "f", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N1", + "italic": false, + "bold": true, + "underline": false, + "labelWidth": 9, + "labelHeight": 21, + "labelPosition": "INSIDE_MIDDLE_CENTER", + "zIndex": 0, + "level": 1 + }, + { + "id": "g", + "type": "rectangle", + "pos": { + "x": 457, + "y": 166 + }, + "width": 54, + "height": 66, + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "borderRadius": 0, + "fill": "B6", + "stroke": "B1", + "shadow": false, + "3d": false, + "multiple": false, + "double-border": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "blend": false, + "fields": null, + "methods": null, + "columns": null, + "label": "g", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N1", + "italic": false, + "bold": true, + "underline": false, + "labelWidth": 9, + "labelHeight": 21, + "labelPosition": "INSIDE_MIDDLE_CENTER", + "zIndex": 0, + "level": 1 + }, + { + "id": "x", + "type": "rectangle", + "pos": { + "x": 571, + "y": 0 + }, + "width": 54, + "height": 66, + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "borderRadius": 0, + "fill": "B6", + "stroke": "B1", + "shadow": false, + "3d": false, + "multiple": false, + "double-border": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "blend": false, + "fields": null, + "methods": null, + "columns": null, + "label": "x", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N1", + "italic": false, + "bold": true, + "underline": false, + "labelWidth": 9, + "labelHeight": 21, + "labelPosition": "INSIDE_MIDDLE_CENTER", + "zIndex": 0, + "level": 1 + } + ], + "connections": [ + { + "id": "(a <-> b)[0]", + "src": "a", + "srcArrow": "triangle", + "dst": "b", + "dstArrow": "triangle", + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "stroke": "B1", + "borderRadius": 10, + "label": "", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N2", + "italic": true, + "bold": false, + "underline": false, + "labelWidth": 0, + "labelHeight": 0, + "labelPosition": "", + "labelPercentage": 0, + "route": [ + { + "x": 172.5, + "y": 46 + }, + { + "x": 56.5, + "y": 102 + }, + { + "x": 27.5, + "y": 126 + }, + { + "x": 27.5, + "y": 166 + } + ], + "isCurve": true, + "animated": true, + "tooltip": "", + "icon": null, + "zIndex": 0 + }, + { + "id": "(a <-> c)[0]", + "src": "a", + "srcArrow": "triangle", + "dst": "c", + "dstArrow": "triangle", + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "stroke": "B1", + "borderRadius": 10, + "label": "", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N2", + "italic": true, + "bold": false, + "underline": false, + "labelWidth": 0, + "labelHeight": 0, + "labelPosition": "", + "labelPercentage": 0, + "route": [ + { + "x": 176, + "y": 66 + }, + { + "x": 148.8000030517578, + "y": 106 + }, + { + "x": 142, + "y": 126 + }, + { + "x": 142, + "y": 166 + } + ], + "isCurve": true, + "animated": true, + "tooltip": "", + "icon": null, + "zIndex": 0 + }, + { + "id": "(a <-> d)[0]", + "src": "a", + "srcArrow": "triangle", + "dst": "d", + "dstArrow": "triangle", + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "stroke": "B1", + "borderRadius": 10, + "label": "", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N2", + "italic": true, + "bold": false, + "underline": false, + "labelWidth": 0, + "labelHeight": 0, + "labelPosition": "", + "labelPercentage": 0, + "route": [ + { + "x": 222.5, + "y": 66 + }, + { + "x": 249.6999969482422, + "y": 106 + }, + { + "x": 256.5, + "y": 126 + }, + { + "x": 256.5, + "y": 166 + } + ], + "isCurve": true, + "animated": true, + "tooltip": "", + "icon": null, + "zIndex": 0 + }, + { + "id": "(a <-> e)[0]", + "src": "a", + "srcArrow": "triangle", + "dst": "e", + "dstArrow": "triangle", + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "stroke": "B1", + "borderRadius": 10, + "label": "", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N2", + "italic": true, + "bold": false, + "underline": false, + "labelWidth": 0, + "labelHeight": 0, + "labelPosition": "", + "labelPercentage": 0, + "route": [ + { + "x": 226.25, + "y": 46.08599853515625 + }, + { + "x": 341.6499938964844, + "y": 102.01699829101562 + }, + { + "x": 370.5, + "y": 126 + }, + { + "x": 370.5, + "y": 166 + } + ], + "isCurve": true, + "animated": false, + "tooltip": "", + "icon": null, + "zIndex": 0 + }, + { + "id": "(f <-> g)[0]", + "src": "f", + "srcArrow": "triangle", + "dst": "g", + "dstArrow": "triangle", + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "stroke": "B1", + "borderRadius": 10, + "label": "", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N2", + "italic": true, + "bold": false, + "underline": false, + "labelWidth": 0, + "labelHeight": 0, + "labelPosition": "", + "labelPercentage": 0, + "route": [ + { + "x": 484, + "y": 66 + }, + { + "x": 484, + "y": 106 + }, + { + "x": 484, + "y": 126 + }, + { + "x": 484, + "y": 166 + } + ], + "isCurve": true, + "animated": true, + "tooltip": "", + "icon": null, + "zIndex": 0 + }, + { + "id": "(x -- x)[0]", + "src": "x", + "srcArrow": "none", + "dst": "x", + "dstArrow": "none", + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "stroke": "B1", + "borderRadius": 10, + "label": "", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N2", + "italic": true, + "bold": false, + "underline": false, + "labelWidth": 0, + "labelHeight": 0, + "labelPosition": "", + "labelPercentage": 0, + "route": [ + { + "x": 624.666015625, + "y": 16 + }, + { + "x": 646.2659912109375, + "y": 3.1989998817443848 + }, + { + "x": 653, + "y": 0 + }, + { + "x": 655, + "y": 0 + }, + { + "x": 657, + "y": 0 + }, + { + "x": 659.666015625, + "y": 6.599999904632568 + }, + { + "x": 661.666015625, + "y": 16.5 + }, + { + "x": 663.666015625, + "y": 26.399999618530273 + }, + { + "x": 663.666015625, + "y": 39.599998474121094 + }, + { + "x": 661.666015625, + "y": 49.5 + }, + { + "x": 659.666015625, + "y": 59.400001525878906 + }, + { + "x": 646.2659912109375, + "y": 62.79999923706055 + }, + { + "x": 624.666015625, + "y": 50 + } + ], + "isCurve": true, + "animated": true, + "tooltip": "", + "icon": null, + "zIndex": 0 + } + ], + "root": { + "id": "", + "type": "", + "pos": { + "x": 0, + "y": 0 + }, + "width": 0, + "height": 0, + "opacity": 0, + "strokeDash": 0, + "strokeWidth": 0, + "borderRadius": 0, + "fill": "N7", + "stroke": "", + "shadow": false, + "3d": false, + "multiple": false, + "double-border": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "blend": false, + "fields": null, + "methods": null, + "columns": null, + "label": "", + "fontSize": 0, + "fontFamily": "", + "language": "", + "color": "", + "italic": false, + "bold": false, + "underline": false, + "labelWidth": 0, + "labelHeight": 0, + "zIndex": 0, + "level": 0 + } +} diff --git a/e2etests/testdata/txtar/sketch-bidirectional-connection-animation/dagre/sketch.exp.svg b/e2etests/testdata/txtar/sketch-bidirectional-connection-animation/dagre/sketch.exp.svg new file mode 100644 index 000000000..01ae7f387 --- /dev/null +++ b/e2etests/testdata/txtar/sketch-bidirectional-connection-animation/dagre/sketch.exp.svg @@ -0,0 +1,116 @@ + + + + + + + + +abcdefgx + + + + + + + + + + \ No newline at end of file diff --git a/e2etests/testdata/txtar/sketch-bidirectional-connection-animation/elk/board.exp.json b/e2etests/testdata/txtar/sketch-bidirectional-connection-animation/elk/board.exp.json new file mode 100644 index 000000000..5abaaf205 --- /dev/null +++ b/e2etests/testdata/txtar/sketch-bidirectional-connection-animation/elk/board.exp.json @@ -0,0 +1,653 @@ +{ + "name": "", + "config": { + "sketch": true, + "themeID": null, + "darkThemeID": null, + "pad": null, + "center": null, + "layoutEngine": null + }, + "isFolderOnly": false, + "fontFamily": "HandDrawn", + "shapes": [ + { + "id": "a", + "type": "rectangle", + "pos": { + "x": 71, + "y": 12 + }, + "width": 160, + "height": 66, + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "borderRadius": 0, + "fill": "B6", + "stroke": "B1", + "shadow": false, + "3d": false, + "multiple": false, + "double-border": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "blend": false, + "fields": null, + "methods": null, + "columns": null, + "label": "a", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N1", + "italic": false, + "bold": true, + "underline": false, + "labelWidth": 9, + "labelHeight": 21, + "labelPosition": "INSIDE_MIDDLE_CENTER", + "zIndex": 0, + "level": 1 + }, + { + "id": "b", + "type": "rectangle", + "pos": { + "x": 12, + "y": 208 + }, + "width": 55, + "height": 66, + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "borderRadius": 0, + "fill": "B6", + "stroke": "B1", + "shadow": false, + "3d": false, + "multiple": false, + "double-border": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "blend": false, + "fields": null, + "methods": null, + "columns": null, + "label": "b", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N1", + "italic": false, + "bold": true, + "underline": false, + "labelWidth": 10, + "labelHeight": 21, + "labelPosition": "INSIDE_MIDDLE_CENTER", + "zIndex": 0, + "level": 1 + }, + { + "id": "c", + "type": "rectangle", + "pos": { + "x": 87, + "y": 208 + }, + "width": 54, + "height": 66, + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "borderRadius": 0, + "fill": "B6", + "stroke": "B1", + "shadow": false, + "3d": false, + "multiple": false, + "double-border": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "blend": false, + "fields": null, + "methods": null, + "columns": null, + "label": "c", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N1", + "italic": false, + "bold": true, + "underline": false, + "labelWidth": 9, + "labelHeight": 21, + "labelPosition": "INSIDE_MIDDLE_CENTER", + "zIndex": 0, + "level": 1 + }, + { + "id": "d", + "type": "rectangle", + "pos": { + "x": 161, + "y": 208 + }, + "width": 55, + "height": 66, + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "borderRadius": 0, + "fill": "B6", + "stroke": "B1", + "shadow": false, + "3d": false, + "multiple": false, + "double-border": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "blend": false, + "fields": null, + "methods": null, + "columns": null, + "label": "d", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N1", + "italic": false, + "bold": true, + "underline": false, + "labelWidth": 10, + "labelHeight": 21, + "labelPosition": "INSIDE_MIDDLE_CENTER", + "zIndex": 0, + "level": 1 + }, + { + "id": "e", + "type": "rectangle", + "pos": { + "x": 236, + "y": 208 + }, + "width": 53, + "height": 66, + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "borderRadius": 0, + "fill": "B6", + "stroke": "B1", + "shadow": false, + "3d": false, + "multiple": false, + "double-border": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "blend": false, + "fields": null, + "methods": null, + "columns": null, + "label": "e", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N1", + "italic": false, + "bold": true, + "underline": false, + "labelWidth": 8, + "labelHeight": 21, + "labelPosition": "INSIDE_MIDDLE_CENTER", + "zIndex": 0, + "level": 1 + }, + { + "id": "f", + "type": "rectangle", + "pos": { + "x": 309, + "y": 12 + }, + "width": 54, + "height": 66, + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "borderRadius": 0, + "fill": "B6", + "stroke": "B1", + "shadow": false, + "3d": false, + "multiple": false, + "double-border": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "blend": false, + "fields": null, + "methods": null, + "columns": null, + "label": "f", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N1", + "italic": false, + "bold": true, + "underline": false, + "labelWidth": 9, + "labelHeight": 21, + "labelPosition": "INSIDE_MIDDLE_CENTER", + "zIndex": 0, + "level": 1 + }, + { + "id": "g", + "type": "rectangle", + "pos": { + "x": 309, + "y": 208 + }, + "width": 54, + "height": 66, + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "borderRadius": 0, + "fill": "B6", + "stroke": "B1", + "shadow": false, + "3d": false, + "multiple": false, + "double-border": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "blend": false, + "fields": null, + "methods": null, + "columns": null, + "label": "g", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N1", + "italic": false, + "bold": true, + "underline": false, + "labelWidth": 9, + "labelHeight": 21, + "labelPosition": "INSIDE_MIDDLE_CENTER", + "zIndex": 0, + "level": 1 + }, + { + "id": "x", + "type": "rectangle", + "pos": { + "x": 433, + "y": 12 + }, + "width": 54, + "height": 66, + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "borderRadius": 0, + "fill": "B6", + "stroke": "B1", + "shadow": false, + "3d": false, + "multiple": false, + "double-border": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "blend": false, + "fields": null, + "methods": null, + "columns": null, + "label": "x", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N1", + "italic": false, + "bold": true, + "underline": false, + "labelWidth": 9, + "labelHeight": 21, + "labelPosition": "INSIDE_MIDDLE_CENTER", + "zIndex": 0, + "level": 1 + } + ], + "connections": [ + { + "id": "(a <-> b)[0]", + "src": "a", + "srcArrow": "triangle", + "dst": "b", + "dstArrow": "triangle", + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "stroke": "B1", + "borderRadius": 10, + "label": "", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N2", + "italic": true, + "bold": false, + "underline": false, + "labelWidth": 0, + "labelHeight": 0, + "labelPosition": "", + "labelPercentage": 0, + "route": [ + { + "x": 103.25, + "y": 78 + }, + { + "x": 103.25, + "y": 118 + }, + { + "x": 39.5, + "y": 118 + }, + { + "x": 39.5, + "y": 208 + } + ], + "animated": true, + "tooltip": "", + "icon": null, + "zIndex": 0 + }, + { + "id": "(a <-> c)[0]", + "src": "a", + "srcArrow": "triangle", + "dst": "c", + "dstArrow": "triangle", + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "stroke": "B1", + "borderRadius": 10, + "label": "", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N2", + "italic": true, + "bold": false, + "underline": false, + "labelWidth": 0, + "labelHeight": 0, + "labelPosition": "", + "labelPercentage": 0, + "route": [ + { + "x": 135.25, + "y": 78 + }, + { + "x": 135.25, + "y": 168 + }, + { + "x": 114, + "y": 168 + }, + { + "x": 114, + "y": 208 + } + ], + "animated": true, + "tooltip": "", + "icon": null, + "zIndex": 0 + }, + { + "id": "(a <-> d)[0]", + "src": "a", + "srcArrow": "triangle", + "dst": "d", + "dstArrow": "triangle", + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "stroke": "B1", + "borderRadius": 10, + "label": "", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N2", + "italic": true, + "bold": false, + "underline": false, + "labelWidth": 0, + "labelHeight": 0, + "labelPosition": "", + "labelPercentage": 0, + "route": [ + { + "x": 167.25, + "y": 78 + }, + { + "x": 167.25, + "y": 168 + }, + { + "x": 188.5, + "y": 168 + }, + { + "x": 188.5, + "y": 208 + } + ], + "animated": true, + "tooltip": "", + "icon": null, + "zIndex": 0 + }, + { + "id": "(a <-> e)[0]", + "src": "a", + "srcArrow": "triangle", + "dst": "e", + "dstArrow": "triangle", + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "stroke": "B1", + "borderRadius": 10, + "label": "", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N2", + "italic": true, + "bold": false, + "underline": false, + "labelWidth": 0, + "labelHeight": 0, + "labelPosition": "", + "labelPercentage": 0, + "route": [ + { + "x": 199.25, + "y": 78 + }, + { + "x": 199.25, + "y": 118 + }, + { + "x": 262.5, + "y": 118 + }, + { + "x": 262.5, + "y": 208 + } + ], + "animated": false, + "tooltip": "", + "icon": null, + "zIndex": 0 + }, + { + "id": "(f <-> g)[0]", + "src": "f", + "srcArrow": "triangle", + "dst": "g", + "dstArrow": "triangle", + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "stroke": "B1", + "borderRadius": 10, + "label": "", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N2", + "italic": true, + "bold": false, + "underline": false, + "labelWidth": 0, + "labelHeight": 0, + "labelPosition": "", + "labelPercentage": 0, + "route": [ + { + "x": 336, + "y": 78 + }, + { + "x": 336, + "y": 208 + } + ], + "animated": true, + "tooltip": "", + "icon": null, + "zIndex": 0 + }, + { + "id": "(x -- x)[0]", + "src": "x", + "srcArrow": "none", + "dst": "x", + "dstArrow": "none", + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "stroke": "B1", + "borderRadius": 10, + "label": "", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N2", + "italic": true, + "bold": false, + "underline": false, + "labelWidth": 0, + "labelHeight": 0, + "labelPosition": "", + "labelPercentage": 0, + "route": [ + { + "x": 433, + "y": 34 + }, + { + "x": 383, + "y": 34 + }, + { + "x": 383, + "y": 56 + }, + { + "x": 433, + "y": 56 + } + ], + "animated": true, + "tooltip": "", + "icon": null, + "zIndex": 0 + } + ], + "root": { + "id": "", + "type": "", + "pos": { + "x": 0, + "y": 0 + }, + "width": 0, + "height": 0, + "opacity": 0, + "strokeDash": 0, + "strokeWidth": 0, + "borderRadius": 0, + "fill": "N7", + "stroke": "", + "shadow": false, + "3d": false, + "multiple": false, + "double-border": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "blend": false, + "fields": null, + "methods": null, + "columns": null, + "label": "", + "fontSize": 0, + "fontFamily": "", + "language": "", + "color": "", + "italic": false, + "bold": false, + "underline": false, + "labelWidth": 0, + "labelHeight": 0, + "zIndex": 0, + "level": 0 + } +} diff --git a/e2etests/testdata/txtar/sketch-bidirectional-connection-animation/elk/sketch.exp.svg b/e2etests/testdata/txtar/sketch-bidirectional-connection-animation/elk/sketch.exp.svg new file mode 100644 index 000000000..9603d3358 --- /dev/null +++ b/e2etests/testdata/txtar/sketch-bidirectional-connection-animation/elk/sketch.exp.svg @@ -0,0 +1,116 @@ + + + + + + + + +abcdefgx + + + + + + + + + + \ No newline at end of file diff --git a/lib/svg/path.go b/lib/svg/path.go index eddbb970f..56201c8ce 100644 --- a/lib/svg/path.go +++ b/lib/svg/path.go @@ -3,6 +3,7 @@ package svg import ( "fmt" "math" + "strconv" "strings" "oss.terrastruct.com/d2/lib/geo" @@ -139,3 +140,179 @@ func BezierCurveSegment(p1, p2, p3, p4 *geo.Point, t0, t1 float64) (geo.Point, g return q1, q2, q3, q4 } + +// Gets a certain line/curve's SVG path string. offsetIdx and pathData provides the points needed +func getSVGPathString(pathType string, offsetIdx int, pathData []string) (string, error) { + switch pathType { + case "M": + return fmt.Sprintf("M %s %s ", pathData[offsetIdx+1], pathData[offsetIdx+2]), nil + case "L": + return fmt.Sprintf("L %s %s ", pathData[offsetIdx+1], pathData[offsetIdx+2]), nil + case "C": + return fmt.Sprintf("C %s %s %s %s %s %s ", pathData[offsetIdx+1], pathData[offsetIdx+2], pathData[offsetIdx+3], pathData[offsetIdx+4], pathData[offsetIdx+5], pathData[offsetIdx+6]), nil + case "S": + return fmt.Sprintf("S %s %s %s %s ", pathData[offsetIdx+1], pathData[offsetIdx+2], pathData[offsetIdx+3], pathData[offsetIdx+4]), nil + default: + return "", fmt.Errorf("unknown svg path command \"%s\"", pathData[offsetIdx]) + } +} + +// Gets how much to increment by on an SVG string to get to the next path command +func getPathStringIncrement(pathType string) (int, error) { + switch pathType { + case "M": + return 3, nil + case "L": + return 3, nil + case "C": + return 7, nil + case "S": + return 5, nil + default: + return 0, fmt.Errorf("unknown svg path command \"%s\"", pathType) + } +} + +// This function finds the length of a path in SVG notation +func pathLength(pathData []string) (float64, error) { + var x, y, pathLength float64 + var prevPosition geo.Point + var increment int + + for i := 0; i < len(pathData); i += increment { + switch pathData[i] { + case "M": + x, _ = strconv.ParseFloat(pathData[i+1], 64) + y, _ = strconv.ParseFloat(pathData[i+2], 64) + case "L": + x, _ = strconv.ParseFloat(pathData[i+1], 64) + y, _ = strconv.ParseFloat(pathData[i+2], 64) + + pathLength += geo.EuclideanDistance(prevPosition.X, prevPosition.Y, x, y) + case "C": + x, _ = strconv.ParseFloat(pathData[i+5], 64) + y, _ = strconv.ParseFloat(pathData[i+6], 64) + + pathLength += geo.EuclideanDistance(prevPosition.X, prevPosition.Y, x, y) + case "S": + x, _ = strconv.ParseFloat(pathData[i+3], 64) + y, _ = strconv.ParseFloat(pathData[i+4], 64) + + pathLength += geo.EuclideanDistance(prevPosition.X, prevPosition.Y, x, y) + default: + return 0, fmt.Errorf("unknown svg path command \"%s\"", pathData[i]) + } + + prevPosition = geo.Point{X: x, Y: y} + + incr, err := getPathStringIncrement(pathData[i]) + + if err != nil { + return 0, err + } + + increment = incr + } + + return pathLength, nil +} + +// Splits an SVG path into two SVG paths, with the first path being ~{percentage}% of the path +func SplitPath(path string, percentage float64) (string, string, error) { + var sumPathLens, curPathLen, x, y float64 + var prevPosition geo.Point + var path1, path2 string + var increment int + + pastHalf := false + pathData := strings.Split(path, " ") + pathLen, err := pathLength(pathData) + + if err != nil { + return "", "", err + } + + for i := 0; i < len(pathData); i += increment { + switch pathData[i] { + case "M": + x, _ = strconv.ParseFloat(pathData[i+1], 64) + y, _ = strconv.ParseFloat(pathData[i+2], 64) + + curPathLen = 0 + case "L": + x, _ = strconv.ParseFloat(pathData[i+1], 64) + y, _ = strconv.ParseFloat(pathData[i+2], 64) + + curPathLen = geo.EuclideanDistance(prevPosition.X, prevPosition.Y, x, y) + case "C": + x, _ = strconv.ParseFloat(pathData[i+5], 64) + y, _ = strconv.ParseFloat(pathData[i+6], 64) + + curPathLen = geo.EuclideanDistance(prevPosition.X, prevPosition.Y, x, y) + case "S": + x, _ = strconv.ParseFloat(pathData[i+3], 64) + y, _ = strconv.ParseFloat(pathData[i+4], 64) + + curPathLen = geo.EuclideanDistance(prevPosition.X, prevPosition.Y, x, y) + default: + return "", "", fmt.Errorf("unknown svg path command \"%s\"", pathData[i]) + } + + curPath, err := getSVGPathString(pathData[i], i, pathData) + if err != nil { + return "", "", err + } + + sumPathLens += curPathLen + + if pastHalf { // add to path2 + path2 += curPath + } else if sumPathLens < pathLen*percentage { // add to path1 + path1 += curPath + } else { // transition from path1 -> path2 + t := (pathLen*percentage - sumPathLens + curPathLen) / curPathLen + + switch pathData[i] { + case "M": + path2 += fmt.Sprintf("M %s %s ", pathData[i+3], pathData[i+4]) + case "L": + path1 += fmt.Sprintf("L %f %f ", (x-prevPosition.X)*t+prevPosition.X, (y-prevPosition.Y)*t+prevPosition.Y) + path2 += fmt.Sprintf("M %f %f L %f %f ", (x-prevPosition.X)*t+prevPosition.X, (y-prevPosition.Y)*t+prevPosition.Y, x, y) + case "C": + h1x, _ := strconv.ParseFloat(pathData[i+1], 64) + h1y, _ := strconv.ParseFloat(pathData[i+2], 64) + h2x, _ := strconv.ParseFloat(pathData[i+3], 64) + h2y, _ := strconv.ParseFloat(pathData[i+4], 64) + + heading1 := geo.Point{X: h1x, Y: h1y} + heading2 := geo.Point{X: h2x, Y: h2y} + nextPoint := geo.Point{X: x, Y: y} + + q1, q2, q3, q4 := BezierCurveSegment(&prevPosition, &heading1, &heading2, &nextPoint, 0, 0.5) + path1 += fmt.Sprintf("C %f %f %f %f %f %f ", q2.X, q2.Y, q3.X, q3.Y, q4.X, q4.Y) + + q1, q2, q3, q4 = BezierCurveSegment(&prevPosition, &heading1, &heading2, &nextPoint, 0.5, 1) + path2 += fmt.Sprintf("M %f %f C %f %f %f %f %f %f ", q1.X, q1.Y, q2.X, q2.Y, q3.X, q3.Y, q4.X, q4.Y) + case "S": + // Skip S curves because they are shorter and we can split along the connection to the next path instead + path1 += fmt.Sprintf("S %s %s %s %s ", pathData[i+1], pathData[i+2], pathData[i+3], pathData[i+4]) + path2 += fmt.Sprintf("M %s %s ", pathData[i+3], pathData[i+4]) + default: + return "", "", fmt.Errorf("unknown svg path command \"%s\"", pathData[i]) + } + + pastHalf = true + } + + incr, err := getPathStringIncrement(pathData[i]) + + if err != nil { + return "", "", err + } + + increment = incr + prevPosition = geo.Point{X: x, Y: y} + } + + return path1, path2, nil +}