🚧 WIP Step 5, bools

This commit is contained in:
Miguel de Benito Delgado 2026-09-21 20:01:30 +02:00
parent bd17e6d431
commit bfde40d1ab
3 changed files with 381 additions and 22 deletions

View File

@ -19,7 +19,10 @@
(frame {:x 0 :y 0 :width 100 :height 100}
(rect))
(group :tiles {}
(rect [:tile 3] {})))
(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})))
`params` is a map with `:seed` (required), optional `:root` attributes for
the canonical root frame, and optional `:defaults` with one attribute
@ -114,3 +117,28 @@
~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.scenes.builder/with-container
:bool
~(first body)
~label-or-attrs
(fn [] ~@(rest body)))
`(benches.render-wasm.scenes.builder/with-container
:bool
~label-or-attrs
nil
(fn [] ~@body))))

View File

@ -9,7 +9,7 @@
A fixture scope owns one seeded generator, one object map, and one label
map. Shape constructors add themselves to the current parent: `rect`, and
the `frame`/`group` container scopes. The scope returns a validated
the `frame`/`group`/`bool` container scopes. The scope returns a validated
snapshot in the ticket02 contract `{:objects ... :refs ...}`.
Construction is synchronous by design: the scope and the current parent
@ -27,13 +27,16 @@
Coordinates stay page-absolute: nesting supplies ownership and order, not
translation. Frames keep authored bounds; groups derive 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 canonical
path engine and inherit style from the head child unless attrs supply it.
Masked groups, frame/group scopes are this ticket; booleans ticket19;
paths ticket07/08."
Masked groups and the frame/group/bool scopes live here; paths ticket07/08."
(:require
[app.common.data :as d]
[app.common.files.helpers :as cfh]
[app.common.geom.shapes :as gsh]
[app.common.types.color :as clr]
[app.common.types.path :as path]
[app.common.types.shape :as cts]
[app.common.types.shape-tree :as ctst]
[app.common.uuid :as uuid]
@ -55,12 +58,12 @@
[seed]
(let [state (atom (bit-or seed 0))]
(fn []
(let [s (swap! state (fn [s] (bit-or (+ s 0x6d2b79f5) 0)))
v (js/Math.imul (bit-xor s (unsigned-bit-shift-right s 15))
(bit-or s 1))
v (bit-xor v (+ v (js/Math.imul (bit-xor v (unsigned-bit-shift-right v 7))
(bit-or v 61))))
v (bit-xor v (unsigned-bit-shift-right v 14))]
(let [s (swap! state (fn [s] (bit-or (+ s 0x6d2b79f5) 0)))
v (js/Math.imul (bit-xor s (unsigned-bit-shift-right s 15))
(bit-or s 1))
v (bit-xor v (+ v (js/Math.imul (bit-xor v (unsigned-bit-shift-right v 7))
(bit-or v 61))))
v (bit-xor v (unsigned-bit-shift-right v 14))]
(/ (unsigned-bit-shift-right v 0) 4294967296)))))
(defn rng-float
@ -256,11 +259,33 @@
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)
@ -289,14 +314,70 @@
(throw (ex-info "group attrs must be a map; the label form takes the attrs map next"
{:type ::invalid-attrs
:id id})))
(doseq [key group-geometry-keys]
(when (some? (get attrs key))
(throw (ex-info "group derives its geometry from children"
{:type ::group-geometry
:id id
:key key}))))
(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 fixture content would depend on renderer
;; initialization and A/B configuration. Compose the pure helpers:
;; fixture 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
@ -313,24 +394,30 @@
(def ^:private container-specs
"Per-container behavior: how to build it, which parent scope it opens,
and how to finalize it after the body. Ticket19 extends the table with
booleans."
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!}})
: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 or group, runs `thunk` with the new parent in scope,
finalizes groups, and returns the container uuid.
"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]}
(let [{:keys [setup parent finalize keep-attrs?]}
(or (get container-specs type)
(throw (ex-info "unsupported container type"
{:type ::invalid-container
@ -338,6 +425,8 @@
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

View File

@ -0,0 +1,242 @@
;; 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 frontend-tests.benches.boolean-fixture-test
(:require
[app.common.types.path :as path]
[app.common.types.shape :as cts]
[app.common.uuid :as uuid]
[benches.render-wasm.scenes.builder :as b :include-macros true]
[benches.render-wasm.scenes.common :as common]
[cljs.test :as t :include-macros true]))
(defn- failure-data
[f]
(try
(f)
nil
(catch :default cause
(ex-data cause))))
(defn- selrect
[shape]
[(:x (:selrect shape)) (:y (:selrect shape))
(:width (:selrect shape)) (:height (:selrect shape))])
(defn- children-of
[instance id]
(mapv (:objects instance) (get-in instance [:objects id :shapes])))
(defn- run-scope
"Runs `f` inside a raw scope and returns the scope state, so tests can
inspect partial builds without calling finish!."
[params f]
(let [state (b/start params)]
(binding [b/*state* state
b/*defaults* {}]
(f))
state))
(t/deftest difference-of-overlapping-rects-derives-the-l-shape
(let [red {:fill-color "#ff0000" :fill-opacity 1}
blue {:fill-color "#0000ff" :fill-opacity 1}
instance (b/fixture {:seed 1}
(b/bool :l-shape {:bool-type :difference :name "L"}
(b/rect {:x 0 :y 0 :width 100 :height 100 :fills [red]})
(b/rect {:x 50 :y 50 :width 100 :height 100 :fills [blue]})))
bool (first (children-of instance uuid/zero))
children (children-of instance (:id bool))]
(t/is (= :bool (:type bool)))
(t/is (= :difference (:bool-type bool)))
(t/is (= (:id bool) (common/ref-id instance :l-shape)))
(t/is (= 2 (count children)))
(t/is (= [(:id (first children)) (:id (second children))] (:shapes bool)))
(t/is (= [(:id bool) (:id bool)]
[(:parent-id (first children)) (:parent-id (second children))]))
(t/is (= [uuid/zero uuid/zero]
[(:frame-id (first children)) (:frame-id (second children))]))
(t/is (= [0 0 100 100] (selrect bool)))
(t/is (= (:selrect bool) (path/calc-selrect (:content bool))))
(t/is (= 4 (count (:points bool))))
(t/is (= #{[0 0] [100 0] [100 50] [50 50] [50 100] [0 100]}
(set (map (juxt :x :y) (path/get-points (:content bool))))))))
(t/deftest union-intersection-and-exclude-derive-canonical-bounds
(doseq [[bool-type expected] [[:union [0 0 150 150]]
[:intersection [50 50 50 50]]
[:exclude [0 0 150 150]]]]
(let [instance (b/fixture {:seed 1}
(b/bool {:bool-type bool-type}
(b/rect {:x 0 :y 0 :width 100 :height 100})
(b/rect {:x 50 :y 50 :width 100 :height 100})))
bool (first (children-of instance uuid/zero))]
(t/is (= bool-type (:bool-type bool)))
(t/is (= expected (selrect bool)) (str bool-type)))))
(t/deftest operation-picks-the-canonical-style-head
(let [red {:fill-color "#ff0000" :fill-opacity 1}
blue {:fill-color "#0000ff" :fill-opacity 1}
blur {:id (uuid/custom 3 3) :type :layer-blur :value 4 :hidden false}
difference (b/fixture {:seed 1}
(b/bool {:bool-type :difference}
(b/rect {:x 0 :y 0 :width 10 :height 10
:fills [red] :blur blur})
(b/rect {:x 5 :y 0 :width 10 :height 10
:fills [blue]})))
union (b/fixture {:seed 1}
(b/bool {:bool-type :union}
(b/rect {:x 0 :y 0 :width 10 :height 10
:fills [red] :blur blur})
(b/rect {:x 5 :y 0 :width 10 :height 10
:fills [blue]})))
diff-bool (first (children-of difference uuid/zero))
union-bool (first (children-of union uuid/zero))]
(t/is (= [red] (:fills diff-bool)))
(t/is (= blur (:blur diff-bool)))
(t/is (= [blue] (:fills union-bool)))
(t/is (nil? (:blur union-bool)))))
(t/deftest supplied-styles-override-inherited
(let [instance (b/fixture {:seed 1}
(b/bool {:bool-type :union :fills []}
(b/rect {:x 0 :y 0 :width 10 :height 10
:fills [{:fill-color "#ff0000" :fill-opacity 1}]})
(b/rect {:x 5 :y 0 :width 10 :height 10
:fills [{:fill-color "#0000ff" :fill-opacity 1}]})))
bool (first (children-of instance uuid/zero))]
(t/is (= [] (:fills bool)))))
(t/deftest nil-styles-do-not-count-as-supplied
(let [red {:fill-color "#ff0000" :fill-opacity 1}
blue {:fill-color "#0000ff" :fill-opacity 1}
instance (b/fixture {:seed 1}
(b/bool {:bool-type :union :fills nil}
(b/rect {:x 0 :y 0 :width 10 :height 10 :fills [red]})
(b/rect {:x 5 :y 0 :width 10 :height 10 :fills [blue]})))
bool (first (children-of instance uuid/zero))]
(t/is (= [blue] (:fills bool)))))
(t/deftest single-child-bool-uses-the-child-path
(let [instance (b/fixture {:seed 1}
(b/bool {:bool-type :difference}
(b/rect {:x 10 :y 20 :width 30 :height 40})))
bool (first (children-of instance uuid/zero))]
(t/is (= 1 (count (:shapes bool))))
(t/is (= [10 20 30 40] (selrect bool)))
(t/is (= (:selrect bool) (path/calc-selrect (:content bool))))))
(t/deftest nested-bools-finalize-inside-out
(let [instance (b/fixture {:seed 1}
(b/bool {:bool-type :union}
(b/bool {:bool-type :difference}
(b/rect {:x 0 :y 0 :width 100 :height 100})
(b/rect {:x 50 :y 50 :width 100 :height 100}))
(b/rect {:x 200 :y 0 :width 50 :height 50})))
outer (first (children-of instance uuid/zero))
inner (first (children-of instance (:id outer)))]
(t/is (= :bool (:type inner)))
(t/is (= [0 0 100 100] (selrect inner)))
(t/is (= [0 0 250 100] (selrect outer)))
(t/is (= (:selrect outer) (path/calc-selrect (:content outer))))))
(t/deftest bool-inside-frame-keeps-frame-ids
(let [instance (b/fixture {:seed 1}
(b/frame {:x 0 :y 0 :width 200 :height 200}
(b/bool {:bool-type :union}
(b/rect {:x 10 :y 10 :width 10 :height 10})
(b/rect {:x 30 :y 10 :width 10 :height 10}))))
frame (first (children-of instance uuid/zero))
bool (first (children-of instance (:id frame)))
rects (children-of instance (:id bool))]
(t/is (= (:id frame) (:parent-id bool)))
(t/is (= (:id frame) (:frame-id bool)))
(t/is (= [(:id frame) (:id frame)] (mapv :frame-id rects)))
(t/is (= [(:id bool) (:id bool)] (mapv :parent-id rects)))))
(t/deftest bool-inside-group-derives-group-bounds
(let [instance (b/fixture {:seed 1}
(b/group {}
(b/bool {:bool-type :union}
(b/rect {:x 10 :y 20 :width 30 :height 40})
(b/rect {:x 50 :y 80 :width 10 :height 10}))))
group (first (children-of instance uuid/zero))
bool (first (children-of instance (:id group)))]
(t/is (= :bool (:type bool)))
(t/is (= [10 20 50 70] (selrect group)))
(t/is (= uuid/zero (:frame-id bool)))))
(t/deftest empty-bool-is-rejected
(let [data (failure-data #(b/fixture {:seed 1} (b/bool {:bool-type :union})))]
(t/is (= ::b/empty-bool (:type data)))))
(t/deftest caught-empty-bool-still-fails-finish
(let [state (run-scope {:seed 1}
(fn []
(try
(b/bool {:bool-type :union})
(catch :default _ nil))))]
(t/is (= ::b/unfinished-container
(:type (failure-data #(b/finish! state)))))))
(t/deftest bool-geometry-attrs-are-rejected
(doseq [attrs [{:bool-type :union :x 1}
{:bool-type :union :selrect {:x 0 :y 0 :width 1 :height 1}}
{:bool-type :union :content []}]]
(let [data (failure-data #(b/fixture {:seed 1} (b/bool attrs (b/rect))))]
(t/is (= ::b/bool-geometry (:type data)) (str attrs))
(t/is (contains? #{:x :selrect :content} (:key data)) (str attrs)))))
(t/deftest bool-transform-attrs-are-rejected
(doseq [attrs [{:bool-type :union :rotation 45}
{:bool-type :union :transform "m"}
{:bool-type :union :transform-inverse "m"}
{:bool-type :union :flip-x true}
{:bool-type :union :flip-y true}]]
(let [data (failure-data #(b/fixture {:seed 1} (b/bool attrs (b/rect))))]
(t/is (= ::b/bool-transform (:type data)) (str attrs)))))
(t/deftest bool-type-is-required-and-validated
(t/is (= ::b/bool-type
(:type (failure-data #(b/fixture {:seed 1} (b/bool {:name "B"} (b/rect)))))))
(let [data (failure-data #(b/fixture {:seed 1} (b/bool {:bool-type :merge} (b/rect))))]
(t/is (= ::b/bool-type (:type data)))
(t/is (= :merge (:bool-type data)))
(t/is (= cts/bool-types (:supported data)))))
(t/deftest frame-child-is-rejected
(let [data (failure-data
#(b/fixture {:seed 1}
(b/bool {:bool-type :union}
(b/frame {:x 0 :y 0 :width 10 :height 10}
(b/rect)))))]
(t/is (= ::b/frame-in-bool (:type data)))
(t/is (uuid? (:child data)))))
(t/deftest bool-generation-ignores-the-renderer-override
(let [original path/wasm:calc-bool-content]
(try
(set! path/wasm:calc-bool-content
(fn [& _]
(throw (ex-info "renderer override ran during generation"
{:type ::unexpected-renderer-call}))))
(let [instance (b/fixture {:seed 1}
(b/bool {:bool-type :difference}
(b/rect {:x 0 :y 0 :width 100 :height 100})
(b/rect {:x 50 :y 50 :width 100 :height 100})))
bool (first (children-of instance uuid/zero))]
(t/is (= [0 0 100 100] (selrect bool))))
(finally
(set! path/wasm:calc-bool-content original)))))
(t/deftest non-map-bool-attrs-are-rejected
(t/is (= ::b/invalid-attrs
(:type (failure-data #(b/fixture {:seed 1} (b/bool :hero (b/rect)))))))
(let [state (run-scope {:seed 1}
(fn []
(try
(b/bool :hero (b/rect))
(catch :default _ nil))))]
(t/is (= 1 (count (get-in @state [:objects uuid/zero :shapes]))))))