This commit is contained in:
alonso.torres 2026-06-04 09:58:16 +02:00
parent f4844ba6c4
commit b529d4ed56
5 changed files with 118 additions and 2 deletions

View File

@ -0,0 +1,33 @@
# Degenerate (collapsed) shape geometry: points vs selrect
A shape can end up with a **degenerate `:points`** (collapsed to a zero-length basis — all corners
coincident on a line/point) while its `:selrect` still has a clamped non-zero size (e.g. width
`0.01`). This happens to auto-sized flex/grid containers that collapse to ~0 (e.g. after hiding all
their children). Such a shape is "frozen": you cannot resize it and it won't hug content, because
**scaling a zero-length basis stays zero** — applying any transform to its points is a no-op.
## Where this bites and the fixes
- Applying a transform/modifier to a shape: `app.common.geom.shapes.transforms/apply-transform-generic`
transforms the shape's `:points`. If they are degenerate it can never grow the shape. Fix: fall
back to `grc/rect->points (:selrect shape)` (a non-zero basis) when the points are degenerate, so
the transform lands and the points get repaired. This is the path the WASM renderer uses too:
`apply-wasm-modifiers` (frontend `data/workspace/modifiers.cljs`) computes the modifier in WASM,
then applies it on the CLJS side via `gsh/apply-transform``apply-transform-generic`.
- Layout auto-size content computation (SVG/CLJS path): `geom.bounds-map/objects->bounds-map`
derives a shape's bounds from `gco/shape->points` (its `:points`); a degenerate basis there makes
the projected content size ~0. Fix: fall back to the `selrect` when the points are degenerate.
## render-wasm is NOT affected by the degenerate-points part
render-wasm (`Shape::calculate_bounds` in `render-wasm/src/shapes.rs`) builds bounds from the
**`selrect`** (x/y/width/height), not from the stored points, so it computes the correct content
size and auto-resize scale even when the CLJS-side points are degenerate. Verified live via wasm
debug logging: for a collapsed auto container it reported `basis_x=0.01`, `auto_main=122`,
`after_w=122` — i.e. the WASM result was correct; the failure was purely the CLJS-side application
onto degenerate points. So a CLJS geometry bug here does NOT necessarily need a render-wasm change —
check which source (points vs selrect) each side uses before duplicating a fix.
The flex/grid + bounds geometry is still duplicated between CLJC (`app.common.geom.shapes.**`,
`geom/modifiers.cljc`) and Rust (`render-wasm/src/math.rs` `Bounds`, `src/shapes/modifiers/**`); keep
that in mind, but confirm a layer is actually broken before changing it. See `mem:render-wasm/core`.

View File

