diff --git a/frontend/performance/benches/render_wasm/builder.clj b/frontend/performance/benches/render_wasm/builder.clj deleted file mode 100644 index 9817dd658e..0000000000 --- a/frontend/performance/benches/render_wasm/builder.clj +++ /dev/null @@ -1,210 +0,0 @@ -;; 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 SUBSIDIARY SL - -(ns benches.render-wasm.builder - "Construction macros for renderer benchmark scenes. - - For runtime helpers and scope state see `builder.cljs`. - - Usage: - - (scene {:seed 42 - :defaults {:rect {:x (gen-int 0 1920)}}} - (rect) ; anonymous, generated attrs - (rect {:x 10}) ; literal x over the generated rest - (rect :hero {}) ; label stored in snapshot :refs - (frame {:x 0 :y 0 :width 100 :height 100} - (rect)) - (group :tiles {} - (rect [:tile 3] {})) - (bool :diff {:bool-type :difference} - (rect {:x 0 :y 0 :width 100 :height 100}) - (rect {:x 50 :y 50 :width 100 :height 100}))) - - Ancestry and order are implicit in the nesting of calls: shape constructors - add themselves to the current parent, in the sequence they are called. - Coordinates are page-absolute like in penpot. Frames use their state bounds - and groups derive their bounds from their children inside out. - A masked group takes the geometry of its first child. - Booleans derive content and geometry from their children with the standard - path engine and inherit style from the head child unless attrs supply it. - - `params` is a map with `:seed` (required), optional `:root` attributes for - the canonical root frame, and optional `:defaults` with one attribute - generator map per shape type. - - Require this namespace with `:include-macros true` (or `:require-macros`) - to use the macros. - - The scope is carried by the dynamic `*state*`, so ordinary `let`, `doseq` - and functions defined outside the scope work inside the body. Scopes - cannot nest and a body exception unwinds without leaking state.") - -(defmacro scene - "Runs `body` in a scene scope and returns the validated snapshot." - [params & body] - `(let [params# ~params - state# (benches.render-wasm.builder/start params#)] - (binding [benches.render-wasm.builder/*state* state# - benches.render-wasm.builder/*defaults* (:defaults params#)] - ~@body - (benches.render-wasm.builder/finish! state#)))) - -(defmacro rect - "Adds one rectangle to the current scene scope and returns its uuid. - - Forms: `(rect)`, `(rect attrs)`, `(rect label)`, `(rect label attrs)`. - A one-argument call is resolved at runtime: a map is interpreted as attributes - and anything else is a label. `label` must be a keyword or vector, stored in - the instance `:refs`; duplicates and other label types are rejected. `attrs` - override the defaults attribute by attribute." - ([] - `(benches.render-wasm.builder/rect! - benches.render-wasm.builder/*state* - benches.render-wasm.builder/*defaults* - nil - {})) - - ([label-or-attrs] - `(benches.render-wasm.builder/rect! - benches.render-wasm.builder/*state* - benches.render-wasm.builder/*defaults* - ~label-or-attrs - {})) - - ([label attrs] - `(benches.render-wasm.builder/rect! - benches.render-wasm.builder/*state* - benches.render-wasm.builder/*defaults* - ~label - ~attrs))) - -(defmacro path - "Adds one path to the current scene scope. - - Forms: `(path)`, `(path attrs)`, `(path label)`, `(path label attrs)`. - A one-argument call is resolved at runtime: a map is interpreted as attributes - and anything else is a label. `label` must be a keyword or vector, stored in - the instance `:refs`; duplicates and other label types are rejected. `attrs` - override the defaults attribute by attribute. - " - ([] - `(benches.render-wasm.builder/path! - benches.render-wasm.builder/*state* - benches.render-wasm.builder/*defaults* - nil - {})) - ([label-or-attrs] - `(benches.render-wasm.builder/path! - benches.render-wasm.builder/*state* - benches.render-wasm.builder/*defaults* - ~label-or-attrs - {})) - ([label attrs] - `(benches.render-wasm.builder/path! - benches.render-wasm.builder/*state* - benches.render-wasm.builder/*defaults* - ~label - ~attrs))) - -(defmacro circle - "Adds one ellipse to the current scene scope and returns its uuid. - - Forms: `(circle)`, `(circle attrs)`, `(circle label)`, `(circle label attrs)`. - A one-argument call is resolved at runtime: a map is interpreted as attributes - and anything else is a label. `label` must be a keyword or vector, stored in - the instance `:refs`; duplicates and other label types are rejected. `attrs` - override the defaults attribute by attribute." - ([] - `(benches.render-wasm.builder/circle! - benches.render-wasm.builder/*state* - benches.render-wasm.builder/*defaults* - nil - {})) - - ([label-or-attrs] - `(benches.render-wasm.builder/circle! - benches.render-wasm.builder/*state* - benches.render-wasm.builder/*defaults* - ~label-or-attrs - {})) - - ([label attrs] - `(benches.render-wasm.builder/circle! - benches.render-wasm.builder/*state* - benches.render-wasm.builder/*defaults* - ~label - ~attrs))) - -(defmacro frame - "Adds a frame to the current scope, runs `body` with the frame as parent, - and returns the frame uuid. - - Forms: `(frame attrs & body)` and `(frame label attrs & body)`. `label` is - a literal keyword or vector stored in the instance `:refs`. Frame attrs - must carry `:x :y :width :height`; children keep page-absolute - coordinates. Empty frames are allowed." - [label-or-attrs & body] - (if (or (keyword? label-or-attrs) (vector? label-or-attrs)) - `(benches.render-wasm.builder/with-container - :frame - ~(first body) - ~label-or-attrs - (fn [] ~@(rest body))) - `(benches.render-wasm.builder/with-container - :frame - ~label-or-attrs - nil - (fn [] ~@body)))) - -(defmacro group - "Adds a group to the current scope, runs `body` with the group as parent, - and returns the group uuid. - - Forms: `(group attrs & body)` and `(group label attrs & body)`. `label` is - a literal keyword or vector stored in the instance `:refs`. Groups derive - their geometry from children, so geometry attrs are rejected. With - `:masked-group true` the first child is the mask. A group needs at least - one child." - [label-or-attrs & body] - (if (or (keyword? label-or-attrs) (vector? label-or-attrs)) - `(benches.render-wasm.builder/with-container - :group - ~(first body) - ~label-or-attrs - (fn [] ~@(rest body))) - `(benches.render-wasm.builder/with-container - :group - ~label-or-attrs - nil - (fn [] ~@body)))) - -(defmacro bool - "Adds a boolean shape to the current scope, runs `body` with the bool as - parent, and returns the bool uuid. - - Forms: `(bool attrs & body)` and `(bool label attrs & body)`. `label` is a - literal keyword or vector stored in the instance `:refs`. `attrs` must - carry `:bool-type` (`:union`, `:difference`, `:intersection` or - `:exclude`). - - Content and geometry derive from the children after they finalize, so geometry, - content and transform attrs are rejected. Fills, strokes, shadows and blurs - are inherited from the head child (first for `:difference`, last otherwise) - unless attrs supply them. A bool needs at least one child and cannot contain - frames." - [label-or-attrs & body] - (if (or (keyword? label-or-attrs) (vector? label-or-attrs)) - `(benches.render-wasm.builder/with-container - :bool - ~(first body) - ~label-or-attrs - (fn [] ~@(rest body))) - `(benches.render-wasm.builder/with-container - :bool - ~label-or-attrs - nil - (fn [] ~@body)))) diff --git a/frontend/performance/benches/render_wasm/builder.cljc b/frontend/performance/benches/render_wasm/builder.cljc new file mode 100644 index 0000000000..20a7beb36b --- /dev/null +++ b/frontend/performance/benches/render_wasm/builder.cljc @@ -0,0 +1,513 @@ +;; 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 SUBSIDIARY SL + +(ns benches.render-wasm.builder + "Seeded construction of benchmark scenes. + + A `scene` consists of one seeded number generator, one object map, and one + label map. + + Shape and container calls attach objects to the current parent in evaluation + order. Coordinates are page-relative. Frames use authored bounds; groups and + booleans derive geometry from their children after the body completes. + + `scene` accepts a required :seed, optional :root attributes and :defaults. + It returns a validated snapshot with :objects and :refs. Labels name objects + within that snapshot. A container whose body or finalizer throws becomes + unfinished, and `finish!` rejects the snapshot. + + Default attributes follow the same theme: `*defaults*` maps a shape type to + an attribute generator map, bound by the `scene` macro from the scope + params. Per-shape attrs are merged over the generated values, so a recipe + can override values locally and rebind `*defaults*` around a section. + + Ordinary :require loads the macros and runtime helpers. Scene bodies support + let, doseq and calls to functions defined outside the scope." + #?(:cljs (:require-macros [benches.render-wasm.builder])) + #?(:cljs + (:require + [app.common.data :as d] + [app.common.files.helpers :as cfh] + [app.common.geom.shapes :as gsh] + [app.common.types.path :as path] + [app.common.types.shape :as cts] + [app.common.types.shape-tree :as ctst] + [app.common.uuid :as uuid] + [benches.render-wasm.random :as rnd :refer + [rng-float rng-hex-color rng-int rng-opacity rng-uuid]] + [benches.render-wasm.snapshot :as snapshot]))) + +#?(:clj + (do + (defn- emit-shape + [constructor label attrs] + `(~constructor + benches.render-wasm.builder/*state* + benches.render-wasm.builder/*defaults* + ~label + ~attrs)) + + (defn- emit-container + [type label-or-attrs body] + (let [labeled? (or (keyword? label-or-attrs) (vector? label-or-attrs)) + attrs (if labeled? (first body) label-or-attrs) + label (when labeled? label-or-attrs) + body (if labeled? (rest body) body)] + `(benches.render-wasm.builder/with-container + ~type ~attrs ~label (fn [] ~@body)))) + + (defmacro scene + "Runs `body` in a scene scope and returns the validated snapshot." + [params & body] + `(let [params# ~params + state# (benches.render-wasm.builder/start params#)] + (binding [benches.render-wasm.builder/*state* state# + benches.render-wasm.builder/*defaults* (:defaults params#)] + ~@body + (benches.render-wasm.builder/finish! state#)))) + + (defmacro rect + "Adds a rectangle and returns its uuid. + + Forms: (rect), (rect attrs), (rect label), (rect label attrs). + A single map argument supplies attributes; keywords and vectors label + the object in :refs. Attributes override the generated defaults." + ([] + (emit-shape 'benches.render-wasm.builder/rect! nil {})) + ([label-or-attrs] + (emit-shape 'benches.render-wasm.builder/rect! label-or-attrs {})) + ([label attrs] + (emit-shape 'benches.render-wasm.builder/rect! label attrs))) + + (defmacro path + "Adds a path and returns its uuid. + + Forms: (path), (path attrs), (path label), (path label attrs). + A single map argument supplies attributes; keywords and vectors label + the object in :refs. Attributes override the generated defaults." + ([] + (emit-shape 'benches.render-wasm.builder/path! nil {})) + ([label-or-attrs] + (emit-shape 'benches.render-wasm.builder/path! label-or-attrs {})) + ([label attrs] + (emit-shape 'benches.render-wasm.builder/path! label attrs))) + + (defmacro circle + "Adds an ellipse and returns its uuid. + + Forms: (circle), (circle attrs), (circle label), (circle label attrs). + A single map argument supplies attributes; keywords and vectors label + the object in :refs. Attributes override the generated defaults." + ([] + (emit-shape 'benches.render-wasm.builder/circle! nil {})) + ([label-or-attrs] + (emit-shape 'benches.render-wasm.builder/circle! label-or-attrs {})) + ([label attrs] + (emit-shape 'benches.render-wasm.builder/circle! label attrs))) + + (defmacro frame + "Adds a frame, runs `body` with the frame as parent, and returns its uuid. + + Forms: (frame attrs & body), (frame label attrs & body). + A keyword or vector form supplies a label in :refs. Attributes must + carry numeric :x :y :width :height. Children use page-relative + coordinates. Frames allow an empty body." + [label-or-attrs & body] + (emit-container :frame label-or-attrs body)) + + (defmacro group + "Adds a group, runs `body` with the group as parent, and returns its uuid. + + Forms: (group attrs & body), (group label attrs & body). + A keyword or vector form supplies a label in :refs. Groups derive their + geometry from children and require at least one child. With + :masked-group true, the first child supplies the mask geometry." + [label-or-attrs & body] + (emit-container :group label-or-attrs body)) + + (defmacro bool + "Adds a boolean shape, runs `body` with it as parent, and returns its uuid. + + Forms: (bool attrs & body), (bool label attrs & body). + A keyword or vector form supplies a label in :refs. Attributes require + :bool-type :union, :difference, :intersection or :exclude. + + Content and geometry derive from the children. Styles inherit from the + first child for :difference and the last child otherwise; supplied + non-nil styles take precedence. A bool requires at least one child and + accepts paths, rectangles, ellipses, groups and other booleans." + [label-or-attrs & body] + (emit-container :bool label-or-attrs body)))) + +#?(:cljs + (do + + + ;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + ;; Attribute generators + ;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + + (defn gen-int + "Integer generator over `[min, max)`." + [min max] + (fn [rng] (rng-int rng min max))) + + (defn gen-float + "Float generator rounded to three decimals so snapshots stay readable." + [min max] + (fn [rng] (rnd/round3 (rng-float rng min max)))) + + (defn gen-hex-color + "Generator of a random `#rrggbb` string." + [] + (fn [rng] (rng-hex-color rng))) + + (defn gen-one-of + "Generator that picks one value per draw. Rejects an empty collection." + [values] + (when (empty? values) + (throw (ex-info "gen-one-of requires at least one value" + {:type ::no-values}))) + (fn [rng] (nth values (rng-int rng 0 (count values))))) + + (defn gen-fill + "Generator of one translucent solid fill." + [] + (fn [rng] + {:fill-color (rng-hex-color rng) + :fill-opacity (rng-opacity rng)})) + + (defn gen-stroke + "Centered stroke like the interim rectangle workload." + [width] + (fn [rng] + {:stroke-width width + :stroke-alignment :center + :stroke-color (rng-hex-color rng) + :stroke-opacity (rng-opacity rng)})) + + (defn gen-vector + "Composes generators into a generator of a vector, one value per generator." + [& generators] + (fn [rng] (mapv #(% rng) generators))) + + ;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + ;; Scope state + ;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + + (def ^:dynamic *state* + "Current scene scope; bound by the `scene` macro." + nil) + + (def ^:dynamic *defaults* + "Per-shape-type attribute generator maps bound by the `scene` macro, + e.g. `{:rect {:x (gen-int 0 1920) ...}}`. Values are literals or one-arg + functions of the scope random source." + {}) + + (def ^:dynamic *parent* + "Current insertion point as `{:id parent-id :frame-id containing-frame}`; + bound by the container scopes, `uuid/zero` at the top." + {:id uuid/zero :frame-id uuid/zero}) + + (defn- check-seed! + "Requires a scene seed from 0 through 4294967295, retaining the builder + error type." + [seed] + (when-not (rnd/seed? seed) + (throw (ex-info "scene scope requires an integer :seed in [0, 2^32)" + {:type ::invalid-seed + :seed seed})))) + + (defn start + "Starts a scene scope and returns its state atom with objects, refs and :rng. + The seeded generator belongs to this scope; other scopes and report draws + cannot advance its state." + [params] + (when (some? *state*) + (throw (ex-info "scene scopes cannot nest" + {:type ::nested-scope}))) + (check-seed! (:seed params)) + (let [seed (:seed params) + root (cts/setup-shape (merge (:root params) + {:id uuid/zero + :type :frame + :name "Root Frame" + :parent-id uuid/zero + :frame-id uuid/zero + :shapes []}))] + (atom {:seed seed + :rng (rnd/create seed) + :objects {uuid/zero root} + :refs {} + :unfinished {}}))) + + (defn- ensure-state + [state] + (or state + (throw (ex-info "shape used outside a scene scope" + {:type ::outside-scope})))) + + (defn- resolve-attr + [rng spec] + (if (fn? spec) (spec rng) spec)) + + (defn- resolve-attrs + [rng defaults attrs] + (merge (reduce-kv (fn [result key spec] + (assoc result key (resolve-attr rng spec))) + {} + defaults) + attrs)) + + (defn- register-ref! + [state label id] + (when (some? label) + (when (contains? (:refs @state) label) + (throw (ex-info (str "duplicate scene label: " label) + {:type ::duplicate-label + :label label}))) + (swap! state assoc-in [:refs label] id))) + + (defn- add-object! + "Inserts `shape` under the current parent and registers `label`." + [state label shape] + (let [{:keys [id frame-id]} *parent*] + (register-ref! state label (:id shape)) + (swap! state + (fn [scope] + (ctst/add-shape (:id shape) shape scope frame-id id nil true))))) + + ;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + ;; Shape constructors + ;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + + (defn- normalize-label-and-attrs + "Resolves map attributes and keyword/vector labels for shape construction." + [label attrs] + (cond + (and (map? label) (empty? attrs)) [nil label] + (nil? label) [nil attrs] + (or (keyword? label) (vector? label)) [label attrs] + :else (throw (ex-info "rect label must be a keyword or vector" + {:type ::invalid-label + :label label})))) + + (defn- construct-shape + [state defaults type label attrs] + (let [state (ensure-state state) + [label attrs] (normalize-label-and-attrs label attrs) + rng (:rng @state) + attrs (resolve-attrs rng (get defaults type) attrs) + id (rng-uuid rng) + shape (cts/setup-shape (merge attrs + {:id id + :type type}))] + (add-object! state label shape) + id)) + + (defn rect! + "Adds a rectangle with the selected defaults and attributes, returning its uuid." + [state defaults label attrs] + (construct-shape state defaults :rect label attrs)) + + (defn path! + "Adds a path with the selected defaults and attributes, returning its uuid." + [state defaults label attrs] + (construct-shape state defaults :path label attrs)) + + (defn circle! + "Adds an ellipse with the selected defaults and attributes, returning its uuid." + [state defaults label attrs] + (construct-shape state defaults :circle label attrs)) + + (def ^:private frame-bounds-keys + "Frames do not derive their bounds from children." + [:x :y :width :height]) + + (def ^:private group-geometry-keys + "Group geometry derives from its children." + [:x :y :width :height :selrect :points]) + + (def ^:private bool-geometry-keys + "Bool geometry, content and placement derive from its children." + [:x :y :width :height :selrect :points :content]) + + (def ^:private bool-transform-keys + "Construction does not accept transforms: `path/update-geometry` would + pivot them on the empty pre-finalization selrect." + [:transform :transform-inverse :rotation :flip-x :flip-y]) + + (defn- reject-attrs! + "Throws when `attrs` carries any of `attr-keys`. Shared by containers + whose geometry or content derives from their children." + [attrs id error-type message attr-keys] + (doseq [key attr-keys] + (when (some? (get attrs key)) + (throw (ex-info message + {:type error-type + :id id + :key key}))))) + + (defn- setup-frame + [id attrs] + (doseq [key frame-bounds-keys] + (let [value (get attrs key)] + (cond + (nil? value) + (throw (ex-info "frame requires :x :y :width :height" + {:type ::frame-bounds + :id id + :missing key})) + + (not (d/num? value)) + (throw (ex-info "frame bounds must be numbers" + {:type ::frame-bounds + :id id + :invalid key}))))) + (cts/setup-shape (assoc attrs :id id :type :frame))) + + (defn- setup-group + [id attrs] + (reject-attrs! attrs id ::group-geometry + "group derives its geometry from children" + group-geometry-keys) + (cts/setup-shape (assoc attrs :id id :type :group))) + + (defn- setup-bool + [id attrs] + (reject-attrs! attrs id ::bool-geometry + "bool derives its geometry and content from children" + bool-geometry-keys) + (reject-attrs! attrs id ::bool-transform + "bool construction does not accept transforms" + bool-transform-keys) + (when-not (contains? cts/bool-types (:bool-type attrs)) + (throw (ex-info "bool requires :bool-type :union :difference :intersection or :exclude" + {:type ::bool-type + :id id + :bool-type (:bool-type attrs) + :supported cts/bool-types}))) + (cts/setup-shape (assoc attrs :id id :type :bool))) + + (defn- bool-head + "Canonical style head: the first child is the base for `:difference`, the + last child otherwise." + [bool-shape children] + (if (= :difference (:bool-type bool-shape)) + (first children) + (last children))) + + (defn- finalize-bool! + [state id] + (let [scope @state + bool (get-in scope [:objects id]) + children (mapv (:objects scope) (:shapes bool))] + (when (empty? children) + (throw (ex-info "bool requires at least one child" + {:type ::empty-bool + :id id}))) + (when-let [frame (first (filter cfh/frame-shape? children))] + (throw (ex-info "bool children cannot be frames" + {:type ::frame-in-bool + :id id + :child (:id frame)}))) + (let [inherited (d/without-nils + (select-keys (bool-head bool children) + path/bool-style-properties)) + supplied (d/without-nils + (select-keys (get-in scope [:container-attrs id]) + path/bool-style-properties)) + bool (-> bool (merge inherited) (merge supplied)) + + ;; `path/update-bool-shape` dispatches to `path/wasm:calc-bool-content`, + ;; the renderer override, so scene content would depend on renderer + ;; initialization and A/B configuration. Compose the pure helpers: + ;; scene generation is untimed and must not vary with the renderer + ;; being compared. + content (path/calc-bool-content bool (:objects scope)) + bool (path/update-geometry bool content)] + (swap! state assoc-in [:objects id] bool)))) + + (defn- finalize-group! + [state id] + (let [scope @state + group (get-in scope [:objects id]) + children (mapv (:objects scope) (:shapes group))] + (when (empty? children) + (throw (ex-info "group requires at least one child" + {:type ::empty-group + :id id}))) + (let [group (if (:masked-group group) + (gsh/update-mask-selrect group children) + (gsh/update-group-selrect group children))] + (swap! state assoc-in [:objects id] group)))) + + (def ^:private container-specs + "Per-container behavior: how to build it, which parent scope it opens, + and how to finalize it after the body. `:keep-attrs?` records the raw + attrs in the scope so the finalizer can let supplied styles win over + inherited ones." + {:frame {:setup setup-frame + :parent (fn [id _parent] {:id id :frame-id id}) + :finalize (fn [_state _id] nil)} + :group {:setup setup-group + :parent (fn [id parent] {:id id :frame-id (:frame-id parent)}) + :finalize finalize-group!} + :bool {:setup setup-bool + :parent (fn [id parent] {:id id :frame-id (:frame-id parent)}) + :finalize finalize-bool! + :keep-attrs? true}}) + + (defn- check-container-attrs + [type id attrs] + (when-not (map? attrs) + (throw (ex-info (str (name type) " attrs must be a map; the label form takes the attrs map next") + {:type ::invalid-attrs :id id}))) + attrs) + + (defn with-container + "Creates a frame, group or bool, runs `thunk` with the new parent in + scope, finalizes the containers that derive state from children, and + returns the container uuid. + + Any exception from the body or the finalizer marks the container + unfinished and propagates; `finish!` then rejects the instance even if the + caller catches the exception." + [type attrs label thunk] + (let [{:keys [setup parent finalize keep-attrs?]} + (or (get container-specs type) + (throw (ex-info "unsupported container type" + {:type ::invalid-container + :container-type type}))) + state (ensure-state *state*) + id (rng-uuid (:rng @state)) + attrs (check-container-attrs type id attrs) + shape (setup id attrs)] + (when keep-attrs? + (swap! state assoc-in [:container-attrs id] attrs)) + (add-object! state label shape) + (binding [*parent* (parent id *parent*)] + (try + (thunk) + (finalize state id) + (catch :default cause + (swap! state assoc-in [:unfinished id] true) + (throw cause)))) + id)) + + (defn finish! + "Validates the scope snapshot and returns it." + [state] + (let [{:keys [objects refs unfinished]} @state] + (when (seq unfinished) + (throw (ex-info "scene contains an unfinished container" + {:type ::unfinished-container + :ids (vec (keys unfinished))}))) + (let [instance {:objects objects + :refs refs}] + (snapshot/validate! instance) + instance))))) diff --git a/frontend/performance/benches/render_wasm/builder.cljs b/frontend/performance/benches/render_wasm/builder.cljs deleted file mode 100644 index 95d270a0a8..0000000000 --- a/frontend/performance/benches/render_wasm/builder.cljs +++ /dev/null @@ -1,419 +0,0 @@ -;; 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 SUBSIDIARY SL - -(ns benches.render-wasm.builder - "Construction of benchmark scenes. - - A `scene` consists of one seeded number generator, one object map, and one - label map. - - A container whose body throws is marked unfinished and `finish!` rejects the - instance, so a caught exception cannot produce a half-built scene. - - Default attributes follow the same theme: `*defaults*` maps a shape type to - an attribute generator map, bound by the `scene` macro from the scope - params. Per-shape attrs are merged over the generated values, so a recipe - can override anything locally and rebind `*defaults*` around a section." - (:require - [app.common.data :as d] - [app.common.files.helpers :as cfh] - [app.common.geom.shapes :as gsh] - [app.common.types.path :as path] - [app.common.types.shape :as cts] - [app.common.types.shape-tree :as ctst] - [app.common.uuid :as uuid] - [benches.render-wasm.random :as rnd :refer - [rng-float rng-hex-color rng-int rng-opacity rng-uuid]] - [benches.render-wasm.snapshot :as snapshot])) - - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Attribute generators -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(defn gen-int - "Integer generator over `[min, max)`." - [min max] - (fn [rng] (rng-int rng min max))) - -(defn gen-float - "Float generator rounded to three decimals so snapshots stay readable." - [min max] - (fn [rng] (rnd/round3 (rng-float rng min max)))) - -(defn gen-hex-color - "Generator of a random `#rrggbb` string." - [] - (fn [rng] (rng-hex-color rng))) - -(defn gen-one-of - "Generator that picks one value per draw. Rejects an empty collection." - [values] - (when (empty? values) - (throw (ex-info "gen-one-of requires at least one value" - {:type ::no-values}))) - (fn [rng] (nth values (rng-int rng 0 (count values))))) - -(defn gen-fill - "Generator of one translucent solid fill." - [] - (fn [rng] - {:fill-color (rng-hex-color rng) - :fill-opacity (rng-opacity rng)})) - -(defn gen-stroke - "Centered stroke like the interim rectangle workload." - [width] - (fn [rng] - {:stroke-width width - :stroke-alignment :center - :stroke-color (rng-hex-color rng) - :stroke-opacity (rng-opacity rng)})) - -(defn gen-vector - "Composes generators into a generator of a vector, one value per - generator." - [& generators] - (fn [rng] (mapv #(% rng) generators))) - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Scope state -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(def ^:dynamic *state* - "Current scene scope; bound by the `scene` macro." - nil) - -(def ^:dynamic *defaults* - "Per-shape-type attribute generator maps bound by the `scene` macro, - e.g. `{:rect {:x (gen-int 0 1920) ...}}`. Values are literals or one-arg - functions of the scope random source." - {}) - -(def ^:dynamic *parent* - "Current insertion point as `{:id parent-id :frame-id containing-frame}`; - bound by the container scopes, `uuid/zero` at the top." - {:id uuid/zero :frame-id uuid/zero}) - -(defn- check-seed! - "Requires a scene seed from 0 through 4294967295, retaining the builder error type." - [seed] - (when-not (rnd/seed? seed) - (throw (ex-info "scene scope requires an integer :seed in [0, 2^32)" - {:type ::invalid-seed - :seed seed})))) - -(defn start - "Starts a scene scope and returns its state atom with objects, refs and :rng. - The seeded generator belongs to this scope; other scopes and report draws - cannot advance its state." - [params] - (when (some? *state*) - (throw (ex-info "scene scopes cannot nest" - {:type ::nested-scope}))) - (check-seed! (:seed params)) - (let [seed (:seed params) - root (cts/setup-shape (merge (:root params) - {:id uuid/zero - :type :frame - :name "Root Frame" - :parent-id uuid/zero - :frame-id uuid/zero - :shapes []}))] - (atom {:seed seed - :rng (rnd/create seed) - :objects {uuid/zero root} - :refs {} - :unfinished {}}))) - -(defn- ensure-state - [state] - (or state - (throw (ex-info "shape used outside a scene scope" - {:type ::outside-scope})))) - -(defn- resolve-attr - [rng spec] - (if (fn? spec) (spec rng) spec)) - -(defn- resolve-attrs - [rng defaults attrs] - (merge (reduce-kv (fn [result key spec] - (assoc result key (resolve-attr rng spec))) - {} - defaults) - attrs)) - -(defn- register-ref! - [state label id] - (when (some? label) - (when (contains? (:refs @state) label) - (throw (ex-info (str "duplicate scene label: " label) - {:type ::duplicate-label - :label label}))) - (swap! state assoc-in [:refs label] id))) - -(defn- add-object! - "Inserts `shape` under the current parent and registers `label`." - [state label shape] - (let [{:keys [id frame-id]} *parent*] - (register-ref! state label (:id shape)) - (swap! state - (fn [scope] - (ctst/add-shape (:id shape) shape scope frame-id id nil true))))) - -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Shape constructors -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - -(defn- normalize-label-and-attrs - "Resolves the one-argument `rect`/`circle` forms at runtime: a map is attrs, - any other value is a label. Rejects label types outside keywords and vectors." - [label attrs] - (cond - (and (map? label) (empty? attrs)) [nil label] - (nil? label) [nil attrs] - (or (keyword? label) (vector? label)) [label attrs] - :else (throw (ex-info "rect label must be a keyword or vector" - {:type ::invalid-label - :label label})))) - -(defn rect! - "Adds one rectangle to the scope and returns its uuid. - Runtime primitive behind the `rect` macro." - [state defaults label attrs] - (let [state (ensure-state state) - [label attrs] (normalize-label-and-attrs label attrs) - rng (:rng @state) - attrs (resolve-attrs rng (:rect defaults) attrs) - id (rng-uuid rng) - shape (cts/setup-shape (merge attrs - {:id id - :type :rect}))] - (add-object! state label shape) - id)) - -(defn path! - "Adds one path to the scope and returns its uuid. - Runtime primitive behind the `path` macro." - [state defaults label attrs] - (let [state (ensure-state state) - [label attrs] (normalize-label-and-attrs label attrs) - rng (:rng @state) - attrs (resolve-attrs rng (:path defaults) attrs) - id (rng-uuid rng) - shape (cts/setup-shape (merge attrs - {:id id - :type :path}))] - (add-object! state label shape) - id)) - -(defn circle! - "Adds one circle to the scope and returns its uuid. - Runtime primitive behind the `circle` macro." - [state defaults label attrs] - (let [state (ensure-state state) - [label attrs] (normalize-label-and-attrs label attrs) - rng (:rng @state) - attrs (resolve-attrs rng (:circle defaults) attrs) - id (rng-uuid rng) - shape (cts/setup-shape (merge attrs - {:id id - :type :circle}))] - (add-object! state label shape) - id)) - -(def ^:private frame-bounds-keys - "Frames do not derive their bounds from children." - [:x :y :width :height]) - -(def ^:private group-geometry-keys - "Group geometry derives from its children." - [:x :y :width :height :selrect :points]) - -(def ^:private bool-geometry-keys - "Bool geometry, content and placement derive from its children." - [:x :y :width :height :selrect :points :content]) - -(def ^:private bool-transform-keys - "Construction does not accept transforms: `path/update-geometry` would - pivot them on the empty pre-finalization selrect." - [:transform :transform-inverse :rotation :flip-x :flip-y]) - -(defn- reject-attrs! - "Throws when `attrs` carries any of `attr-keys`. Shared by containers - whose geometry or content derives from their children." - [attrs id error-type message attr-keys] - (doseq [key attr-keys] - (when (some? (get attrs key)) - (throw (ex-info message - {:type error-type - :id id - :key key}))))) - -(defn- setup-frame - [id attrs] - (when-not (map? attrs) - (throw (ex-info "frame attrs must be a map; the label form takes the attrs map next" - {:type ::invalid-attrs - :id id}))) - (doseq [key frame-bounds-keys] - (let [value (get attrs key)] - (cond - (nil? value) - (throw (ex-info "frame requires :x :y :width :height" - {:type ::frame-bounds - :id id - :missing key})) - - (not (d/num? value)) - (throw (ex-info "frame bounds must be numbers" - {:type ::frame-bounds - :id id - :invalid key}))))) - (cts/setup-shape (assoc attrs :id id :type :frame))) - -(defn- setup-group - [id attrs] - (when-not (map? attrs) - (throw (ex-info "group attrs must be a map; the label form takes the attrs map next" - {:type ::invalid-attrs - :id id}))) - (reject-attrs! attrs id ::group-geometry - "group derives its geometry from children" - group-geometry-keys) - (cts/setup-shape (assoc attrs :id id :type :group))) - -(defn- setup-bool - [id attrs] - (when-not (map? attrs) - (throw (ex-info "bool attrs must be a map; the label form takes the attrs map next" - {:type ::invalid-attrs - :id id}))) - (reject-attrs! attrs id ::bool-geometry - "bool derives its geometry and content from children" - bool-geometry-keys) - (reject-attrs! attrs id ::bool-transform - "bool construction does not accept transforms" - bool-transform-keys) - (when-not (contains? cts/bool-types (:bool-type attrs)) - (throw (ex-info "bool requires :bool-type :union :difference :intersection or :exclude" - {:type ::bool-type - :id id - :bool-type (:bool-type attrs) - :supported cts/bool-types}))) - (cts/setup-shape (assoc attrs :id id :type :bool))) - -(defn- bool-head - "Canonical style head: the first child is the base for `:difference`, the - last child otherwise." - [bool-shape children] - (if (= :difference (:bool-type bool-shape)) - (first children) - (last children))) - -(defn- finalize-bool! - [state id] - (let [scope @state - bool (get-in scope [:objects id]) - children (mapv (:objects scope) (:shapes bool))] - (when (empty? children) - (throw (ex-info "bool requires at least one child" - {:type ::empty-bool - :id id}))) - (when-let [frame (first (filter cfh/frame-shape? children))] - (throw (ex-info "bool children cannot be frames" - {:type ::frame-in-bool - :id id - :child (:id frame)}))) - (let [inherited (d/without-nils - (select-keys (bool-head bool children) - path/bool-style-properties)) - supplied (d/without-nils - (select-keys (get-in scope [:container-attrs id]) - path/bool-style-properties)) - bool (-> bool (merge inherited) (merge supplied)) - - ;; `path/update-bool-shape` dispatches to `path/wasm:calc-bool-content`, - ;; the renderer override, so scene content would depend on renderer - ;; initialization and A/B configuration. Compose the pure helpers: - ;; scene generation is untimed and must not vary with the renderer - ;; being compared. - content (path/calc-bool-content bool (:objects scope)) - bool (path/update-geometry bool content)] - (swap! state assoc-in [:objects id] bool)))) - -(defn- finalize-group! - [state id] - (let [scope @state - group (get-in scope [:objects id]) - children (mapv (:objects scope) (:shapes group))] - (when (empty? children) - (throw (ex-info "group requires at least one child" - {:type ::empty-group - :id id}))) - (let [group (if (:masked-group group) - (gsh/update-mask-selrect group children) - (gsh/update-group-selrect group children))] - (swap! state assoc-in [:objects id] group)))) - -(def ^:private container-specs - "Per-container behavior: how to build it, which parent scope it opens, - and how to finalize it after the body. `:keep-attrs?` records the raw - attrs in the scope so the finalizer can let supplied styles win over - inherited ones." - {:frame {:setup setup-frame - :parent (fn [id _parent] {:id id :frame-id id}) - :finalize (fn [_state _id] nil)} - :group {:setup setup-group - :parent (fn [id parent] {:id id :frame-id (:frame-id parent)}) - :finalize finalize-group!} - :bool {:setup setup-bool - :parent (fn [id parent] {:id id :frame-id (:frame-id parent)}) - :finalize finalize-bool! - :keep-attrs? true}}) - -(defn with-container - "Creates a frame, group or bool, runs `thunk` with the new parent in - scope, finalizes the containers that derive state from children, and - returns the container uuid. - - Any exception from the body or the finalizer marks the container - unfinished and propagates; `finish!` then rejects the instance even if the - caller catches the exception." - [type attrs label thunk] - (let [{:keys [setup parent finalize keep-attrs?]} - (or (get container-specs type) - (throw (ex-info "unsupported container type" - {:type ::invalid-container - :container-type type}))) - state (ensure-state *state*) - id (rng-uuid (:rng @state)) - shape (setup id attrs)] - (when keep-attrs? - (swap! state assoc-in [:container-attrs id] attrs)) - (add-object! state label shape) - (binding [*parent* (parent id *parent*)] - (try - (thunk) - (finalize state id) - (catch :default cause - (swap! state assoc-in [:unfinished id] true) - (throw cause)))) - id)) - -(defn finish! - "Validates the scope snapshot and returns it." - [state] - (let [{:keys [objects refs unfinished]} @state] - (when (seq unfinished) - (throw (ex-info "scene contains an unfinished container" - {:type ::unfinished-container - :ids (vec (keys unfinished))}))) - (let [instance {:objects objects - :refs refs}] - (snapshot/validate! instance) - instance))) diff --git a/frontend/performance/benches/render_wasm/scenes/masks.cljs b/frontend/performance/benches/render_wasm/scenes/masks.cljs index 589bcbda5a..a6a41fe4ff 100644 --- a/frontend/performance/benches/render_wasm/scenes/masks.cljs +++ b/frontend/performance/benches/render_wasm/scenes/masks.cljs @@ -7,7 +7,7 @@ (ns benches.render-wasm.scenes.masks "Fixed grid of masked groups with circle masks and ordered descendants." (:require - [benches.render-wasm.builder :as sb :include-macros true] + [benches.render-wasm.builder :as sb] [benches.render-wasm.declarations :as decl :include-macros true] [benches.render-wasm.runtime.camera :as camera])) diff --git a/frontend/performance/benches/render_wasm/scenes/paths.cljs b/frontend/performance/benches/render_wasm/scenes/paths.cljs index 3bba2c5b5d..411f2bd531 100644 --- a/frontend/performance/benches/render_wasm/scenes/paths.cljs +++ b/frontend/performance/benches/render_wasm/scenes/paths.cljs @@ -9,7 +9,7 @@ (:require [app.common.schema :as sm] [app.common.types.path :as path] - [benches.render-wasm.builder :as sb :include-macros true] + [benches.render-wasm.builder :as sb] [benches.render-wasm.declarations :as decl :include-macros true] [benches.render-wasm.random :refer [rng-int]] [benches.render-wasm.runtime.camera :as camera])) diff --git a/frontend/performance/benches/render_wasm/scenes/plus.cljs b/frontend/performance/benches/render_wasm/scenes/plus.cljs index 2a5314859d..7f5ad0ec87 100644 --- a/frontend/performance/benches/render_wasm/scenes/plus.cljs +++ b/frontend/performance/benches/render_wasm/scenes/plus.cljs @@ -8,7 +8,7 @@ "Plus signs made of two open line subpaths" (:require [app.common.types.path :as path] - [benches.render-wasm.builder :as sb :include-macros true] + [benches.render-wasm.builder :as sb] [benches.render-wasm.declarations :as decl :include-macros true] [benches.render-wasm.random :refer [rng-int]] [benches.render-wasm.runtime.camera :as camera])) diff --git a/frontend/performance/benches/render_wasm/scenes/rects.cljs b/frontend/performance/benches/render_wasm/scenes/rects.cljs index de4d0b2955..3952b3045d 100644 --- a/frontend/performance/benches/render_wasm/scenes/rects.cljs +++ b/frontend/performance/benches/render_wasm/scenes/rects.cljs @@ -14,7 +14,7 @@ they render the same scene." (:require [app.common.schema :as sm] - [benches.render-wasm.builder :as sb :include-macros true] + [benches.render-wasm.builder :as sb] [benches.render-wasm.declarations :as decl :include-macros true] [benches.render-wasm.runtime.camera :as camera])) diff --git a/frontend/test/performance_tests/render_wasm/boolean_test.cljs b/frontend/test/performance_tests/render_wasm/boolean_test.cljs index 87a3889f2e..ccea94ce2e 100644 --- a/frontend/test/performance_tests/render_wasm/boolean_test.cljs +++ b/frontend/test/performance_tests/render_wasm/boolean_test.cljs @@ -11,7 +11,7 @@ [app.common.types.path :as path] [app.common.types.path.segment :as segm] [app.common.uuid :as uuid] - [benches.render-wasm.builder :as b :include-macros true] + [benches.render-wasm.builder :as b] [benches.render-wasm.snapshot :as snapshot] [cljs.test :as t :include-macros true] [performance-tests.render-wasm.test-helpers :as helpers])) diff --git a/frontend/test/performance_tests/render_wasm/builder_test.cljs b/frontend/test/performance_tests/render_wasm/builder_test.cljs index dbb8e49cac..4e2194b4f8 100644 --- a/frontend/test/performance_tests/render_wasm/builder_test.cljs +++ b/frontend/test/performance_tests/render_wasm/builder_test.cljs @@ -9,7 +9,7 @@ Fixed random sequences preserve scene draws during generator extraction." (:require [app.common.uuid :as uuid] - [benches.render-wasm.builder :as b :include-macros true] + [benches.render-wasm.builder :as b] [benches.render-wasm.declarations :as decl] [benches.render-wasm.random :as random] [benches.render-wasm.scenes.rects] @@ -101,6 +101,32 @@ (t/is (= 7 (:x rect))) (t/is (= [] (:fills rect))))) +(t/deftest shapes-use-their-defaults-before-drawing-ids + (doseq [[type low high construct] [[:rect 0.1 0.2 #(b/rect %)] + [:path 0.3 0.4 #(b/path %)] + [:circle 0.5 0.6 #(b/circle %)]] + attrs [{} {:opacity 0.75}]] + (let [rng (random/create 7) + opacity (random/round3 (random/rng-float rng low high)) + id (random/rng-uuid rng) + instance (b/scene {:seed 7 + :defaults {:rect {:opacity (b/gen-float 0.1 0.2)} + :path {:opacity (b/gen-float 0.3 0.4)} + :circle {:opacity (b/gen-float 0.5 0.6)}}} + (construct attrs)) + shape (first (helpers/root-shapes instance))] + (t/is (= {:type type :id id :opacity (get attrs :opacity opacity)} + (select-keys shape [:type :id :opacity])))))) + +(t/deftest scene-and-shape-expressions-evaluate-once-in-order + (let [events (atom []) + instance (b/scene (do (swap! events conj :params) {:seed 1}) + (b/rect (do (swap! events conj :label) :hero) + (do (swap! events conj :attrs) {:x 11}))) + hero (get-in instance [:objects (get (:refs instance) :hero)])] + (t/is (= [:params :label :attrs] @events)) + (t/is (= 11 (:x hero))))) + (t/deftest ordinary-forms-work-inside-the-scope (let [instance (b/scene {:seed 1} (let [first-id (b/rect)] @@ -260,4 +286,3 @@ (b/circle {:x 0 :y 0 :width 20 :height 20 :rx 5 :ry 7})) circle (first (helpers/root-shapes instance))] (t/is (= [5 7] [(:rx circle) (:ry circle)])))) - diff --git a/frontend/test/performance_tests/render_wasm/containers_test.cljs b/frontend/test/performance_tests/render_wasm/containers_test.cljs index cf495409d3..19d68dc833 100644 --- a/frontend/test/performance_tests/render_wasm/containers_test.cljs +++ b/frontend/test/performance_tests/render_wasm/containers_test.cljs @@ -7,7 +7,8 @@ (ns performance-tests.render-wasm.containers-test (:require [app.common.uuid :as uuid] - [benches.render-wasm.builder :as builder :include-macros true] + [benches.render-wasm.builder :as builder] + [benches.render-wasm.random :as random] [cljs.test :as t :include-macros true] [performance-tests.render-wasm.test-helpers :as helpers])) @@ -102,6 +103,35 @@ (t/is (= (get refs [:g 1]) (first (helpers/child-ids instance (get refs :hero))))) (t/is (= 1 (count (helpers/child-ids instance uuid/zero)))))) +(t/deftest container-expressions-evaluate-once-in-order + (let [events (atom []) + instance (builder/scene {:seed 1} + (builder/frame [:frame (do (swap! events conj :label) 3)] + (do (swap! events conj :attrs) + {:x 0 :y 0 :width 20 :height 20}) + (swap! events conj :body) + (builder/rect {:x 10}))) + frame-id (get (:refs instance) [:frame 3])] + (t/is (= [:attrs :label :body] @events)) + (t/is (= [10] (mapv :x (helpers/children-of instance frame-id)))))) + +(t/deftest invalid-container-attrs-retain-seeded-construction-order + (doseq [construct [#(builder/frame :hero :invalid) + #(builder/group :hero :invalid) + #(builder/bool :hero :invalid)]] + (let [rng (random/create 7) + failed-id (random/rng-uuid rng) + next-id (random/rng-uuid rng) + failure (volatile! nil) + state (helpers/run-scope + {:seed 7} + (fn [] + (vreset! failure (helpers/failure-data construct)) + (builder/rect))) + instance (builder/finish! state)] + (t/is (= {:type ::builder/invalid-attrs :id failed-id} @failure)) + (t/is (= [next-id] (helpers/child-ids instance uuid/zero)))))) + (t/deftest empty-group-is-rejected (let [data (helpers/failure-data #(builder/scene {:seed 1} (builder/group {})))] (t/is (= ::builder/empty-group (:type data)))))