diff --git a/common/src/app/common/types/path/segment.cljc b/common/src/app/common/types/path/segment.cljc index da53193ddb..e4bdcb1966 100644 --- a/common/src/app/common/types/path/segment.cljc +++ b/common/src/app/common/types/path/segment.cljc @@ -946,7 +946,7 @@ (let [cmd (nth content i) nxt (nth content (inc i) nil) at-p? (and (not= :close-path (:command cmd)) - (= point (helpers/segment->point cmd)))] + (gpt/close? point (helpers/segment->point cmd)))] (cond ;; Offset a subpath start. (and at-p? (= :move-to (:command cmd))) diff --git a/common/src/app/common/types/path/selection.cljc b/common/src/app/common/types/path/selection.cljc index 7b2ffdeee4..0e8cc29d98 100644 --- a/common/src/app/common/types/path/selection.cljc +++ b/common/src/app/common/types/path/selection.cljc @@ -9,6 +9,7 @@ (:require [app.common.data :as d] [app.common.geom.point :as gpt] + [app.common.math :as mth] [app.common.types.path.helpers :as helpers] [app.common.types.path.impl :as impl])) @@ -177,32 +178,36 @@ (defn distribute-content "Distributes three or more selected positions along `axis`." [content indices axis] - (let [content (vec content) - indices (set indices) - entries (selected-node-entries content indices) - horizontal? (= axis :horizontal) - coord (fn [p] (if horizontal? (:x p) (:y p))) - groups (->> entries - (group-by (fn [[_ p]] [(:x p) (:y p)])) - (mapv (fn [[_ es]] - {:point (second (first es)) - :indices (mapv first es)})))] + (let [content (vec content) + indices (set indices) + entries (selected-node-entries content indices) + index->point (into {} entries) + horizontal? (= axis :horizontal) + coord (fn [p] (if horizontal? (:x p) (:y p))) + groups (->> entries + (group-by (fn [[_ p]] [(mth/round (:x p) 0.1) (mth/round (:y p) 0.1)])) + (mapv (fn [[_ es]] + {:point (second (first es)) + :indices (mapv first es)}))) + sorted (sort-by (comp coord :point) groups)] (if (< (count groups) 3) (impl/from-plain content) - (let [sorted (sort-by (comp coord :point) groups) - lo (coord (:point (first sorted))) + (let [lo (coord (:point (first sorted))) hi (coord (:point (last sorted))) step (/ (- hi lo) (dec (count sorted))) deltas (into {} (comp (map-indexed (fn [k {:keys [point indices]}] - (let [target (+ lo (* k step)) - d (- target (coord point)) - dp (if horizontal? - (gpt/point d 0) - (gpt/point 0 d))] - (map (fn [i] [i dp]) indices)))) + (let [target (+ lo (* k step))] + (map (fn [i] + (let [node-point (get index->point i) + d (- target (coord node-point)) + dp (if horizontal? + (gpt/point d 0) + (gpt/point 0 d))] + [i dp])) + indices)))) cat) sorted)] (impl/from-plain (translate-nodes content indices deltas)))))) diff --git a/common/test/common_tests/types/path_data_test.cljc b/common/test/common_tests/types/path_data_test.cljc index 7e381ce2f8..bac78c9e00 100644 --- a/common/test/common_tests/types/path_data_test.cljc +++ b/common/test/common_tests/types/path_data_test.cljc @@ -1564,6 +1564,18 @@ (t/is (= (pts content) (pts (path/distribute-content content #{1 2 3} :horizontal)))))) +(t/deftest segment-distribute-content-with-floating-point-coordinates + (t/testing "distribute-content groups nodes with floating-point rounding differences" + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 3.0001 :y 7.0}} + {:command :line-to :params {:x 3.0002 :y 7.0}} + {:command :line-to :params {:x 10.0 :y 0.0}}]) + pts (fn [c] (mapv (comp (juxt :x :y) :params) (vec c)))] + ;; Nodes at ~3.0 should be grouped together + (t/is (= [[0.0 0.0] [5.0 7.0] [5.0 7.0] [10.0 0.0]] + (pts (path/distribute-content content #{0 1 2 3} :horizontal))))))) + (t/deftest helpers-curve-arc-length-t (let [arc-len (fn [curve a b] (->> (range 1001) @@ -1865,6 +1877,18 @@ ;; separate-nodes should return a collection (vector or seq) (t/is (coll? result)))) +(t/deftest segment-separate-nodes-with-floating-point-coordinates + (t/testing "separate-nodes finds nodes with floating-point rounding differences" + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 10.0001 :y 0.0}} + {:command :line-to :params {:x 20.0 :y 0.0}}]) + pt (gpt/point 10.0002 0.0) + result (path.segment/separate-nodes content #{pt})] + ;; Should still find and separate the node + (t/is (coll? result)) + (t/is (> (count result) (count content)))))) + (t/deftest segment-make-corner-point (let [content (path/content sample-content-2) ;; Take a curve point and make it a corner diff --git a/frontend/src/app/main/data/workspace/path/clipboard.cljs b/frontend/src/app/main/data/workspace/path/clipboard.cljs index a98cea1037..504284e1bb 100644 --- a/frontend/src/app/main/data/workspace/path/clipboard.cljs +++ b/frontend/src/app/main/data/workspace/path/clipboard.cljs @@ -60,18 +60,18 @@ (def ^:private paste-offset (gpt/point 10 10)) -(defn- collision-step +(defn collision-step "Returns the non-negative paste-offset step that makes two nodes coincide." [pasted existing] (let [delta (gpt/subtract existing pasted) x-step (/ (:x delta) (:x paste-offset)) y-step (/ (:y delta) (:y paste-offset))] (when (and (not (neg? x-step)) - (= x-step y-step) - (= x-step (mth/floor x-step))) - (long x-step)))) + (mth/close? x-step y-step) + (mth/close? x-step (mth/floor x-step))) + (long (mth/round x-step))))) -(defn- available-offset-step +(defn available-offset-step "Returns the first paste-offset step with no node collisions." [existing pasted] (let [blocked diff --git a/frontend/src/app/main/data/workspace/path/edition.cljs b/frontend/src/app/main/data/workspace/path/edition.cljs index 9787108e12..fda97edd3a 100644 --- a/frontend/src/app/main/data/workspace/path/edition.cljs +++ b/frontend/src/app/main/data/workspace/path/edition.cljs @@ -867,20 +867,26 @@ (declare stop-path-edit) -(defn- resolve-edit-fills +(defn resolve-edit-fills "Resolves the fills inherited by the editing copy. Frames stop group fill inheritance." [shape objects] (let [own (svg-fills/resolve-shape-fills shape)] (if (seq own) own - (loop [parent-id (:parent-id shape)] - (let [parent (get objects parent-id)] - (cond - (nil? parent) [] - (cfh/group-shape? parent) (svg-fills/resolve-shape-fills parent) - (cfh/frame-shape? parent) [] - :else (recur (:parent-id parent)))))))) + (loop [parent-id (:parent-id shape) + visited #{}] + (cond + (nil? parent-id) [] + (visited parent-id) [] + :else + (let [parent (get objects parent-id)] + (cond + (nil? parent) [] + (cfh/group-shape? parent) (svg-fills/resolve-shape-fills parent) + (cfh/frame-shape? parent) [] + :else (recur (:parent-id parent) + (conj visited parent-id))))))))) (defn start-path-edit [id] diff --git a/frontend/test/frontend_tests/data/workspace_path_edition_test.cljs b/frontend/test/frontend_tests/data/workspace_path_edition_test.cljs index 4cfabcdce2..2df0b96592 100644 --- a/frontend/test/frontend_tests/data/workspace_path_edition_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_path_edition_test.cljs @@ -11,6 +11,7 @@ [app.common.test-helpers.shapes :as cths] [app.main.data.shortcuts :as dsc] [app.main.data.workspace :as dw] + [app.main.data.workspace.path.edition :as path.edition] [app.main.data.workspace.path.shortcuts :as psc] [app.main.data.workspace.selection :as dws] [app.main.data.workspace.shortcuts :as wsc] @@ -76,3 +77,21 @@ (t/deftest test-enter-toggles-path-editing-mode (doseq [shape-type [:rect :circle :path :image]] (run-scenario shape-type))) + +(t/deftest resolve-edit-fills-with-normal-parent-chain + (t/testing "resolve-edit-fills resolves fills from parent chain" + (let [objects {1 {:type :group :parent-id 2 :fills [{:fill-color "#ff0000"}]} + 2 {:type :frame :parent-id nil :fills []}} + shape {:type :path :parent-id 1 :fills []} + result (path.edition/resolve-edit-fills shape objects)] + ;; Should inherit fills from group parent + (t/is (= [{:fill-color "#ff0000"}] result))))) + +(t/deftest resolve-edit-fills-with-circular-parent-chain + (t/testing "resolve-edit-fills handles circular parent references gracefully" + (let [objects {1 {:type :rect :parent-id 2 :fills []} + 2 {:type :rect :parent-id 1 :fills []}} + shape {:type :path :parent-id 1 :fills []} + result (path.edition/resolve-edit-fills shape objects)] + ;; Should return empty fills instead of infinite loop + (t/is (= [] result))))) diff --git a/frontend/test/frontend_tests/logic/path_clipboard_test.cljs b/frontend/test/frontend_tests/logic/path_clipboard_test.cljs index 486cd83a8f..eba6281b39 100644 --- a/frontend/test/frontend_tests/logic/path_clipboard_test.cljs +++ b/frontend/test/frontend_tests/logic/path_clipboard_test.cljs @@ -202,3 +202,38 @@ store done events (fn [new-state] (t/is (empty? (page-paths new-state)))))))) + +(t/deftest collision-step-with-exact-coordinates + (t/testing "collision-step detects collision with exact coordinates" + (let [pasted (gpt/point 10.0 10.0) + existing (gpt/point 20.0 20.0) + step (path.clipboard/collision-step pasted existing)] + ;; Should detect collision at step 1 + (t/is (some? step)) + (t/is (= 1 step))))) + +(t/deftest collision-step-with-floating-point-coordinates + (t/testing "collision-step detects collision with floating-point rounding differences" + (let [pasted (gpt/point 10.0 10.0) + existing (gpt/point 20.001 20.002) + step (path.clipboard/collision-step pasted existing)] + ;; x-step = 1.0001, y-step = 1.0002 + ;; With tolerance, these should be considered equal + (t/is (some? step)) + (t/is (= 1 step))))) + +(t/deftest available-offset-step-with-exact-coordinates + (t/testing "available-offset-step finds first available step with exact coordinates" + (let [existing #{(gpt/point 20.0 20.0)} + pasted #{(gpt/point 10.0 10.0)} + step (path.clipboard/available-offset-step existing pasted)] + ;; Should find step 0 (no collision at step 0) + (t/is (= 0 step))))) + +(t/deftest available-offset-step-with-floating-point-coordinates + (t/testing "available-offset-step finds first available step with floating-point rounding differences" + (let [existing #{(gpt/point 20.0001 20.0002)} + pasted #{(gpt/point 10.0001 10.0002)} + step (path.clipboard/available-offset-step existing pasted)] + ;; Should detect collision at step 1 and return step 0 as available + (t/is (= 0 step)))))