mirror of
https://github.com/penpot/penpot.git
synced 2026-08-14 08:48:55 +00:00
805 lines
44 KiB
Clojure
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.
|