penpot/frontend/test/frontend_tests/logic/path_tools_test.cljs
2026-07-29 09:38:03 +02:00

805 lines
44 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-tools-test
(:require
[app.common.geom.point :as gpt]
[app.common.geom.rect :as grc]
[app.common.types.path :as path]
[app.main.data.workspace.path.drawing :as path.drawing]
[app.main.data.workspace.path.edition :as path.edition]
[app.main.data.workspace.path.helpers :as path.helpers]
[app.main.data.workspace.path.selection :as path.selection]
[app.main.data.workspace.path.state :as path.state]
[app.main.data.workspace.path.tools :as path.tools]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[frontend-tests.logic.path-test-helpers :as pth]
[potok.v2.core :as ptk]))
(t/deftest mixed-node-conversions-only-change-opposite-node-type
(let [id (random-uuid)
content (pth/mixed-corner-curve-content)
points (path/get-points content)
corner-point (first points)
curve-point (second points)
state (pth/selectable-path-state
id content
{:nodes #{0 1} :segments #{} :handlers #{}})
curved-state (ptk/update (path.tools/make-curve) state)
cornered-state (ptk/update (path.tools/make-corner) state)
curved-content (path.state/get-path curved-state :content)
corner-content (path.state/get-path cornered-state :content)]
(t/is (path/is-curve-point? curved-content corner-point))
(t/is (= (nth content 2) (nth curved-content 2)))
(t/is (not (path/is-curve-point? corner-content corner-point)))
(t/is (not (path/is-curve-point? corner-content curve-point)))))
(t/deftest plain-and-shift-selection-work-across-path-element-types
(let [id (random-uuid)
content (pth/selectable-path-content)
state (pth/selectable-path-state
id content
{:nodes #{0}
:segments #{1}
:handlers #{[1 :c1]}})
state' (ptk/update (path.selection/select-handler 1 :c2 false) state)
state'' (ptk/update (path.selection/select-segment 2 true) state')
state''' (ptk/update (path.selection/select-handler 1 :c2 true) state'')
state'''' (ptk/update (path.selection/select-handler 2 :c1 true) state''')]
(t/is (= {:nodes #{}
:segments #{}
:handlers #{[1 :c2]}}
(get-in state' [:workspace-local :edit-path id :selection])))
(t/is (= {:nodes #{}
:segments #{2}
:handlers #{[1 :c2]}}
(get-in state'' [:workspace-local :edit-path id :selection])))
(t/is (= {:nodes #{}
:segments #{2}
:handlers #{[2 :c1]}}
(get-in state'''' [:workspace-local :edit-path id :selection])))))
(t/deftest path-area-selection-prioritizes-nodes-over-segments-over-handlers
(let [id (random-uuid)
content (pth/selectable-path-content)
selection path.helpers/empty-selection
state (pth/selectable-path-state id content selection)
;; Contains segment 1 and handler [1 :c1] but no node
rect (grc/make-rect 1 -2 3 4)
state' (ptk/update (path.selection/select-path-area
rect selection false)
state)
;; Contains node 0, segment 1 and handler [1 :c1]
node-rect (grc/make-rect -1 -1 4 2)
state'' (ptk/update (path.selection/select-path-area
node-rect selection false)
state)]
(t/is (= {:nodes #{}
:segments #{1}
:handlers #{}}
(get-in state' [:workspace-local :edit-path id :selection])))
(t/is (= {:nodes #{0}
:segments #{}
:handlers #{}}
(get-in state'' [:workspace-local :edit-path id :selection])))))
(t/deftest path-area-selection-picks-handlers-only-when-nothing-else-is-inside
(let [id (random-uuid)
;; Curve bulging up to y 7.5 with both handlers on y 10, away
;; from the curve itself
content (path/content
[{:command :move-to
:params {:x 0 :y 0}}
{:command :curve-to
:params {:c1x 0 :c1y 10
:c2x 10 :c2y 10
:x 10 :y 0}}])
selection path.helpers/empty-selection
state (pth/selectable-path-state id content selection)
;; Contains only the [1 :c1] handler control point
handler-rect (grc/make-rect -1 9 2 2)
state' (ptk/update (path.selection/select-path-area
handler-rect selection false)
state)
;; Contains both handlers and the top of the curve
mixed-rect (grc/make-rect -1 5 12 7)
state'' (ptk/update (path.selection/select-path-area
mixed-rect selection false)
state)]
(t/is (= {:nodes #{}
:segments #{}
:handlers #{[1 :c1]}}
(get-in state' [:workspace-local :edit-path id :selection])))
(t/is (= {:nodes #{}
:segments #{1}
:handlers #{}}
(get-in state'' [:workspace-local :edit-path id :selection])))))
(t/deftest path-area-selection-ignores-empty-buffer-emissions
(let [id (random-uuid)
content (pth/selectable-path-content)
selection path.helpers/empty-selection
state (pth/selectable-path-state id content selection)]
(t/is (= state
(ptk/update (path.selection/select-path-area
nil selection false)
state)))))
(t/deftest selected-segments-resolve-to-unique-endpoint-nodes
(let [content (pth/selectable-path-content)]
(t/is (= #{0 1}
(path.helpers/segment-node-indices content #{1})))
(t/is (= #{0 1 2}
(path.helpers/segment-node-indices content #{1 2})))))
(t/deftest moving-selected-segments-translates-endpoints-and-handlers
(let [id (random-uuid)
content (pth/selectable-path-content)
selection {:nodes #{}
:segments #{1}
:handlers #{}}
state (pth/selectable-path-state id content selection)
event (path.edition/move-selected-path-segment
(gpt/point 5 0)
(gpt/point 8 4))
state' (ptk/update event state)
modifiers (get-in state' [:workspace-local :edit-path id :content-modifiers])
content' (path/apply-content-modifiers content modifiers)]
(t/is (= (gpt/point 3 4) (path.helpers/node-position content' 0)))
(t/is (= (gpt/point 13 4) (path.helpers/node-position content' 1)))
(t/is (= (gpt/point 5 4) (path/get-handler-point content' 1 :c1)))
(t/is (= (gpt/point 11 4) (path/get-handler-point content' 1 :c2)))
(t/is (= (gpt/point 15 4) (path/get-handler-point content' 2 :c1)))))
(t/deftest moving-a-segment-between-selected-nodes-moves-the-node-selection
;; A segment between selected nodes moves with the node selection.
(let [id (random-uuid)
content (pth/selectable-path-content)
selection {:nodes #{0 1} :segments #{} :handlers #{}}
state (pth/selectable-path-state id content selection)
event (path.edition/move-selected-path-segment
(gpt/point 5 0)
(gpt/point 8 4))
state' (ptk/update event state)
modifiers (get-in state' [:workspace-local :edit-path id :content-modifiers])
content' (path/apply-content-modifiers content modifiers)]
;; both selected nodes translate by (+3,+4); the unselected node stays put
(t/is (= (gpt/point 3 4) (path.helpers/node-position content' 0)))
(t/is (= (gpt/point 13 4) (path.helpers/node-position content' 1)))
(t/is (= (gpt/point 20 0) (path.helpers/node-position content' 2)))))
(t/deftest moving-selected-opposite-handlers-translates-both
(let [id (random-uuid)
content (pth/selectable-path-content)
selection {:nodes #{}
:segments #{}
:handlers #{[1 :c2] [2 :c1]}}
state (pth/selectable-path-state id content selection)
event (path.edition/modify-selected-handlers
id [1 :c2] {} 3 4 :smart true)
state' (ptk/update event state)
modifiers (get-in state' [:workspace-local :edit-path id :content-modifiers])
content' (path/apply-content-modifiers content modifiers)]
(t/is (= {:c2x 3 :c2y 4} (get modifiers 1)))
(t/is (= {:c1x 3 :c1y 4} (get modifiers 2)))
(t/is (= (gpt/point 11 4) (path/get-handler-point content' 1 :c2)))
(t/is (= (gpt/point 15 4) (path/get-handler-point content' 2 :c1)))))
(t/deftest moving-selected-handlers-honours-each-explicit-node-mode
(let [id (random-uuid)
content (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 12 :c1y 0 :c2x 18 :c2y 0 :x 20 :y 0}}
{:command :curve-to
:params {:c1x 22 :c1y 0 :c2x 28 :c2y 0 :x 30 :y 0}}])
selection {:nodes #{}
:segments #{}
:handlers #{[2 :c1] [3 :c1]}}
state (-> (pth/selectable-path-state id content selection)
(assoc-in [:workspace-local :edit-path id :handler-types]
{1 :independent 2 :mirror}))
event (path.edition/modify-selected-handlers
id [2 :c1] {} 3 4 :independent true)
state' (ptk/update event state)
modifiers (get-in state' [:workspace-local :edit-path id :content-modifiers])
content' (path/apply-content-modifiers content modifiers)]
;; Selected handlers receive the same drag delta.
(t/is (= (gpt/point 15 4) (path/get-handler-point content' 2 :c1)))
(t/is (= (gpt/point 25 4) (path/get-handler-point content' 3 :c1)))
;; Each node applies its own mode to the opposite handle.
(t/is (= (gpt/point 8 0) (path/get-handler-point content' 1 :c2)))
(t/is (= (gpt/point 15 -4) (path/get-handler-point content' 2 :c2)))))
(t/deftest arrow-move-nudges-selected-handlers
;; Arrow keys nudge selected handlers.
(let [id (random-uuid)
content (pth/selectable-path-content)
state (pth/selectable-path-state id content
{:nodes #{} :segments #{} :handlers #{[2 :c1]}})
state' (ptk/update (path.edition/set-move-modifier [] #{[2 :c1]} (gpt/point 0 5))
state)
mods (get-in state' [:workspace-local :edit-path id :content-modifiers])
content' (path/apply-content-modifiers content mods)]
(t/is (= {:c1x 0 :c1y 5} (get mods 2)))
;; [2 :c1] base (12,0) -> (12,5); node 1 and its opposite handle stay put
(t/is (= (gpt/point 12 5) (path/get-handler-point content' 2 :c1)))
(t/is (= (gpt/point 10 0) (path.helpers/node-position content' 1)))
(t/is (= (gpt/point 8 0) (path/get-handler-point content' 1 :c2)))))
(t/deftest arrow-move-nudges-selected-segment-endpoints
;; Arrow keys nudge segment endpoints and their handles.
(let [id (random-uuid)
content (pth/selectable-path-content)
state (pth/selectable-path-state id content
{:nodes #{} :segments #{1} :handlers #{}})
node-idx (path.helpers/segment-node-indices content #{1})
points (path.helpers/node-positions content node-idx)
state' (ptk/update (path.edition/set-move-modifier points #{} (gpt/point 0 5))
state)
mods (get-in state' [:workspace-local :edit-path id :content-modifiers])
content' (path/apply-content-modifiers content mods)]
;; segment 1 connects node 0 (0,0) and node 1 (10,0); both move by (0,5)
(t/is (= (gpt/point 0 5) (path.helpers/node-position content' 0)))
(t/is (= (gpt/point 10 5) (path.helpers/node-position content' 1)))
;; the endpoint nodes' handles move rigidly with them
(t/is (= (gpt/point 2 5) (path/get-handler-point content' 1 :c1)))
(t/is (= (gpt/point 12 5) (path/get-handler-point content' 2 :c1)))))
(t/deftest align-nodes-aligns-selected-nodes-to-an-edge
;; Aligning nodes updates the drawing content.
(let [id (random-uuid)
content (path/content
[{:command :move-to :params {:x 0 :y 0}}
{:command :line-to :params {:x 10 :y 4}}
{:command :line-to :params {:x 4 :y 20}}])
state (pth/selectable-path-state id content
{:nodes #{0 1 2} :segments #{} :handlers #{}})
state' (ptk/update (path.tools/align-nodes :hleft) state)
content' (get-in state' [:workspace-drawing :object :content])]
;; every selected node's x becomes the min x (0), y is untouched
(t/is (= (gpt/point 0 0) (path.helpers/node-position content' 0)))
(t/is (= (gpt/point 0 4) (path.helpers/node-position content' 1)))
(t/is (= (gpt/point 0 20) (path.helpers/node-position content' 2)))))
(t/deftest distribute-nodes-spaces-selected-nodes-evenly
(let [id (random-uuid)
content (path/content
[{:command :move-to :params {:x 0 :y 0}}
{:command :line-to :params {:x 3 :y 5}}
{:command :line-to :params {:x 10 :y 9}}])
state (pth/selectable-path-state id content
{:nodes #{0 1 2} :segments #{} :handlers #{}})
state' (ptk/update (path.tools/distribute-nodes :horizontal) state)
content' (get-in state' [:workspace-drawing :object :content])]
;; the middle node is centered on x between the two extremes (0 and 10)
(t/is (= (gpt/point 5 5) (path.helpers/node-position content' 1)))
;; the extreme nodes stay put
(t/is (= (gpt/point 0 0) (path.helpers/node-position content' 0)))
(t/is (= (gpt/point 10 9) (path.helpers/node-position content' 2)))))
(t/deftest set-selection-coordinate-moves-selected-points
;; Coordinate edits move selected nodes and handlers.
(let [id (random-uuid)
content (path/content
[{:command :move-to :params {:x 0 :y 0}}
{:command :line-to :params {:x 10 :y 0}}
{:command :line-to :params {:x 20 :y 0}}])]
;; single node: only that node moves
(let [state (pth/selectable-path-state id content
{:nodes #{1} :segments #{} :handlers #{}})
state' (ptk/update (path.tools/set-selection-coordinate :y 7) state)
content' (get-in state' [:workspace-drawing :object :content])]
(t/is (= (gpt/point 10 7) (path.helpers/node-position content' 1)))
(t/is (= (gpt/point 0 0) (path.helpers/node-position content' 0)))
(t/is (= (gpt/point 20 0) (path.helpers/node-position content' 2))))
;; multi node: every selected node's coordinate is set to the value
(let [state (pth/selectable-path-state id content
{:nodes #{0 2} :segments #{} :handlers #{}})
state' (ptk/update (path.tools/set-selection-coordinate :x 5) state)
content' (get-in state' [:workspace-drawing :object :content])]
(t/is (= (gpt/point 5 0) (path.helpers/node-position content' 0)))
(t/is (= (gpt/point 5 0) (path.helpers/node-position content' 2)))
(t/is (= (gpt/point 10 0) (path.helpers/node-position content' 1)))))
;; a coincident closed-seam node moves as one logical node
(let [id (random-uuid)
content (path/content
[{:command :move-to :params {:x 0 :y 0}}
{:command :line-to :params {:x 10 :y 0}}
{:command :line-to :params {:x 0 :y 0}}])
state (pth/selectable-path-state id content
{:nodes #{0} :segments #{} :handlers #{}})
state' (ptk/update (path.tools/set-selection-coordinate :y 7) state)
content' (get-in state' [:workspace-drawing :object :content])]
(t/is (= (gpt/point 0 7) (path.helpers/node-position content' 0)))
(t/is (= (gpt/point 0 7) (path.helpers/node-position content' 2))))
;; a selected handler on an independent node moves only its own control point
(let [id (random-uuid)
content (path/content
[{:command :move-to :params {:x 0 :y 0}}
{:command :curve-to :params {:c1x 2 :c1y 2 :c2x 8 :c2y 2 :x 10 :y 0}}])
state (pth/selectable-path-state id content
{:nodes #{} :segments #{} :handlers #{[1 :c1]}})
state' (ptk/update (path.tools/set-selection-coordinate :x 4) state)
curve (nth (get-in state' [:workspace-drawing :object :content]) 1)]
;; c1 x set to 4; c1y, c2 and the anchor untouched
(t/is (= 4 (get-in curve [:params :c1x])))
(t/is (= 2 (get-in curve [:params :c1y])))
(t/is (= 8 (get-in curve [:params :c2x])))
(t/is (= 10 (get-in curve [:params :x])))))
(t/deftest set-selection-coordinate-mirrors-opposite-handler
;; Moving a mirrored handler updates its opposite.
(let [id (random-uuid)
;; node 1 (10,0) has collinear equal handles: c2 of cmd1 at (8,-2) and
;; c1 of cmd2 at (12,2) — a mirror node by geometry
content (path/content
[{:command :move-to :params {:x 0 :y 0}}
{:command :curve-to :params {:c1x 2 :c1y 0 :c2x 8 :c2y -2 :x 10 :y 0}}
{:command :curve-to :params {:c1x 12 :c1y 2 :c2x 18 :c2y 0 :x 20 :y 0}}])
state (pth/selectable-path-state id content
{:nodes #{} :segments #{} :handlers #{[1 :c2]}})
state' (ptk/update (path.tools/set-selection-coordinate :x 6) state)
content' (get-in state' [:workspace-drawing :object :content])]
;; dragged handle c2 of cmd1 -> x=6 (keeps y=-2)
(t/is (= (gpt/point 6 -2) (path/get-handler-point content' 1 :c2)))
;; opposite (c1 of cmd2) mirrors it about the node (10,0): 2*10-6=14, 2*0-(-2)=2
(t/is (= (gpt/point 14 2) (path/get-handler-point content' 2 :c1)))))
(t/deftest change-to-draw-mode-starts-a-line-from-the-selected-node
(let [id (random-uuid)
content (path/content
[{:command :move-to :params {:x 0 :y 0}}
{:command :line-to :params {:x 10 :y 0}}
{:command :line-to :params {:x 20 :y 0}}])]
;; a middle node: opens a new subpath (move-to) at the node and makes it the
;; pending origin, so the next click draws a line from it
(let [state (pth/selectable-path-state id content
{:nodes #{1} :segments #{} :handlers #{}})
state' (ptk/update (path.drawing/change-edit-mode :draw) state)
content' (get-in state' [:workspace-drawing :object :content])]
(t/is (= (gpt/point 10 0)
(get-in state' [:workspace-local :edit-path id :last-point])))
(t/is (= 4 (count content')))
(t/is (= :move-to (:command (nth content' 3))))
(t/is (= (gpt/point 10 0) (path.helpers/node-position content' 3))))
;; the drawing tip: just becomes the pending origin (extends), no new subpath
(let [state (pth/selectable-path-state id content
{:nodes #{2} :segments #{} :handlers #{}})
state' (ptk/update (path.drawing/change-edit-mode :draw) state)
content' (get-in state' [:workspace-drawing :object :content])]
(t/is (= (gpt/point 20 0)
(get-in state' [:workspace-local :edit-path id :last-point])))
(t/is (= 3 (count content'))))
;; nothing selected: no pending line
(let [state (pth/selectable-path-state id content
{:nodes #{} :segments #{} :handlers #{}})
state' (ptk/update (path.drawing/change-edit-mode :draw) state)]
(t/is (nil? (get-in state' [:workspace-local :edit-path id :last-point]))))))
(t/deftest set-selection-coordinate-translates-segments
;; Coordinate edits translate selected segments by their bounds.
(let [id (random-uuid)
content (path/content
[{:command :move-to :params {:x 0 :y 0}}
{:command :line-to :params {:x 10 :y 0}}
{:command :line-to :params {:x 10 :y 10}}])
;; select segment index 2 (the vertical line from (10,0) to (10,10));
;; its surrounding rect top-left x is 10
state (pth/selectable-path-state id content
{:nodes #{} :segments #{2} :handlers #{}})
state' (ptk/update (path.tools/set-selection-coordinate :x 30) state)
content' (get-in state' [:workspace-drawing :object :content])]
;; the segment's endpoint nodes (1 and 2) move +20 in x; node 0 stays
(t/is (= (gpt/point 0 0) (path.helpers/node-position content' 0)))
(t/is (= (gpt/point 30 0) (path.helpers/node-position content' 1)))
(t/is (= (gpt/point 30 10) (path.helpers/node-position content' 2))))
;; Moving a segment attached to a closed seam keeps both seam commands together.
(let [id (random-uuid)
content (path/content
[{:command :move-to :params {:x 0 :y 0}}
{:command :line-to :params {:x 10 :y 0}}
{:command :line-to :params {:x 10 :y 10}}
{:command :line-to :params {:x 0 :y 10}}
{:command :line-to :params {:x 0 :y 0}}])
state (pth/selectable-path-state id content
{:nodes #{} :segments #{4} :handlers #{}})
state' (ptk/update (path.tools/set-selection-coordinate :x 20) state)
content' (get-in state' [:workspace-drawing :object :content])]
(t/is (= (gpt/point 20 0) (path.helpers/node-position content' 0)))
(t/is (= (gpt/point 20 10) (path.helpers/node-position content' 3)))
(t/is (= (gpt/point 20 0) (path.helpers/node-position content' 4)))))
(t/deftest set-selection-coordinate-translates-mixed-segment-and-node-selection
;; Selected segments and nodes translate as one group.
(let [id (random-uuid)
content (path/content
[{:command :move-to :params {:x 0 :y 0}}
{:command :line-to :params {:x 10 :y 0}}
{:command :line-to :params {:x 20 :y 0}}
{:command :line-to :params {:x 30 :y 0}}])
;; The combined bounds start at x=0.
state (pth/selectable-path-state id content
{:nodes #{0} :segments #{3} :handlers #{}})
state' (ptk/update (path.tools/set-selection-coordinate :x 10) state)
content' (get-in state' [:workspace-drawing :object :content])]
(t/is (= (gpt/point 10 0) (path.helpers/node-position content' 0)))
(t/is (= (gpt/point 10 0) (path.helpers/node-position content' 1)))
(t/is (= (gpt/point 30 0) (path.helpers/node-position content' 2)))
(t/is (= (gpt/point 40 0) (path.helpers/node-position content' 3)))))
(t/deftest set-selection-coordinate-translates-mixed-segment-and-handler-selection
;; Standalone selected handlers translate with the group.
(let [id (random-uuid)
content (path/content
[{:command :move-to :params {:x 0 :y 0}}
{:command :curve-to
:params {:c1x 2 :c1y 2 :c2x 8 :c2y 2 :x 10 :y 0}}
{:command :line-to :params {:x 20 :y 0}}
{:command :line-to :params {:x 30 :y 0}}])
;; The standalone handler makes the bounds start at x=2.
state (pth/selectable-path-state id content
{:nodes #{}
:segments #{3}
:handlers #{[1 :c1]}})
state' (ptk/update (path.tools/set-selection-coordinate :x 12) state)
content' (get-in state' [:workspace-drawing :object :content])]
(t/is (= (gpt/point 12 2) (path/get-handler-point content' 1 :c1)))
(t/is (= (gpt/point 0 0) (path.helpers/node-position content' 0)))
(t/is (= (gpt/point 10 0) (path.helpers/node-position content' 1)))
(t/is (= (gpt/point 30 0) (path.helpers/node-position content' 2)))
(t/is (= (gpt/point 40 0) (path.helpers/node-position content' 3)))))
(t/deftest flip-nodes-includes-selected-segment-endpoints
(let [id (random-uuid)
content (pth/selectable-path-content)
state (pth/selectable-path-state id content
{:nodes #{} :segments #{1} :handlers #{}})
state' (ptk/update (path.tools/flip-nodes :horizontal) state)
content' (get-in state' [:workspace-drawing :object :content])]
;; segment 1's endpoints (nodes 0 and 1) mirror across their bbox centre (x=5)
(t/is (= (gpt/point 10 0) (path.helpers/node-position content' 0)))
(t/is (= (gpt/point 0 0) (path.helpers/node-position content' 1)))
;; node 2 is not an endpoint of segment 1, so it stays put
(t/is (= (gpt/point 20 0) (path.helpers/node-position content' 2)))))
(t/deftest merge-nodes-includes-selected-segment-endpoints
(let [id (random-uuid)
content (pth/selectable-path-content)
state (pth/selectable-path-state id content
{:nodes #{} :segments #{1} :handlers #{}})
state' (ptk/update (path.tools/merge-nodes) state)
content' (get-in state' [:workspace-drawing :object :content])
pts (path/get-points content')]
;; segment 1's endpoints (0,0) and (10,0) merge to their midpoint (5,0)
(t/is (some #(= (gpt/point 5 0) %) pts))
(t/is (< (count pts) 3))))
(t/deftest delete-selected-opens-segments-else-removes-nodes
(let [id (random-uuid)
content (path/content
[{:command :move-to :params {:x 0 :y 0}}
{:command :line-to :params {:x 10 :y 0}}
{:command :line-to :params {:x 20 :y 0}}
{:command :line-to :params {:x 30 :y 0}}])
run (fn [selection]
(let [state (pth/selectable-path-state id content selection)
events (atom [])]
(->> (ptk/watch (path.tools/delete-selected) state nil)
(rx/subs! #(swap! events conj %)))
;; delete-selected emits a single node-tool event; apply it
(ptk/update (first @events) state)))
move-tos (fn [st] (->> (get-in st [:workspace-drawing :object :content])
vec
(filter #(= :move-to (:command %)))
count))
nodes (fn [st] (count (path/get-points
(get-in st [:workspace-drawing :object :content]))))]
;; deleting the middle segment (index 2) opens the path into two subpaths
(t/is (> (move-tos (run {:nodes #{} :segments #{2} :handlers #{}})) 1))
;; Deleting a node leaves fewer than four nodes.
(t/is (< (nodes (run {:nodes #{1} :segments #{} :handlers #{}})) 4))
;; Mixed node and segment deletion heals the selected node.
(let [mixed (run {:nodes #{1} :segments #{2} :handlers #{}})]
(t/is (< (nodes mixed) 4))
(t/is (= 1 (move-tos mixed))))))
(t/deftest deleting-a-closed-seam-node-heals-its-adjacent-segments
(let [id (random-uuid)
content (path/content
[{:command :move-to :params {:x 0 :y 0}}
{:command :line-to :params {:x 10 :y 0}}
{:command :line-to :params {:x 10 :y 10}}
{:command :line-to :params {:x 0 :y 10}}
{:command :line-to :params {:x 0 :y 0}}])
state (pth/selectable-path-state id content
{:nodes #{0} :segments #{} :handlers #{}})
events (atom [])
_ (->> (ptk/watch (path.tools/delete-selected) state nil)
(rx/subs! #(swap! events conj %)))
state' (ptk/update (first @events) state)
content' (vec (get-in state' [:workspace-drawing :object :content]))]
(t/is (= [:move-to :line-to :line-to :curve-to]
(mapv :command content')))
(t/is (= (gpt/point 10 0) (path.helpers/node-position content' 0)))
(t/is (= (gpt/point 10 0) (path.helpers/node-position content' 3)))))
(t/deftest deleting-a-touching-subpath-seam-heals-before-exiting-edition
(let [id (random-uuid)
content (path/content
[{:command :move-to :params {:x 0 :y 10}}
{:command :line-to :params {:x -10 :y 7}}
{:command :line-to :params {:x -10 :y 3}}
{:command :line-to :params {:x 0 :y 0}}
{:command :move-to :params {:x 0 :y 0}}
{:command :line-to :params {:x 10 :y 3}}
{:command :line-to :params {:x 10 :y 7}}
{:command :line-to :params {:x 0 :y 10}}])
state (pth/selectable-path-state id content
{:nodes #{0 7} :segments #{} :handlers #{}})
events (atom [])
_ (->> (ptk/watch (path.tools/delete-selected) state nil)
(rx/subs! #(swap! events conj %)))
state' (ptk/update (first @events) state)
content' (vec (get-in state' [:workspace-drawing :object :content]))]
(t/is (= [:move-to :line-to :line-to :line-to :line-to :curve-to]
(mapv :command content')))
(t/is (= (gpt/point -10 7) (path.helpers/node-position content' 0)))
(t/is (= (gpt/point -10 7) (path.helpers/node-position content' 5)))))
(t/deftest delete-selected-with-segments-opens-a-gap-around-the-node
(let [id (random-uuid)
content (path/content
[{:command :move-to :params {:x 0 :y 0}}
{:command :line-to :params {:x 10 :y 0}}
{:command :line-to :params {:x 20 :y 0}}
{:command :line-to :params {:x 30 :y 0}}
{:command :line-to :params {:x 40 :y 0}}])
state (pth/selectable-path-state id content {:nodes #{2} :segments #{} :handlers #{}})
events (atom [])
_ (->> (ptk/watch (path.tools/delete-selected-with-segments) state nil)
(rx/subs! #(swap! events conj %)))
state' (ptk/update (first @events) state)
content' (get-in state' [:workspace-drawing :object :content])
move-tos (->> content' vec (filter #(= :move-to (:command %))) count)
nodes (count (path/get-points content'))]
;; Removing incident segments opens a gap around the node.
(t/is (= 4 nodes))
(t/is (= 2 move-tos))))
(t/deftest group-handler-drag-ignores-stale-handler-identities
(let [id (random-uuid)
content (path/content
[{:command :move-to
:params {:x 0 :y 0}}
{:command :line-to
:params {:x 10 :y 0}}
{:command :curve-to
:params {:c1x 12 :c1y 0
:c2x 18 :c2y 0
:x 20 :y 0}}])
selection {:nodes #{}
:segments #{}
;; [1 :c1] points to a line-to and [9 :c2] is out of range
:handlers #{[1 :c1] [2 :c1] [9 :c2]}}
state (pth/selectable-path-state id content selection)
event (path.edition/modify-selected-handlers
id [2 :c1] {} 3 4 :smart true)
state' (ptk/update event state)
modifiers (get-in state' [:workspace-local :edit-path id :content-modifiers])]
(t/is (= {:c1x 3 :c1y 4} (get modifiers 2)))
(t/is (nil? (get modifiers 1)))
(t/is (nil? (get modifiers 9)))))
(t/deftest handler-drag-smart-keeps-a-smooth-node-smooth
(let [id (random-uuid)
content (pth/selectable-path-content)
state (pth/selectable-path-state id content {:nodes #{} :segments #{}
:handlers #{[2 :c1]}})
;; Smart mode keeps the handles aligned.
event (path.edition/modify-selected-handlers id [2 :c1] {} 0 4 :smart true)
state' (ptk/update event state)
modifiers (get-in state' [:workspace-local :edit-path id :content-modifiers])
content' (path/apply-content-modifiers content modifiers)]
(t/is (= (gpt/point 12 4) (path/get-handler-point content' 2 :c1)))
;; the opposite rotated to stay collinear -> still a smooth node
(t/is (not= (gpt/point 8 0) (path/get-handler-point content' 1 :c2)))
(t/is (path.helpers/handlers-joined? content' 2 :c1))))
(t/deftest handler-drag-independent-breaks-a-smooth-node
(let [id (random-uuid)
content (pth/selectable-path-content)
state (pth/selectable-path-state id content {:nodes #{} :segments #{}
:handlers #{[2 :c1]}})
;; Independent mode leaves the opposite handle in place.
event (path.edition/modify-selected-handlers id [2 :c1] {} 0 4 :independent false)
state' (ptk/update event state)
modifiers (get-in state' [:workspace-local :edit-path id :content-modifiers])
content' (path/apply-content-modifiers content modifiers)]
(t/is (= (gpt/point 12 4) (path/get-handler-point content' 2 :c1)))
(t/is (= (gpt/point 8 0) (path/get-handler-point content' 1 :c2)))
(t/is (not (path.helpers/handlers-joined? content' 2 :c1)))))
(t/deftest handler-drag-mirror-rejoins-a-corner-node
(let [id (random-uuid)
content (pth/corner-path-content)
state (pth/selectable-path-state id content {:nodes #{} :segments #{}
:handlers #{[2 :c1]}})
;; Mirror mode matches the opposite handle's angle and length.
event (path.edition/modify-selected-handlers id [2 :c1] {} 2 -6 :mirror false)
state' (ptk/update event state)
modifiers (get-in state' [:workspace-local :edit-path id :content-modifiers])
content' (path/apply-content-modifiers content modifiers)]
(t/is (= (gpt/point 14 0) (path/get-handler-point content' 2 :c1)))
(t/is (= (gpt/point 6 0) (path/get-handler-point content' 1 :c2)))
(t/is (path.helpers/handlers-joined? content' 2 :c1))))
(t/deftest handler-drag-aligned-mirrors-angle-keeping-length
(let [id (random-uuid)
content (pth/corner-path-content)
state (pth/selectable-path-state id content {:nodes #{} :segments #{}
:handlers #{}})
;; Aligned mode matches the angle and keeps the opposite length.
event (path.edition/modify-selected-handlers id [2 :c1] {} -2 -2 :aligned false)
state' (ptk/update event state)
modifiers (get-in state' [:workspace-local :edit-path id :content-modifiers])
content' (path/apply-content-modifiers content modifiers)]
(t/is (= (gpt/point 10 4) (path/get-handler-point content' 2 :c1)))
(t/is (= (gpt/point 10 -2) (path/get-handler-point content' 1 :c2)))
(t/is (path.helpers/handlers-joined? content' 2 :c1))))
(t/deftest remove-handler-collapses-the-clicked-handler
(let [id (random-uuid)
content (pth/selectable-path-content)
state (pth/selectable-path-state id content
{:nodes #{} :segments #{}
:handlers #{[1 :c2]}})
state' (ptk/update (path.tools/remove-handler 1 :c2) state)
content' (get-in state' [:workspace-drawing :object :content])]
;; the clicked handler rests on its node (10,0); the others are untouched
(t/is (= (gpt/point 10 0) (path/get-handler-point content' 1 :c2)))
(t/is (= (gpt/point 2 0) (path/get-handler-point content' 1 :c1)))
(t/is (= (gpt/point 12 0) (path/get-handler-point content' 2 :c1)))))
(t/deftest toggle-segment-curve-switches-line-and-curve
(let [id (random-uuid)
content (path/content
[{:command :move-to :params {:x 0 :y 0}}
{:command :line-to :params {:x 30 :y 0}}])
state (pth/selectable-path-state id content path.helpers/empty-selection)
state' (ptk/update (path.tools/toggle-segment-curve 1) state)
content' (get-in state' [:workspace-drawing :object :content])
state'' (ptk/update (path.tools/toggle-segment-curve 1) state')
content'' (get-in state'' [:workspace-drawing :object :content])]
(t/is (= :curve-to (:command (nth content' 1))))
;; handles a third along, offset perpendicular (0.25 * length) into a bow
(t/is (= (gpt/point 10 7.5) (path/get-handler-point content' 1 :c1)))
(t/is (= (gpt/point 20 7.5) (path/get-handler-point content' 1 :c2)))
(t/is (= :line-to (:command (nth content'' 1))))))
(t/deftest remove-segment-opens-the-path-keeping-nodes
(let [id (random-uuid)
content (path/content
[{:command :move-to :params {:x 0 :y 0}}
{:command :line-to :params {:x 10 :y 0}}
{:command :line-to :params {:x 20 :y 0}}
{:command :line-to :params {:x 30 :y 0}}])
state (pth/selectable-path-state id content
{:nodes #{} :segments #{2} :handlers #{}})
state' (ptk/update (path.tools/remove-segment 2) state)
content' (get-in state' [:workspace-drawing :object :content])]
(t/is (= [[:move-to {:x 0 :y 0}] [:line-to {:x 10 :y 0}]
[:move-to {:x 20 :y 0}] [:line-to {:x 30 :y 0}]]
(mapv (juxt :command :params) content')))
;; the removed segment's now-stale selection is pruned
(t/is (= #{} (get-in state' [:workspace-local :edit-path id :selection :segments])))))
(t/deftest remove-segment-remaps-handler-types-when-node-indices-shift
(let [id (random-uuid)
content (path/content
[{:command :move-to :params {:x 0 :y 0}}
{:command :line-to :params {:x 10 :y 0}}
{:command :curve-to
:params {:c1x 12 :c1y 4 :c2x 18 :c2y 4 :x 20 :y 0}}
{:command :curve-to
:params {:c1x 22 :c1y -4 :c2x 28 :c2y -4 :x 30 :y 0}}])
state (-> (pth/selectable-path-state
id content {:nodes #{2} :segments #{} :handlers #{}})
(assoc-in [:workspace-local :edit-path id :handler-types]
{2 :aligned}))
state' (ptk/update (path.tools/remove-segment 1) state)]
;; Remap the selected node after dropping the dangling start.
(t/is (= 3 (count (get-in state' [:workspace-drawing :object :content]))))
(t/is (= #{1} (get-in state' [:workspace-local :edit-path id :selection :nodes])))
;; Keep the mode attached to the surviving node.
(t/is (= {1 :aligned}
(get-in state' [:workspace-local :edit-path id :handler-types])))))
(t/deftest removing-an-earlier-node-preserves-a-surviving-mirror-mode
(let [id (random-uuid)
content (path/content
[{:command :move-to :params {:x 0 :y 0}}
{:command :curve-to
:params {:c1x 3 :c1y 0 :c2x 7 :c2y 0 :x 10 :y 0}}
{:command :curve-to
:params {:c1x 13 :c1y 0 :c2x 17 :c2y 0 :x 20 :y 0}}
{:command :curve-to
:params {:c1x 23 :c1y 0 :c2x 28 :c2y 0 :x 30 :y 0}}
{:command :curve-to
:params {:c1x 34 :c1y 0 :c2x 37 :c2y 0 :x 40 :y 0}}
{:command :curve-to
:params {:c1x 43 :c1y 0 :c2x 47 :c2y 0 :x 50 :y 0}}])
state (-> (pth/selectable-path-state
id content {:nodes #{0} :segments #{} :handlers #{}})
(assoc-in [:workspace-local :edit-path id :handler-types]
{3 :mirror}))
state' (ptk/update (path.tools/remove-node) state)]
;; Geometry alone derives the fourth node as aligned.
(t/is (= :aligned (path.helpers/derive-handler-type content 3)))
;; Remap the explicit mode with the surviving node.
(t/is (= {2 :mirror}
(get-in state' [:workspace-local :edit-path id :handler-types])))))
(t/deftest remove-node-with-segments-opens-a-gap
(let [id (random-uuid)
content (path/content
[{:command :move-to :params {:x 0 :y 0}}
{:command :line-to :params {:x 10 :y 0}}
{:command :line-to :params {:x 20 :y 0}}
{:command :line-to :params {:x 30 :y 0}}])
state (pth/selectable-path-state id content path.helpers/empty-selection)
emitted (atom [])
_ (->> (ptk/watch (path.tools/remove-node-with-segments 1) state nil)
(rx/subs! #(swap! emitted conj %)))
state' (reduce #(ptk/update %2 %1) state @emitted)
content' (get-in state' [:workspace-drawing :object :content])]
;; node (10,0) and both incident segments are gone; the (0,0) start is
;; left dangling and dropped too, the rest of the path survives
(t/is (= [[:move-to {:x 20 :y 0}] [:line-to {:x 30 :y 0}]]
(mapv (juxt :command :params) content')))))
(t/deftest dragging-a-node-or-segment-onto-another-merges-them
(let [id (random-uuid)
content (path/content
[{:command :move-to :params {:x 0 :y 0}}
{:command :line-to :params {:x 10 :y 0}}
{:command :move-to :params {:x 12 :y 0}}
{:command :line-to :params {:x 30 :y 0}}])
mk (fn [selection]
(-> (pth/selectable-path-state id content selection)
(assoc-in [:workspace-local :zoom] 1)))
emit-of (fn [state]
(let [out (atom [])]
(->> (ptk/watch (path.edition/merge-dragged-on-drop)
state (rx/subject))
(rx/subs! #(swap! out conj %)))
@out))
welded [{:x 0 :y 0} {:x 11 :y 0} {:x 30 :y 0}]]
(t/testing "a single node dropped within range of another node merges them"
(let [state (mk {:nodes #{1} :segments #{} :handlers #{}})
events (emit-of state)
content' (vec (get-in (ptk/update (first events) state)
[:workspace-drawing :object :content]))]
(t/is (= 1 (count events)))
;; Dropped subpath endpoints weld at their midpoint.
(t/is (= welded (mapv :params content')))))
(t/testing "a dragged segment whose endpoint lands on a node merges too"
;; segment 1 (nodes (0,0)-(10,0)); its (10,0) end is within range of (12,0)
(let [state (mk {:nodes #{} :segments #{1} :handlers #{}})
events (emit-of state)
content' (vec (get-in (ptk/update (first events) state)
[:workspace-drawing :object :content]))]
(t/is (= 1 (count events)))
(t/is (= welded (mapv :params content')))))
(t/testing "a node dropped with no neighbour in range does not merge"
(t/is (empty? (emit-of (mk {:nodes #{3} :segments #{} :handlers #{}})))))))
;; Path-local undo and redo events use a seeded local stack.