diff --git a/common/src/app/common/files/shape_compact.cljc b/common/src/app/common/files/shape_compact.cljc new file mode 100644 index 0000000000..c3c0d88c78 --- /dev/null +++ b/common/src/app/common/files/shape_compact.cljc @@ -0,0 +1,145 @@ +;; 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 app.common.files.shape-compact + (:require + [app.common.data :as d] + [app.common.data.macros :as dm] + [app.common.geom.matrix :as gmt] + [app.common.geom.rect :as grc] + [app.common.geom.shapes :as gsh] + [app.common.math :as mth] + [app.common.types.shape :as cts])) + +#?(:clj (set! *warn-on-reflection* true)) + +;; --- Identity transform detection (handles both Matrix instances and plain maps) + +(defn- identity-transform? + [transform] + (if (nil? transform) + true + (if (gmt/matrix? transform) + (gmt/unit? transform) + (and (mth/close? (d/nilv (:a transform) 1) 1) + (mth/close? (d/nilv (:b transform) 0) 0) + (mth/close? (d/nilv (:c transform) 0) 0) + (mth/close? (d/nilv (:d transform) 1) 1) + (mth/close? (d/nilv (:e transform) 0) 0) + (mth/close? (d/nilv (:f transform) 0) 0))))) + +;; --- Default values that can be omitted + +(def ^:private default-attrs + {:rotation 0 + :proportion-lock false + :hide-fill-on-export false + :hide-in-viewer false + :show-content false + :blocked false + :collapsed false + :locked false + :hidden false + :masked-group false + :fixed-scroll false + :grow-type :fixed}) + +;; --- Empty collections that can be omitted these are optional attrs with +;; default behavior when absent + +(def ^:private empty-collections + [:fills :strokes :shadow :exports :interactions :grids :shapes]) + +;; --- Compaction + +(defn compact-shape + "Prune redundant and derivable fields from a shape for compact serialization. + Omits: + - nil values + - identity transform and transform-inverse + - selrect and points when derivable from x/y/width/height (non-rotated shapes) + - page-id (redundant with the containing page) + - default values (rotation 0, proportion-lock false, etc.) + - empty collections (fills, strokes, shadow, etc.) + The resulting shape can be restored via expand-shape." + [shape] + ;; Convert to plain map first (cr/defrecord does not fully remove + ;; declared fields on dissoc; it sets them to nil instead). + (let [shape (reduce-kv (fn [m k v] + (if (nil? v) m (assoc m k v))) + {} shape) + id-xf? (identity-transform? (:transform shape)) + type (dm/get-prop shape :type) + path? (or (= type :path) (= type :bool)) + rot (dm/get-prop shape :rotation) + safe? (and id-xf? (or (nil? rot) (zero? rot)))] + (-> shape + (dissoc :page-id) + (cond-> id-xf? + (dissoc :transform :transform-inverse)) + (cond-> (and safe? (not path?)) + (dissoc :selrect :points)) + (cond-> (and id-xf? path?) + (dissoc :points)) + (as-> s + (reduce-kv (fn [s k v] + (let [d (get default-attrs k ::nf)] + (if (and (not= d ::nf) (= v d)) + (dissoc s k) + s))) + s s)) + (as-> s + (reduce (fn [s k] + (let [v (get s k)] + (if (and (coll? v) (empty? v)) + (dissoc s k) + s))) + s empty-collections))))) + +;; --- Expansion + +(defn expand-shape + "Restore fields omitted by compact-shape. Restores identity + transform/transform-inverse, and recomputes selrect and points using + the standard shape setup functions. Returns a Shape record." + [shape] + (let [type (dm/get-prop shape :type) + path? (or (= type :path) (= type :bool))] + (-> shape + (cond-> (nil? (:transform shape)) + (assoc :transform (gmt/matrix))) + (cond-> (nil? (:transform-inverse shape)) + (assoc :transform-inverse (gmt/matrix))) + (as-> s + (if path? + (cts/setup-path s) + (cts/setup-rect s))) + (cts/create-shape)))) + +;; --- Float rounding + +(defn round-values + "Round all numeric values in data to 4 decimal places. + Eliminates float32 artifacts like 0.6000000238418579." + [data] + (letfn [(round-n [n] + (if (and (number? n) (mth/finite? n) (not (integer? n))) + (mth/precision n 4) + n)) + (walk [node] + (cond + (map? node) + (reduce-kv (fn [m k v] (assoc m k (walk v))) node node) + + (vector? node) + (mapv walk node) + + (number? node) + (round-n node) + + :else + node))] + (walk data))) diff --git a/common/test/common_tests/files_shape_compact_test.cljc b/common/test/common_tests/files_shape_compact_test.cljc new file mode 100644 index 0000000000..83372919b3 --- /dev/null +++ b/common/test/common_tests/files_shape_compact_test.cljc @@ -0,0 +1,250 @@ +;; 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 common-tests.files-shape-compact-test + (:require + [app.common.files.shape-compact :as sc] + [app.common.geom.matrix :as gmt] + [app.common.geom.point :as gpt] + [app.common.geom.rect :as grc] + [app.common.math :as mth] + [app.common.schema :as sm] + [app.common.schema.generators :as sg] + [app.common.schema.test :as smt] + [app.common.types.shape :as cts] + [app.common.uuid :as uuid] + [clojure.test :as t])) + +;; --- Helpers + +(defn- make-rect + [props] + (cts/setup-shape (merge {:type :rect :x 0 :y 0 :width 10 :height 10} props))) + +(defn- make-path + [props] + (cts/setup-shape + (merge {:type :path + :content [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 10 :y 10}}]} + props))) + +;; --- round-values + +(t/deftest round-values-integers-unchanged + (t/is (= 5 (sc/round-values 5))) + (t/is (= 0 (sc/round-values 0)))) + +(t/deftest round-values-floats-rounded + (t/is (= 0.6 (sc/round-values 0.6000000238418579))) + (t/is (= 4213.6 (sc/round-values 4213.60009765625))) + (t/is (= 0.1235 (sc/round-values 0.12345678)))) + +(t/deftest round-values-nested-maps + (let [data {:x 100.1234567 :y 200.9876543 + :meta {:opacity 0.6000000238418579}}] + (t/is (= {:x 100.1235 :y 200.9877 + :meta {:opacity 0.6}} + (sc/round-values data))))) + +(t/deftest round-values-vectors + (let [data [1.2345678 2.00001 3]] + (t/is (= [1.2346 2.0 3] (sc/round-values data))))) + +(t/deftest round-values-non-numeric-unchanged + (t/is (= "hello" (sc/round-values "hello"))) + (t/is (= :foo (sc/round-values :foo))) + (t/is (= true (sc/round-values true))) + (t/is (nil? (sc/round-values nil))) + (let [id (uuid/next)] + (t/is (= id (sc/round-values id))))) + +;; --- compact-shape + +(t/deftest compact-shape-removes-nils + (let [shape {:id (uuid/next) :type :rect :name "test" + :flip-x nil :flip-y nil + :fills [] :strokes []} + c (sc/compact-shape shape)] + (t/is (not (contains? c :flip-x))) + (t/is (not (contains? c :flip-y))) + (t/is (not (contains? c :fills))) + (t/is (not (contains? c :strokes))))) + +(t/deftest compact-shape-removes-identity-transform + (let [shape (make-rect {:x 100 :y 200 :width 50 :height 30}) + c (sc/compact-shape shape)] + (t/is (not (contains? c :transform))) + (t/is (not (contains? c :transform-inverse))))) + +(t/deftest compact-shape-keeps-non-identity-transform + (let [shape (-> (make-rect {:x 100 :y 200 :width 50 :height 30}) + (assoc :transform (gmt/matrix 1 0.5 0 1 0 0))) + c (sc/compact-shape shape)] + (t/is (contains? c :transform)) + (t/is (contains? c :transform-inverse)))) + +(t/deftest compact-shape-removes-derivable-geometry-rect + (let [shape (make-rect {:x 100 :y 200 :width 50 :height 30}) + c (sc/compact-shape shape)] + (t/is (not (contains? c :selrect))) + (t/is (not (contains? c :points))))) + +(t/deftest compact-shape-keeps-geometry-with-rotation + (let [shape (-> (make-rect {:x 100 :y 200 :width 50 :height 30}) + (assoc :rotation 45)) + c (sc/compact-shape shape)] + (t/is (contains? c :selrect)) + (t/is (contains? c :points)))) + +(t/deftest compact-shape-path-keeps-selrect + (let [shape (make-path {}) + c (sc/compact-shape shape)] + (t/is (contains? c :selrect)) + (t/is (not (contains? c :points))) + (t/is (contains? c :content)))) + +(t/deftest compact-shape-removes-page-id + (let [shape (assoc (make-rect {}) :page-id (uuid/next)) + c (sc/compact-shape shape)] + (t/is (not (contains? c :page-id))))) + +(t/deftest compact-shape-removes-defaults + (let [shape (make-rect {:rotation 0 :proportion-lock false :blocked false}) + c (sc/compact-shape shape)] + (t/is (not (contains? c :rotation))) + (t/is (not (contains? c :proportion-lock))) + (t/is (not (contains? c :blocked))))) + +(t/deftest compact-shape-keeps-non-defaults + (let [shape (make-rect {:rotation 30 :blocked true :opacity 0.5}) + c (sc/compact-shape shape)] + (t/is (= 30 (:rotation c))) + (t/is (true? (:blocked c))) + (t/is (= 0.5 (:opacity c))))) + +(t/deftest compact-shape-preserves-component-attrs + (let [comp-id (uuid/next) + ref-id (uuid/next) + shape (-> (make-rect {}) + (assoc :component-id comp-id + :component-file (uuid/next) + :shape-ref ref-id + :touched #{:geometry-group})) + c (sc/compact-shape shape)] + (t/is (= comp-id (:component-id c))) + (t/is (= ref-id (:shape-ref c))) + (t/is (= #{:geometry-group} (:touched c))))) + +;; --- expand-shape + +(t/deftest expand-shape-restores-transform + (let [compact {:id (uuid/next) :type :rect :name "test" + :x 100 :y 200 :width 50 :height 30 + :frame-id uuid/zero :parent-id uuid/zero} + expanded (sc/expand-shape compact)] + (t/is (gmt/matrix? (:transform expanded))) + (t/is (gmt/matrix? (:transform-inverse expanded))) + (t/is (true? (gmt/unit? (:transform expanded)))))) + +(t/deftest expand-shape-restores-selrect-rect + (let [compact {:id (uuid/next) :type :rect :name "test" + :x 100 :y 200 :width 50 :height 30 + :frame-id uuid/zero :parent-id uuid/zero} + expanded (sc/expand-shape compact)] + (t/is (grc/rect? (:selrect expanded))) + (t/is (= 100 (:x (:selrect expanded)))) + (t/is (= 200 (:y (:selrect expanded)))) + (t/is (= 50 (:width (:selrect expanded)))) + (t/is (= 30 (:height (:selrect expanded)))))) + +(t/deftest expand-shape-restores-points + (let [compact {:id (uuid/next) :type :rect :name "test" + :x 100 :y 200 :width 50 :height 30 + :frame-id uuid/zero :parent-id uuid/zero} + expanded (sc/expand-shape compact) + points (:points expanded)] + (t/is (vector? points)) + (t/is (= 4 (count points))) + (t/is (every? gpt/point? points)))) + +(t/deftest expand-shape-path + (let [compact {:id (uuid/next) :type :path :name "test" + :selrect (grc/make-rect 0 0 10 10) + :content [{:command :move-to :params {:x 0 :y 0}} + {:command :line-to :params {:x 10 :y 10}}] + :frame-id uuid/zero :parent-id uuid/zero} + expanded (sc/expand-shape compact)] + (t/is (grc/rect? (:selrect expanded))) + (t/is (vector? (:points expanded))) + (t/is (= 4 (count (:points expanded)))))) + +;; --- Round-trip + +(t/deftest compact-expand-roundtrip-rect + (let [shape (make-rect {:x 123 :y 456 :width 78 :height 90}) + compact (sc/compact-shape shape) + result (sc/expand-shape compact)] + (t/is (= (:id shape) (:id result))) + (t/is (= (:type shape) (:type result))) + (t/is (= (:name shape) (:name result))) + (t/is (= (:x shape) (:x result))) + (t/is (= (:y shape) (:y result))) + (t/is (= (:width shape) (:width result))) + (t/is (= (:height shape) (:height result))) + (t/is (grc/rect? (:selrect result))) + (t/is (gmt/matrix? (:transform result))) + (t/is (sm/validate cts/schema:shape result)))) + +(t/deftest compact-expand-roundtrip-preserves-ids + (let [id (uuid/next) + frame-id (uuid/next) + parent-id (uuid/next) + shape (-> (make-rect {:x 10 :y 20 :width 30 :height 40}) + (assoc :id id :frame-id frame-id :parent-id parent-id)) + compact (sc/compact-shape shape) + result (sc/expand-shape compact)] + (t/is (= id (:id result))) + (t/is (= frame-id (:frame-id result))) + (t/is (= parent-id (:parent-id result))))) + +;; --- Generative + +(t/deftest compact-expand-schema-valid + (smt/check! + (smt/for [shape (sg/generator cts/schema:shape)] + (let [compact (sc/compact-shape shape) + expanded (sc/expand-shape compact)] + (sm/validate cts/schema:shape expanded))) + {:num 200})) + +(t/deftest compact-expand-preserves-geometry + (smt/check! + (smt/for [shape (sg/generator cts/schema:shape)] + (let [compact (sc/compact-shape shape) + expanded (sc/expand-shape compact)] + (and (= (:id shape) (:id expanded)) + (= (:type shape) (:type expanded)) + (= (:name shape) (:name expanded)) + (= (:frame-id shape) (:frame-id expanded)) + (= (:parent-id shape) (:parent-id expanded)) + (= (:component-id shape) (:component-id expanded)) + (= (:component-file shape) (:component-file expanded)) + (= (:shape-ref shape) (:shape-ref expanded)) + (= (:touched shape) (:touched expanded))))) + {:num 200})) + +(t/deftest compact-expand-preserves-content + (smt/check! + (smt/for [shape (sg/generator cts/schema:shape)] + (let [compact (sc/compact-shape shape) + expanded (sc/expand-shape compact)] + (or (not= :path (:type shape)) + (and (= (:id shape) (:id expanded)) + (= (:content shape) (:content expanded)) + (some? (:selrect expanded)) + (vector? (:points expanded)))))) + {:num 50}))