mirror of
https://github.com/penpot/penpot.git
synced 2026-09-09 04:09:38 -04:00
Compare commits
1
Commits
develop
...
issue-11415
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed65e2200e |
No files matched your search
@@ -460,6 +460,13 @@
|
||||
(let [content (impl/path-data content)]
|
||||
(segment/merge-nodes content points)))
|
||||
|
||||
(defn merge-coincident-nodes
|
||||
"Collapses the nodes sharing a position at the given points into one node."
|
||||
[content points]
|
||||
(let [content (impl/path-data content)]
|
||||
(-> (segment/merge-coincident-nodes content points)
|
||||
(impl/from-plain))))
|
||||
|
||||
(defn join-nodes
|
||||
"Creates new segments between points that weren't previously connected."
|
||||
[content points]
|
||||
|
||||
@@ -940,18 +940,21 @@
|
||||
(not= :close-path (:command c))))]
|
||||
(loop [i 0
|
||||
k 0
|
||||
start nil
|
||||
result (transient [])]
|
||||
(if (>= i n)
|
||||
(persistent! result)
|
||||
(let [cmd (nth content i)
|
||||
nxt (nth content (inc i) nil)
|
||||
move? (= :move-to (:command cmd))
|
||||
start (if move? (helpers/segment->point cmd) start)
|
||||
at-p? (and (not= :close-path (:command cmd))
|
||||
(gpt/close? point (helpers/segment->point cmd)))]
|
||||
(cond
|
||||
;; Offset a subpath start.
|
||||
(and at-p? (= :move-to (:command cmd)))
|
||||
(and at-p? move?)
|
||||
(let [off (gpt/point (* k ox) (* k oy))]
|
||||
(recur (inc i) (inc k)
|
||||
(recur (inc i) (inc k) start
|
||||
(conj! result (-> cmd
|
||||
(update-in [:params :x] + (:x off))
|
||||
(update-in [:params :y] + (:y off))))))
|
||||
@@ -972,7 +975,7 @@
|
||||
(= :curve-to (:command nxt))
|
||||
(-> (update-in [:params :c1x] + (:x off2))
|
||||
(update-in [:params :c1y] + (:y off2))))]
|
||||
(recur (+ i 2) (inc k2)
|
||||
(recur (+ i 2) (inc k2) start
|
||||
(-> result (conj! cmd') (conj! mv) (conj! nxt'))))
|
||||
|
||||
;; Open and offset a closed seam.
|
||||
@@ -985,12 +988,20 @@
|
||||
(-> (update-in [:params :c2x] + (:x off))
|
||||
(update-in [:params :c2y] + (:y off))))]
|
||||
;; Drop the close command so the seam stays open.
|
||||
(recur (+ i 2) (inc k) (conj! result cmd')))
|
||||
(recur (+ i 2) (inc k) start (conj! result cmd')))
|
||||
|
||||
;; Open the seam of a subpath that closes back onto the node.
|
||||
(and (= :close-path (:command cmd))
|
||||
(some? start)
|
||||
(gpt/close? point start))
|
||||
(let [off (gpt/point (* k ox) (* k oy))]
|
||||
(recur (inc i) (inc k) start
|
||||
(conj! result (helpers/make-line-to (gpt/add point off)))))
|
||||
|
||||
;; Offset the end of an open subpath.
|
||||
(and at-p? (seg? cmd) (not= :close-path (:command nxt)))
|
||||
(let [off (gpt/point (* k ox) (* k oy))]
|
||||
(recur (inc i) (inc k)
|
||||
(recur (inc i) (inc k) start
|
||||
(conj! result (cond-> (-> cmd
|
||||
(update-in [:params :x] + (:x off))
|
||||
(update-in [:params :y] + (:y off)))
|
||||
@@ -999,7 +1010,7 @@
|
||||
(update-in [:params :c2y] + (:y off)))))))
|
||||
|
||||
:else
|
||||
(recur (inc i) k (conj! result cmd))))))))
|
||||
(recur (inc i) k start (conj! result cmd))))))))
|
||||
|
||||
(defn separate-nodes
|
||||
"Removes segments between points or splits one node into offset open ends."
|
||||
@@ -1072,7 +1083,7 @@
|
||||
|
||||
result (cond-> result
|
||||
(and (nil? set-a) (nil? set-b))
|
||||
(conj #{point-a point-b})
|
||||
(conj (hash-set point-a point-b))
|
||||
|
||||
(and (some? set-a) (nil? set-b))
|
||||
(add-to-set set-a point-b)
|
||||
@@ -1108,6 +1119,46 @@
|
||||
(->> content
|
||||
(mapv replace-command))))
|
||||
|
||||
(defn- remove-empty-segments
|
||||
"Drops segments with no length whose ends are accepted by `at-point?`."
|
||||
[content at-point?]
|
||||
(loop [result (transient [])
|
||||
prev nil
|
||||
segments? false
|
||||
pending (seq content)]
|
||||
(if-let [{:keys [command] :as segment} (first pending)]
|
||||
(let [close? (= :close-path command)
|
||||
move? (= :move-to command)
|
||||
point (when-not close? (helpers/segment->point segment))
|
||||
;; A close command on a subpath without segments draws nothing.
|
||||
empty? (if close?
|
||||
(not segments?)
|
||||
(and (not move?)
|
||||
(some? prev)
|
||||
(gpt/close? prev point)
|
||||
(at-point? point)))]
|
||||
(if empty?
|
||||
(recur result prev segments? (next pending))
|
||||
(recur (conj! result segment)
|
||||
(if close? nil point)
|
||||
(not (or move? close?))
|
||||
(next pending))))
|
||||
(persistent! result))))
|
||||
|
||||
(defn merge-coincident-nodes
|
||||
"Collapses the commands sharing a position at `points` into a single node.
|
||||
|
||||
Drops empty segments and stitches the subpath ends meeting at one of the
|
||||
points, closing the resulting loops. A point where more than two segments
|
||||
meet is left alone: the format needs one command per segment there."
|
||||
[content points]
|
||||
(let [at-point? (fn [point] (some #(gpt/close? point %) points))]
|
||||
(-> (vec content)
|
||||
(remove-empty-segments at-point?)
|
||||
(subpath/close-subpaths at-point?)
|
||||
;; A subpath whose ends meet carries an explicit close command.
|
||||
(subpath/close-loops))))
|
||||
|
||||
(defn merge-nodes
|
||||
"Joins and merges `points` into one point."
|
||||
[content points]
|
||||
@@ -1116,10 +1167,12 @@
|
||||
(if (seq segments)
|
||||
(let [point->merge-point (-> segments
|
||||
(group-segments)
|
||||
(calculate-merge-points points))]
|
||||
(calculate-merge-points points))
|
||||
merge-points (set (vals point->merge-point))]
|
||||
(-> content
|
||||
(separate-nodes points)
|
||||
(replace-points point->merge-point)))
|
||||
(replace-points point->merge-point)
|
||||
(merge-coincident-nodes merge-points)))
|
||||
content)))
|
||||
|
||||
(defn transform-content
|
||||
|
||||
@@ -99,25 +99,30 @@
|
||||
|
||||
(defn- merge-paths
|
||||
"Tries to merge into candidate the subpaths. Will return the candidate with the subpaths merged
|
||||
and removed from subpaths the subpaths merged"
|
||||
[candidate subpaths]
|
||||
(let [merge-with-candidate
|
||||
and removed from subpaths the subpaths merged. Only meeting points accepted
|
||||
by `meet?` are joined"
|
||||
[candidate subpaths meet?]
|
||||
(let [joins?
|
||||
(fn [point other]
|
||||
(and (pt= point other) (meet? point)))
|
||||
|
||||
merge-with-candidate
|
||||
(fn [[candidate result] current]
|
||||
(cond
|
||||
(pt= (:to current) (:from current))
|
||||
;; Subpath is already a closed path
|
||||
[candidate (conj result current)]
|
||||
|
||||
(pt= (:to candidate) (:from current))
|
||||
(joins? (:to candidate) (:from current))
|
||||
[(subpaths-join candidate current) result]
|
||||
|
||||
(pt= (:from candidate) (:to current))
|
||||
(joins? (:from candidate) (:to current))
|
||||
[(subpaths-join current candidate) result]
|
||||
|
||||
(pt= (:to candidate) (:to current))
|
||||
(joins? (:to candidate) (:to current))
|
||||
[(subpaths-join candidate (reverse-subpath current)) result]
|
||||
|
||||
(pt= (:from candidate) (:from current))
|
||||
(joins? (:from candidate) (:from current))
|
||||
[(subpaths-join (reverse-subpath current) candidate) result]
|
||||
|
||||
:else
|
||||
@@ -163,35 +168,37 @@
|
||||
(into [] xf-mapcat-data merged)))
|
||||
|
||||
(defn close-subpaths
|
||||
"Searches a path for possible subpaths that can create closed loops and merge them"
|
||||
[content]
|
||||
(let [subpaths (get-subpaths content)
|
||||
closed-subpaths
|
||||
(loop [result []
|
||||
current (first subpaths)
|
||||
subpaths (rest subpaths)]
|
||||
"Searches a path for possible subpaths that can create closed loops and merge them.
|
||||
When `meet?` is given only subpaths that touch at an accepted point are merged"
|
||||
([content]
|
||||
(close-subpaths content (constantly true)))
|
||||
([content meet?]
|
||||
(let [subpaths (get-subpaths content)
|
||||
closed-subpaths
|
||||
(loop [result []
|
||||
current (first subpaths)
|
||||
subpaths (rest subpaths)]
|
||||
|
||||
(if (some? current)
|
||||
(let [[new-current new-subpaths]
|
||||
(if (is-closed? current)
|
||||
[current subpaths]
|
||||
(merge-paths current subpaths))]
|
||||
(if (some? current)
|
||||
(let [[new-current new-subpaths]
|
||||
(if (is-closed? current)
|
||||
[current subpaths]
|
||||
(merge-paths current subpaths meet?))]
|
||||
|
||||
(if (= current new-current)
|
||||
;; If equal we haven't found any matching subpaths we advance
|
||||
(recur (conj result new-current)
|
||||
(first new-subpaths)
|
||||
(rest new-subpaths))
|
||||
(if (= current new-current)
|
||||
;; If equal we haven't found any matching subpaths we advance
|
||||
(recur (conj result new-current)
|
||||
(first new-subpaths)
|
||||
(rest new-subpaths))
|
||||
|
||||
;; If different we need to pass again the merge to check for additional
|
||||
;; subpaths to join
|
||||
(recur result
|
||||
new-current
|
||||
new-subpaths)))
|
||||
result))]
|
||||
;; If different we need to pass again the merge to check for additional
|
||||
;; subpaths to join
|
||||
(recur result
|
||||
new-current
|
||||
new-subpaths)))
|
||||
result))]
|
||||
|
||||
|
||||
(into [] xf-mapcat-data closed-subpaths)))
|
||||
(into [] xf-mapcat-data closed-subpaths))))
|
||||
|
||||
(defn- close-loop
|
||||
"Adds an explicit close command when a subpath's endpoints meet."
|
||||
|
||||
@@ -1373,6 +1373,20 @@
|
||||
(t/is (= {:c2x 4.0 :c2y 4.0}
|
||||
(select-keys (:params (peek result)) [:c2x :c2y])))))
|
||||
|
||||
(t/deftest segment-separate-single-node-closed-subpath-start
|
||||
;; The seam of a closed subpath opens even when it is the subpath start.
|
||||
(let [content (path/content
|
||||
[{:command :move-to :params {:x 0.0 :y 0.0}}
|
||||
{:command :line-to :params {:x 10.0 :y 0.0}}
|
||||
{:command :line-to :params {:x 10.0 :y 10.0}}
|
||||
{:command :close-path :params {}}])
|
||||
result (vec (path/separate-nodes content #{(gpt/point 0.0 0.0)}))]
|
||||
;; the close command becomes the second, offset, open end
|
||||
(t/is (= [:move-to :line-to :line-to :line-to] (mapv :command result)))
|
||||
(t/is (= [{:x 0.0 :y 0.0} {:x 10.0 :y 0.0}
|
||||
{:x 10.0 :y 10.0} {:x 8.0 :y 8.0}]
|
||||
(mapv #(select-keys (:params %) [:x :y]) result)))))
|
||||
|
||||
(t/deftest segment-separate-single-node-endpoint-noop
|
||||
;; an endpoint node has no following segment, so nothing is split
|
||||
(let [content (path/content
|
||||
@@ -2092,7 +2106,7 @@
|
||||
(t/is (some? result)))))
|
||||
|
||||
(t/deftest path-merge-disconnected-nodes
|
||||
;; Merging separate subpaths joins them at the shared midpoint.
|
||||
;; Merging separate subpaths stitches them into one at the shared midpoint.
|
||||
(let [content (path/content
|
||||
[{:command :move-to :params {:x 0.0 :y 0.0}}
|
||||
{:command :line-to :params {:x 10.0 :y 0.0}}
|
||||
@@ -2100,10 +2114,107 @@
|
||||
{:command :line-to :params {:x 10.0 :y 10.0}}])
|
||||
pts #{(gpt/point 10.0 0.0) (gpt/point 0.0 10.0)}
|
||||
result (vec (path/merge-nodes content pts))]
|
||||
(t/is (= [{:x 0.0 :y 0.0} {:x 5.0 :y 5.0}
|
||||
{:x 5.0 :y 5.0} {:x 10.0 :y 10.0}]
|
||||
(t/is (= [:move-to :line-to :line-to] (mapv :command result)))
|
||||
(t/is (= [{:x 0.0 :y 0.0} {:x 5.0 :y 5.0} {:x 10.0 :y 10.0}]
|
||||
(mapv :params result)))))
|
||||
|
||||
(t/deftest path-merge-nodes-leaves-a-single-node
|
||||
;; The merged node exists once, so separating it yields a fresh split.
|
||||
(let [content (path/content
|
||||
[{:command :move-to :params {:x 0.0 :y 0.0}}
|
||||
{:command :line-to :params {:x 10.0 :y 10.0}}
|
||||
{:command :move-to :params {:x 20.0 :y 0.0}}
|
||||
{:command :line-to :params {:x 12.0 :y 12.0}}])
|
||||
merged (path/merge-nodes content #{(gpt/point 10.0 10.0)
|
||||
(gpt/point 12.0 12.0)})
|
||||
node (gpt/point 11.0 11.0)]
|
||||
(t/is (= 1 (count (path/point-indices merged node))))
|
||||
;; separating splits the node in two ends, none of them the merged nodes
|
||||
(let [result (vec (path/separate-nodes merged #{node} (gpt/point 8.0 8.0)))]
|
||||
(t/is (= [{:x 0.0 :y 0.0} {:x 11.0 :y 11.0}
|
||||
{:x 19.0 :y 19.0} {:x 20.0 :y 0.0}]
|
||||
(mapv #(select-keys (:params %) [:x :y]) result))))))
|
||||
|
||||
(t/deftest path-merge-nodes-on-empty-segment
|
||||
;; Merging across an empty segment returns a content instead of throwing
|
||||
(let [content (path/content
|
||||
[{:command :move-to :params {:x 0.0 :y 0.0}}
|
||||
{:command :line-to :params {:x 0.0 :y 0.0}}
|
||||
{:command :line-to :params {:x 20.0 :y 0.0}}])]
|
||||
(t/is (some? (path/merge-nodes content #{(gpt/point 0.0 0.0)
|
||||
(gpt/point 20.0 0.0)})))))
|
||||
|
||||
(t/deftest path-merge-coincident-nodes-stitches-dragged-ends
|
||||
;; Two open ends left at the same position become one node
|
||||
(let [content (path/content
|
||||
[{:command :move-to :params {:x 0.0 :y 0.0}}
|
||||
{:command :line-to :params {:x 10.0 :y 10.0}}
|
||||
{:command :move-to :params {:x 20.0 :y 0.0}}
|
||||
{:command :line-to :params {:x 10.0 :y 10.0}}])
|
||||
result (vec (path/merge-coincident-nodes content #{(gpt/point 10.0 10.0)}))]
|
||||
(t/is (= [:move-to :line-to :line-to] (mapv :command result)))
|
||||
(t/is (= [{:x 0.0 :y 0.0} {:x 10.0 :y 10.0} {:x 20.0 :y 0.0}]
|
||||
(mapv :params result)))))
|
||||
|
||||
(t/deftest path-merge-coincident-nodes-drops-empty-segment
|
||||
;; A node dragged onto its neighbour leaves no segment behind
|
||||
(let [content (path/content
|
||||
[{:command :move-to :params {:x 0.0 :y 0.0}}
|
||||
{:command :line-to :params {:x 0.0 :y 0.0}}
|
||||
{:command :line-to :params {:x 20.0 :y 0.0}}])
|
||||
result (vec (path/merge-coincident-nodes content #{(gpt/point 0.0 0.0)}))]
|
||||
(t/is (= [:move-to :line-to] (mapv :command result)))
|
||||
(t/is (= [{:x 0.0 :y 0.0} {:x 20.0 :y 0.0}] (mapv :params result)))))
|
||||
|
||||
(t/deftest path-merge-coincident-nodes-closes-the-loop
|
||||
;; Dragging both ends of a subpath together closes it
|
||||
(let [content (path/content
|
||||
[{:command :move-to :params {:x 0.0 :y 0.0}}
|
||||
{:command :line-to :params {:x 10.0 :y 0.0}}
|
||||
{:command :line-to :params {:x 0.0 :y 0.0}}])
|
||||
result (vec (path/merge-coincident-nodes content #{(gpt/point 0.0 0.0)}))]
|
||||
(t/is (= [:move-to :line-to :close-path] (mapv :command result)))))
|
||||
|
||||
(t/deftest path-merge-coincident-nodes-only-at-given-points
|
||||
;; Subpaths touching somewhere else are left alone
|
||||
(let [content (path/content
|
||||
[{:command :move-to :params {:x 0.0 :y 0.0}}
|
||||
{:command :line-to :params {:x 10.0 :y 10.0}}
|
||||
{:command :move-to :params {:x 20.0 :y 0.0}}
|
||||
{:command :line-to :params {:x 10.0 :y 10.0}}])
|
||||
result (path/merge-coincident-nodes content #{(gpt/point 20.0 0.0)})]
|
||||
(t/is (= (vec content) (vec result)))))
|
||||
|
||||
(t/deftest path-merge-coincident-nodes-keeps-closed-subpaths
|
||||
;; Closed subpaths keep their close command, wherever the merge happens
|
||||
(let [rect (path/content
|
||||
[{:command :move-to :params {:x 0.0 :y 0.0}}
|
||||
{:command :line-to :params {:x 10.0 :y 0.0}}
|
||||
{:command :line-to :params {:x 10.0 :y 10.0}}
|
||||
{:command :line-to :params {:x 0.0 :y 10.0}}
|
||||
{:command :close-path :params {}}])
|
||||
curve (path/content
|
||||
[{:command :move-to :params {:x 0.0 :y 0.0}}
|
||||
{:command :curve-to :params {:c1x 2.0 :c1y 2.0 :c2x 8.0 :c2y 8.0
|
||||
:x 10.0 :y 10.0}}
|
||||
{:command :curve-to :params {:c1x 8.0 :c1y -8.0 :c2x 2.0 :c2y -2.0
|
||||
:x 0.0 :y 0.0}}
|
||||
{:command :close-path :params {}}])]
|
||||
(t/is (= (vec rect) (vec (path/merge-coincident-nodes rect #{(gpt/point 10.0 0.0)}))))
|
||||
(t/is (= (vec rect) (vec (path/merge-coincident-nodes rect #{(gpt/point 0.0 0.0)}))))
|
||||
(t/is (= (vec curve) (vec (path/merge-coincident-nodes curve #{(gpt/point 0.0 0.0)}))))))
|
||||
|
||||
(t/deftest path-merge-coincident-nodes-keeps-junctions
|
||||
;; Four segments meeting at a point need one command per incoming segment
|
||||
(let [content (path/content
|
||||
[{:command :move-to :params {:x 5.0 :y 5.0}}
|
||||
{:command :line-to :params {:x 10.0 :y 0.0}}
|
||||
{:command :line-to :params {:x 5.0 :y 5.0}}
|
||||
{:command :line-to :params {:x 0.0 :y 10.0}}
|
||||
{:command :close-path :params {}}])
|
||||
result (path/merge-coincident-nodes content #{(gpt/point 5.0 5.0)})]
|
||||
(t/is (= (vec content) (vec result)))))
|
||||
|
||||
(t/deftest path-duplicate-node-content
|
||||
;; Duplicating a node copies its incident segments as subpaths.
|
||||
(let [content (path/content
|
||||
|
||||
@@ -466,6 +466,7 @@
|
||||
(rx/map #(move-selected-path-point start-position %))
|
||||
(rx/take-until stopper))
|
||||
(rx/of (apply-content-modifiers)
|
||||
(tools/merge-coincident-nodes)
|
||||
(merge-dragged-on-drop)))))))
|
||||
|
||||
(declare drag-selected-segments)
|
||||
@@ -555,6 +556,7 @@
|
||||
(rx/map #(move-selected-path-segment start-position %))
|
||||
(rx/take-until stopper))
|
||||
(rx/of (apply-content-modifiers)
|
||||
(tools/merge-coincident-nodes)
|
||||
(merge-dragged-on-drop))))))))
|
||||
|
||||
(defn bend-segment-modifier
|
||||
@@ -753,6 +755,7 @@
|
||||
(rx/of (move-selected direction shift?)))
|
||||
|
||||
(rx/of (apply-content-modifiers)
|
||||
(tools/merge-coincident-nodes)
|
||||
(finish-move-selected))))
|
||||
(rx/empty)))))))
|
||||
|
||||
|
||||
@@ -301,17 +301,33 @@
|
||||
(remove nil?))
|
||||
(segment-entries content))))
|
||||
|
||||
(defn coincident-node-indices
|
||||
"Adds to `indices` every other command sharing one of their positions.
|
||||
|
||||
Commands at the same position are one node: they move together, so an
|
||||
action cannot depend on which of them the selection holds."
|
||||
[content indices]
|
||||
(let [indices (into #{} (filter #(node? content %)) indices)]
|
||||
(into indices
|
||||
(mapcat #(path/point-indices content %))
|
||||
(node-positions content indices))))
|
||||
|
||||
(defn selected-node-count
|
||||
"Number of nodes in the selection, counting coincident commands as one."
|
||||
[content selection]
|
||||
(count (node-positions content (get selection :nodes #{}))))
|
||||
|
||||
(defn check-enabled
|
||||
"Returns path actions enabled for selected node indices."
|
||||
[content selected-nodes]
|
||||
(when content
|
||||
(let [selected-nodes (into #{} (filter #(node? content %)) selected-nodes)
|
||||
(let [selected-nodes (coincident-node-indices content selected-nodes)
|
||||
selected-segments (filter (fn [{:keys [from-index to-index]}]
|
||||
(and (contains? selected-nodes from-index)
|
||||
(contains? selected-nodes to-index)))
|
||||
(segment-entries content))
|
||||
num-segments (count selected-segments)
|
||||
num-nodes (count selected-nodes)
|
||||
num-nodes (count (node-positions content selected-nodes))
|
||||
nodes-selected? (seq selected-nodes)
|
||||
segments-selected? (seq selected-segments)
|
||||
max-segments (/ (* num-nodes (dec num-nodes)) 2)
|
||||
@@ -523,6 +539,12 @@
|
||||
(let [selection (or selection empty-selection)]
|
||||
(if (= (count old-content) (count new-content))
|
||||
(-> selection
|
||||
;; Drop indices that stopped being nodes.
|
||||
(update :nodes
|
||||
(fn [nodes]
|
||||
(into #{}
|
||||
(filter #(node? new-content %))
|
||||
nodes)))
|
||||
(update :handlers
|
||||
(fn [handlers]
|
||||
(into #{}
|
||||
|
||||
@@ -136,17 +136,21 @@
|
||||
(make-curve point)))))))))
|
||||
|
||||
(defn- update-path-content
|
||||
"Updates path content, geometry, selection, and handler types."
|
||||
[state new-content]
|
||||
(let [id (st/get-path-id state)
|
||||
old-content (st/get-path state :content)]
|
||||
(-> (cond-> (st/set-content state new-content)
|
||||
(seq new-content)
|
||||
(update-in (st/get-path-location state) path/update-geometry))
|
||||
(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)))))
|
||||
"Updates path content, geometry, selection, and handler types.
|
||||
|
||||
The selection is remapped by position, so a tool that moves nodes before
|
||||
changing the content structure passes the moved content as `old-content`."
|
||||
([state new-content]
|
||||
(update-path-content state (st/get-path state :content) new-content))
|
||||
([state old-content new-content]
|
||||
(let [id (st/get-path-id state)]
|
||||
(-> (cond-> (st/set-content state new-content)
|
||||
(seq new-content)
|
||||
(update-in (st/get-path-location state) path/update-geometry))
|
||||
(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))))))
|
||||
|
||||
(defn remove-segments
|
||||
"Removes segments and opens the path at their endpoints."
|
||||
@@ -227,6 +231,30 @@
|
||||
(defn merge-nodes []
|
||||
(process-path-tool path/merge-nodes))
|
||||
|
||||
(defn- merge-coincident
|
||||
"Collapses the nodes of `indices` sharing a position with another node."
|
||||
[content indices]
|
||||
(path/merge-coincident-nodes content (helpers/node-positions content indices)))
|
||||
|
||||
(defn merge-coincident-nodes
|
||||
"Merges the selected nodes sharing a position with another node.
|
||||
|
||||
Runs after a move, which leaves the nodes it brings together as one
|
||||
command each."
|
||||
[]
|
||||
(ptk/reify ::merge-coincident-nodes
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(let [id (st/get-path-id state)
|
||||
content (st/get-path state :content)
|
||||
indices (helpers/selected-node-indices content (st/get-selection state id))
|
||||
new-content (when (and (some? content) (seq indices))
|
||||
(merge-coincident content indices))]
|
||||
(if (and (some? new-content)
|
||||
(not= (vec new-content) (vec content)))
|
||||
(update-path-content state new-content)
|
||||
state)))))
|
||||
|
||||
(defn join-nodes []
|
||||
(process-path-tool path/join-nodes))
|
||||
|
||||
@@ -281,9 +309,8 @@
|
||||
indices (if (seq selected)
|
||||
selected
|
||||
(helpers/node-indices content))
|
||||
content (path/flip-content content indices axis)]
|
||||
(-> (st/set-content state content)
|
||||
(update-in (st/get-path-location state) path/update-geometry))))))
|
||||
flipped (path/flip-content content indices axis)]
|
||||
(update-path-content state flipped (merge-coincident flipped indices))))))
|
||||
|
||||
(defn align-nodes
|
||||
"Aligns selected nodes and their handles within their bounds."
|
||||
@@ -294,9 +321,8 @@
|
||||
(let [id (st/get-path-id state)
|
||||
content (st/get-path state :content)
|
||||
selected (get (st/get-selection state id) :nodes #{})
|
||||
content (path/align-content content selected axis)]
|
||||
(-> (st/set-content state content)
|
||||
(update-in (st/get-path-location state) path/update-geometry))))))
|
||||
aligned (path/align-content content selected axis)]
|
||||
(update-path-content state aligned (merge-coincident aligned selected))))))
|
||||
|
||||
(defn distribute-nodes
|
||||
"Distributes selected nodes evenly along `axis`."
|
||||
@@ -307,9 +333,8 @@
|
||||
(let [id (st/get-path-id state)
|
||||
content (st/get-path state :content)
|
||||
selected (get (st/get-selection state id) :nodes #{})
|
||||
content (path/distribute-content content selected axis)]
|
||||
(-> (st/set-content state content)
|
||||
(update-in (st/get-path-location state) path/update-geometry))))))
|
||||
spread (path/distribute-content content selected axis)]
|
||||
(update-path-content state spread (merge-coincident spread selected))))))
|
||||
|
||||
(defn- axis-point
|
||||
"Copy of `p` with `axis` (`:x`/`:y`) replaced by `value`."
|
||||
@@ -379,8 +404,8 @@
|
||||
(cond-> content
|
||||
(seq node-idx) (path/set-nodes-coordinate node-idx axis value)
|
||||
(seq pts) (path/set-handler-points pts))))]
|
||||
(-> (st/set-content state new-content)
|
||||
(update-in (st/get-path-location state) path/update-geometry))))))
|
||||
(update-path-content state new-content
|
||||
(merge-coincident new-content node-idx))))))
|
||||
|
||||
(defn toggle-snap []
|
||||
(ptk/reify ::toggle-snap
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
[app.main.data.helpers :as dsh]
|
||||
[app.main.data.workspace :as udw]
|
||||
[app.main.data.workspace.common :as dwc]
|
||||
[app.main.data.workspace.path.helpers :as path.helpers]
|
||||
[app.main.data.workspace.path.state :as path.state]
|
||||
[app.main.features :as features]
|
||||
[app.main.refs :as refs]
|
||||
@@ -115,8 +116,11 @@
|
||||
path-editing?
|
||||
(path.state/editing? edit-path edition)
|
||||
|
||||
;; Coincident commands are one node, so count positions.
|
||||
path-node-count
|
||||
(count (dm/get-in edit-path-state [:selection :nodes]))
|
||||
(mf/with-memo [drawing edit-path-state]
|
||||
(path.helpers/selected-node-count (dm/get-in drawing [:object :content])
|
||||
(:selection edit-path-state)))
|
||||
|
||||
files
|
||||
(mf/deref refs/files)
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
(ns frontend-tests.logic.path-actions-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.helpers :as path.helpers]
|
||||
[app.main.ui.workspace.viewport.path-actions :as path.actions]
|
||||
@@ -22,7 +23,8 @@
|
||||
(t/is (true? (:make-corner enabled)))
|
||||
(t/is (true? (:make-curve enabled)))))
|
||||
|
||||
(t/deftest action-eligibility-keeps-coincident-node-identities
|
||||
(t/deftest action-eligibility-treats-coincident-commands-as-one-node
|
||||
;; Both commands sit at (0,0): one node, with a corner and a curve on it.
|
||||
(let [content (path/content
|
||||
[{:command :move-to :params {:x 0 :y 0}}
|
||||
{:command :line-to :params {:x 10 :y 0}}
|
||||
@@ -32,8 +34,42 @@
|
||||
enabled (path.helpers/check-enabled content #{0 2})]
|
||||
(t/is (true? (:make-corner enabled)))
|
||||
(t/is (true? (:make-curve enabled)))
|
||||
(t/is (true? (:merge-nodes enabled)))
|
||||
(t/is (true? (:join-nodes enabled)))))
|
||||
;; there is a single node, so there is nothing to merge it with
|
||||
(t/is (false? (:merge-nodes enabled)))
|
||||
(t/is (false? (:join-nodes enabled)))
|
||||
(t/is (true? (:separate-nodes enabled)))))
|
||||
|
||||
(t/deftest a-junction-node-offers-the-same-actions-however-it-is-selected
|
||||
;; Three lines meeting at (50,50); a rubber band catches every command
|
||||
;; there while a click catches one of them.
|
||||
(let [content (path/content
|
||||
[{:command :move-to :params {:x 0 :y 0}}
|
||||
{:command :line-to :params {:x 50 :y 50}}
|
||||
{:command :move-to :params {:x 100 :y 0}}
|
||||
{:command :line-to :params {:x 50 :y 50}}
|
||||
{:command :move-to :params {:x 50 :y 100}}
|
||||
{:command :line-to :params {:x 50 :y 50}}])
|
||||
in-rect (path.helpers/nodes-in-rect content (grc/make-rect 45 45 10 10))
|
||||
clicked (path.helpers/check-enabled content #{1})
|
||||
dragged (path.helpers/check-enabled content in-rect)]
|
||||
(t/is (= #{1 3 5} in-rect))
|
||||
(t/is (= clicked dragged))
|
||||
;; and they are the actions of a single node
|
||||
(t/is (false? (:merge-nodes dragged)))
|
||||
(t/is (false? (:join-nodes dragged)))
|
||||
(t/is (true? (:separate-nodes dragged)))
|
||||
;; two distinct nodes offer the multiple-node actions
|
||||
(t/is (true? (:merge-nodes (path.helpers/check-enabled content #{1 4}))))))
|
||||
|
||||
(t/deftest selected-node-count-counts-a-junction-once
|
||||
(let [content (path/content
|
||||
[{:command :move-to :params {:x 0 :y 0}}
|
||||
{:command :line-to :params {:x 50 :y 50}}
|
||||
{:command :move-to :params {:x 100 :y 0}}
|
||||
{:command :line-to :params {:x 50 :y 50}}])]
|
||||
(t/is (= 1 (path.helpers/selected-node-count content {:nodes #{1 3}})))
|
||||
(t/is (= 2 (path.helpers/selected-node-count content {:nodes #{0 1}})))
|
||||
(t/is (= 0 (path.helpers/selected-node-count content {})))))
|
||||
|
||||
(t/deftest toolbar-separators-only-render-between-visible-tool-groups
|
||||
(t/are [structural? shape? handler? expected]
|
||||
|
||||
@@ -306,8 +306,11 @@
|
||||
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)))))
|
||||
(t/is (= (gpt/point 10 0) (path.helpers/node-position content' 1)))
|
||||
;; both ends land on (5,0), where they merge and close the subpath
|
||||
(t/is (= [:move-to :line-to :close-path] (mapv :command (vec content'))))
|
||||
;; the merged node stays selected
|
||||
(t/is (= #{0} (get-in state' [:workspace-local :edit-path id :selection :nodes])))))
|
||||
;; a coincident closed-seam node moves as one logical node
|
||||
(let [id (random-uuid)
|
||||
content (path/content
|
||||
@@ -319,7 +322,8 @@
|
||||
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))))
|
||||
;; the seam is one node, so the subpath closes on it
|
||||
(t/is (= [:move-to :line-to :close-path] (mapv :command (vec content')))))
|
||||
;; a selected handler on an independent node moves only its own control point
|
||||
(let [id (random-uuid)
|
||||
content (path/content
|
||||
@@ -415,7 +419,8 @@
|
||||
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)))))
|
||||
;; the seam commands merge into the subpath close
|
||||
(t/is (= :close-path (:command (nth (vec content') 4))))))
|
||||
|
||||
(t/deftest set-selection-coordinate-translates-mixed-segment-and-node-selection
|
||||
;; Selected segments and nodes translate as one group.
|
||||
@@ -428,12 +433,12 @@
|
||||
;; 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)
|
||||
state' (ptk/update (path.tools/set-selection-coordinate :x 5) 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 5 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/is (= (gpt/point 25 0) (path.helpers/node-position content' 2)))
|
||||
(t/is (= (gpt/point 35 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.
|
||||
@@ -801,4 +806,39 @@
|
||||
(t/testing "a node dropped with no neighbour in range does not merge"
|
||||
(t/is (empty? (emit-of (mk {:nodes #{3} :segments #{} :handlers #{}})))))))
|
||||
|
||||
(t/deftest nodes-dropped-on-the-same-position-are-merged
|
||||
;; An exact drop leaves both commands at one position, with no node near it.
|
||||
(let [id (random-uuid)
|
||||
content (path/content
|
||||
[{:command :move-to :params {:x 0 :y 0}}
|
||||
{:command :line-to :params {:x 10 :y 10}}
|
||||
{:command :move-to :params {:x 20 :y 0}}
|
||||
{:command :line-to :params {:x 10 :y 10}}])
|
||||
state (pth/selectable-path-state
|
||||
id content {:nodes #{3} :segments #{} :handlers #{}})
|
||||
result (-> (ptk/update (path.tools/merge-coincident-nodes) state)
|
||||
(path.state/get-path :content))]
|
||||
(t/is (= [:move-to :line-to :line-to] (mapv :command (vec result))))
|
||||
;; the two ends are one node, so separating them cannot restore them
|
||||
(t/is (= 1 (count (path/point-indices result (gpt/point 10.0 10.0)))))))
|
||||
|
||||
(t/deftest aligning-nodes-onto-each-other-merges-them
|
||||
(let [id (random-uuid)
|
||||
content (path/content
|
||||
[{:command :move-to :params {:x 0 :y 0}}
|
||||
{:command :line-to :params {:x 20 :y 0}}
|
||||
{:command :move-to :params {:x 0 :y 10}}
|
||||
{:command :line-to :params {:x 20 :y 10}}])
|
||||
state (pth/selectable-path-state
|
||||
id content {:nodes #{1 3} :segments #{} :handlers #{}})
|
||||
state' (ptk/update (path.tools/align-nodes :vcenter) state)
|
||||
result (path.state/get-path state' :content)]
|
||||
;; both ends meet at (20,5) and become a single node
|
||||
(t/is (= [:move-to :line-to :line-to] (mapv :command (vec result))))
|
||||
(t/is (= 1 (count (path/point-indices result (gpt/point 20.0 5.0)))))
|
||||
;; and that node stays selected
|
||||
(t/is (= #{1} (get-in state' [:workspace-local :edit-path id :selection :nodes])))
|
||||
(t/is (= (gpt/point 20.0 5.0)
|
||||
(path.helpers/node-position result 1)))))
|
||||
|
||||
;; Path-local undo and redo events use a seeded local stack.
|
||||
Reference in new issue
Block a user