diff --git a/frontend/src/app/main/data/workspace/path/edition.cljs b/frontend/src/app/main/data/workspace/path/edition.cljs index d35434da55..05b40fd92c 100644 --- a/frontend/src/app/main/data/workspace/path/edition.cljs +++ b/frontend/src/app/main/data/workspace/path/edition.cljs @@ -163,6 +163,7 @@ (-> state (assoc-in [:workspace-local :edit-path id :content-modifiers] modifiers) (assoc-in [:workspace-local :edit-path id :moving-handler] moving-handler) + (assoc-in [:workspace-local :edit-path id :edited-handler] primary) (cond-> (some? new-prev-handler) (assoc-in [:workspace-local :edit-path id :prev-handler] new-prev-handler))))))) @@ -286,7 +287,10 @@ content-modifiers))] (-> state - (assoc-in [:workspace-local :edit-path id :content-modifiers] content-modifiers)))))) + (assoc-in [:workspace-local :edit-path id :content-modifiers] content-modifiers) + (cond-> (= 1 (count handler-ids)) + (assoc-in [:workspace-local :edit-path id :edited-handler] + (first handler-ids)))))))) (defn- move-node-indices [state node-indices from-point to-point] @@ -946,7 +950,7 @@ (ptk/data-event :layout/update {:ids [id]}))))) (defn- split-segments - [_id {:keys [from-p to-p t]}] + [id {:keys [from-p to-p t]}] (ptk/reify ::split-segments ptk/UpdateEvent (update [_ state] @@ -955,7 +959,9 @@ (st/set-content (-> content (path/split-segments #{from-p to-p} t) (path/content))) - (update-in (st/get-path-location state) path/update-geometry)))))) + (update-in (st/get-path-location state) path/update-geometry) + ;; The inserted command shifts the indices a handler id refers to. + (update-in [:workspace-local :edit-path id] dissoc :edited-handler)))))) (defn create-node-at-position [params] diff --git a/frontend/src/app/main/data/workspace/path/helpers.cljs b/frontend/src/app/main/data/workspace/path/helpers.cljs index 2557cd0e5e..5f28983e78 100644 --- a/frontend/src/app/main/data/workspace/path/helpers.cljs +++ b/frontend/src/app/main/data/workspace/path/helpers.cljs @@ -180,6 +180,36 @@ :else nil))) +(defn node-handler-ids + "Returns a node's curve handlers, its primary handle first." + [content node-index] + (if-let [[index prefix :as primary] (node-primary-handler content node-index)] + (let [[op-idx op-prefix] (path/opposite-index content index prefix)] + (if (some? op-idx) + [primary [op-idx op-prefix]] + [primary])) + [])) + +(defn handler-type-reference + "Returns the handler that keeps its geometry when a node's handler type changes. + + The other handler adapts to it. Priority: the node's only selected handler, + then `edited-handler` when it is one of this node's two handlers, then its + primary handle. `edited-handler` is the last handler edited anywhere in the + path, so a node only gets this hint while it holds the latest edit." + [content selection edited-handler node-index] + (let [handler-ids (node-handler-ids content node-index) + selected (filterv (get selection :handlers #{}) handler-ids)] + (cond + (= 1 (count selected)) + (first selected) + + (some #{edited-handler} handler-ids) + edited-handler + + :else + (first handler-ids)))) + (defn handlers-equal-length? "True when a node's two handlers are the same distance from the node." [content index prefix] diff --git a/frontend/src/app/main/data/workspace/path/tools.cljs b/frontend/src/app/main/data/workspace/path/tools.cljs index 271634bc7a..364115b79e 100644 --- a/frontend/src/app/main/data/workspace/path/tools.cljs +++ b/frontend/src/app/main/data/workspace/path/tools.cljs @@ -49,7 +49,8 @@ (update-in [:workspace-local :edit-path id :selection] #(helpers/remap-selection % old-content new-content)) (update-in [:workspace-local :edit-path id :handler-types] - #(helpers/remap-handler-types % old-content new-content)))) + #(helpers/remap-handler-types % old-content new-content)) + (update-in [:workspace-local :edit-path id] dissoc :edited-handler))) state))) ptk/WatchEvent @@ -80,9 +81,11 @@ (reduce path/make-curve-point content)))))) (defn- apply-handler-type-modifiers - "Returns modifiers that reshape a node's handlers to `type`." - [content node-index type] - (if-let [[idx prefix] (helpers/node-primary-handler content node-index)] + "Returns modifiers that reshape a node's handlers to `type`. + + `reference` keeps its geometry; the opposite handler adapts to it." + [content reference type] + (if-let [[idx prefix] reference] (case type :mirror (helpers/move-handler-modifiers content idx prefix true true true 0 0) :aligned (helpers/align-handler-modifiers content idx prefix 0 0) @@ -98,10 +101,13 @@ (let [id (st/get-path-id state) content (st/get-path state :content) selection (st/get-selection state id) + edited (dm/get-in state [:workspace-local :edit-path id :edited-handler]) nodes (helpers/handler-target-nodes content selection)] (if (and (some? content) (seq nodes)) (let [modifiers (reduce (fn [acc node-index] - (d/deep-merge acc (apply-handler-type-modifiers content node-index type))) + (let [reference (helpers/handler-type-reference + content selection edited node-index)] + (d/deep-merge acc (apply-handler-type-modifiers content reference type)))) {} nodes) new-content (path/apply-content-modifiers content modifiers)] (-> (st/set-content state new-content) @@ -146,7 +152,8 @@ (update-in [:workspace-local :edit-path id :selection] #(helpers/remap-selection % old-content new-content)) (update-in [:workspace-local :edit-path id :handler-types] - #(helpers/remap-handler-types % old-content new-content))))) + #(helpers/remap-handler-types % old-content new-content)) + (update-in [:workspace-local :edit-path id] dissoc :edited-handler)))) (defn remove-segments "Removes segments and opens the path at their endpoints." diff --git a/frontend/test/frontend_tests/logic/path_tools_test.cljs b/frontend/test/frontend_tests/logic/path_tools_test.cljs index f8a806f5af..0c4bd36ead 100644 --- a/frontend/test/frontend_tests/logic/path_tools_test.cljs +++ b/frontend/test/frontend_tests/logic/path_tools_test.cljs @@ -802,3 +802,117 @@ (t/is (empty? (emit-of (mk {:nodes #{3} :segments #{} :handlers #{}}))))))) ;; Path-local undo and redo events use a seeded local stack. + +;; --- Handler type changes pick which handler keeps its geometry + +(defn- aligned-uneven-handlers-content + "Returns content whose node (10,0) has aligned handlers at (8,0) and (16,0)." + [] + (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :curve-to + :params {:c1x 2 :c1y 0 :c2x 8 :c2y 0 :x 10 :y 0}} + {:command :curve-to + :params {:c1x 16 :c1y 0 :c2x 28 :c2y 0 :x 30 :y 0}}])) + +(t/deftest making-handlers-equal-keeps-the-selected-handler-length + (let [id (random-uuid) + content (aligned-uneven-handlers-content) + state (pth/selectable-path-state + id content {:nodes #{} :segments #{} :handlers #{[2 :c1]}}) + result (-> (ptk/update (path.tools/set-handler-type :mirror) state) + (path.state/get-path :content))] + ;; The node is aligned with handlers of unequal length. + (t/is (= :aligned (path.helpers/derive-handler-type content 1))) + ;; The selected handler keeps its length and the opposite one adapts. + (t/is (= (gpt/point 16 0) (path/get-handler-point result 2 :c1))) + (t/is (= (gpt/point 4 0) (path/get-handler-point result 1 :c2))))) + +(t/deftest making-handlers-equal-falls-back-to-the-last-edited-handler + (let [id (random-uuid) + content (aligned-uneven-handlers-content) + state (-> (pth/selectable-path-state + id content {:nodes #{1} :segments #{} :handlers #{}}) + (assoc-in [:workspace-local :edit-path id :edited-handler] + [2 :c1])) + result (-> (ptk/update (path.tools/set-handler-type :mirror) state) + (path.state/get-path :content))] + (t/is (= (gpt/point 16 0) (path/get-handler-point result 2 :c1))) + (t/is (= (gpt/point 4 0) (path/get-handler-point result 1 :c2))))) + +(t/deftest making-handlers-equal-uses-the-incoming-handler-with-no-hint + (let [id (random-uuid) + content (aligned-uneven-handlers-content) + state (pth/selectable-path-state + id content {:nodes #{1} :segments #{} :handlers #{}}) + result (-> (ptk/update (path.tools/set-handler-type :mirror) state) + (path.state/get-path :content))] + (t/is (= (gpt/point 8 0) (path/get-handler-point result 1 :c2))) + (t/is (= (gpt/point 12 0) (path/get-handler-point result 2 :c1))))) + +(t/deftest aligning-handlers-takes-the-angle-of-the-selected-handler + (let [id (random-uuid) + content (path/content + [{:command :move-to :params {:x 0 :y 0}} + {:command :curve-to + :params {:c1x 2 :c1y 0 :c2x 10 :c2y -3 :x 10 :y 0}} + {:command :curve-to + :params {:c1x 16 :c1y 0 :c2x 28 :c2y 0 :x 30 :y 0}}]) + state (pth/selectable-path-state + id content {:nodes #{} :segments #{} :handlers #{[2 :c1]}}) + result (-> (ptk/update (path.tools/set-handler-type :aligned) state) + (path.state/get-path :content))] + ;; The selected handler stays put; the opposite one rotates onto its axis + ;; keeping its own length. + (t/is (= (gpt/point 16 0) (path/get-handler-point result 2 :c1))) + (t/is (= (gpt/point 7 0) (path/get-handler-point result 1 :c2))))) + +(t/deftest dragging-a-handler-records-it-as-the-last-edited-one + (let [id (random-uuid) + content (aligned-uneven-handlers-content) + state (pth/selectable-path-state + id content {:nodes #{} :segments #{} :handlers #{[2 :c1]}}) + state' (ptk/update + (path.edition/modify-selected-handlers id [2 :c1] {} 3 0 :independent false) + state)] + (t/is (= [2 :c1] + (get-in state' [:workspace-local :edit-path id :edited-handler]))))) + +(t/deftest making-handlers-equal-ignores-an-ambiguous-handler-selection + (let [id (random-uuid) + content (aligned-uneven-handlers-content) + mk (fn [] (pth/selectable-path-state + id content + {:nodes #{} :segments #{} :handlers #{[1 :c2] [2 :c1]}})) + equal (fn [state] + (-> (ptk/update (path.tools/set-handler-type :mirror) state) + (path.state/get-path :content)))] + (t/testing "with both handlers selected the last edited one wins" + (let [result (equal (-> (mk) + (assoc-in [:workspace-local :edit-path id :edited-handler] + [2 :c1])))] + (t/is (= (gpt/point 16 0) (path/get-handler-point result 2 :c1))) + (t/is (= (gpt/point 4 0) (path/get-handler-point result 1 :c2))))) + (t/testing "with both handlers selected and no hint the incoming one wins" + (let [result (equal (mk))] + (t/is (= (gpt/point 8 0) (path/get-handler-point result 1 :c2))) + (t/is (= (gpt/point 12 0) (path/get-handler-point result 2 :c1))))))) + +(t/deftest inserting-a-node-forgets-the-last-edited-handler + (let [id (random-uuid) + content (aligned-uneven-handlers-content) + state (-> (pth/selectable-path-state + id content {:nodes #{} :segments #{} :handlers #{}}) + (assoc-in [:workspace-local :edit-path id :edited-handler] [2 :c1])) + events (let [out (atom [])] + (->> (ptk/watch (path.edition/create-node-at-position + {:from-p (gpt/point 0 0) + :to-p (gpt/point 10 0) + :t 0.5}) + state (rx/subject)) + (rx/subs! #(swap! out conj %))) + @out) + state' (ptk/update (first events) state)] + ;; The new command shifts every later index, so [2 :c1] is another node now. + (t/is (= 4 (count (path.state/get-path state' :content)))) + (t/is (nil? (get-in state' [:workspace-local :edit-path id :edited-handler])))))