@ -9,6 +9,8 @@
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.files.helpers :as cfh]
[app.common.geom.point :as gpt]
[app.common.geom.rect :as grc]
[app.common.geom.shapes.common :as gco]
[app.common.geom.shapes.points :as gpo]
[app.common.geom.shapes.transforms :as gtr]
@ -16,11 +18,31 @@
[app.common.types.modifiers :as ctm]
[app.common.uuid :as uuid]))
(defn- degenerate-points?
"True when the points have a zero-length basis (their bounds collapsed to a line or a
point), so they can't be used as a coordinate frame for layout."
[[p1 p2 _ p4]]
(and (some? p1) (some? p2) (some? p4)
(or (mth/almost-zero? (gpt/length (gpt/to-vec p1 p2)))
(mth/almost-zero? (gpt/length (gpt/to-vec p1 p4))))))
(defn- shape->bounds-points
[{:keys [selrect] :as shape}]
(let [points (gco/shape->points shape)]
;; A collapsed basis (produced e.g. by hiding every child of an auto-sized layout) makes
;; the bounds unusable as a coordinate frame and prevents the layout from recovering its
;; size on reflow: the auto-size is reapplied as a scale, and a zero basis can't be scaled
;; back up. Fall back to the selrect (whose dimensions are clamped to a minimum) so the
;; next reflow can recompute the real size from the visible children.
(if (and (some? selrect) (degenerate-points? points))
(grc/rect->points selrect)
points)))
(defn objects->bounds-map
[objects]
(d/lazy-map
(keys objects)
#(gco/shape->points (get objects %))))
#(shape->bounds-points (get objects %))))
(defn- create-bounds
"Create the bounds object for the current shape in this context"

View File

@ -335,11 +335,31 @@
(assoc :points points))))
(defn- degenerate-points?
"True when the points collapsed to a zero-length basis (a line or a point), so they
can't be used as the source of a transform."
[[p1 p2 _ p4]]
(and (some? p1) (some? p2) (some? p4)
(or (mth/almost-zero? (gpt/length (gpt/to-vec p1 p2)))
(mth/almost-zero? (gpt/length (gpt/to-vec p1 p4))))))
(defn- apply-transform-generic
"Given a new set of points transformed, set up the rectangle so it keeps
its properties. We adjust de x,y,width,height and create a custom transform"
[shape transform-mtx]
(let [points (-> (dm/get-prop shape :points)
(let [shape-selrect (dm/get-prop shape :selrect)
stored-points (dm/get-prop shape :points)
;; A shape whose points collapsed to a zero-length basis (e.g. a layout container that
;; was collapsed to ~0 size) can't be transformed: scaling a degenerate basis keeps it
;; degenerate, so resizes and auto-size recoveries have no effect. Fall back to the
;; selrect, which keeps a non-zero basis, so the transform applies and the points get
;; repaired.
base-points (if (and (some? shape-selrect) (degenerate-points? stored-points))
(grc/rect->points shape-selrect)
stored-points)
points (-> base-points
(gco/transform-points transform-mtx))
shape (adjust-shape-flips shape points)

View File

@ -414,3 +414,22 @@
;; Width and height should be clamped to at least 0.01
(t/is (>= (gpo/width-points bounds) 0.01))
(t/is (>= (gpo/height-points bounds) 0.01))))))
(t/deftest objects->bounds-map-degenerate-points-falls-back-to-selrect-test
(t/testing "A shape whose points collapsed to a zero-length basis falls back to its selrect"
;; A collapsed basis (e.g. produced by hiding every child of an auto-sized layout) cannot
;; be used as a coordinate frame: the auto-size is reapplied as a scale and a zero basis
;; can't be scaled back up. The bounds-map must derive usable points from the selrect so the
;; next reflow can recover the real size.
(let [id (uuid/next)
shape (-> (make-rect id 10 20 30 40)
;; horizontal basis collapsed: all points share the same x
(assoc :points [(gpt/point 10 20) (gpt/point 10 20)
(gpt/point 10 60) (gpt/point 10 60)]))
bm (gbm/objects->bounds-map {id shape})
bounds @(get bm id)]
;; basis recovered from the selrect (30x40), not the collapsed points (0 width)
(t/is (mth/close? 30.0 (gpo/width-points bounds)))
(t/is (mth/close? 40.0 (gpo/height-points bounds)))
(t/is (mth/close? 10.0 (:x (gpo/origin bounds))))
(t/is (mth/close? 20.0 (:y (gpo/origin bounds)))))))

View File

@ -271,3 +271,25 @@
(t/is (true? (:flip-y result)))
;; rotation must not be negated when both axes are flipped
(t/is (mth/close? 30 (:rotation result))))))
;; ---- transform recovery for degenerate (collapsed) points ----
(t/deftest resize-recovers-shape-with-degenerate-points
(t/testing "Resizing a shape whose points collapsed to a zero-length basis recovers its size"
;; Simulate a collapsed layout container: selrect keeps a (clamped) 0.01 width, but the
;; points degenerated to a vertical line (zero horizontal basis). A resize transform must
;; still grow it, instead of staying collapsed (scaling a zero basis stays zero).
(let [base (create-test-shape :rect {})
x (get-in base [:selrect :x])
y (get-in base [:selrect :y])
h (get-in base [:selrect :height])
shape (assoc base
:selrect (grc/make-rect x y 0.01 h)
:width 0.01
:points [(gpt/point x y) (gpt/point x y)
(gpt/point x (+ y h)) (gpt/point x (+ y h))])
;; scale x by 100/0.01 around the left edge -> target width 100
mods (ctm/resize-modifiers (gpt/point (/ 100 0.01) 1) (gpt/point x y))
result (gsh/transform-shape shape mods)]
(t/is (mth/close? 100 (:width result) 0.5))
(t/is (mth/close? h (:height result) 0.5)))))