diff --git a/common/test/common_tests/types/path_data_test.cljc b/common/test/common_tests/types/path_data_test.cljc index 672df228d8..d1a70707b2 100644 --- a/common/test/common_tests/types/path_data_test.cljc +++ b/common/test/common_tests/types/path_data_test.cljc @@ -10,6 +10,7 @@ [app.common.data :as d] [app.common.geom.matrix :as gmt] [app.common.geom.point :as gpt] + [app.common.geom.rect :as grc] [app.common.math :as mth] [app.common.pprint :as pp] [app.common.transit :as trans] @@ -18,6 +19,7 @@ [app.common.types.path.helpers :as path.helpers] [app.common.types.path.impl :as path.impl] [app.common.types.path.segment :as path.segment] + [app.common.types.path.subpath :as path.subpath] [clojure.test :as t])) (def sample-content @@ -537,3 +539,693 @@ (t/deftest calculate-bool-content (let [result (path.bool/calculate-content :union contents-for-bool)] (t/is (= result bool-result)))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; SUBPATH TESTS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest subpath-pt= + (t/testing "pt= returns true for nearby points" + (t/is (path.subpath/pt= (gpt/point 0 0) (gpt/point 0.05 0.05)))) + (t/testing "pt= returns false for distant points" + (t/is (not (path.subpath/pt= (gpt/point 0 0) (gpt/point 1 0)))))) + +(t/deftest subpath-make-subpath + (t/testing "make-subpath from a single move-to command" + (let [cmd {:command :move-to :params {:x 5.0 :y 10.0}} + sp (path.subpath/make-subpath cmd)] + (t/is (= (gpt/point 5.0 10.0) (:from sp))) + (t/is (= (gpt/point 5.0 10.0) (:to sp))) + (t/is (= [cmd] (:data sp))))) + (t/testing "make-subpath from explicit from/to/data" + (let [from (gpt/point 0 0) + to (gpt/point 10 10) + data [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 10 :y 10}}] + sp (path.subpath/make-subpath from to data)] + (t/is (= from (:from sp))) + (t/is (= to (:to sp))) + (t/is (= data (:data sp)))))) + +(t/deftest subpath-add-subpath-command + (t/testing "adding a line-to command extends the subpath" + (let [cmd0 {:command :move-to :params {:x 0.0 :y 0.0}} + cmd1 {:command :line-to :params {:x 5.0 :y 5.0}} + sp (-> (path.subpath/make-subpath cmd0) + (path.subpath/add-subpath-command cmd1))] + (t/is (= (gpt/point 5.0 5.0) (:to sp))) + (t/is (= 2 (count (:data sp)))))) + (t/testing "adding a close-path is replaced by a line-to at from" + (let [cmd0 {:command :move-to :params {:x 1.0 :y 2.0}} + sp (path.subpath/make-subpath cmd0) + sp2 (path.subpath/add-subpath-command sp {:command :close-path :params {}})] + ;; The close-path gets replaced by a line-to back to :from + (t/is (= (gpt/point 1.0 2.0) (:to sp2)))))) + +(t/deftest subpath-reverse-command + (let [prev {:command :move-to :params {:x 0.0 :y 0.0}} + cmd {:command :line-to :params {:x 5.0 :y 3.0}} + rev (path.subpath/reverse-command cmd prev)] + (t/is (= :line-to (:command rev))) + (t/is (= 0.0 (get-in rev [:params :x]))) + (t/is (= 0.0 (get-in rev [:params :y]))))) + +(t/deftest subpath-reverse-command-curve + (let [prev {:command :move-to :params {:x 0.0 :y 0.0}} + cmd {:command :curve-to + :params {:x 10.0 :y 0.0 :c1x 3.0 :c1y 5.0 :c2x 7.0 :c2y 5.0}} + rev (path.subpath/reverse-command cmd prev)] + ;; end-point should be previous point coords + (t/is (= 0.0 (get-in rev [:params :x]))) + (t/is (= 0.0 (get-in rev [:params :y]))) + ;; handlers are swapped + (t/is (= 7.0 (get-in rev [:params :c1x]))) + (t/is (= 5.0 (get-in rev [:params :c1y]))) + (t/is (= 3.0 (get-in rev [:params :c2x]))) + (t/is (= 5.0 (get-in rev [:params :c2y]))))) + +(def ^:private simple-open-content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 10.0}}]) + +(def ^:private simple-closed-content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 10.0}} + {:command :line-to :params {:x 0.0 :y 0.0}}]) + +(t/deftest subpath-get-subpaths + (t/testing "open path produces one subpath" + (let [sps (path.subpath/get-subpaths simple-open-content)] + (t/is (= 1 (count sps))) + (t/is (= (gpt/point 0.0 0.0) (get-in sps [0 :from]))))) + (t/testing "content with two move-to produces two subpaths" + (let [content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 5.0 :y 0.0}} + {:command :move-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 15.0 :y 0.0}}] + sps (path.subpath/get-subpaths content)] + (t/is (= 2 (count sps)))))) + +(t/deftest subpath-is-closed? + (t/testing "subpath with same from/to is closed" + (let [sp (path.subpath/make-subpath (gpt/point 0 0) (gpt/point 0 0) [])] + (t/is (path.subpath/is-closed? sp)))) + (t/testing "subpath with different from/to is not closed" + (let [sp (path.subpath/make-subpath (gpt/point 0 0) (gpt/point 10 10) [])] + (t/is (not (path.subpath/is-closed? sp)))))) + +(t/deftest subpath-reverse-subpath + (let [content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 10.0}}] + sps (path.subpath/get-subpaths content) + sp (first sps) + rev (path.subpath/reverse-subpath sp)] + (t/is (= (:to sp) (:from rev))) + (t/is (= (:from sp) (:to rev))) + ;; reversed data starts with a move-to at old :to + (t/is (= :move-to (get-in rev [:data 0 :command]))))) + +(t/deftest subpath-subpaths-join + (let [sp1 (path.subpath/make-subpath (gpt/point 0 0) (gpt/point 5 0) + [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 5 :y 0}}]) + sp2 (path.subpath/make-subpath (gpt/point 5 0) (gpt/point 10 0) + [{:command :move-to :params {:x 5 :y 0}} + {:command :line-to :params {:x 10 :y 0}}]) + joined (path.subpath/subpaths-join sp1 sp2)] + (t/is (= (gpt/point 0 0) (:from joined))) + (t/is (= (gpt/point 10 0) (:to joined))) + ;; data has move-to from sp1 + line-to from sp1 + line-to from sp2 (rest of sp2) + (t/is (= 3 (count (:data joined)))))) + +(t/deftest subpath-close-subpaths + (t/testing "content that is already a closed triangle stays closed" + (let [result (path.subpath/close-subpaths simple-closed-content)] + (t/is (seq result)))) + (t/testing "two open fragments that form a closed loop get merged" + ;; fragment A: 0,0 → 5,0 + ;; fragment B: 10,0 → 5,0 (reversed, connects to A's end) + ;; After close-subpaths the result should have segments + (let [content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 5.0 :y 0.0}} + {:command :move-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 5.0 :y 0.0}}] + result (path.subpath/close-subpaths content)] + (t/is (seq result))))) + +(t/deftest subpath-reverse-content + (let [result (path.subpath/reverse-content simple-open-content)] + (t/is (= (count simple-open-content) (count result))) + ;; First command of reversed content is a move-to at old end + (t/is (= :move-to (:command (first result)))) + (t/is (mth/close? 10.0 (get-in (first result) [:params :x]))) + (t/is (mth/close? 10.0 (get-in (first result) [:params :y]))))) + +(t/deftest subpath-clockwise? + (t/testing "square drawn clockwise is detected as clockwise" + ;; A square drawn clockwise: top-left → top-right → bottom-right → bottom-left + (let [cw-content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 10.0}} + {:command :line-to :params {:x 0.0 :y 10.0}} + {:command :line-to :params {:x 0.0 :y 0.0}}]] + (t/is (path.subpath/clockwise? cw-content)))) + (t/testing "counter-clockwise square is not clockwise" + (let [ccw-content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 0.0 :y 10.0}} + {:command :line-to :params {:x 10.0 :y 10.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 0.0 :y 0.0}}]] + (t/is (not (path.subpath/clockwise? ccw-content)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; HELPERS TESTS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest helpers-s= + (t/is (path.helpers/s= 0.0 0.0)) + (t/is (path.helpers/s= 1.0 1.0000000001)) + (t/is (not (path.helpers/s= 0.0 1.0)))) + +(t/deftest helpers-make-move-to + (let [pt (gpt/point 3.0 7.0) + cmd (path.helpers/make-move-to pt)] + (t/is (= :move-to (:command cmd))) + (t/is (= 3.0 (get-in cmd [:params :x]))) + (t/is (= 7.0 (get-in cmd [:params :y]))))) + +(t/deftest helpers-make-line-to + (let [pt (gpt/point 4.0 8.0) + cmd (path.helpers/make-line-to pt)] + (t/is (= :line-to (:command cmd))) + (t/is (= 4.0 (get-in cmd [:params :x]))) + (t/is (= 8.0 (get-in cmd [:params :y]))))) + +(t/deftest helpers-make-curve-params + (t/testing "single point form — point and handlers coincide" + (let [p (gpt/point 5.0 5.0) + params (path.helpers/make-curve-params p)] + (t/is (mth/close? 5.0 (:x params))) + (t/is (mth/close? 5.0 (:c1x params))) + (t/is (mth/close? 5.0 (:c2x params))))) + (t/testing "two-arg form — handler specified" + (let [p (gpt/point 10.0 0.0) + h (gpt/point 5.0 5.0) + params (path.helpers/make-curve-params p h)] + (t/is (mth/close? 10.0 (:x params))) + (t/is (mth/close? 5.0 (:c1x params))) + ;; c2 defaults to point + (t/is (mth/close? 10.0 (:c2x params)))))) + +(t/deftest helpers-make-curve-to + (let [to (gpt/point 10.0 0.0) + h1 (gpt/point 3.0 5.0) + h2 (gpt/point 7.0 5.0) + cmd (path.helpers/make-curve-to to h1 h2)] + (t/is (= :curve-to (:command cmd))) + (t/is (= 10.0 (get-in cmd [:params :x]))) + (t/is (= 3.0 (get-in cmd [:params :c1x]))) + (t/is (= 7.0 (get-in cmd [:params :c2x]))))) + +(t/deftest helpers-update-curve-to + (let [base {:command :line-to :params {:x 10.0 :y 0.0}} + h1 (gpt/point 3.0 5.0) + h2 (gpt/point 7.0 5.0) + cmd (path.helpers/update-curve-to base h1 h2)] + (t/is (= :curve-to (:command cmd))) + (t/is (= 3.0 (get-in cmd [:params :c1x]))) + (t/is (= 7.0 (get-in cmd [:params :c2x]))))) + +(t/deftest helpers-prefix->coords + (t/is (= [:c1x :c1y] (path.helpers/prefix->coords :c1))) + (t/is (= [:c2x :c2y] (path.helpers/prefix->coords :c2))) + (t/is (nil? (path.helpers/prefix->coords nil)))) + +(t/deftest helpers-position-fixed-angle + (t/testing "returns point unchanged when from-point is nil" + (let [pt (gpt/point 5.0 3.0)] + (t/is (= pt (path.helpers/position-fixed-angle pt nil))))) + (t/testing "snaps to nearest 45-degree angle" + (let [from (gpt/point 0 0) + ;; Angle ~30° from from, should snap to 45° + to (gpt/point 10 6) + snapped (path.helpers/position-fixed-angle to from)] + ;; result should have same distance + (let [d-orig (gpt/distance to from) + d-snapped (gpt/distance snapped from)] + (t/is (mth/close? d-orig d-snapped 0.01)))))) + +(t/deftest helpers-command->line + (let [prev {:command :move-to :params {:x 0.0 :y 0.0}} + cmd {:command :line-to :params {:x 5.0 :y 3.0} :prev (gpt/point 0 0)} + [from to] (path.helpers/command->line cmd (path.helpers/segment->point prev))] + (t/is (= (gpt/point 0.0 0.0) from)) + (t/is (= (gpt/point 5.0 3.0) to)))) + +(t/deftest helpers-command->bezier + (let [prev {:command :move-to :params {:x 0.0 :y 0.0}} + cmd {:command :curve-to + :params {:x 10.0 :y 0.0 :c1x 3.0 :c1y 5.0 :c2x 7.0 :c2y 5.0}} + [from to h1 h2] (path.helpers/command->bezier cmd (path.helpers/segment->point prev))] + (t/is (= (gpt/point 0.0 0.0) from)) + (t/is (= (gpt/point 10.0 0.0) to)) + (t/is (= (gpt/point 3.0 5.0) h1)) + (t/is (= (gpt/point 7.0 5.0) h2)))) + +(t/deftest helpers-line-values + (let [from (gpt/point 0.0 0.0) + to (gpt/point 10.0 0.0) + mid (path.helpers/line-values [from to] 0.5)] + (t/is (mth/close? 5.0 (:x mid))) + (t/is (mth/close? 0.0 (:y mid))))) + +(t/deftest helpers-curve-split + (let [start (gpt/point 0.0 0.0) + end (gpt/point 10.0 0.0) + h1 (gpt/point 3.0 5.0) + h2 (gpt/point 7.0 5.0) + [[s1 e1 _ _] [s2 e2 _ _]] (path.helpers/curve-split start end h1 h2 0.5)] + ;; First sub-curve starts at start and ends near midpoint + (t/is (mth/close? 0.0 (:x s1) 0.01)) + (t/is (mth/close? 10.0 (:x e2) 0.01)) + ;; The split point (e1 / s2) should be the same + (t/is (mth/close? (:x e1) (:x s2) 0.01)) + (t/is (mth/close? (:y e1) (:y s2) 0.01)))) + +(t/deftest helpers-split-line-to + (let [from (gpt/point 0.0 0.0) + seg {:command :line-to :params {:x 10.0 :y 0.0}} + [s1 s2] (path.helpers/split-line-to from seg 0.5)] + (t/is (= :line-to (:command s1))) + (t/is (mth/close? 5.0 (get-in s1 [:params :x]))) + (t/is (= s2 seg)))) + +(t/deftest helpers-split-curve-to + (let [from (gpt/point 0.0 0.0) + seg {:command :curve-to + :params {:x 10.0 :y 0.0 :c1x 3.0 :c1y 5.0 :c2x 7.0 :c2y 5.0}} + [s1 s2] (path.helpers/split-curve-to from seg 0.5)] + (t/is (= :curve-to (:command s1))) + (t/is (= :curve-to (:command s2))) + ;; s2 ends at original endpoint + (t/is (mth/close? 10.0 (get-in s2 [:params :x]) 0.01)) + (t/is (mth/close? 0.0 (get-in s2 [:params :y]) 0.01)))) + +(t/deftest helpers-split-line-to-ranges + (t/testing "no split values returns original segment" + (let [from (gpt/point 0.0 0.0) + seg {:command :line-to :params {:x 10.0 :y 0.0}} + result (path.helpers/split-line-to-ranges from seg [])] + (t/is (= [seg] result)))) + (t/testing "splits at 0.25 and 0.75 produces 3 segments" + (let [from (gpt/point 0.0 0.0) + seg {:command :line-to :params {:x 10.0 :y 0.0}} + result (path.helpers/split-line-to-ranges from seg [0.25 0.75])] + (t/is (= 3 (count result)))))) + +(t/deftest helpers-split-curve-to-ranges + (t/testing "no split values returns original segment" + (let [from (gpt/point 0.0 0.0) + seg {:command :curve-to + :params {:x 10.0 :y 0.0 :c1x 3.0 :c1y 5.0 :c2x 7.0 :c2y 5.0}} + result (path.helpers/split-curve-to-ranges from seg [])] + (t/is (= [seg] result)))) + (t/testing "split at 0.5 produces 2 segments" + (let [from (gpt/point 0.0 0.0) + seg {:command :curve-to + :params {:x 10.0 :y 0.0 :c1x 3.0 :c1y 5.0 :c2x 7.0 :c2y 5.0}} + result (path.helpers/split-curve-to-ranges from seg [0.5])] + (t/is (= 2 (count result)))))) + +(t/deftest helpers-line-has-point? + (let [from (gpt/point 0.0 0.0) + to (gpt/point 10.0 0.0)] + (t/is (path.helpers/line-has-point? (gpt/point 5.0 0.0) [from to])) + (t/is (not (path.helpers/line-has-point? (gpt/point 5.0 1.0) [from to]))))) + +(t/deftest helpers-segment-has-point? + (let [from (gpt/point 0.0 0.0) + to (gpt/point 10.0 0.0)] + (t/is (path.helpers/segment-has-point? (gpt/point 5.0 0.0) [from to])) + ;; Outside segment bounds even though on same infinite line + (t/is (not (path.helpers/segment-has-point? (gpt/point 15.0 0.0) [from to]))))) + +(t/deftest helpers-curve-has-point? + (let [start (gpt/point 0.0 0.0) + end (gpt/point 10.0 0.0) + h1 (gpt/point 0.0 0.0) + h2 (gpt/point 10.0 0.0) + ;; degenerate curve (same as line) — midpoint should be on it + curve [start end h1 h2]] + (t/is (path.helpers/curve-has-point? (gpt/point 5.0 0.0) curve)) + (t/is (not (path.helpers/curve-has-point? (gpt/point 5.0 100.0) curve))))) + +(t/deftest helpers-curve-tangent + (let [start (gpt/point 0.0 0.0) + end (gpt/point 10.0 0.0) + h1 (gpt/point 3.0 0.0) + h2 (gpt/point 7.0 0.0) + tangent (path.helpers/curve-tangent [start end h1 h2] 0.5)] + ;; For a nearly-horizontal curve, the tangent y-component is small + (t/is (mth/close? 1.0 (:x tangent) 0.01)) + (t/is (mth/close? 0.0 (:y tangent) 0.01)))) + +(t/deftest helpers-curve->lines + (let [start (gpt/point 0.0 0.0) + end (gpt/point 10.0 0.0) + h1 (gpt/point 3.0 5.0) + h2 (gpt/point 7.0 5.0) + lines (path.helpers/curve->lines start end h1 h2)] + ;; curve->lines produces num-segments lines (10 by default, closed [0..1] => 11 pairs) + (t/is (pos? (count lines))) + (t/is (= 2 (count (first lines)))))) + +(t/deftest helpers-line-line-intersect + (t/testing "perpendicular lines intersect" + (let [l1 [(gpt/point 5.0 0.0) (gpt/point 5.0 10.0)] + l2 [(gpt/point 0.0 5.0) (gpt/point 10.0 5.0)] + result (path.helpers/line-line-intersect l1 l2)] + (t/is (some? result)))) + (t/testing "parallel lines do not intersect" + (let [l1 [(gpt/point 0.0 0.0) (gpt/point 10.0 0.0)] + l2 [(gpt/point 0.0 5.0) (gpt/point 10.0 5.0)] + result (path.helpers/line-line-intersect l1 l2)] + (t/is (nil? result))))) + +(t/deftest helpers-subcurve-range + (let [start (gpt/point 0.0 0.0) + end (gpt/point 10.0 0.0) + h1 (gpt/point 3.0 5.0) + h2 (gpt/point 7.0 5.0) + [s e _ _] (path.helpers/subcurve-range start end h1 h2 0.25 0.75)] + ;; sub-curve should start near t=0.25 and end near t=0.75 + (t/is (some? s)) + (t/is (some? e)))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; SEGMENT UNTESTED FUNCTIONS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest segment-update-handler + (let [cmd {:command :curve-to + :params {:x 10.0 :y 0.0 :c1x 0.0 :c1y 0.0 :c2x 0.0 :c2y 0.0}} + pt (gpt/point 3.0 5.0) + r (path.segment/update-handler cmd :c1 pt)] + (t/is (= 3.0 (get-in r [:params :c1x]))) + (t/is (= 5.0 (get-in r [:params :c1y]))))) + +(t/deftest segment-get-handler + (let [cmd {:command :curve-to + :params {:x 10.0 :y 0.0 :c1x 3.0 :c1y 5.0 :c2x 7.0 :c2y 2.0}}] + (t/is (= (gpt/point 3.0 5.0) (path.segment/get-handler cmd :c1))) + (t/is (= (gpt/point 7.0 2.0) (path.segment/get-handler cmd :c2))) + (t/is (nil? (path.segment/get-handler {:command :line-to :params {:x 1 :y 2}} :c1))))) + +(t/deftest segment-handler->node + (let [content (path/content sample-content-2)] + ;; For :c1 prefix, the node is the previous segment + (let [node (path.segment/handler->node (vec content) 2 :c1)] + (t/is (some? node))) + ;; For :c2 prefix, the node is the current segment's endpoint + (let [node (path.segment/handler->node (vec content) 2 :c2)] + (t/is (some? node))))) + +(t/deftest segment-calculate-opposite-handler + (let [pt (gpt/point 5.0 5.0) + h (gpt/point 8.0 5.0) + opp (path.segment/calculate-opposite-handler pt h)] + (t/is (mth/close? 2.0 (:x opp))) + (t/is (mth/close? 5.0 (:y opp))))) + +(t/deftest segment-opposite-handler + (let [pt (gpt/point 5.0 5.0) + h (gpt/point 8.0 5.0) + opp (path.segment/opposite-handler pt h)] + (t/is (mth/close? 2.0 (:x opp))) + (t/is (mth/close? 5.0 (:y opp))))) + +(t/deftest segment-point-indices + (let [content (path/content sample-content-2) + pt (gpt/point 480.0 839.0) + idxs (path.segment/point-indices content pt)] + (t/is (= [0] (vec idxs))))) + +(t/deftest segment-opposite-index + (let [content (path/content sample-content-2)] + ;; Index 2 with :c2 prefix — the node is the current point of index 2 + (let [result (path.segment/opposite-index content 2 :c2)] + ;; result is either nil or [index prefix] + (t/is (or (nil? result) (vector? result)))))) + +(t/deftest segment-split-segments + (let [content (path/content sample-content-square) + points #{(gpt/point 10.0 0.0) + (gpt/point 0.0 0.0)} + result (path.segment/split-segments content points 0.5)] + ;; result should have more segments than original (splits added) + (t/is (> (count result) (count sample-content-square))))) + +(t/deftest segment-content->selrect + (let [content (path/content sample-content-square) + rect (path.segment/content->selrect content)] + (t/is (some? rect)) + (t/is (mth/close? 0.0 (:x1 rect) 0.1)) + (t/is (mth/close? 0.0 (:y1 rect) 0.1)) + (t/is (mth/close? 10.0 (:x2 rect) 0.1)) + (t/is (mth/close? 10.0 (:y2 rect) 0.1)))) + +(t/deftest segment-content-center + (let [content (path/content sample-content-square) + center (path.segment/content-center content)] + (t/is (some? center)) + (t/is (mth/close? 5.0 (:x center) 0.1)) + (t/is (mth/close? 5.0 (:y center) 0.1)))) + +(t/deftest segment-move-content + (let [content (path/content sample-content-square) + move-vec (gpt/point 5.0 5.0) + result (path.segment/move-content content move-vec) + first-seg (first (vec result))] + (t/is (= :move-to (:command first-seg))) + (t/is (mth/close? 5.0 (get-in first-seg [:params :x]))))) + +(t/deftest segment-is-curve? + (let [content (path/content sample-content-2)] + ;; point at index 0 is 480,839 — no handler offset, not a curve + (let [pt (gpt/point 480.0 839.0)] + ;; is-curve? can return nil (falsy) or boolean — just check it doesn't throw + (t/is (not (path.segment/is-curve? content pt)))) + ;; A point that is reached by a curve-to command should be detectable + (let [curve-pt (gpt/point 4.0 4.0)] + (t/is (or (nil? (path.segment/is-curve? content curve-pt)) + (boolean? (path.segment/is-curve? content curve-pt))))))) + +(t/deftest segment-append-segment + (let [content (path/content sample-content) + seg {:command :line-to :params {:x 100.0 :y 100.0}} + result (path.segment/append-segment content seg)] + (t/is (= (inc (count (vec content))) (count result))))) + +(t/deftest segment-remove-nodes + (let [content (path/content simple-open-content) + ;; remove the midpoint + pt (gpt/point 10.0 0.0) + result (path.segment/remove-nodes content #{pt})] + ;; should have fewer segments + (t/is (< (count result) (count simple-open-content))))) + +(t/deftest segment-join-nodes + (let [content (path/content simple-open-content) + pt1 (gpt/point 0.0 0.0) + pt2 (gpt/point 10.0 10.0) + result (path.segment/join-nodes content #{pt1 pt2})] + ;; join-nodes adds new segments connecting the given points + (t/is (>= (count result) (count simple-open-content))))) + +(t/deftest segment-separate-nodes + (let [content (path/content simple-open-content) + pt (gpt/point 10.0 0.0) + result (path.segment/separate-nodes content #{pt})] + ;; separate-nodes should return a collection (vector or seq) + (t/is (coll? result)))) + +(t/deftest segment-make-corner-point + (let [content (path/content sample-content-2) + ;; Take a curve point and make it a corner + pt (gpt/point 439.0 802.0) + result (path.segment/make-corner-point content pt)] + ;; Result is a PathData instance + (t/is (some? result)))) + +(t/deftest segment-next-node + (t/testing "no prev-point returns move-to" + (let [content (path/content sample-content) + position (gpt/point 100.0 100.0) + result (path.segment/next-node content position nil nil)] + (t/is (= :move-to (:command result))))) + (t/testing "with prev-point and no handler and last command is not close-path" + ;; Use a content that does NOT end with :close-path + (let [content (path/content simple-open-content) + position (gpt/point 100.0 100.0) + prev (gpt/point 50.0 50.0) + result (path.segment/next-node content position prev nil)] + (t/is (= :line-to (:command result)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; PATH TOP-LEVEL UNTESTED FUNCTIONS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest path-from-plain + (let [result (path/from-plain sample-content)] + (t/is (path/content? result)) + (t/is (= (count sample-content) (count (vec result)))))) + +(t/deftest path-calc-selrect + (let [content (path/content sample-content-square) + rect (path/calc-selrect content)] + (t/is (some? rect)) + (t/is (mth/close? 0.0 (:x1 rect) 0.1)) + (t/is (mth/close? 0.0 (:y1 rect) 0.1)))) + +(t/deftest path-close-subpaths + (let [content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :move-to :params {:x 10.0 :y 5.0}} + {:command :line-to :params {:x 0.0 :y 0.0}}] + result (path/close-subpaths content)] + (t/is (path/content? result)) + (t/is (seq (vec result))))) + +(t/deftest path-move-content + (let [content (path/content sample-content-square) + move-vec (gpt/point 3.0 4.0) + result (path/move-content content move-vec) + first-r (first (vec result))] + (t/is (= :move-to (:command first-r))) + (t/is (mth/close? 3.0 (get-in first-r [:params :x]))) + (t/is (mth/close? 4.0 (get-in first-r [:params :y]))))) + +(t/deftest path-move-content-zero-vec + (t/testing "moving by zero returns same content" + (let [content (path/content sample-content-square) + result (path/move-content content (gpt/point 0 0))] + ;; should return same object (identity) when zero vector + (t/is (= (vec content) (vec result)))))) + +(t/deftest path-shape-with-open-path? + (t/testing "path shape with open content is open" + (let [shape {:type :path + :content (path/content simple-open-content)}] + (t/is (path/shape-with-open-path? shape)))) + (t/testing "path shape with closed content is not open" + (let [shape {:type :path + :content (path/content simple-closed-content)}] + (t/is (not (path/shape-with-open-path? shape)))))) + +(t/deftest path-get-byte-size + (let [content (path/content sample-content) + size (path/get-byte-size content)] + (t/is (pos? size)))) + +(t/deftest path-apply-content-modifiers + (let [content (path/content sample-content) + ;; shift the first point by x=5, y=3 + modifiers {0 {:x 5.0 :y 3.0}} + result (path/apply-content-modifiers content modifiers) + first-seg (first (vec result))] + (t/is (mth/close? (+ 480.0 5.0) (get-in first-seg [:params :x]))) + (t/is (mth/close? (+ 839.0 3.0) (get-in first-seg [:params :y]))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; BOOL OPERATIONS — INTERSECTION / DIFFERENCE / EXCLUSION +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +;; Two non-overlapping rectangles for bool tests +(def ^:private rect-a + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 0.0}} + {:command :line-to :params {:x 10.0 :y 10.0}} + {:command :line-to :params {:x 0.0 :y 10.0}} + {:command :line-to :params {:x 0.0 :y 0.0}} + {:command :close-path :params {}}]) + +(def ^:private rect-b + [{:command :move-to :params {:x 5.0 :y 5.0}} + {:command :line-to :params {:x 15.0 :y 5.0}} + {:command :line-to :params {:x 15.0 :y 15.0}} + {:command :line-to :params {:x 5.0 :y 15.0}} + {:command :line-to :params {:x 5.0 :y 5.0}} + {:command :close-path :params {}}]) + +(def ^:private rect-c + [{:command :move-to :params {:x 20.0 :y 20.0}} + {:command :line-to :params {:x 30.0 :y 20.0}} + {:command :line-to :params {:x 30.0 :y 30.0}} + {:command :line-to :params {:x 20.0 :y 30.0}} + {:command :line-to :params {:x 20.0 :y 20.0}} + {:command :close-path :params {}}]) + +(t/deftest bool-difference + (let [result (path.bool/calculate-content :difference [rect-a rect-b])] + ;; difference result must be a sequence (possibly empty for degenerate cases) + (t/is (or (nil? result) (sequential? result))))) + +(t/deftest bool-intersection + (let [result (path.bool/calculate-content :intersection [rect-a rect-b])] + (t/is (or (nil? result) (sequential? result))))) + +(t/deftest bool-exclusion + (let [result (path.bool/calculate-content :exclude [rect-a rect-b])] + (t/is (or (nil? result) (sequential? result))))) + +(t/deftest bool-union-non-overlapping + (let [result (path.bool/calculate-content :union [rect-a rect-c])] + ;; non-overlapping union should contain both shapes' segments + (t/is (seq result)) + (t/is (> (count result) (count rect-a))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; SHAPE-TO-PATH TESTS (via path/convert-to-path) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- make-selrect [x y w h] + (grc/make-rect x y w h)) + +(t/deftest shape-to-path-rect-simple + (let [shape {:type :rect :x 0.0 :y 0.0 :width 100.0 :height 50.0 + :selrect (make-selrect 0.0 0.0 100.0 50.0)} + result (path/convert-to-path shape {})] + (t/is (= :path (:type result))) + (t/is (path/content? (:content result))) + ;; A simple rect (no radius) produces an empty path in the current impl + ;; so we just check it doesn't throw and returns a :path type + (t/is (some? (:content result))))) + +(t/deftest shape-to-path-circle + (let [shape {:type :circle :x 0.0 :y 0.0 :width 100.0 :height 100.0 + :selrect (make-selrect 0.0 0.0 100.0 100.0)} + result (path/convert-to-path shape {})] + (t/is (= :path (:type result))) + (t/is (path/content? (:content result))) + ;; A circle converts to bezier curves — should have multiple segments + (t/is (> (count (vec (:content result))) 1)))) + +(t/deftest shape-to-path-path + (let [shape {:type :path :content (path/content sample-content)} + result (path/convert-to-path shape {})] + ;; A path shape stays a path shape unchanged + (t/is (= :path (:type result))))) + +(t/deftest shape-to-path-rect-with-radius + (let [shape {:type :rect :x 0.0 :y 0.0 :width 100.0 :height 100.0 + :r1 10.0 :r2 10.0 :r3 10.0 :r4 10.0 + :selrect (make-selrect 0.0 0.0 100.0 100.0)} + result (path/convert-to-path shape {})] + (t/is (= :path (:type result))) + ;; rounded rect should have curve-to segments + (let [segs (vec (:content result)) + curve-segs (filter #(= :curve-to (:command %)) segs)] + (t/is (pos? (count curve-segs))))))