From 31aa7f1d4a0cc5525b8ca588ef76b084ac61f1e5 Mon Sep 17 00:00:00 2001 From: Andrew Foltz-Morrison Date: Mon, 20 Feb 2023 11:09:18 -0500 Subject: [PATCH 01/13] Add [failing] tests of SVG path string parsing --- test/thi/ng/geom/test/types/path.cljc | 78 +++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 test/thi/ng/geom/test/types/path.cljc diff --git a/test/thi/ng/geom/test/types/path.cljc b/test/thi/ng/geom/test/types/path.cljc new file mode 100644 index 0000000..294c082 --- /dev/null +++ b/test/thi/ng/geom/test/types/path.cljc @@ -0,0 +1,78 @@ +(ns thi.ng.geom.test.types.path + #?(:cljs + (:require-macros [cemerick.cljs.test :refer (is deftest)])) + (:require + [thi.ng.math.core :as m] + [thi.ng.geom.core :as g] + [thi.ng.geom.types] + [thi.ng.geom.line :as l] + [thi.ng.geom.vector :as v] + [thi.ng.geom.path :as p] + #?(:clj + [clojure.test :refer :all] + :cljs + [cemerick.cljs.test]))) + +(def svg-path-examples + "Examples of path definitions for various commands taken from MDN: + + https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorial/Paths + https://developer.mozilla.org/en-US/docs/Web/SVG/Element/path" + [;; square + "M 10 10 H 90 V 90 H 10 L 10 10" + ;; heart + "M 10,30 +A 20,20 0,0,1 50,30 +A 20,20 0,0,1 90,30 +Q 90,60 50,90 +Q 10,60 10,30 z" + ;; cubic curves + "M 10 10 C 20 20, 40 20, 50 10" + "M 70 10 C 70 20, 110 20, 110 10" + "M 130 10 C 120 20, 180 20, 170 10" + "M 10 60 C 20 80, 40 80, 50 60" + "M 70 60 C 70 80, 110 80, 110 60" + "M 130 60 C 120 80, 180 80, 170 60" + "M 10 110 C 20 140, 40 140, 50 110" + "M 70 110 C 70 140, 110 140, 110 110" + "M 130 110 C 120 140, 180 140, 170 110" + ;; smooth cubic + "M 10 80 C 40 10, 65 10, 95 80 S 150 150, 180 80" + ;; quadratic + "M 10 80 Q 95 10 180 80" + ;; quadratic shorthand + "M 10 80 Q 52.5 10, 95 80 T 180 80" + ;; arcs + "M 10 315 +L 110 215 +A 30 50 0 0 1 162.55 162.45 +L 172.55 152.45 +A 30 50 -45 0 1 215.1 109.9 +L 315 10" + "M 10 315 +L 110 215 +A 36 60 0 0 1 150.71 170.29 +L 172.55 152.45 +A 30 50 -45 0 1 215.1 109.9 +L 315 10" + "M 80 80 +A 45 45, 0, 0, 0, 125 125 +L 125 80 Z" + "M 230 80 +A 45 45, 0, 1, 0, 275 125 +L 275 80 Z" + "M 80 230 +A 45 45, 0, 0, 1, 125 275 +L 125 230 Z" + "M 230 230 +A 45 45, 0, 1, 1, 275 275 +L 275 230 Z" + ] + ) + +(deftest svg-path-parse + (testing "parsing SVG path definitions") + (doseq [ex svg-path-examples] + (testing (str ex) + (is (some? (p/parse-svg-path ex)) + "SVG path should parse into geometry type")))) From 12cdf2d375abb7cf2db61f9117c7271d327e9d66 Mon Sep 17 00:00:00 2001 From: Andrew Foltz-Morrison Date: Mon, 20 Feb 2023 11:29:14 -0500 Subject: [PATCH 02/13] Expand tests to include geometry objects + successful parsing The W3C triangle example parses correctly according to these tests. --- test/thi/ng/geom/test/types/path.cljc | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/test/thi/ng/geom/test/types/path.cljc b/test/thi/ng/geom/test/types/path.cljc index 294c082..efdd336 100644 --- a/test/thi/ng/geom/test/types/path.cljc +++ b/test/thi/ng/geom/test/types/path.cljc @@ -4,7 +4,7 @@ (:require [thi.ng.math.core :as m] [thi.ng.geom.core :as g] - [thi.ng.geom.types] + [thi.ng.geom.types :as types] [thi.ng.geom.line :as l] [thi.ng.geom.vector :as v] [thi.ng.geom.path :as p] @@ -14,11 +14,14 @@ [cemerick.cljs.test]))) (def svg-path-examples - "Examples of path definitions for various commands taken from MDN: + "Examples of path definitions for various commands taken from MDN + W3C: https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorial/Paths - https://developer.mozilla.org/en-US/docs/Web/SVG/Element/path" - [;; square + https://developer.mozilla.org/en-US/docs/Web/SVG/Element/path + https://www.w3.org/TR/SVG11/paths.html#PathElement" + [;; triangle + "M 100 100 L 300 100 L 200 300 z" + ;; square "M 10 10 H 90 V 90 H 10 L 10 10" ;; heart "M 10,30 @@ -26,6 +29,7 @@ A 20,20 0,0,1 50,30 A 20,20 0,0,1 90,30 Q 90,60 50,90 Q 10,60 10,30 z" + ;; cubic curves "M 10 10 C 20 20, 40 20, 50 10" "M 70 10 C 70 20, 110 20, 110 10" @@ -70,9 +74,20 @@ L 275 230 Z" ] ) +(defn num-commands [path-str] + (count (re-seq #"[MmLlHhVvCcSsQqAaTt]\s+" path-str))) + (deftest svg-path-parse (testing "parsing SVG path definitions") (doseq [ex svg-path-examples] (testing (str ex) - (is (some? (p/parse-svg-path ex)) - "SVG path should parse into geometry type")))) + (let [parsed (p/parse-svg-path ex) + n-commands (num-commands ex) + path-geom (types/->Path2 parsed)] + (is (some? parsed) + "SVG path should parse into segment definitions") + (is (some? (:segments path-geom)) + "SVG path should parse into geometry object") + (is (= n-commands + (count (:segments path-geom))) + "Geometry should have the same number of segments as the number of path commands"))))) From 71c0d9b1b1a89f88f52408f326c23c7ea1916574 Mon Sep 17 00:00:00 2001 From: Andrew Foltz-Morrison Date: Tue, 21 Feb 2023 09:37:00 -0500 Subject: [PATCH 03/13] Rework coordinate regex to handle two W3C-defined corner cases Quoting from the section of the spec defining the grammar: "...in the string "M 100-200" ... the first coordinate will be "100" and the second coordinate will be "-200". "Similarly, for the string "M 0.6.5"... the first coordinate will be "0.6" and the second coordinate will be ".5". --- src/thi/ng/geom/path.cljc | 4 ++- test/thi/ng/geom/test/types/path.cljc | 51 ++++++++++++++++++++------- 2 files changed, 41 insertions(+), 14 deletions(-) diff --git a/src/thi/ng/geom/path.cljc b/src/thi/ng/geom/path.cljc index a084557..d7400dd 100644 --- a/src/thi/ng/geom/path.cljc +++ b/src/thi/ng/geom/path.cljc @@ -44,10 +44,12 @@ (conj paths curr) paths))) +(def coordinate-regex #"[\-\+]?[0-9]+\.?[0-9]*|\.[0-9]+") + (defn parse-svg-coords [coords] (->> coords - (re-seq #"[0-9\.\-\+]+") + (re-seq coordinate-regex) #?(:clj (map #(Double/parseDouble %)) :cljs (map js/parseFloat)) (partition 2) (mapv vec2))) diff --git a/test/thi/ng/geom/test/types/path.cljc b/test/thi/ng/geom/test/types/path.cljc index efdd336..5a9ac0c 100644 --- a/test/thi/ng/geom/test/types/path.cljc +++ b/test/thi/ng/geom/test/types/path.cljc @@ -10,6 +10,7 @@ [thi.ng.geom.path :as p] #?(:clj [clojure.test :refer :all] + [clojure.string :as str] :cljs [cemerick.cljs.test]))) @@ -29,6 +30,14 @@ A 20,20 0,0,1 50,30 A 20,20 0,0,1 90,30 Q 90,60 50,90 Q 10,60 10,30 z" + ;; implicit polyline after moveto command + ;; this invalidates the test defined using the command count function below + "M 10,80 20,20 40,40 0,10 Z" + ;; relative equivalent of above shape + "m 10 80 10 -60 l 20 20 l -40 -30 z" + + ;; implicit move + explicit polyline + "m 10 80 10 -60 l 20 20 l -40 -30 v 10 l 5 5 5 10 20 10" ;; cubic curves "M 10 10 C 20 20, 40 20, 50 10" @@ -71,6 +80,12 @@ L 125 230 Z" "M 230 230 A 45 45, 0, 1, 1, 275 275 L 275 230 Z" + + ;; incorrect / pathological / flawed examples + ;; ↓ extra coordinate not in a pair + "m 10 80 l 20 20 l -40 -30 20 v 10 l 5 5 5 10 20 10" + "M 100-200" ; lack of space after coordinate + "M 0.6.5" ; multiple decimals within coordinate ] ) @@ -78,16 +93,26 @@ L 275 230 Z" (count (re-seq #"[MmLlHhVvCcSsQqAaTt]\s+" path-str))) (deftest svg-path-parse - (testing "parsing SVG path definitions") - (doseq [ex svg-path-examples] - (testing (str ex) - (let [parsed (p/parse-svg-path ex) - n-commands (num-commands ex) - path-geom (types/->Path2 parsed)] - (is (some? parsed) - "SVG path should parse into segment definitions") - (is (some? (:segments path-geom)) - "SVG path should parse into geometry object") - (is (= n-commands - (count (:segments path-geom))) - "Geometry should have the same number of segments as the number of path commands"))))) + (testing "coordinate parsing" + (is (= [(v/vec2 0.6 0.5)] (p/parse-svg-coords "0.6.5"))) + (is (= [(v/vec2 100 -200)] (p/parse-svg-coords "100-200"))) + ) + + (testing "parsing SVG path definitions" + (doseq [ex svg-path-examples] + (testing (str ex) + (let [segments (p/parse-svg-path ex) + n-commands (num-commands ex) + path-geom (types/->Path2 segments) + no-commas (str/replace ex #"\," "")] + (is (some? segments) + "SVG path should parse into segment definitions") + (is (some? (:segments path-geom)) + "SVG path should parse into geometry object") + (is (= n-commands + (count (:segments path-geom))) + "Geometry should have the same number of segments as the number of path commands") + + (is (= segments (p/parse-svg-path no-commas)) + "Comma placement should be irrelevant to parsing") + ))))) From 9d6c579e773366dc4f4c3c474ff9f6b0fcd919b6 Mon Sep 17 00:00:00 2001 From: Andrew Foltz-Morrison Date: Thu, 23 Feb 2023 18:38:17 -0500 Subject: [PATCH 04/13] The beginning of a new, more general parsing implementation --- src/thi/ng/geom/path.cljc | 156 +++++++++++++++++++++----- test/thi/ng/geom/test/types/path.cljc | 14 ++- 2 files changed, 139 insertions(+), 31 deletions(-) diff --git a/src/thi/ng/geom/path.cljc b/src/thi/ng/geom/path.cljc index d7400dd..5e71b0f 100644 --- a/src/thi/ng/geom/path.cljc +++ b/src/thi/ng/geom/path.cljc @@ -54,36 +54,138 @@ (partition 2) (mapv vec2))) +;; the general parsing strategy is designed to line up with the intended output: +;; a sequence of segments. +;; 1. use command regex to generate a partially parsed sequence of commands +;; 2. loop through this sequence while holding on to some "current position" +;; state information to ensure the segments begin and end correctly + + + +;; regex to separate by command indicators; supporting an arbitrary number of +;; coordinate pairs + +;; used to generate the sequence of path commands +(def ^:private cmd-regex #"(?i)([achlmqstvz])([^achlmqstvz]*)") + +(comment + (parse-svg-coords "40,40") + + (parse-svg-path + "M 10,80 20,20 40,40 0,10 Z") + + (re-seq #"(?i)([achlmqstvz])([^achlmqstvz]+)" + "M 10 80 10 -60 l 20 20 l -40 -30 v 10 l 5 5 5 10 20 10") + + (re-seq cmd-regex + "M 10 80 10 -60 l 20 20 l -40 -30 v 10 l 5 5 5 10 20 10 Z") + + (re-seq cmd-regex "M 10,80 20,20 40,40 0,10 Z") + + ) + +(defn move-to [cmd current-pos [start-pt & line-coords]] + ;; implicit line: return the segment and the current position + ;; (as described by the final point in the line) + (if line-coords + (let [line (reduce (fn [pts nxt] (conj pts (vec2 nxt))) + [start-pt] + line-coords)] + [{:type :line :points line} + (peek line)]) + ;; standard move: return only the current position + [nil start-pt])) + +(defn line-to [cmd current-pos [next-pt & pts]] + (if pts + [{:type :line-string :points (reduce conj [current-pos next-pt] pts)} + (peek pts)] + [{:type :line :points [current-pos next-pt]} + next-pt])) + (defn parse-svg-path - ([svg] + ([path-str] (parse-svg-path - (->> svg - (re-seq #"([MLCZz])\s*(((([0-9\.\-]+)\,?){2}\s*){0,3})") - (map (fn [[_ t c]] - [t (parse-svg-coords c)]))) - [0 0] [0 0])) - ([[[type points :as seg] & more] p0 pc] + (map (fn parse-coords [[_m cmd coord-str]] + [cmd (parse-svg-coords coord-str)]) + (re-seq cmd-regex path-str)) + {:origin [0 0] + :current [0 0]})) + ([[[cmd coords :as seg] & more] + {:keys [current origin] + :as pts}] (when seg - (cond - (= "M" type) - (let [p (first points)] (recur more p p)) - - (= "L" type) - (let [p (first points)] - (lazy-seq (cons {:type :line :points [pc p]} - (parse-svg-path more p0 p)))) - - (= "C" type) - (let [p (last points)] - (lazy-seq (cons {:type :bezier :points (cons pc points)} - (parse-svg-path more p0 p)))) - - (or (= "Z" type) (= "z" type)) - (lazy-seq (cons {:type :close :points [pc p0]} - (parse-svg-path more p0 p0))) - - :default - (err/unsupported! (str "Unsupported path segment type" type)))))) + (case cmd + "M" + (let [[new-segment new-pos] + (move-to cmd current coords)] + (if new-segment + (lazy-seq + (cons new-segment + (parse-svg-path more (assoc pts :current new-pos)))) + (recur more (assoc pts :current new-pos)))) + "m" + (let [[line-segment new-pos] + (move-to cmd current coords)] + (if line-segment + (lazy-seq + (cons line-segment + (parse-svg-path + more + (assoc pts :current new-pos)))) + (recur more (assoc pts :current new-pos)))) + "L" (let [[line-segment new-pos] (line-to cmd current coords)] + (lazy-seq + (cons line-segment + (parse-svg-path more (assoc pts :current new-pos))))) + "l" (let [[line-segment new-pos] (line-to cmd current coords)] + (lazy-seq + (cons line-segment + (parse-svg-path more (assoc pts :current new-pos))))) + ;; "H" (h-line-to cmd current-pos coords) + ;; "h" (h-line-to cmd current-pos coords) + ;; "V" (v-line-to cmd current-pos coords) + ;; "v" (v-line-to cmd current-pos coords) + ;; "C" (cubic-to cmd current-pos coords) + ;; "c" (cubic-to cmd current-pos coords) + ;; "S" nil + ;; "s" nil + "Z" (lazy-seq (cons {:type :close :points [current origin]} + (parse-svg-path more (assoc pts :current origin)))) + nil + )))) + +(comment + (defn parse-svg-path + ([svg] + (parse-svg-path + (->> svg + (re-seq #"([MLCZz])\s*(((([0-9\.\-]+)\,?){2}\s*){0,3})") + (map (fn [[_ t c]] + [t (parse-svg-coords c)]))) + [0 0] [0 0])) + ([[[type points :as seg] & more] p0 pc] + (when seg + (cond + (= "M" type) + (let [p (first points)] (recur more p p)) + + (= "L" type) + (let [p (first points)] + (lazy-seq (cons {:type :line :points [pc p]} + (parse-svg-path more p0 p)))) + + (= "C" type) + (let [p (last points)] + (lazy-seq (cons {:type :bezier :points (cons pc points)} + (parse-svg-path more p0 p)))) + + (or (= "Z" type) (= "z" type)) + (lazy-seq (cons {:type :close :points [pc p0]} + (parse-svg-path more p0 p0))) + + :default + (err/unsupported! (str "Unsupported path segment type" type))))))) #?(:clj (defn parse-svg diff --git a/test/thi/ng/geom/test/types/path.cljc b/test/thi/ng/geom/test/types/path.cljc index 5a9ac0c..ceb7a6d 100644 --- a/test/thi/ng/geom/test/types/path.cljc +++ b/test/thi/ng/geom/test/types/path.cljc @@ -14,6 +14,12 @@ :cljs [cemerick.cljs.test]))) +;; idea: store all of these examples in an actual SVG document +;; they can be moved with a transform operation so they don't overlap +;; and metadata about each one can be stored as XML properties + +;; will it work for invalid paths that also need to be tested? + (def svg-path-examples "Examples of path definitions for various commands taken from MDN + W3C: @@ -98,15 +104,15 @@ L 275 230 Z" (is (= [(v/vec2 100 -200)] (p/parse-svg-coords "100-200"))) ) - (testing "parsing SVG path definitions" + (testing "parsing SVG path definitions\n\n" (doseq [ex svg-path-examples] (testing (str ex) (let [segments (p/parse-svg-path ex) n-commands (num-commands ex) path-geom (types/->Path2 segments) - no-commas (str/replace ex #"\," "")] - (is (some? segments) - "SVG path should parse into segment definitions") + no-commas (str/replace ex #"\," " ")] + (is (seq? segments) + "SVG path should parse into a sequence of segment definitions") (is (some? (:segments path-geom)) "SVG path should parse into geometry object") (is (= n-commands From 4f5f3a64820f401e027a33ee9c21c6b4c20032d7 Mon Sep 17 00:00:00 2001 From: Andrew Foltz-Morrison Date: Sat, 25 Feb 2023 10:54:51 -0500 Subject: [PATCH 05/13] Add SVG grid layout export capabilities + annotate example paths --- src/thi/ng/geom/path.cljc | 2 + test/thi/ng/geom/test/types/path.cljc | 185 ++++++++++++++++++++------ 2 files changed, 145 insertions(+), 42 deletions(-) diff --git a/src/thi/ng/geom/path.cljc b/src/thi/ng/geom/path.cljc index 5e71b0f..a278fd1 100644 --- a/src/thi/ng/geom/path.cljc +++ b/src/thi/ng/geom/path.cljc @@ -152,6 +152,8 @@ ;; "s" nil "Z" (lazy-seq (cons {:type :close :points [current origin]} (parse-svg-path more (assoc pts :current origin)))) + "z" (lazy-seq (cons {:type :close :points [current origin]} + (parse-svg-path more (assoc pts :current origin)))) nil )))) diff --git a/test/thi/ng/geom/test/types/path.cljc b/test/thi/ng/geom/test/types/path.cljc index ceb7a6d..6a47081 100644 --- a/test/thi/ng/geom/test/types/path.cljc +++ b/test/thi/ng/geom/test/types/path.cljc @@ -8,6 +8,7 @@ [thi.ng.geom.line :as l] [thi.ng.geom.vector :as v] [thi.ng.geom.path :as p] + [hiccup.core :refer [html]] #?(:clj [clojure.test :refer :all] [clojure.string :as str] @@ -26,73 +27,128 @@ https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorial/Paths https://developer.mozilla.org/en-US/docs/Web/SVG/Element/path https://www.w3.org/TR/SVG11/paths.html#PathElement" - [;; triangle - "M 100 100 L 300 100 L 200 300 z" - ;; square - "M 10 10 H 90 V 90 H 10 L 10 10" + [{:path "M 100 100 L 300 100 L 200 300 z" + :description "triangle" + :num-segments 3} + {:path "M 10 10 H 90 V 90 H 10 L 10 10" + :description "square" + :num-segments 4} ;; heart - "M 10,30 + {:path + "M 10,30 A 20,20 0,0,1 50,30 A 20,20 0,0,1 90,30 Q 90,60 50,90 Q 10,60 10,30 z" - ;; implicit polyline after moveto command - ;; this invalidates the test defined using the command count function below - "M 10,80 20,20 40,40 0,10 Z" + :description "heart" + :num-segments 5} + {:path "M 10,80 20,20 40,40 0,10 Z" + :description "implicit polyline after move command" + :num-segments 2} ;; relative equivalent of above shape - "m 10 80 10 -60 l 20 20 l -40 -30 z" + {:path "m 10 80 10 -60 l 20 20 l -40 -30 z" + :description "implicit polyline after move command (relative)" + :num-segments 2} ;; implicit move + explicit polyline - "m 10 80 10 -60 l 20 20 l -40 -30 v 10 l 5 5 5 10 20 10" + {:path "m 10 80 10 -60 l 20 20 l -40 -30 v 10 l 5 5 5 10 20 10" + :description "implicit polyline after move command + explicit polyline" + :num-segments 5} ;; cubic curves - "M 10 10 C 20 20, 40 20, 50 10" - "M 70 10 C 70 20, 110 20, 110 10" - "M 130 10 C 120 20, 180 20, 170 10" - "M 10 60 C 20 80, 40 80, 50 60" - "M 70 60 C 70 80, 110 80, 110 60" - "M 130 60 C 120 80, 180 80, 170 60" - "M 10 110 C 20 140, 40 140, 50 110" - "M 70 110 C 70 140, 110 140, 110 110" - "M 130 110 C 120 140, 180 140, 170 110" + {:path "M 10 10 C 20 20, 40 20, 50 10" + :description "cubic curve" + :num-segments 1} + {:path "M 70 10 C 70 20, 110 20, 110 10" + :description "cubic curve" + :num-segments 1} + {:path "M 130 10 C 120 20, 180 20, 170 10" + :description "cubic curve" + :num-segments 1} + {:path "M 10 60 C 20 80, 40 80, 50 60" + :description "cubic curve" + :num-segments 1} + {:path "M 70 60 C 70 80, 110 80, 110 60" + :description "cubic curve" + :num-segments 1} + {:path "M 130 60 C 120 80, 180 80, 170 60" + :description "cubic curve" + :num-segments 1} + {:path "M 10 110 C 20 140, 40 140, 50 110" + :description "cubic curve" + :num-segments 1} + {:path "M 70 110 C 70 140, 110 140, 110 110" + :description "cubic curve" + :num-segments 1} + {:path "M 130 110 C 120 140, 180 140, 170 110" + :description "cubic curve" + :num-segments 1} ;; smooth cubic - "M 10 80 C 40 10, 65 10, 95 80 S 150 150, 180 80" + {:path "M 10 80 C 40 10, 65 10, 95 80 S 150 150, 180 80" + :description "smooth cubic curve" + :num-segments 2} ;; quadratic - "M 10 80 Q 95 10 180 80" + {:path "M 10 80 Q 95 10 180 80" + :description "quadratic curve" + :num-segments 1} ;; quadratic shorthand - "M 10 80 Q 52.5 10, 95 80 T 180 80" + {:path "M 10 80 Q 52.5 10, 95 80 T 180 80" + :description "quadratic curve (shorthand)" + :num-segments 2} ;; arcs - "M 10 315 + {:path + "M 10 315 L 110 215 A 30 50 0 0 1 162.55 162.45 L 172.55 152.45 A 30 50 -45 0 1 215.1 109.9 L 315 10" - "M 10 315 + :description "arc" + :num-segments 5} + {:path + "M 10 315 L 110 215 A 36 60 0 0 1 150.71 170.29 L 172.55 152.45 A 30 50 -45 0 1 215.1 109.9 L 315 10" - "M 80 80 + :description "arc" + :num-segments 5} + {:path + "M 80 80 A 45 45, 0, 0, 0, 125 125 L 125 80 Z" - "M 230 80 + :description "arc" + :num-segments 2} + {:path + "M 230 80 A 45 45, 0, 1, 0, 275 125 L 275 80 Z" - "M 80 230 + :description "arc" + :num-segments 2} + {:path + "M 80 230 A 45 45, 0, 0, 1, 125 275 L 125 230 Z" - "M 230 230 + :description "arc" + :num-segments 2} + {:path + "M 230 230 A 45 45, 0, 1, 1, 275 275 L 275 230 Z" + :description "arc" + :num-segments 2} ;; incorrect / pathological / flawed examples - ;; ↓ extra coordinate not in a pair - "m 10 80 l 20 20 l -40 -30 20 v 10 l 5 5 5 10 20 10" - "M 100-200" ; lack of space after coordinate - "M 0.6.5" ; multiple decimals within coordinate - ] + {:path "m 10 80 l 20 20 l -40 -30 20 v 10 l 5 5 5 10 20 10" + :description "extra coordinate not in an appropriate pair for line command" + :num-segments 4} + {:path "M 100-200" + :description "no whitespace following coordinate" + :num-segments 0} + {:path "M 0.6.5" + :description "multiple decimals within coordinate" + :num-segments 0}] ) (defn num-commands [path-str] @@ -104,21 +160,66 @@ L 275 230 Z" (is (= [(v/vec2 100 -200)] (p/parse-svg-coords "100-200"))) ) - (testing "parsing SVG path definitions\n\n" - (doseq [ex svg-path-examples] - (testing (str ex) - (let [segments (p/parse-svg-path ex) - n-commands (num-commands ex) + (testing "parsing SVG path definitions -" + (doseq [{:keys [path description num-segments] :as ex} + svg-path-examples] + (testing (str description ":\n" path) + (let [segments (p/parse-svg-path path) + n-commands (num-commands path) path-geom (types/->Path2 segments) - no-commas (str/replace ex #"\," " ")] + no-commas (str/replace path #"\," " ")] (is (seq? segments) "SVG path should parse into a sequence of segment definitions") + (is (= num-segments (count segments)) + "SVG path should parse into the correct number of segments for the given shape") (is (some? (:segments path-geom)) "SVG path should parse into geometry object") - (is (= n-commands - (count (:segments path-geom))) - "Geometry should have the same number of segments as the number of path commands") + #_(is (= n-commands + (count (:segments path-geom))) + "Geometry should have the same number of segments as the number of path commands") (is (= segments (p/parse-svg-path no-commas)) "Comma placement should be irrelevant to parsing") ))))) + + +(defn path-svg-grid + "Generate Hiccup data for SVG export from example paths according a simple grid layout." + [paths {:keys [width height] :as opts}] + (let [n-paths (count paths) + nearest-square + (->> (range (max width height)) + (map #(Math/pow % 2)) + (filter #(> % n-paths)) + first + Math/sqrt) + x-interval (/ (* width 1.0) nearest-square) + y-interval (/ (* height 1.0) nearest-square)] + + (->> (for [x (range nearest-square) + y (range nearest-square)] [x y]) + (take n-paths) + (map (fn [[x y]] + (let [ix (+ (* x nearest-square) y) + {:keys [path description num-segments]} (nth paths ix)] + [:path {:id (str "svg-test-example-" (long ix)) + :d path + :transform (format "translate(%f,%f)" + (* x x-interval) + (* y y-interval)) + :data-description description + :data-num-segments num-segments}] + ))) + (reduce conj [:svg {:id "svg-path-test-data" + :width width + :height height}])))) + +(comment + + ;; generate SVG for visual inspection from example paths + (spit "assets/test-path-grid.svg" + (html {:mode :xml} (path-svg-grid svg-path-examples {:width 1000 :height 1000})) + ) + + + ) From 285054b97d44c39940474274378850c94c680dec Mon Sep 17 00:00:00 2001 From: Andrew Foltz-Morrison Date: Mon, 27 Feb 2023 09:36:23 -0500 Subject: [PATCH 06/13] Expand parsing to handle cases of single-coordinate sequences This is a necessary requirement for the horizontal and vertical move commands. --- src/thi/ng/geom/path.cljc | 14 +++++--------- test/thi/ng/geom/test/types/path.cljc | 8 ++++---- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/thi/ng/geom/path.cljc b/src/thi/ng/geom/path.cljc index a278fd1..41abb33 100644 --- a/src/thi/ng/geom/path.cljc +++ b/src/thi/ng/geom/path.cljc @@ -46,13 +46,15 @@ (def coordinate-regex #"[\-\+]?[0-9]+\.?[0-9]*|\.[0-9]+") + (defn parse-svg-coords [coords] (->> coords (re-seq coordinate-regex) - #?(:clj (map #(Double/parseDouble %)) :cljs (map js/parseFloat)) - (partition 2) - (mapv vec2))) + #?(:clj (mapv #(Double/parseDouble %)) :cljs (map js/parseFloat)))) + +(defn svg-coord-pairs [parsed-coords] + (mapv vec2 (partition 2 parsed-coords))) ;; the general parsing strategy is designed to line up with the intended output: ;; a sequence of segments. @@ -74,12 +76,6 @@ (parse-svg-path "M 10,80 20,20 40,40 0,10 Z") - (re-seq #"(?i)([achlmqstvz])([^achlmqstvz]+)" - "M 10 80 10 -60 l 20 20 l -40 -30 v 10 l 5 5 5 10 20 10") - - (re-seq cmd-regex - "M 10 80 10 -60 l 20 20 l -40 -30 v 10 l 5 5 5 10 20 10 Z") - (re-seq cmd-regex "M 10,80 20,20 40,40 0,10 Z") ) diff --git a/test/thi/ng/geom/test/types/path.cljc b/test/thi/ng/geom/test/types/path.cljc index 6a47081..93f1210 100644 --- a/test/thi/ng/geom/test/types/path.cljc +++ b/test/thi/ng/geom/test/types/path.cljc @@ -2,6 +2,7 @@ #?(:cljs (:require-macros [cemerick.cljs.test :refer (is deftest)])) (:require + [clojure.string :as str] [thi.ng.math.core :as m] [thi.ng.geom.core :as g] [thi.ng.geom.types :as types] @@ -156,8 +157,8 @@ L 275 230 Z" (deftest svg-path-parse (testing "coordinate parsing" - (is (= [(v/vec2 0.6 0.5)] (p/parse-svg-coords "0.6.5"))) - (is (= [(v/vec2 100 -200)] (p/parse-svg-coords "100-200"))) + (is (= [0.6 0.5] (p/parse-svg-coords "0.6.5"))) + (is (= [100.0 -200.0] (p/parse-svg-coords "100-200") )) ) (testing "parsing SVG path definitions -" @@ -179,8 +180,7 @@ L 275 230 Z" "Geometry should have the same number of segments as the number of path commands") (is (= segments (p/parse-svg-path no-commas)) - "Comma placement should be irrelevant to parsing") - ))))) + "Comma placement should be irrelevant to parsing")))))) (defn path-svg-grid From 61f848bf4417ce812df940db629dc6d650326b53 Mon Sep 17 00:00:00 2001 From: Andrew Foltz-Morrison Date: Sun, 12 Mar 2023 11:19:26 -0400 Subject: [PATCH 07/13] Add parsing capabilities for curves + arcs; fix some tests --- src/thi/ng/geom/path.cljc | 127 +++++++++++++++++++++----- test/thi/ng/geom/test/types/path.cljc | 29 ++++-- 2 files changed, 125 insertions(+), 31 deletions(-) diff --git a/src/thi/ng/geom/path.cljc b/src/thi/ng/geom/path.cljc index 41abb33..6af9d0f 100644 --- a/src/thi/ng/geom/path.cljc +++ b/src/thi/ng/geom/path.cljc @@ -92,18 +92,50 @@ ;; standard move: return only the current position [nil start-pt])) -(defn line-to [cmd current-pos [next-pt & pts]] - (if pts - [{:type :line-string :points (reduce conj [current-pos next-pt] pts)} +(defn line-to [cmd current-pos pts] + (if (not= 1 (count pts)) + [{:type :line-string :points (reduce conj [current-pos] pts)} (peek pts)] - [{:type :line :points [current-pos next-pt]} - next-pt])) + [{:type :line :points [current-pos (first pts)]} + (first pts)])) + +(defn h-line-to [cmd [cx cy :as current-pos] [next-x & xs]] + (if xs + [{:type :line-string + :points (reduce (fn [pts x] (conj pts (vec2 x cy))) + [current-pos (vec2 next-x cy)] + xs)} + (vec2 (peek xs) cy)] + [{:type :line :points [current-pos (vec2 next-x cy)]} + (vec2 next-x cy)])) + +(defn v-line-to [cmd [cx cy :as current-pos] [next-y & ys]] + (if ys + [{:type :line-string + :points (reduce (fn [pts y] (conj pts (vec2 cx y))) + [current-pos (vec2 cx next-y)] + ys)} + (vec2 cx (peek ys))] + [{:type :line :points [current-pos (vec2 cx next-y)]} + (vec2 cx next-y)])) + +(defn bezier-to [cmd current-pos pts] + [{:type :bezier :points (reduce conj [current-pos] pts)} + (peek pts)]) + +(defn arc-to [cmd current-pos pts] + [{:type :arc :points (reduce conj [current-pos] [pts])} + (peek pts)]) (defn parse-svg-path ([path-str] (parse-svg-path (map (fn parse-coords [[_m cmd coord-str]] - [cmd (parse-svg-coords coord-str)]) + [cmd + (let [parsed (parse-svg-coords coord-str)] + ;; don't partition coordinates into pairs for 1d line commands + (if (#{"V" "v" "H" "h"} cmd) parsed + (svg-coord-pairs parsed)))]) (re-seq cmd-regex path-str)) {:origin [0 0] :current [0 0]})) @@ -138,25 +170,72 @@ (lazy-seq (cons line-segment (parse-svg-path more (assoc pts :current new-pos))))) - ;; "H" (h-line-to cmd current-pos coords) - ;; "h" (h-line-to cmd current-pos coords) - ;; "V" (v-line-to cmd current-pos coords) - ;; "v" (v-line-to cmd current-pos coords) - ;; "C" (cubic-to cmd current-pos coords) - ;; "c" (cubic-to cmd current-pos coords) - ;; "S" nil - ;; "s" nil + "H" (let [[line-segment new-pos] (h-line-to cmd current coords)] + (lazy-seq + (cons line-segment + (parse-svg-path more (assoc pts :current new-pos))))) + "h" (let [[line-segment new-pos] (h-line-to cmd current coords)] + (lazy-seq + (cons line-segment + (parse-svg-path more (assoc pts :current new-pos))))) + "V" (let [[line-segment new-pos] (h-line-to cmd current coords)] + (lazy-seq + (cons line-segment + (parse-svg-path more (assoc pts :current new-pos))))) + "v" (let [[line-segment new-pos] (h-line-to cmd current coords)] + (lazy-seq + (cons line-segment + (parse-svg-path more (assoc pts :current new-pos))))) + "Q" (let [[line-segment new-pos] (bezier-to cmd current coords)] + (lazy-seq + (cons line-segment + (parse-svg-path more (assoc pts :current new-pos))))) + "q" (let [[line-segment new-pos] (bezier-to cmd current coords)] + (lazy-seq + (cons line-segment + (parse-svg-path more (assoc pts :current new-pos))))) + "T" (let [[line-segment new-pos] (bezier-to cmd current coords)] + (lazy-seq + (cons line-segment + (parse-svg-path more (assoc pts :current new-pos))))) + "t" (let [[line-segment new-pos] (bezier-to cmd current coords)] + (lazy-seq + (cons line-segment + (parse-svg-path more (assoc pts :current new-pos))))) + "C" (let [[line-segment new-pos] (bezier-to cmd current coords)] + (lazy-seq + (cons line-segment + (parse-svg-path more (assoc pts :current new-pos))))) + "c" (let [[line-segment new-pos] (bezier-to cmd current coords)] + (lazy-seq + (cons line-segment + (parse-svg-path more (assoc pts :current new-pos))))) + "S" (let [[line-segment new-pos] (bezier-to cmd current coords)] + (lazy-seq + (cons line-segment + (parse-svg-path more (assoc pts :current new-pos))))) + "s" (let [[line-segment new-pos] (bezier-to cmd current coords)] + (lazy-seq + (cons line-segment + (parse-svg-path more (assoc pts :current new-pos))))) + "A" (let [[line-segment new-pos] (arc-to cmd current coords)] + (lazy-seq + (cons line-segment + (parse-svg-path more (assoc pts :current new-pos))))) + "a" (let [[line-segment new-pos] (arc-to cmd current coords)] + (lazy-seq + (cons line-segment + (parse-svg-path more (assoc pts :current new-pos))))) "Z" (lazy-seq (cons {:type :close :points [current origin]} (parse-svg-path more (assoc pts :current origin)))) "z" (lazy-seq (cons {:type :close :points [current origin]} (parse-svg-path more (assoc pts :current origin)))) - nil - )))) + nil)))) (comment - (defn parse-svg-path + (defn parse-svg-path-old ([svg] - (parse-svg-path + (parse-svg-path-old (->> svg (re-seq #"([MLCZz])\s*(((([0-9\.\-]+)\,?){2}\s*){0,3})") (map (fn [[_ t c]] @@ -171,19 +250,23 @@ (= "L" type) (let [p (first points)] (lazy-seq (cons {:type :line :points [pc p]} - (parse-svg-path more p0 p)))) + (parse-svg-path-old more p0 p)))) (= "C" type) (let [p (last points)] (lazy-seq (cons {:type :bezier :points (cons pc points)} - (parse-svg-path more p0 p)))) + (parse-svg-path-old more p0 p)))) (or (= "Z" type) (= "z" type)) (lazy-seq (cons {:type :close :points [pc p0]} - (parse-svg-path more p0 p0))) + (parse-svg-path-old more p0 p0))) :default - (err/unsupported! (str "Unsupported path segment type" type))))))) + (err/unsupported! (str "Unsupported path segment type" type)))))) + + (parse-svg-path-old "M 10 10 C 20 20, 40 20, 50 10") + + ) #?(:clj (defn parse-svg diff --git a/test/thi/ng/geom/test/types/path.cljc b/test/thi/ng/geom/test/types/path.cljc index 93f1210..3580c7c 100644 --- a/test/thi/ng/geom/test/types/path.cljc +++ b/test/thi/ng/geom/test/types/path.cljc @@ -47,7 +47,7 @@ Q 10,60 10,30 z" :description "implicit polyline after move command" :num-segments 2} ;; relative equivalent of above shape - {:path "m 10 80 10 -60 l 20 20 l -40 -30 z" + {:path "m 10,80 20,20 40,40 0,10 z" :description "implicit polyline after move command (relative)" :num-segments 2} @@ -120,23 +120,23 @@ L 315 10" A 45 45, 0, 0, 0, 125 125 L 125 80 Z" :description "arc" - :num-segments 2} + :num-segments 3} {:path "M 230 80 A 45 45, 0, 1, 0, 275 125 L 275 80 Z" :description "arc" - :num-segments 2} + :num-segments 3} {:path "M 80 230 A 45 45, 0, 0, 1, 125 275 L 125 230 Z" :description "arc" - :num-segments 2} + :num-segments 3} {:path "M 230 230 A 45 45, 0, 1, 1, 275 275 -L 275 230 Z" +L 275 230" :description "arc" :num-segments 2} @@ -152,21 +152,23 @@ L 275 230 Z" :num-segments 0}] ) -(defn num-commands [path-str] - (count (re-seq #"[MmLlHhVvCcSsQqAaTt]\s+" path-str))) - (deftest svg-path-parse (testing "coordinate parsing" (is (= [0.6 0.5] (p/parse-svg-coords "0.6.5"))) (is (= [100.0 -200.0] (p/parse-svg-coords "100-200") )) ) + (testing "individual commands" + (let [[{:keys [type points]}] (p/move-to "m" [0 0] [[10 80] [10 -60] [10 -90]]) ] + (is (= :line type)) + (is (= 3 (count points)) + "Polyline should be produced from move command when defined"))) + (testing "parsing SVG path definitions -" (doseq [{:keys [path description num-segments] :as ex} svg-path-examples] (testing (str description ":\n" path) (let [segments (p/parse-svg-path path) - n-commands (num-commands path) path-geom (types/->Path2 segments) no-commas (str/replace path #"\," " ")] (is (seq? segments) @@ -222,4 +224,13 @@ L 275 230 Z" ) + (p/parse-svg-path (:path (first svg-path-examples))) + (p/parse-svg-path (:path (nth svg-path-examples 1))) + + (p/parse-svg-path "m 10 80 l 20 20 l -40 -30 20 v 10 l 5 5 5 10 20 10") + (p/parse-svg-path "m 10 80 10 -60 l 20 20 l -40 -30 z") + + (let [[a b & rest] [1 2 3 4 5 6 7]] + (peek rest)) + ) From 6a274277d1b6b067d50459890f6176f91d314a16 Mon Sep 17 00:00:00 2001 From: Andrew Foltz-Morrison Date: Sun, 12 Mar 2023 11:31:06 -0400 Subject: [PATCH 08/13] A corner case: a move command with no other commands One unresolved question: should move-only commands parse into Vec2 geometries or return nil? --- src/thi/ng/geom/path.cljc | 4 +++- test/thi/ng/geom/test/types/path.cljc | 15 +++++++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/thi/ng/geom/path.cljc b/src/thi/ng/geom/path.cljc index 6af9d0f..cb6a1a4 100644 --- a/src/thi/ng/geom/path.cljc +++ b/src/thi/ng/geom/path.cljc @@ -142,7 +142,8 @@ ([[[cmd coords :as seg] & more] {:keys [current origin] :as pts}] - (when seg + (if (nil? seg) + '() (case cmd "M" (let [[new-segment new-pos] @@ -265,6 +266,7 @@ (err/unsupported! (str "Unsupported path segment type" type)))))) (parse-svg-path-old "M 10 10 C 20 20, 40 20, 50 10") + (parse-svg-path-old "M 10 10 ") ) diff --git a/test/thi/ng/geom/test/types/path.cljc b/test/thi/ng/geom/test/types/path.cljc index 3580c7c..2653ccf 100644 --- a/test/thi/ng/geom/test/types/path.cljc +++ b/test/thi/ng/geom/test/types/path.cljc @@ -140,16 +140,21 @@ L 275 230" :description "arc" :num-segments 2} + ;; move only, no lines + {:path "m 10 10" + :description "move command without lines" + :num-segments 0} + ;; incorrect / pathological / flawed examples {:path "m 10 80 l 20 20 l -40 -30 20 v 10 l 5 5 5 10 20 10" :description "extra coordinate not in an appropriate pair for line command" :num-segments 4} - {:path "M 100-200" + {:path "M 10 10 L 100-200" :description "no whitespace following coordinate" - :num-segments 0} - {:path "M 0.6.5" + :num-segments 1} + {:path "M 10 10 L 0.6.5" :description "multiple decimals within coordinate" - :num-segments 0}] + :num-segments 1}] ) (deftest svg-path-parse @@ -230,6 +235,8 @@ L 275 230" (p/parse-svg-path "m 10 80 l 20 20 l -40 -30 20 v 10 l 5 5 5 10 20 10") (p/parse-svg-path "m 10 80 10 -60 l 20 20 l -40 -30 z") + (p/parse-svg-path "M 100-200") + (let [[a b & rest] [1 2 3 4 5 6 7]] (peek rest)) From b43a673abd08b49587ca597cdc2f2df26b491407 Mon Sep 17 00:00:00 2001 From: Andrew Foltz-Morrison Date: Sun, 12 Mar 2023 11:55:43 -0400 Subject: [PATCH 09/13] Add failing tests for unimplemented command geometry types --- test/thi/ng/geom/test/types/path.cljc | 45 +++++++++++++++------------ 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/test/thi/ng/geom/test/types/path.cljc b/test/thi/ng/geom/test/types/path.cljc index 2653ccf..c54c60e 100644 --- a/test/thi/ng/geom/test/types/path.cljc +++ b/test/thi/ng/geom/test/types/path.cljc @@ -154,17 +154,15 @@ L 275 230" :num-segments 1} {:path "M 10 10 L 0.6.5" :description "multiple decimals within coordinate" - :num-segments 1}] - ) + :num-segments 1}]) (deftest svg-path-parse (testing "coordinate parsing" (is (= [0.6 0.5] (p/parse-svg-coords "0.6.5"))) - (is (= [100.0 -200.0] (p/parse-svg-coords "100-200") )) - ) + (is (= [100.0 -200.0] (p/parse-svg-coords "100-200")))) (testing "individual commands" - (let [[{:keys [type points]}] (p/move-to "m" [0 0] [[10 80] [10 -60] [10 -90]]) ] + (let [[{:keys [type points]}] (p/move-to "m" [0 0] [[10 80] [10 -60] [10 -90]])] (is (= :line type)) (is (= 3 (count points)) "Polyline should be produced from move command when defined"))) @@ -182,14 +180,11 @@ L 275 230" "SVG path should parse into the correct number of segments for the given shape") (is (some? (:segments path-geom)) "SVG path should parse into geometry object") - #_(is (= n-commands - (count (:segments path-geom))) - "Geometry should have the same number of segments as the number of path commands") - + (is (some? (g/sample-uniform path-geom 1.0 true)) + "Geometry object should be functional") (is (= segments (p/parse-svg-path no-commas)) "Comma placement should be irrelevant to parsing")))))) - (defn path-svg-grid "Generate Hiccup data for SVG export from example paths according a simple grid layout." [paths {:keys [width height] :as opts}] @@ -215,8 +210,7 @@ L 275 230" (* x x-interval) (* y y-interval)) :data-description description - :data-num-segments num-segments}] - ))) + :data-num-segments num-segments}]))) (reduce conj [:svg {:id "svg-path-test-data" :width width :height height}])))) @@ -225,19 +219,30 @@ L 275 230" ;; generate SVG for visual inspection from example paths (spit "assets/test-path-grid.svg" - (html {:mode :xml} (path-svg-grid svg-path-examples {:width 1000 :height 1000})) - ) + (html {:mode :xml} (path-svg-grid svg-path-examples {:width 1000 :height 1000}))) + + (g/sample-uniform + (types/->Path2 (p/parse-svg-path-old "M 10 10 L 20 20")) 2.5 true) + (g/sample-uniform (p/path2 (first (p/parse-svg-path-old "M 10 10 L 20 20"))) + 2.5 true) + (g/random-point + (types/->Path2 (p/parse-svg-path-old "M 10 10 L 10 10"))) + + (count svg-path-examples) (p/parse-svg-path (:path (first svg-path-examples))) (p/parse-svg-path (:path (nth svg-path-examples 1))) (p/parse-svg-path "m 10 80 l 20 20 l -40 -30 20 v 10 l 5 5 5 10 20 10") - (p/parse-svg-path "m 10 80 10 -60 l 20 20 l -40 -30 z") - - (p/parse-svg-path "M 100-200") + (-> "M 10 315 +L 110 215 +A 36 60 0 0 1 150.71 170.29 +L 172.55 152.45 +A 30 50 -45 0 1 215.1 109.9 +L 315 10" + (p/parse-svg-path) + types/->Path2) (let [[a b & rest] [1 2 3 4 5 6 7]] - (peek rest)) - - ) + (peek rest))) From b159f5c26678cd9c7cd2e370b9e92acae482130a Mon Sep 17 00:00:00 2001 From: Andrew Foltz-Morrison Date: Wed, 22 Mar 2023 08:37:05 -0400 Subject: [PATCH 10/13] Include relative info in parsed path + fix command dispatch The geometry tests still indicate that the parsed segments do not yet have a working geometric implementation. --- src/thi/ng/geom/path.cljc | 66 ++++++++++++++++++++++++--------------- 1 file changed, 40 insertions(+), 26 deletions(-) diff --git a/src/thi/ng/geom/path.cljc b/src/thi/ng/geom/path.cljc index cb6a1a4..9a82843 100644 --- a/src/thi/ng/geom/path.cljc +++ b/src/thi/ng/geom/path.cljc @@ -93,39 +93,53 @@ [nil start-pt])) (defn line-to [cmd current-pos pts] - (if (not= 1 (count pts)) - [{:type :line-string :points (reduce conj [current-pos] pts)} - (peek pts)] - [{:type :line :points [current-pos (first pts)]} - (first pts)])) + (let [rel (= "l" cmd)] + (if (not= 1 (count pts)) + [{:type :line-string :points (reduce conj [current-pos] pts) + :relative? rel} + (peek pts)] + [{:type :line :points [current-pos (first pts)] + :relative? rel} + (first pts)]))) (defn h-line-to [cmd [cx cy :as current-pos] [next-x & xs]] - (if xs - [{:type :line-string - :points (reduce (fn [pts x] (conj pts (vec2 x cy))) - [current-pos (vec2 next-x cy)] - xs)} - (vec2 (peek xs) cy)] - [{:type :line :points [current-pos (vec2 next-x cy)]} - (vec2 next-x cy)])) + (let [rel (= "h" cmd)] + (if xs + [{:type :line-string + :points (reduce (fn [pts x] (conj pts (vec2 x cy))) + [current-pos (vec2 next-x cy)] + xs) + :relative? rel} + (vec2 (peek xs) cy)] + [{:type :line :points [current-pos (vec2 next-x cy)] + :relative? rel} + (vec2 next-x cy)]))) (defn v-line-to [cmd [cx cy :as current-pos] [next-y & ys]] - (if ys - [{:type :line-string - :points (reduce (fn [pts y] (conj pts (vec2 cx y))) - [current-pos (vec2 cx next-y)] - ys)} - (vec2 cx (peek ys))] - [{:type :line :points [current-pos (vec2 cx next-y)]} - (vec2 cx next-y)])) + (let [rel (= "v" cmd)] + (if ys + [{:type :line-string + :points (reduce (fn [pts y] (conj pts (vec2 cx y))) + [current-pos (vec2 cx next-y)] + ys) + :relative? rel} + (vec2 cx (peek ys))] + [{:type :line :points [current-pos (vec2 cx next-y)] + :relative? rel} + (vec2 cx next-y)]))) (defn bezier-to [cmd current-pos pts] - [{:type :bezier :points (reduce conj [current-pos] pts)} - (peek pts)]) + (let [rel (some? (#{"c" "s" "q" "t"} cmd))] + [{:type :bezier :points (reduce conj [current-pos] pts) + :relative? rel} + (peek pts)])) (defn arc-to [cmd current-pos pts] - [{:type :arc :points (reduce conj [current-pos] [pts])} - (peek pts)]) + (let [rel (= "a" cmd)] + [{:type :arc :points (reduce conj [current-pos] [pts]) + :relative? rel} + (peek pts)])) + (defn parse-svg-path ([path-str] @@ -183,7 +197,7 @@ (lazy-seq (cons line-segment (parse-svg-path more (assoc pts :current new-pos))))) - "v" (let [[line-segment new-pos] (h-line-to cmd current coords)] + "v" (let [[line-segment new-pos] (v-line-to cmd current coords)] (lazy-seq (cons line-segment (parse-svg-path more (assoc pts :current new-pos))))) From 12d9b149934559ce38fcf8e3193cf7f4476ac5a0 Mon Sep 17 00:00:00 2001 From: Andrew Foltz-Morrison Date: Tue, 28 Mar 2023 09:06:25 -0400 Subject: [PATCH 11/13] =?UTF-8?q?Designate=20specific=20command=20names=20?= =?UTF-8?q?for=20specific=20B=C3=A9zier=20curve=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/thi/ng/geom/path.cljc | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/thi/ng/geom/path.cljc b/src/thi/ng/geom/path.cljc index 9a82843..4c48d7a 100644 --- a/src/thi/ng/geom/path.cljc +++ b/src/thi/ng/geom/path.cljc @@ -128,9 +128,27 @@ :relative? rel} (vec2 cx next-y)]))) -(defn bezier-to [cmd current-pos pts] - (let [rel (some? (#{"c" "s" "q" "t"} cmd))] - [{:type :bezier :points (reduce conj [current-pos] pts) +(defn cubic-to [cmd current-pos pts] + (let [rel (= "c" cmd)] + [{:type :cubic :points (reduce conj [current-pos] pts) + :relative? rel} + (peek pts)])) + +(defn cubic-chain-to [cmd current-pos pts] + (let [rel (= "s" cmd)] + [{:type :cubic-chain :points (reduce conj [current-pos] pts) + :relative? rel} + (peek pts)])) + +(defn quadratic-to [cmd current-pos pts] + (let [rel (= "q" cmd)] + [{:type :quadratic :points (reduce conj [current-pos] pts) + :relative? rel} + (peek pts)])) + +(defn quadratic-chain-to [cmd current-pos pts] + (let [rel (= "t" cmd)] + [{:type :quadratic-chain :points (reduce conj [current-pos] pts) :relative? rel} (peek pts)])) From 8bcc014b7e80b7f3cce21e2d090b6979f7450d7b Mon Sep 17 00:00:00 2001 From: Andrew Foltz-Morrison Date: Tue, 28 Mar 2023 09:26:24 -0400 Subject: [PATCH 12/13] Add more multimethods for segment types Plus, align segment definition with terminology used elsewhere in the library. --- src/thi/ng/geom/path.cljc | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/src/thi/ng/geom/path.cljc b/src/thi/ng/geom/path.cljc index 4c48d7a..0576f2e 100644 --- a/src/thi/ng/geom/path.cljc +++ b/src/thi/ng/geom/path.cljc @@ -22,11 +22,33 @@ [{[a b] :points} res last?] (gu/sample-segment-with-res a b res last?)) +#_(defmethod sample-segment :line-strip + [{points :points} res last?] + (gu/sample-uniform res)) + (defmethod sample-segment :close [{[a b] :points} res last?] (gu/sample-segment-with-res a b res last?)) -(defmethod sample-segment :bezier +;; Implementing geometry capabilities for the elliptical arc command +;; will involve building out geometry capabilities for the currently +;; largely unimplemented Ellipse2 type +#_(defmethod sample-segment :arc + nil) + +(defmethod sample-segment :cubic + [{points :points} res last?] + (b/sample-with-res res last? points)) + +(defmethod sample-segment :cubic-chain + [{points :points} res last?] + (b/sample-with-res res last? points)) + +(defmethod sample-segment :quadratic + [{points :points} res last?] + (b/sample-with-res res last? points)) + +(defmethod sample-segment :quadratic-chain [{points :points} res last?] (b/sample-with-res res last? points)) @@ -95,7 +117,7 @@ (defn line-to [cmd current-pos pts] (let [rel (= "l" cmd)] (if (not= 1 (count pts)) - [{:type :line-string :points (reduce conj [current-pos] pts) + [{:type :line-strip :points (reduce conj [current-pos] pts) :relative? rel} (peek pts)] [{:type :line :points [current-pos (first pts)] @@ -105,7 +127,7 @@ (defn h-line-to [cmd [cx cy :as current-pos] [next-x & xs]] (let [rel (= "h" cmd)] (if xs - [{:type :line-string + [{:type :line-strip :points (reduce (fn [pts x] (conj pts (vec2 x cy))) [current-pos (vec2 next-x cy)] xs) @@ -118,7 +140,7 @@ (defn v-line-to [cmd [cx cy :as current-pos] [next-y & ys]] (let [rel (= "v" cmd)] (if ys - [{:type :line-string + [{:type :line-strip :points (reduce (fn [pts y] (conj pts (vec2 cx y))) [current-pos (vec2 cx next-y)] ys) From 88ffa09d28d170ca58335c98ea12990dfce5f8f9 Mon Sep 17 00:00:00 2001 From: Andrew Foltz-Morrison Date: Wed, 29 Mar 2023 09:14:21 -0400 Subject: [PATCH 13/13] Add "move" segment (which is really just a point) This enables consistent sampling behavior on paths that consist only of a single move command --- src/thi/ng/geom/path.cljc | 36 ++++-- test/thi/ng/geom/test/types/path.cljc | 173 ++++++++------------------ 2 files changed, 74 insertions(+), 135 deletions(-) diff --git a/src/thi/ng/geom/path.cljc b/src/thi/ng/geom/path.cljc index 0576f2e..5e5720a 100644 --- a/src/thi/ng/geom/path.cljc +++ b/src/thi/ng/geom/path.cljc @@ -18,13 +18,17 @@ (defmulti sample-segment (fn [s res last?] (get s :type))) +(defmethod sample-segment :move + [{pt :point} res last?] + (gu/sample-uniform res last? [pt])) + (defmethod sample-segment :line [{[a b] :points} res last?] (gu/sample-segment-with-res a b res last?)) -#_(defmethod sample-segment :line-strip - [{points :points} res last?] - (gu/sample-uniform res)) +(defmethod sample-segment :line-strip + [{points :points} res last?] + (gu/sample-uniform res last? points)) (defmethod sample-segment :close [{[a b] :points} res last?] @@ -32,7 +36,7 @@ ;; Implementing geometry capabilities for the elliptical arc command ;; will involve building out geometry capabilities for the currently -;; largely unimplemented Ellipse2 type +;; largely unimplemented Ellipse2 type - or an Arc2 type #_(defmethod sample-segment :arc nil) @@ -98,6 +102,9 @@ (parse-svg-path "M 10,80 20,20 40,40 0,10 Z") + (parse-svg-path "m 10 80") + (parse-svg-path "M 10 80") + (re-seq cmd-regex "M 10,80 20,20 40,40 0,10 Z") ) @@ -112,7 +119,8 @@ [{:type :line :points line} (peek line)]) ;; standard move: return only the current position - [nil start-pt])) + [{:type :move :point start-pt} + start-pt])) (defn line-to [cmd current-pos pts] (let [rel (= "l" cmd)] @@ -241,35 +249,35 @@ (lazy-seq (cons line-segment (parse-svg-path more (assoc pts :current new-pos))))) - "Q" (let [[line-segment new-pos] (bezier-to cmd current coords)] + "Q" (let [[line-segment new-pos] (quadratic-to cmd current coords)] (lazy-seq (cons line-segment (parse-svg-path more (assoc pts :current new-pos))))) - "q" (let [[line-segment new-pos] (bezier-to cmd current coords)] + "q" (let [[line-segment new-pos] (quadratic-to cmd current coords)] (lazy-seq (cons line-segment (parse-svg-path more (assoc pts :current new-pos))))) - "T" (let [[line-segment new-pos] (bezier-to cmd current coords)] + "T" (let [[line-segment new-pos] (quadratic-chain-to cmd current coords)] (lazy-seq (cons line-segment (parse-svg-path more (assoc pts :current new-pos))))) - "t" (let [[line-segment new-pos] (bezier-to cmd current coords)] + "t" (let [[line-segment new-pos] (quadratic-chain-to cmd current coords)] (lazy-seq (cons line-segment (parse-svg-path more (assoc pts :current new-pos))))) - "C" (let [[line-segment new-pos] (bezier-to cmd current coords)] + "C" (let [[line-segment new-pos] (cubic-to cmd current coords)] (lazy-seq (cons line-segment (parse-svg-path more (assoc pts :current new-pos))))) - "c" (let [[line-segment new-pos] (bezier-to cmd current coords)] + "c" (let [[line-segment new-pos] (cubic-to cmd current coords)] (lazy-seq (cons line-segment (parse-svg-path more (assoc pts :current new-pos))))) - "S" (let [[line-segment new-pos] (bezier-to cmd current coords)] + "S" (let [[line-segment new-pos] (cubic-chain-to cmd current coords)] (lazy-seq (cons line-segment (parse-svg-path more (assoc pts :current new-pos))))) - "s" (let [[line-segment new-pos] (bezier-to cmd current coords)] + "s" (let [[line-segment new-pos] (cubic-chain-to cmd current coords)] (lazy-seq (cons line-segment (parse-svg-path more (assoc pts :current new-pos))))) @@ -322,6 +330,8 @@ (parse-svg-path-old "M 10 10 C 20 20, 40 20, 50 10") (parse-svg-path-old "M 10 10 ") + (gu/sample-uniform 1.0 true [(vec2 10 10)]) + ) #?(:clj diff --git a/test/thi/ng/geom/test/types/path.cljc b/test/thi/ng/geom/test/types/path.cljc index c54c60e..f2a2ad2 100644 --- a/test/thi/ng/geom/test/types/path.cljc +++ b/test/thi/ng/geom/test/types/path.cljc @@ -28,133 +28,62 @@ https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorial/Paths https://developer.mozilla.org/en-US/docs/Web/SVG/Element/path https://www.w3.org/TR/SVG11/paths.html#PathElement" - [{:path "M 100 100 L 300 100 L 200 300 z" - :description "triangle" + [{:path "M 100 100 L 300 100 L 200 300 z", :description "triangle", :num-segments 4} + {:path "M 10 10 H 90 V 90 H 10 L 10 10", :description "square", :num-segments 5} + {:path "M 10,30\nA 20,20 0,0,1 50,30\nA 20,20 0,0,1 90,30\nQ 90,60 50,90\nQ 10,60 10,30 z", + :description "heart", :num-segments 6} + {:path "M 10,80 20,20 40,40 0,10 Z", + :description "implicit polyline after move command", :num-segments 2} + {:path "m 10,80 20,20 40,40 0,10 z", + :description "implicit polyline after move command (relative)", :num-segments 2} + {:path "m 10 80 10 -60 l 20 20 l -40 -30 v 10 l 5 5 5 10 20 10", + :description "implicit polyline after move command + explicit polyline", :num-segments 5} + {:path "M 10 10 C 20 20, 40 20, 50 10", + :description "cubic curve", :num-segments 2} + {:path "M 70 10 C 70 20, 110 20, 110 10", + :description "cubic curve", :num-segments 2} + {:path "M 130 10 C 120 20, 180 20, 170 10", + :description "cubic curve", :num-segments 2} + {:path "M 10 60 C 20 80, 40 80, 50 60", + :description "cubic curve", :num-segments 2} + {:path "M 70 60 C 70 80, 110 80, 110 60", + :description "cubic curve", :num-segments 2} + {:path "M 130 60 C 120 80, 180 80, 170 60", + :description "cubic curve", :num-segments 2} + {:path "M 10 110 C 20 140, 40 140, 50 110", + :description "cubic curve", :num-segments 2} + {:path "M 70 110 C 70 140, 110 140, 110 110", + :description "cubic curve", :num-segments 2} + {:path "M 130 110 C 120 140, 180 140, 170 110", + :description "cubic curve", :num-segments 2} + {:path "M 10 80 C 40 10, 65 10, 95 80 S 150 150, 180 80", + :description "smooth cubic curve", :num-segments 3} - {:path "M 10 10 H 90 V 90 H 10 L 10 10" - :description "square" + {:path "M 10 80 Q 95 10 180 80", :description "quadratic curve", :num-segments 2} + {:path "M 10 80 Q 52.5 10, 95 80 T 180 80", :description "quadratic curve (shorthand)", + :num-segments 3} + {:path "M 10 315\nL 110 215\nA 30 50 0 0 1 162.55 162.45\nL 172.55 152.45\nA 30 50 -45 0 1 215.1 109.9\nL 315 10", :description "arc", + :num-segments 6} + {:path "M 10 315\nL 110 215\nA 36 60 0 0 1 150.71 170.29\nL 172.55 152.45\nA 30 50 -45 0 1 215.1 109.9\nL 315 10", :description "arc", + :num-segments 6} + {:path "M 80 80\nA 45 45, 0, 0, 0, 125 125\nL 125 80 Z", :description "arc", :num-segments 4} - ;; heart - {:path - "M 10,30 -A 20,20 0,0,1 50,30 -A 20,20 0,0,1 90,30 -Q 90,60 50,90 -Q 10,60 10,30 z" - :description "heart" - :num-segments 5} - {:path "M 10,80 20,20 40,40 0,10 Z" - :description "implicit polyline after move command" - :num-segments 2} - ;; relative equivalent of above shape - {:path "m 10,80 20,20 40,40 0,10 z" - :description "implicit polyline after move command (relative)" - :num-segments 2} - - ;; implicit move + explicit polyline - {:path "m 10 80 10 -60 l 20 20 l -40 -30 v 10 l 5 5 5 10 20 10" - :description "implicit polyline after move command + explicit polyline" - :num-segments 5} - - ;; cubic curves - {:path "M 10 10 C 20 20, 40 20, 50 10" - :description "cubic curve" - :num-segments 1} - {:path "M 70 10 C 70 20, 110 20, 110 10" - :description "cubic curve" - :num-segments 1} - {:path "M 130 10 C 120 20, 180 20, 170 10" - :description "cubic curve" - :num-segments 1} - {:path "M 10 60 C 20 80, 40 80, 50 60" - :description "cubic curve" - :num-segments 1} - {:path "M 70 60 C 70 80, 110 80, 110 60" - :description "cubic curve" - :num-segments 1} - {:path "M 130 60 C 120 80, 180 80, 170 60" - :description "cubic curve" - :num-segments 1} - {:path "M 10 110 C 20 140, 40 140, 50 110" - :description "cubic curve" - :num-segments 1} - {:path "M 70 110 C 70 140, 110 140, 110 110" - :description "cubic curve" - :num-segments 1} - {:path "M 130 110 C 120 140, 180 140, 170 110" - :description "cubic curve" - :num-segments 1} - ;; smooth cubic - {:path "M 10 80 C 40 10, 65 10, 95 80 S 150 150, 180 80" - :description "smooth cubic curve" - :num-segments 2} - ;; quadratic - {:path "M 10 80 Q 95 10 180 80" - :description "quadratic curve" + {:path "M 230 80\nA 45 45, 0, 1, 0, 275 125\nL 275 80 Z", :description "arc", + :num-segments 4} + {:path "M 80 230\nA 45 45, 0, 0, 1, 125 275\nL 125 230 Z", :description "arc", + :num-segments 4} + {:path "M 230 230\nA 45 45, 0, 1, 1, 275 275\nL 275 230", :description "arc", + :num-segments 3} + {:path "m 10 10", :description "move command without lines", :num-segments 1} - ;; quadratic shorthand - {:path "M 10 80 Q 52.5 10, 95 80 T 180 80" - :description "quadratic curve (shorthand)" - :num-segments 2} - ;; arcs - {:path - "M 10 315 -L 110 215 -A 30 50 0 0 1 162.55 162.45 -L 172.55 152.45 -A 30 50 -45 0 1 215.1 109.9 -L 315 10" - :description "arc" + {:path "m 10 80 l 20 20 l -40 -30 20 v 10 l 5 5 5 10 20 10", + :description "extra coordinate not in an appropriate pair for line command", :num-segments 5} - {:path - "M 10 315 -L 110 215 -A 36 60 0 0 1 150.71 170.29 -L 172.55 152.45 -A 30 50 -45 0 1 215.1 109.9 -L 315 10" - :description "arc" - :num-segments 5} - {:path - "M 80 80 -A 45 45, 0, 0, 0, 125 125 -L 125 80 Z" - :description "arc" - :num-segments 3} - {:path - "M 230 80 -A 45 45, 0, 1, 0, 275 125 -L 275 80 Z" - :description "arc" - :num-segments 3} - {:path - "M 80 230 -A 45 45, 0, 0, 1, 125 275 -L 125 230 Z" - :description "arc" - :num-segments 3} - {:path - "M 230 230 -A 45 45, 0, 1, 1, 275 275 -L 275 230" - :description "arc" + {:path "M 10 10 L 100-200", :description "no whitespace following coordinate", :num-segments 2} - - ;; move only, no lines - {:path "m 10 10" - :description "move command without lines" - :num-segments 0} - - ;; incorrect / pathological / flawed examples - {:path "m 10 80 l 20 20 l -40 -30 20 v 10 l 5 5 5 10 20 10" - :description "extra coordinate not in an appropriate pair for line command" - :num-segments 4} - {:path "M 10 10 L 100-200" - :description "no whitespace following coordinate" - :num-segments 1} - {:path "M 10 10 L 0.6.5" - :description "multiple decimals within coordinate" - :num-segments 1}]) + {:path "M 10 10 L 0.6.5", :description "multiple decimals within coordinate", + :num-segments 2}] + ) (deftest svg-path-parse (testing "coordinate parsing"