mirror of
https://github.com/penpot/penpot.git
synced 2026-08-23 13:18:36 +00:00
🐛 Fix floating-point equality issues in path editing
Replace exact equality checks with tolerance-based comparisons in path editing functions to handle floating-point rounding differences after transforms, rotations, or curve fitting. Changes: - distribute-content: Round coordinates to 0.1 precision before grouping to ensure coincident nodes move together - separate-node: Use gpt/close? instead of exact equality to find nodes with floating-point imprecision - collision-step: Use mth/close? for tolerance-based comparison to detect paste collisions correctly - resolve-edit-fills: Add cycle detection to prevent infinite loops with corrupted parent chains Made collision-step, available-offset-step, and resolve-edit-fills public for better testability. Added comprehensive tests for all fixes covering both exact and floating-point coordinate scenarios. AI-assisted-by: qwen3.7-plus
This commit is contained in:
parent
6fedea0f4d
commit
7770aa807b
@ -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)))
|
||||
|
||||
@ -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))))))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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)))))
|
||||
|
||||
@ -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)))))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user