mirror of
https://github.com/penpot/penpot.git
synced 2026-08-28 07:38:42 +00:00
* ✨ Improve path operations and edition * 🐛 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 * 🐛 Fix path editor code review findings Fix issues identified during code review of path editor enhancements: - Fix unused binding lint warning in distribute-content that blocked CI - Fix collision-step floor comparison to use round instead of floor, correctly detecting collisions when coordinates drift slightly below integer boundaries - Fix resolve-edit-fills to recurse through empty parent groups when searching for inherited fills in nested group hierarchies - Fix expand-coincident-node-indices to use fuzzy comparison (gpt/close?) instead of exact equality, handling floating-point divergence after transforms or rotations - Remove unreachable dead code in path-point* on-pointer-down handler - Add tests for collision-step boundary cases, nested group fill inheritance, and coincident node alignment/flipping AI-assisted-by: mimo-v2.5-pro --------- Co-authored-by: Andrey Antukh <niwi@niwi.nz>
118 lines
5.3 KiB
Clojure
118 lines
5.3 KiB
Clojure
;; This Source Code Form is subject to the terms of the Mozilla Public
|
|
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
;;
|
|
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
|
|
|
|
|
(ns frontend-tests.logic.path-helpers-test
|
|
(:require
|
|
[app.common.geom.point :as gpt]
|
|
[app.common.types.path :as path]
|
|
[app.main.data.workspace.path.helpers :as path.helpers]
|
|
[app.main.data.workspace.path.streams :as path.streams]
|
|
[app.main.store :as st]
|
|
[cljs.test :as t :include-macros true]
|
|
[frontend-tests.logic.path-test-helpers :as pth]))
|
|
|
|
(t/deftest to-pixel-snap-quantises-to-half-pixels-past-the-zoom-threshold
|
|
;; Pixel snapping uses half steps above 300% zoom.
|
|
(let [original @st/state
|
|
snap (fn [zoom p]
|
|
(reset! st/state {:workspace-layout #{:snap-pixel-grid}
|
|
:workspace-local {:zoom zoom}})
|
|
(path.streams/to-pixel-snap p))]
|
|
(try
|
|
;; at or below the threshold, snapping rounds to whole pixels
|
|
(t/is (= (gpt/point 100 100) (snap 1 (gpt/point 100.4 100.4))))
|
|
(t/is (= (gpt/point 100 100) (snap 3 (gpt/point 100.4 100.4))))
|
|
;; above 300% zoom it rounds to the nearest half pixel
|
|
(t/is (= (gpt/point 100.5 100.5) (snap 6 (gpt/point 100.4 100.4))))
|
|
;; with pixel snapping off the position passes through unchanged
|
|
(reset! st/state {:workspace-layout #{} :workspace-local {:zoom 6}})
|
|
(t/is (= (gpt/point 100.4 100.4)
|
|
(path.streams/to-pixel-snap (gpt/point 100.4 100.4))))
|
|
(finally
|
|
(reset! st/state original)))))
|
|
|
|
(t/deftest node-merge-snap-finds-the-closest-target-for-multiple-moving-points
|
|
(let [start-point (gpt/point 0 0)
|
|
selected-points #{start-point (gpt/point 100 0)}
|
|
points (into selected-points
|
|
[(gpt/point 8 14)
|
|
(gpt/point 111 10.5)
|
|
(gpt/point 500 500)])
|
|
snap-position (path.streams/make-node-merge-snap
|
|
start-point selected-points points 10)]
|
|
;; The closest merge target moves the full selection.
|
|
(t/is (= (gpt/point 11 10.5)
|
|
(snap-position (gpt/point 10 10))))
|
|
;; Missing merge targets return no snap delta.
|
|
(t/is (nil? (snap-position (gpt/point 300 300))))))
|
|
|
|
(t/deftest insertion-preview-reuses-precomputed-segment-midpoints
|
|
(let [content (path/content
|
|
[{:command :move-to :params {:x 0 :y 0}}
|
|
{:command :line-to :params {:x 10 :y 0}}
|
|
{:command :curve-to
|
|
:params {:c1x 10 :c1y 0
|
|
:c2x 20 :c2y 10
|
|
:x 20 :y 0}}
|
|
{:command :close-path :params {}}])
|
|
midpoints (path.helpers/insertion-mid-points content)
|
|
line-mid (first midpoints)
|
|
curve-mid (second midpoints)]
|
|
(t/is (= 2 (count midpoints)))
|
|
(t/is (= (gpt/point 5 0) line-mid))
|
|
(t/is (= {:from-p (gpt/point 0 0)
|
|
:to-p (gpt/point 10 0)
|
|
:t 0.5}
|
|
(meta line-mid)))
|
|
(t/is (= line-mid
|
|
(path.helpers/insertion-point
|
|
content (gpt/point 5.5 0) 1 false midpoints)))
|
|
(t/is (nil? (path.helpers/insertion-point
|
|
content (gpt/point 200 200) 1 false midpoints)))
|
|
;; Alt/insert-anywhere remains dynamic and ignores the midpoint cache.
|
|
(t/is (some? (path.helpers/insertion-point
|
|
content curve-mid 1 true [])))))
|
|
|
|
(t/deftest selected-node-indices-folds-segment-endpoints
|
|
(let [content (pth/selectable-path-content)]
|
|
;; segment index 1 connects nodes 0 and 1
|
|
(t/is (= #{0 1}
|
|
(path.helpers/selected-node-indices content {:nodes #{} :segments #{1}})))
|
|
;; explicit nodes and segment endpoints are unioned
|
|
(t/is (= #{0 1 2}
|
|
(path.helpers/selected-node-indices content {:nodes #{2} :segments #{1}})))))
|
|
|
|
(t/deftest remap-selection-follows-content-structure
|
|
(let [content (pth/selectable-path-content)
|
|
;; Same command layout: index 1 turned into a line-to
|
|
corner (path/content
|
|
(assoc (vec content) 1 {:command :line-to
|
|
:params {:x 10 :y 0}}))
|
|
;; Different layout: the middle node was removed
|
|
shorter (path/content
|
|
[{:command :move-to
|
|
:params {:x 0 :y 0}}
|
|
{:command :line-to
|
|
:params {:x 20 :y 0}}])
|
|
selection {:nodes #{1 2}
|
|
:segments #{2}
|
|
:handlers #{[1 :c1] [2 :c2]}}]
|
|
(t/is (= {:nodes #{1 2}
|
|
:segments #{2}
|
|
:handlers #{[2 :c2]}}
|
|
(path.helpers/remap-selection selection content corner)))
|
|
(t/is (= {:nodes #{1}
|
|
:segments #{}
|
|
:handlers #{}}
|
|
(path.helpers/remap-selection selection content shorter)))))
|
|
|
|
(t/deftest handlers-joined-detects-smooth-vs-corner-nodes
|
|
;; node (10,0): incoming [1 :c2]=(8,0), outgoing [2 :c1]
|
|
(t/is (path.helpers/handlers-joined? (pth/selectable-path-content) 2 :c1))
|
|
(t/is (not (path.helpers/handlers-joined? (pth/corner-path-content) 2 :c1))))
|
|
|