From 78b5d13b7aa28fc50d7343932b608e9e21dda1eb Mon Sep 17 00:00:00 2001 From: Alonso Torres Date: Thu, 17 Sep 2026 16:42:22 +0200 Subject: [PATCH] :bug: Fix bend curve sharp edges (#11715) --- common/src/app/common/types/path/helpers.cljc | 24 +++++ .../common_tests/types/path_data_test.cljc | 89 +++++++++++++++++++ .../app/main/data/workspace/path/edition.cljs | 25 ++++-- 3 files changed, 129 insertions(+), 9 deletions(-) diff --git a/common/src/app/common/types/path/helpers.cljc b/common/src/app/common/types/path/helpers.cljc index fd9ecdf64e..5d19f5aece 100644 --- a/common/src/app/common/types/path/helpers.cljc +++ b/common/src/app/common/types/path/helpers.cljc @@ -238,6 +238,30 @@ (/ (- half d0) (- d1 d0)))] (/ (+ i frac) n))))) +(def ^:private straight-handler-ratio + "Chord fraction that places the handlers of a straight segment." + (/ 1.0 3.0)) + +(defn bend-reference-curve + "Returns the cubic a bend of `curve` is measured against. + + A straight cubic gets its handlers spread along the chord thirds: it draws + the same segment with tangents that open up progressively while it bends." + [[start end h1 h2 :as curve]] + (if (and (gpt/close? start h1) + (gpt/close? end h2)) + (let [offset (gpt/scale (gpt/to-vec start end) straight-handler-ratio)] + [start end (gpt/add start offset) (gpt/subtract end offset)]) + curve)) + +(defn curve-handler-deltas + "Returns the handler offsets that turn `curve` into `other`." + [[_ _ h1 h2] [_ _ o1 o2]] + (let [d1 (gpt/to-vec h1 o1) + d2 (gpt/to-vec h2 o2)] + {:c1x (:x d1) :c1y (:y d1) + :c2x (:x d2) :c2y (:y d2)})) + (defn bend-curve-deltas "Returns the smallest handler deltas that move the point at `t` to `target`." [curve t target] diff --git a/common/test/common_tests/types/path_data_test.cljc b/common/test/common_tests/types/path_data_test.cljc index 94f96d8870..6c1b957691 100644 --- a/common/test/common_tests/types/path_data_test.cljc +++ b/common/test/common_tests/types/path_data_test.cljc @@ -1296,6 +1296,95 @@ (t/is (mth/close? (:x target) (:x p) 0.001)) (t/is (mth/close? (:y target) (:y p) 0.001)))))) +(defn- bend-straight-segment + "Returns the cubic that the path editor draws when `curve` is grabbed at + `grab` and bent onto `target`." + [[start end h1 h2 :as curve] grab target] + (let [base (path.helpers/bend-reference-curve curve) + t (path.helpers/curve-closest-t base grab 0.001) + deltas (merge-with + + (path.helpers/curve-handler-deltas curve base) + (path.helpers/bend-curve-deltas base t target))] + [start end + (gpt/point (+ (:x h1) (:c1x deltas)) (+ (:y h1) (:c1y deltas))) + (gpt/point (+ (:x h2) (:c2x deltas)) (+ (:y h2) (:c2y deltas)))])) + +(defn- start-tangent-angle + "Angle, in degrees, between the chord of a cubic and its tangent at the start." + [[start end h1 _]] + (gpt/angle-with-other (gpt/to-vec start h1) (gpt/to-vec start end))) + +(def ^:private straight-segment + [(gpt/point 0.0 0.0) (gpt/point 100.0 0.0) + (gpt/point 0.0 0.0) (gpt/point 100.0 0.0)]) + +(t/deftest helpers-bend-reference-curve-spreads-straight-handlers + ;; a straight cubic carries its handlers on the chord thirds + (let [[_ _ h1 h2] (path.helpers/bend-reference-curve straight-segment)] + (t/is (gpt/close? (gpt/point 33.333 0.0) h1)) + (t/is (gpt/close? (gpt/point 66.667 0.0) h2)))) + +(t/deftest helpers-bend-reference-curve-keeps-the-segment + ;; the spread handlers draw the same straight segment + (let [base (path.helpers/bend-reference-curve straight-segment)] + (doseq [t [0.0 0.25 0.5 0.75 1.0]] + (let [p (path.helpers/curve-values base t)] + (t/is (mth/close? 0.0 (:y p) 0.001)) + (t/is (mth/close? (* 100.0 t) (:x p) 0.001)))))) + +(t/deftest helpers-bend-reference-curve-leaves-curves-alone + ;; a segment that already holds its handlers keeps them + (let [curve [(gpt/point 0.0 0.0) (gpt/point 100.0 0.0) + (gpt/point 10.0 50.0) (gpt/point 90.0 50.0)]] + (t/is (= curve (path.helpers/bend-reference-curve curve))))) + +(t/deftest helpers-bend-straight-segment-opens-the-tangent-gradually + ;; bending a straight segment starts flat and opens up as it is dragged away + (let [grab (gpt/point 50.0 0.0) + angles (mapv #(start-tangent-angle + (bend-straight-segment straight-segment grab (gpt/point 50.0 %))) + [1.0 10.0 50.0])] + (t/is (< (nth angles 0) 5.0)) + (t/is (< (nth angles 0) (nth angles 1) (nth angles 2))) + (t/is (< (nth angles 2) 90.0)))) + +(t/deftest helpers-bend-straight-segment-passes-through-target + ;; the grabbed point lands exactly under the pointer + (doseq [grab [(gpt/point 25.0 0.0) (gpt/point 50.0 0.0) (gpt/point 70.0 0.0)] + offset [(gpt/point 0.0 12.0) (gpt/point 5.0 -30.0)]] + (let [target (gpt/add grab offset) + base (path.helpers/bend-reference-curve straight-segment) + t (path.helpers/curve-closest-t base grab 0.001) + p (path.helpers/curve-values + (bend-straight-segment straight-segment grab target) t)] + (t/is (mth/close? (:x target) (:x p) 0.001)) + (t/is (mth/close? (:y target) (:y p) 0.001))))) + +(t/deftest helpers-bend-straight-segment-turns-a-line-into-a-soft-curve + ;; the deltas reach the stored line through the content modifiers + (let [content (path/content + [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 100.0 :y 0.0}}]) + entry (d/seek #(= 1 (:index %)) (path/segment-entries content)) + curve (path.helpers/entry->bezier entry) + base (path.helpers/bend-reference-curve curve) + grab (gpt/point 50.0 0.0) + target (gpt/point 50.0 10.0) + t (path.helpers/curve-closest-t base grab 0.001) + deltas (merge-with + + (path.helpers/curve-handler-deltas curve base) + (path.helpers/bend-curve-deltas base t target)) + segment (second (vec (path/apply-content-modifiers content {1 deltas}))) + {:keys [c1x c1y c2x c2y]} (:params segment) + bent [(gpt/point 0.0 0.0) (gpt/point 100.0 0.0) + (gpt/point c1x c1y) (gpt/point c2x c2y)] + p (path.helpers/curve-values bent t)] + (t/is (= :curve-to (:command segment))) + ;; a 10 unit pull on a 100 unit segment barely tilts the tangents + (t/is (< (start-tangent-angle bent) 25.0)) + (t/is (mth/close? (:x target) (:x p) 0.01)) + (t/is (mth/close? (:y target) (:y p) 0.01)))) + (t/deftest segment-flip-content-horizontal ;; mirror every node across the bbox center on the vertical axis (let [content (path/content diff --git a/frontend/src/app/main/data/workspace/path/edition.cljs b/frontend/src/app/main/data/workspace/path/edition.cljs index 2186500b8a..5e0f9f8c24 100644 --- a/frontend/src/app/main/data/workspace/path/edition.cljs +++ b/frontend/src/app/main/data/workspace/path/edition.cljs @@ -564,13 +564,17 @@ (merge-dragged-on-drop)))))))) (defn bend-segment-modifier - "Bends segment `index` so its point at `t` reaches `target`." - [index base-curve t target] + "Bends segment `index` so its point at `t` reaches `target`. + + `base-deltas` offsets the stored handlers onto `base-curve`." + [index base-curve base-deltas t target] (ptk/reify ::bend-segment-modifier ptk/UpdateEvent (update [_ state] (let [id (st/get-path-id state) - deltas (path.helpers/bend-curve-deltas base-curve t target) + deltas (merge-with + + base-deltas + (path.helpers/bend-curve-deltas base-curve t target)) modifiers (dm/get-in state [:workspace-local :edit-path id :content-modifiers] {})] (assoc-in state [:workspace-local :edit-path id :content-modifiers] (assoc modifiers index deltas)))))) @@ -580,17 +584,20 @@ (ptk/reify ::bend-selected-segment ptk/WatchEvent (watch [_ state stream] - (let [stopper (mse/drag-stopper stream) - content (st/get-path state :content) - entry (segment-entry content index) - base-curve (path.helpers/entry->bezier entry) + (let [stopper (mse/drag-stopper stream) + content (st/get-path state :content) + entry (segment-entry content index) + curve (path.helpers/entry->bezier entry) + ;; A straight segment bends from handlers spread along its chord. + base-curve (path.helpers/bend-reference-curve curve) + base-deltas (path.helpers/curve-handler-deltas curve base-curve) ;; Keep the grabbed curve parameter fixed during the drag. - t (path.helpers/curve-closest-t base-curve start-position 0.001)] + t (path.helpers/curve-closest-t base-curve start-position 0.001)] (rx/concat (->> ms/mouse-position (rx/filter gpt/point?) (rx/map streams/to-pixel-snap) - (rx/map #(bend-segment-modifier index base-curve t %)) + (rx/map #(bend-segment-modifier index base-curve base-deltas t %)) (rx/take-until stopper)) (rx/of (apply-content-modifiers)))))))