feat(transition,shape): silhouette and blob masks, feathered wipes, path trim and morph - #403
Merged
Merged
Conversation
…ipes, and shape draw_start/path_morph #383: generalises iris to an arbitrary silhouette. `mask` scales a `polygon`/`path` (reusing the clip-path `kind` vocabulary) about its own bounding-box centre from `from_scale` to `to_scale`, centred on `origin`. `blob` grows a procedural organic silhouette (lobes/wobble/seed) with its covering radius solved for automatically, the way iris's is, including a safety margin for wobble-shrunk lobes. `feather`+`band_color` add a soft, optionally tinted edge to `mask`/`blob` and the `wipe_*` family, built on a shared `composite_through_mask` (alpha-blurred mask, tint peaking at the alpha=0.5 boundary). Every one of these hard-short-circuits to the raw source/destination frame at progress<=0/>=1, matching zoom_blur/whip's "exactly zero at both ends" contract instead of an asymptotic fade. #377: draw_start (the other end of draw_progress's trim) and path_morph (keyframed `d`) land on `shape` specifically, as a component-local mechanism (`Computed<f32>` expr, and a small keyframe list) rather than through AnimatedProperties/animator.rs, which is owned by another workstream right now. Both are built on two new pure primitives in renderer/shapes.rs: `trim_path_between` (a real PathMeasure segment extraction, not a dash-effect hack — avoiding the zero-length-dash-paints- a-dot bug documented for line.rs/svg.rs) and `interpolate_path_data` (point-by-point lerp when two keyframes share the same verb structure; a structural mismatch is reported on stderr and holds the earlier shape, same precedent as clip_path_to_skia's node-path handling). Also patches 7 pre-existing test-only `Shape { .. }` struct literals in box_builder.rs/legacy_dispatch.rs (mechanical `draw_start: None, path_morph: None` additions) — required for the crate to compile once Shape grows new fields; nothing else in those files was touched. New tests: mask_blob_transitions.rs (18), path_geometry.rs (11), plus 5 inline in shape.rs — all proving the zero-at-both-ends and trim/interpolate contracts explicitly, several catching real bugs during development (a wobble-shrunk blob lobe under-covering the frame; a stale pointer from collecting skia's PathIterRec before reading its points()).
LeadcodeDev
added a commit
that referenced
this pull request
Sep 28, 2026
…ties (#404) Both were blocked on the same thing: a new field on `AnimatedProperties`, in a file another workstream owned while #363/#380 and #377 were in flight. Neither agent reached across the boundary, and one of them explicitly refused to add `letter_spacing` to `KNOWN_MOTION_PROPERTIES` on its own — that alone would have made it validate and stay inert, which is the exact defect #363 was about. **`letter_spacing`.** The intrinsic measurement keeps the static `style.letter-spacing`, so animating it does not re-run layout — the same policy every other layout property follows. The consequence is real and documented rather than hidden: a text whose spacing opens overflows its box. Two mechanisms now share a name. A `timeline` changing `letter-spacing` snaps, and the validator warns; a `keyframes` track on `letter_spacing` interpolates. Different subsystems, deliberately different behaviour, said plainly in the rule file. **`draw_start`.** `draw_progress` advances the head of a stroke; this advances its tail. Together they are a window travelling along the path — a line that runs rather than one that grows. On its own it erases the beginning of a finished stroke. `line` and `svg` both trim through `PathMeasure::get_segment`, the primitive #403 added, rather than a dash effect. That is what keeps a zero-length dash with a round cap from painting the stray dot #376 had to remove. `shape` keeps its own component-level `draw_start` from #403: it carries `path_morph` too, and the two belong together. Five tests. Three fail without the wiring — `tight=48px, loose=48px` for spacing, and a painted first half for the trim. Two assert equality and pass both ways on purpose: an absent animated value must leave the declared one alone, which is why the field defaults to NaN rather than zero. One probe stops six pixels short of the cut on purpose, with the reason in the assertion: a round cap extends half the stroke width back past the trim, which is the cap working, not the trim failing. Closes #380 Closes #377
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #383. Refs #377, #388 — #377 is partial by necessity, see below.
#383 — reveal through any silhouette
maskgrows an arbitrary polygon or SVG path about its own bounding-box centre;blobbuilds a smooth closed silhouette fromlobesnoise-perturbed points joined by quadratic curves.featherandband_colorare shared by both and by all four existingwipe_*through one new primitive that paints the boundary into anAlpha8mask, blurs it, and tints where the alpha is near 0.5.feather <= 0keeps the original hard-clip path byte-for-byte, so no existing scenario changes.Zero at both ends is a hard short-circuit before any silhouette or blur maths, like
zoom_blurandwhip: byte-identity with the raw frames atfrom_scale: 0.2, to_scale: 40, atlobes: 12, wobble: 0.6, and atfeather: 40with a band colour.A bug the tests caught, not the spec.
blobfirst usediris_max_radiusunscaled as its target, and a wobble-shrunk lobe could fall short of a frame corner — leaving a sliver of the outgoing scene visible:Fixed by dividing by
(1 − wobble). That auto-solve is not in the issue at all.Reused:
iris_max_radiusdirectly, theorigintype, and thekind: polygon|pathvocabulary fromclip_path_to_skia. Not reused:clip_path_to_skiaitself, because it resolves percentages against a layout box — a concept that does not exist for two already-painted frame buffers.shape-only, and stays openshape.draw_startandshape.path_morphwork, with two real primitives behind them:trim_path_betweenusingPathMeasure::get_segment(not a dash-effect hack, so it cannot reproduce the zero-length-dash round-cap dot that #376 just fixed inline.rs), andinterpolate_path_datalerping point-by-point when both keyframes share a verb sequence. A mismatch prints to stderr and holds the earlier shape — thenode-pathprecedent, not a snap.svg,line,arrowandconnectorstill have nodraw_start. That needs a newAnimatedPropertiesfield, in a file another workstream owned. So the issue's literal JSON — astyle.animationkeyframe on any drawable — is not what shipped, and #377 stays open for it.A real Rust bug worth recording
Path::iter()yieldsPathIterRec, which is self-referential: its.points()slice points into the Rec's own inline storage. CollectingVec<PathIterRec>and reading.points()afterwards reads dangling data — it surfaced asRect { left: 10.0, top: 0.0, right: 1.1598642e29, bottom: 10.0 }instead of the path's actual bounds. Fixed by extracting owned points inside the iterator'smap.Verification
34 new tests across two new files plus five inline.
cargo fmt --all --check,cargo clippy --workspace --all-targets -- -D warnings,cargo test --workspace(1713) all clean.Two things flagged rather than buried
mask'soriginis pixels, followingirisandzoom_blur, although the issue's example looks fractional. Documented as a deliberate deviation rather than a second inconsistent convention.Shape { .. }test literals inbox_builder.rsandlegacy_dispatch.rsneeded twoNonefields each — Rust struct literals are exhaustive and none used..Default::default(). Mechanical, outside the stated ownership, and named by the agent.Written comment-free, per the codebase-wide rule from #345.