From 4fab402e29236a3f02fb273e210ec74b200f1c7c Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Sat, 26 Sep 2026 23:58:31 +0200 Subject: [PATCH] chore: remove every comment from the Rust sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 16 355 comments removed across 250 files: every `//` and every `//!` is now at zero, and the 1 671 `///` that remain are the ones clap and schemars read. Those are not documentation, they are interface text — removing them would not delete a comment, it would empty `rustmotion --help` and 1 223 descriptions out of the exported schema, which `validate --strict-attrs` consumes. Done by a lexer, not by regex. `"https://example.com/a//b"` contains `//`, and a raw string can hold `/* anything */`, so the stripper tracks string, raw-string, char and block-comment state instead of matching lines. Two invariants pinned it, captured from a binary built before the change and diffed against one built after: - `rustmotion schema` — byte-identical, 1 223 descriptions preserved - `rustmotion --help` plus all twelve subcommands — byte-identical Both caught real losses on the first attempt, which is the reason they were captured. Two structural faults in the detector: - a multi-line attribute. `#[derive(Parser)]` followed by a `#[command(\n name = "rustmotion",\n …\n)]` spanning five lines: the inner lines match neither "attribute" nor "doc comment", so they were read as code and cleared the pending derive list before `struct Cli` was reached. Every global option lost its help text — `-q, --quiet` printed with an empty description. - braces inside string literals. Comments were blanked before counting brace depth, but literals were not, so a `{` in an `about = "…"` or a `format!` shifted the depth that decides where a protected body ends, and fields near the end of a struct fell outside it. Three consequences that are not cosmetic. **Five doctests were tests, and they lived in doc comments.** `VarScope`, `build_css2_url`, `parse_ttf_urls`, `family_slug` and `expr`'s module example have been ported to `#[test]` functions in the `mod tests` each file already had. Test count is unchanged: 1510 by default, 1701 with `--features studio`. **Two `if` chains turned out to have identical branches.** Only the comments distinguished them; the code never did. let depth_sin = if tilt_rad.abs() > 0.01 { // With tilt, depth is based on the untilted Y theta.sin() } else { // Without tilt, use Y component for depth theta.sin() }; `stepper.rs` had the same shape: "Filled circle for active" and "Filled circle for completed" are the same call. Both collapsed to exactly what they already computed — no rendering changes — which makes the defect visible instead of hidden behind a comment that claimed a distinction. Worth a second look by whoever knows what the tilt branch was meant to do. **One flaky test, pre-existing, surfaced by running the suite.** `annotation_id()` derived an id from `SystemTime::now().as_nanos()` alone, so two annotations minted inside one clock tick collided — a real defect in the studio, not just a capricious test. It now carries an atomic tiebreaker. Unrelated to comment removal: the code is byte-identical to before it, and the studio already built at `opt-level = 3`. 40 consecutive runs green. CLAUDE.md now states the rule for the whole codebase, names the clap/schemars exception as interface text rather than an exemption, and records that writing an executable example from here on means writing a test. --- crates/rustmotion-components/src/arrow.rs | 9 +- .../src/audio_spectrum.rs | 3 - crates/rustmotion-components/src/avatar.rs | 5 - .../rustmotion-components/src/avatar_group.rs | 9 - crates/rustmotion-components/src/badge.rs | 34 - .../rustmotion-components/src/box_builder.rs | 824 +---------- crates/rustmotion-components/src/callout.rs | 15 - crates/rustmotion-components/src/caption.rs | 139 +- .../rustmotion-components/src/chart/axes.rs | 24 - crates/rustmotion-components/src/chart/bar.rs | 86 -- .../rustmotion-components/src/chart/funnel.rs | 2 - .../rustmotion-components/src/chart/line.rs | 21 - crates/rustmotion-components/src/chart/mod.rs | 21 - crates/rustmotion-components/src/chart/pie.rs | 19 - .../rustmotion-components/src/chart/radar.rs | 15 - .../rustmotion-components/src/chart/radial.rs | 5 - .../src/chart/scatter.rs | 6 - .../src/chart/waterfall.rs | 6 - .../rustmotion-components/src/comparison.rs | 1 - crates/rustmotion-components/src/connector.rs | 2 - crates/rustmotion-components/src/countdown.rs | 26 +- crates/rustmotion-components/src/counter.rs | 33 - crates/rustmotion-components/src/cursor.rs | 25 - crates/rustmotion-components/src/dot_map.rs | 26 +- crates/rustmotion-components/src/gauge.rs | 6 - crates/rustmotion-components/src/gif.rs | 98 +- .../src/gradient_text.rs | 86 +- crates/rustmotion-components/src/heatmap.rs | 32 - crates/rustmotion-components/src/icon.rs | 22 - crates/rustmotion-components/src/intrinsic.rs | 384 +----- crates/rustmotion-components/src/kbd.rs | 37 +- .../src/legacy_dispatch.rs | 97 +- crates/rustmotion-components/src/lib.rs | 107 -- crates/rustmotion-components/src/line.rs | 2 - crates/rustmotion-components/src/list.rs | 8 - crates/rustmotion-components/src/lottie.rs | 97 -- crates/rustmotion-components/src/marquee.rs | 11 - crates/rustmotion-components/src/mockup.rs | 19 - .../rustmotion-components/src/number_wheel.rs | 35 +- crates/rustmotion-components/src/particle.rs | 2 - crates/rustmotion-components/src/pill_nav.rs | 12 - crates/rustmotion-components/src/pointer.rs | 29 - crates/rustmotion-components/src/progress.rs | 27 - crates/rustmotion-components/src/rich_text.rs | 70 - crates/rustmotion-components/src/shape.rs | 81 -- crates/rustmotion-components/src/skeleton.rs | 3 - crates/rustmotion-components/src/slider.rs | 19 - crates/rustmotion-components/src/sparkline.rs | 19 - crates/rustmotion-components/src/stat.rs | 48 - crates/rustmotion-components/src/stepper.rs | 22 +- .../src/success_check.rs | 27 - crates/rustmotion-components/src/svg.rs | 81 +- crates/rustmotion-components/src/switch.rs | 16 - crates/rustmotion-components/src/table.rs | 49 - crates/rustmotion-components/src/tag_cloud.rs | 11 - crates/rustmotion-components/src/text.rs | 323 +---- crates/rustmotion-components/src/timeline.rs | 34 +- crates/rustmotion-components/src/tooltip.rs | 15 - crates/rustmotion-components/src/treemap.rs | 29 - crates/rustmotion-components/src/video.rs | 35 - crates/rustmotion-components/src/waveform.rs | 4 - .../rustmotion-components/src/world_bitmap.rs | 855 +++++------- .../rustmotion-components/tests/audit_ws_d.rs | 63 - .../rustmotion-components/tests/audit_ws_i.rs | 111 -- .../tests/caption_presets.rs | 39 - .../tests/degenerate_inputs.rs | 25 - .../html_css_mental_model_margin_padding.rs | 24 - .../tests/relative_font_size.rs | 20 - .../tests/text_autofit.rs | 27 - .../tests/transition_interpolation.rs | 36 +- crates/rustmotion-core/src/audio/dsp.rs | 74 - crates/rustmotion-core/src/audio/mod.rs | 22 - crates/rustmotion-core/src/audio/score.rs | 31 - crates/rustmotion-core/src/audio/synth.rs | 37 - crates/rustmotion-core/src/audio/voices.rs | 18 - crates/rustmotion-core/src/css/animation.rs | 62 - crates/rustmotion-core/src/css/cascade.rs | 26 - crates/rustmotion-core/src/css/computed.rs | 175 +-- crates/rustmotion-core/src/css/mod.rs | 11 - crates/rustmotion-core/src/css/style.rs | 308 +---- .../rustmotion-core/src/css/taffy_bridge.rs | 116 +- crates/rustmotion-core/src/css/units.rs | 96 -- crates/rustmotion-core/src/engine/animator.rs | 731 +--------- crates/rustmotion-core/src/engine/box_tree.rs | 34 - crates/rustmotion-core/src/engine/deps.rs | 231 ---- .../src/engine/heropatterns.rs | 3 - .../rustmotion-core/src/engine/layout_pass.rs | 40 - .../rustmotion-core/src/engine/paint_pass.rs | 500 +------ .../src/engine/renderer/assets.rs | 183 --- .../src/engine/renderer/audio_analysis.rs | 22 - .../src/engine/renderer/colors.rs | 94 -- .../src/engine/renderer/fonts.rs | 123 +- .../src/engine/renderer/google_fonts.rs | 130 +- .../src/engine/renderer/shapes.rs | 1 - .../src/engine/renderer/text.rs | 358 +---- .../src/engine/renderer/yuv.rs | 4 - crates/rustmotion-core/src/engine/shake.rs | 48 +- .../rustmotion-core/src/engine/text/cosmic.rs | 51 +- crates/rustmotion-core/src/engine/text/mod.rs | 2 - .../rustmotion-core/src/engine/transition.rs | 201 +-- crates/rustmotion-core/src/error.rs | 25 - crates/rustmotion-core/src/expand.rs | 269 ---- crates/rustmotion-core/src/expr/ast.rs | 20 - crates/rustmotion-core/src/expr/builtins.rs | 51 - crates/rustmotion-core/src/expr/eval.rs | 48 +- crates/rustmotion-core/src/expr/lexer.rs | 24 - crates/rustmotion-core/src/expr/mod.rs | 174 +-- crates/rustmotion-core/src/expr/parser.rs | 40 - crates/rustmotion-core/src/macros.rs | 13 - .../rustmotion-core/src/schema/animation.rs | 21 - .../rustmotion-core/src/schema/background.rs | 94 -- crates/rustmotion-core/src/schema/scenario.rs | 157 +-- crates/rustmotion-core/src/schema/shake.rs | 10 - crates/rustmotion-core/src/schema/style.rs | 3 - crates/rustmotion-core/src/schema/time.rs | 95 +- crates/rustmotion-core/src/schema/video.rs | 151 -- .../rustmotion-core/src/traits/animatable.rs | 3 - .../src/traits/backgrounded.rs | 3 - crates/rustmotion-core/src/traits/bordered.rs | 3 - crates/rustmotion-core/src/traits/clipped.rs | 1 - crates/rustmotion-core/src/traits/painter.rs | 31 - crates/rustmotion-core/src/traits/rounded.rs | 3 - crates/rustmotion-core/src/traits/shadowed.rs | 3 - crates/rustmotion-core/src/traits/styled.rs | 3 - crates/rustmotion-core/src/traits/timed.rs | 1 - crates/rustmotion-core/src/variables.rs | 147 +- crates/rustmotion-core/src/vars/mod.rs | 104 -- crates/rustmotion-core/src/vars/schema.rs | 33 - crates/rustmotion-core/src/vars/scope.rs | 107 +- crates/rustmotion-core/src/vars/track.rs | 93 +- crates/rustmotion-core/tests/audit_ws_a.rs | 34 - crates/rustmotion-core/tests/audit_ws_h.rs | 25 - crates/rustmotion-core/tests/audit_ws_j.rs | 20 - crates/rustmotion-core/tests/audit_ws_k.rs | 47 - .../rustmotion-core/tests/chromatic_wipe.rs | 18 - .../tests/exported_schema_examples.rs | 41 +- .../rustmotion-core/tests/spring_duration.rs | 13 - crates/rustmotion-html/src/element.rs | 49 - crates/rustmotion-html/src/lib.rs | 179 +-- crates/rustmotion-html/src/scene.rs | 23 - crates/rustmotion-html/src/style.rs | 54 - crates/rustmotion-html/tests/audit_ws_f.rs | 29 - .../tests/silent_loss_regressions.rs | 74 - crates/rustmotion/CLAUDE.md | 33 +- crates/rustmotion/build.rs | 40 +- crates/rustmotion/src/assets.rs | 40 - crates/rustmotion/src/cli/claude_md.rs | 22 - .../src/cli/commands/audio_report.rs | 106 -- crates/rustmotion/src/cli/commands/batch.rs | 194 +-- .../rustmotion/src/cli/commands/captions.rs | 84 -- .../rustmotion/src/cli/commands/geometry.rs | 1226 +---------------- crates/rustmotion/src/cli/commands/info.rs | 69 - crates/rustmotion/src/cli/commands/migrate.rs | 88 -- crates/rustmotion/src/cli/commands/render.rs | 62 - crates/rustmotion/src/cli/commands/schema.rs | 64 - crates/rustmotion/src/cli/commands/sheet.rs | 70 +- crates/rustmotion/src/cli/commands/still.rs | 55 - .../rustmotion/src/cli/commands/validate.rs | 216 +-- .../src/cli/commands/validate_attrs.rs | 92 +- .../src/cli/commands/validate_schema.rs | 307 +---- .../rustmotion/src/cli/commands/validation.rs | 267 ---- crates/rustmotion/src/cli/mod.rs | 65 +- crates/rustmotion/src/cli/skills.rs | 38 - crates/rustmotion/src/cli/tui.rs | 24 +- crates/rustmotion/src/encode/audio.rs | 248 +--- .../rustmotion/src/encode/audio_analysis.rs | 45 - crates/rustmotion/src/encode/video/ffmpeg.rs | 314 +---- crates/rustmotion/src/encode/video/formats.rs | 150 -- crates/rustmotion/src/encode/video/h264.rs | 61 +- crates/rustmotion/src/encode/video/mod.rs | 4 - crates/rustmotion/src/encode/video/mux.rs | 17 - crates/rustmotion/src/encode/video/tasks.rs | 344 +---- crates/rustmotion/src/encode/video_audio.rs | 236 +--- crates/rustmotion/src/engine/preload.rs | 63 - .../src/engine/render/background.rs | 286 +--- .../src/engine/render/canvas_guard.rs | 15 - .../src/engine/render/post_effects.rs | 109 -- crates/rustmotion/src/engine/render/scene.rs | 455 ------ crates/rustmotion/src/engine/world.rs | 157 --- crates/rustmotion/src/include.rs | 81 -- crates/rustmotion/src/lib.rs | 12 - crates/rustmotion/src/loader.rs | 269 ---- .../src/studio/editor/annotations.rs | 8 +- crates/rustmotion/src/tests.rs | 525 +------ crates/rustmotion/tests/animated_box_size.rs | 31 +- crates/rustmotion/tests/audit_ws_b.rs | 88 +- crates/rustmotion/tests/audit_ws_c.rs | 65 - crates/rustmotion/tests/audit_ws_d.rs | 54 - crates/rustmotion/tests/audit_ws_e.rs | 34 - crates/rustmotion/tests/audit_ws_h.rs | 17 - .../rustmotion/tests/deep_scenario_stack.rs | 9 - crates/rustmotion/tests/include_vars.rs | 23 - .../tests/motion_path_strict_anim.rs | 65 +- crates/rustmotion/tests/node_effects_cost.rs | 51 - .../tests/node_effects_leak_proof.rs | 34 - crates/rustmotion/tests/shimmer_effect.rs | 25 - .../tests/skill_files_match_disk.rs | 30 - .../rustmotion/tests/templates_iteration.rs | 42 - crates/rustmotion/tests/viewport_units.rs | 18 - 199 files changed, 652 insertions(+), 17116 deletions(-) diff --git a/crates/rustmotion-components/src/arrow.rs b/crates/rustmotion-components/src/arrow.rs index e585886e..be33a055 100644 --- a/crates/rustmotion-components/src/arrow.rs +++ b/crates/rustmotion-components/src/arrow.rs @@ -85,37 +85,30 @@ rustmotion_core::impl_traits!(Arrow { }); impl Arrow { - /// Build the bezier path for this arrow (without arrowheads). fn build_path(&self) -> Path { let mut path = PathBuilder::new(); path.move_to((self.x1, self.y1)); if let (Some(cp1), Some(cp2)) = (&self.cp1, &self.cp2) { - // Cubic bezier path.cubic_to((cp1.x, cp1.y), (cp2.x, cp2.y), (self.x2, self.y2)); } else if let Some(cp) = &self.cp { - // Quadratic bezier path.quad_to((cp.x, cp.y), (self.x2, self.y2)); } else if let Some(curve) = self.curve { - // Auto-generate a quadratic control point based on curve intensity let mid_x = (self.x1 + self.x2) / 2.0; let mid_y = (self.y1 + self.y2) / 2.0; let dx = self.x2 - self.x1; let dy = self.y2 - self.y1; let len = (dx * dx + dy * dy).sqrt(); - // Perpendicular offset let perp_x = -dy / len * curve * len * 0.3; let perp_y = dx / len * curve * len * 0.3; path.quad_to((mid_x + perp_x, mid_y + perp_y), (self.x2, self.y2)); } else { - // Straight line path.line_to((self.x2, self.y2)); } path.detach() } - /// Draw an arrowhead at the given position along the path. fn draw_arrowhead( canvas: &Canvas, path: &Path, @@ -144,7 +137,7 @@ impl Arrow { }; let angle = tangent.y.atan2(tangent.x); - let half_angle = std::f32::consts::PI / 6.0; // 30 degrees + let half_angle = std::f32::consts::PI / 6.0; let mut arrow_path = PathBuilder::new(); arrow_path.move_to(pos); diff --git a/crates/rustmotion-components/src/audio_spectrum.rs b/crates/rustmotion-components/src/audio_spectrum.rs index 6e361858..b4fd1a18 100644 --- a/crates/rustmotion-components/src/audio_spectrum.rs +++ b/crates/rustmotion-components/src/audio_spectrum.rs @@ -78,13 +78,10 @@ impl AudioSpectrum { let n = self.bars.max(1) as usize; - // Empty cache / missing track → all-zero values; the painter clamps - // each bar to `min_height`, so this degrades to a flat baseline. let Some(analysis) = analysis else { return vec![0.0; n]; }; - // Resample from 16 bands to n bars let num_bands = 16usize; (0..n) .map(|i| { diff --git a/crates/rustmotion-components/src/avatar.rs b/crates/rustmotion-components/src/avatar.rs index 7a358133..ed491799 100644 --- a/crates/rustmotion-components/src/avatar.rs +++ b/crates/rustmotion-components/src/avatar.rs @@ -79,7 +79,6 @@ impl Avatar { let w = layout_w; let h = layout_h; - // Load image let cache = asset_cache(); let img = if let Some(cached) = cache.get(&self.src) { cached.clone() @@ -98,7 +97,6 @@ impl Avatar { decoded }; - // Circular clip + draw image with cover fit let oval_rect = Rect::from_xywh(0.0, 0.0, w, h); let oval_rrect = RRect::new_oval(oval_rect); @@ -118,7 +116,6 @@ impl Avatar { canvas.restore(); - // Border let border_width = self.border_width.unwrap_or(0.0); if border_width > 0.0 { if let Some(border_color) = &self.border_color { @@ -133,7 +130,6 @@ impl Avatar { } } - // Status indicator if !matches!(self.status, AvatarStatus::NoStatus) { let dot_radius = w * 0.15; let dot_color = self @@ -149,7 +145,6 @@ impl Avatar { let cy = h - dot_radius; canvas.draw_circle((cx, cy), dot_radius, &dot_paint); - // White border around dot let mut border_paint = paint_from_hex("#FFFFFF"); border_paint.set_style(PaintStyle::Stroke); border_paint.set_stroke_width(2.0); diff --git a/crates/rustmotion-components/src/avatar_group.rs b/crates/rustmotion-components/src/avatar_group.rs index 09c3ce57..ef86b98e 100644 --- a/crates/rustmotion-components/src/avatar_group.rs +++ b/crates/rustmotion-components/src/avatar_group.rs @@ -93,18 +93,15 @@ impl AvatarGroup { let overflow = self.overflow_count(); let cache = asset_cache(); - // Draw avatars in reverse order so first avatar is on top for rev_i in (0..visible).rev() { let avatar = &self.avatars[rev_i]; let x = rev_i as f32 * step; - // Border circle (background ring) let mut border_paint = paint_from_hex(&self.border_color); border_paint.set_style(PaintStyle::Fill); border_paint.set_anti_alias(true); canvas.draw_circle((x + s / 2.0, s / 2.0), s / 2.0, &border_paint); - // Load image let img = if let Some(cached) = cache.get(&avatar.src) { cached.clone() } else { @@ -122,7 +119,6 @@ impl AvatarGroup { decoded }; - // Clip to circle inset by border_width let inset = self.border_width; let inner_r = s / 2.0 - inset; let cx = x + s / 2.0; @@ -135,7 +131,6 @@ impl AvatarGroup { canvas.save(); canvas.clip_rrect(clip_rrect, skia_safe::ClipOp::Intersect, true); - // Draw image with cover fit let img_w = img.width() as f32; let img_h = img.height() as f32; let d = inner_r * 2.0; @@ -150,26 +145,22 @@ impl AvatarGroup { canvas.restore(); } - // "+N" overflow badge if overflow > 0 { let x = visible as f32 * step; let cx = x + s / 2.0; let cy = s / 2.0; - // Background circle let mut bg_paint = paint_from_hex("#374151"); bg_paint.set_style(PaintStyle::Fill); bg_paint.set_anti_alias(true); canvas.draw_circle((cx, cy), s / 2.0, &bg_paint); - // Border let mut border_paint = paint_from_hex(&self.border_color); border_paint.set_style(PaintStyle::Stroke); border_paint.set_stroke_width(self.border_width); border_paint.set_anti_alias(true); canvas.draw_circle((cx, cy), s / 2.0 - self.border_width / 2.0, &border_paint); - // Text let text = format!("+{}", overflow); let font_size = s * 0.35; let font_style = skia_safe::FontStyle::bold(); diff --git a/crates/rustmotion-components/src/badge.rs b/crates/rustmotion-components/src/badge.rs index f9fa9c54..bcc52ebb 100644 --- a/crates/rustmotion-components/src/badge.rs +++ b/crates/rustmotion-components/src/badge.rs @@ -34,7 +34,6 @@ pub enum BadgeSize { impl BadgeSize { fn params(&self) -> (f32, f32, f32, f32) { - // (font_size, h_padding, v_padding, icon_size) match self { BadgeSize::Sm => (12.0, 8.0, 4.0, 14.0), BadgeSize::Md => (14.0, 12.0, 6.0, 18.0), @@ -99,9 +98,6 @@ fn default_badge_style() -> CssStyle { } } -/// Deserializes `style` normally, then defaults `align-self` to -/// `flex-start` when the author didn't set it explicitly — see the doc -/// comment on [`Badge::style`]. fn deserialize_no_stretch_style<'de, D>(deserializer: D) -> Result where D: serde::Deserializer<'de>, @@ -120,10 +116,6 @@ rustmotion_core::impl_traits!(Badge { }); impl Badge { - /// Resolves `font-size` against a real per-frame viewport (`rem`/`vw`/ - /// `vh` now resolve instead of silently dropping to 0px — lot B, wave - /// S). `em`/`%` on `font-size` itself remain approximate — see - /// `crate::intrinsic::font_size_ctx`'s doc comment. fn resolved_font_size(&self, ctx: &PaintCtx) -> f32 { self.style.font_size_px_ctx( &crate::intrinsic::font_size_ctx(ctx.video_width as f32, ctx.video_height as f32, 0.0), @@ -131,9 +123,6 @@ impl Badge { ) } - /// Returns (h_padding, v_padding, icon_size) scaled proportionally - /// to the resolved font size. If style.font_size overrides the default, - /// padding and icon scale with it. fn resolved_params(&self, ctx: &PaintCtx) -> (f32, f32, f32) { let (default_fs, h_pad, v_pad, icon_size) = self.badge_size.params(); let actual_fs = self.resolved_font_size(ctx); @@ -161,7 +150,6 @@ impl Badge { let h = layout_h; let radius = h / 2.0; - // Background / outline let rect = Rect::from_xywh(0.0, 0.0, w, h); let rrect = RRect::new_rect_xy(rect, radius, radius); @@ -180,7 +168,6 @@ impl Badge { } } - // Icon let mut x_offset = h_pad; if let Some(icon_id) = &self.icon { let icon_color = if matches!(self.variant, BadgeVariant::Solid) { @@ -243,7 +230,6 @@ impl Badge { x_offset += icon_size + 6.0 * ratio; } - // Text let text_color = if matches!(self.variant, BadgeVariant::Solid) { "#FFFFFF" } else { @@ -259,7 +245,6 @@ impl Badge { let (_, metrics) = font.metrics(); let ascent = -metrics.ascent; - // Use cap height for visual centering (excludes descenders like g, p, y) let cap_h = if metrics.cap_height > 0.0 { metrics.cap_height } else { @@ -278,14 +263,12 @@ impl Badge { &text_paint, ); - // Dot indicator (top-right) if self.dot { let dot_r = font_size * 0.3; let dot_cx = w - dot_r * 0.5; let dot_cy = dot_r * 0.5; let dot_color = self.dot_color.as_deref().unwrap_or(color); - // Pulse ring animation if self.pulse { let phase = (time * 2.0).fract() as f32; let pulse_r = dot_r * (1.0 + phase * 1.5); @@ -303,7 +286,6 @@ impl Badge { canvas.draw_circle((dot_cx, dot_cy), dot_r, &dot_paint); } - // Count badge (top-right, outside bounds) if let Some(count) = self.count { let count_text = if count > 99 { "99+".to_string() @@ -327,7 +309,6 @@ impl Badge { let badge_x = w - badge_w * 0.5; let badge_y = -badge_h * 0.3; - // Red background pill let badge_rect = Rect::from_xywh(badge_x, badge_y, badge_w, badge_h); let badge_rrect = RRect::new_rect_xy(badge_rect, badge_h / 2.0, badge_h / 2.0); let mut count_bg = paint_from_hex("#EF4444"); @@ -335,7 +316,6 @@ impl Badge { count_bg.set_anti_alias(true); canvas.draw_rrect(badge_rrect, &count_bg); - // Count text let mut count_paint = paint_from_hex("#FFFFFF"); count_paint.set_anti_alias(true); let (_, count_metrics) = count_font.metrics(); @@ -377,20 +357,12 @@ mod tests { #[test] fn style_defaults_to_flex_start_when_absent() { - // #127: `align-items: stretch` (the flex column default) was - // winning over `BadgeIntrinsic`, stretching every badge in a card - // to the container's full width. No `style` key at all in the - // JSON is the common case — this must still default away from - // stretch. let badge = parse(r#"{"type":"badge","text":"v1"}"#); assert_eq!(badge.style.align_self, Some(AlignSelf::FlexStart)); } #[test] fn style_defaults_to_flex_start_with_other_style_keys_present() { - // Same fix, but exercised through the `deserialize_with` path - // (some `style` object present, just not `align-self`) rather - // than the field-level `default` path (`style` entirely absent). let badge = parse(r##"{"type":"badge","text":"v1","style":{"background":"#f00"}}"##); assert_eq!(badge.style.align_self, Some(AlignSelf::FlexStart)); assert_eq!(badge.style.background_color_str(), Some("#f00")); @@ -402,8 +374,6 @@ mod tests { assert_eq!(badge.style.align_self, Some(AlignSelf::Center)); } - // ─── Lot B, wave S: relative `font-size` units ───────────────────────── - fn test_ctx() -> PaintCtx { PaintCtx { time: 0.0, @@ -419,8 +389,6 @@ mod tests { #[test] fn rem_font_size_paints_visible_ink() { - // Reproduction: `font-size: "2rem"` used to resolve to 0px via the - // context-free `font_size_px_or`. let mut badge = parse(r#"{"type":"badge","text":"v1"}"#); badge.style.font_size = Some(rustmotion_core::css::Length::String("2rem".into())); const W: i32 = 200; @@ -446,8 +414,6 @@ mod tests { skia_safe::image::CachingHint::Disallow, ); assert!(ok, "pixel read should succeed"); - // Solid variant text is always white — probe for white ink - // specifically, since the pill background paints regardless. let text_ink = buf .as_chunks::<4>() .0 diff --git a/crates/rustmotion-components/src/box_builder.rs b/crates/rustmotion-components/src/box_builder.rs index 4f5fe861..37bb4031 100644 --- a/crates/rustmotion-components/src/box_builder.rs +++ b/crates/rustmotion-components/src/box_builder.rs @@ -1,19 +1,3 @@ -//! Bridge from the `Component` tree to the new `BoxNode` tree. -//! -//! Each `ChildComponent` becomes one `BoxNode`. The component's -//! `style: CssStyle` is augmented with: -//! - `position: absolute` + `top` / `left` when `child.position` is set -//! - `width` / `height` from the component's `size` field (if any) -//! - `z-index` from the child's `z_index` field -//! -//! The single container component (`Component::Container`, tagged `div` and -//! aliased `container`/`card`/`flex`/`grid`/`positioned` — all six spellings -//! deserialize into the same struct) recursively builds child boxes. Leaf -//! components produce an empty-children box that the dispatcher will paint. -//! -//! The builder also returns a flat `Vec<&Component>` indexed by NodeId so -//! the painter can resolve a node back to its component. - use std::sync::Arc; use rustmotion_core::css::style::{AlignSelf, CssStyle, Position, Size as CSize}; @@ -34,62 +18,27 @@ use crate::timeline::TimelineDirection; use crate::tooltip::TooltipArrow; use crate::{ChildComponent, Component}; -/// Frame-level context passed into the builder so animations can be resolved -/// per-node and merged into the resulting `CssStyle`. When `None`, the box -/// tree is built without any animation overrides (resting state). #[derive(Debug, Clone, Copy)] pub struct BuildAnimationCtx { pub time: f64, - /// Seconds since the scenario started, as opposed to `time`, which - /// restarts at every scene. Only the `audio-reactive` binding reads it: - /// the audio analysis is indexed on the scenario's timeline, so using - /// `time` gave a scene starting at t=73 s the analysis at 73 s *into that - /// scene*. Every animation stays on `time`, which is what a delay, a - /// stagger or a preset is written against. pub scenario_time: f64, pub scene_duration: f64, - /// Frames per second of the output video. Required to convert the - /// `shutter` fraction (in `MotionBlurConfig`) into an absolute temporal - /// offset: `shutter_window = shutter / fps` seconds. pub fps: u32, } -/// Padding allowance to keep arrow/connector heads inside the box. const ARROW_BBOX_PADDING: f32 = 16.0; -/// Result of building a box tree from a scene description. pub struct BuiltScene<'a> { - /// Root box (a flex column container at viewport dimensions). pub root: BoxNode, - /// Lookup table — `components[id as usize]` is the component for `id`. - /// `None` for synthetic boxes (the root scene wrapper). pub components: Vec>, - /// Per-node animation delay: ancestor containers' `stagger` plus the - /// node's own `start_at` (indexed like `components`). Consumed by the - /// paint dispatcher so internal animations shift by the same amount as - /// the CSS overrides resolved at build time — and so an entrance/exit - /// animation on a `start_at`ed node plays from its own first keyframe - /// instead of one already resolved at the untouched scene clock. pub stagger_delays: Vec, - /// Per-node affine time remap accumulated from ancestor containers' - /// `time_scale`/`time_offset`. Entry `i` is `(scale, shift)` where - /// `t_local = scale * t_global + shift`. Default `(1.0, 0.0)` = identity. - /// Indexed like `components` and `stagger_delays`. pub time_params: Vec<(f64, f64)>, } -/// Build a box tree for a flat list of scene-level children at a given -/// viewport size. -/// -/// The implicit scene root is a `display: flex; flex-direction: column; -/// width/height: 100%`; children flow vertically unless they specify -/// `position: { x, y }`, in which case they become `position: absolute`. pub fn build_scene<'a>(children: &'a [ChildComponent], viewport: (f32, f32)) -> BuiltScene<'a> { build_scene_with_root(children, viewport, default_root_css(viewport)) } -/// Same as [`build_scene`] but lets the caller supply the root container's -/// `CssStyle`. Width/height are forced to the viewport regardless. pub fn build_scene_with_root<'a>( children: &'a [ChildComponent], viewport: (f32, f32), @@ -98,9 +47,6 @@ pub fn build_scene_with_root<'a>( build_scene_from_refs(children.iter(), viewport, root_css, None) } -/// Like [`build_scene_with_root`] but resolves animations at `time` (seconds) -/// for each node and merges the result into its `CssStyle`. Use this when -/// rendering an animated frame. pub fn build_scene_at_time<'a>( children: &'a [ChildComponent], viewport: (f32, f32), @@ -110,8 +56,6 @@ pub fn build_scene_at_time<'a>( build_scene_from_refs(children.iter(), viewport, root_css, Some(anim)) } -/// Like [`build_scene`] but with an animation context. Convenience wrapper -/// that uses the default root CSS (full-viewport flex column). pub fn build_scene_with_anim<'a>( children: &'a [ChildComponent], viewport: (f32, f32), @@ -125,17 +69,6 @@ pub fn build_scene_with_anim<'a>( ) } -/// Same as [`build_scene_with_root`] but accepts an iterator over -/// `&ChildComponent` references. Useful when the caller has filtered or -/// re-ordered the scene's children and doesn't want to clone. -/// -/// Builds with no outer [`Scope`] — see -/// [`build_scene_from_refs_with_scope`]'s doc for what that means and why -/// every other public entry point in this file (this one included) keeps -/// that parameter fixed at `None` rather than exposing it: a scenario using -/// no `vars` and no `node(...)` reference renders through exactly this -/// path, unchanged, whatever `rustmotion/src/engine/render/scene.rs` does -/// on its own richer path. pub fn build_scene_from_refs<'a, I>( children: I, viewport: (f32, f32), @@ -148,24 +81,6 @@ where build_scene_from_refs_with_scope(children, viewport, root_css, anim, None) } -/// Full form of [`build_scene_from_refs`]: `outer_scope` is tried, after the -/// per-node [`rustmotion_core::css::FrameClock`], by every node's own -/// `style.expr` (issue #338) — see [`resolve_computed_style`]'s doc for the -/// exact composition (`rustmotion_core::css::ComposedScope`) and why the -/// clock always wins for its own six reserved names. -/// -/// `outer_scope` is caller-owned and frame-global: unlike `anim` (which -/// `build_child` remaps per node through each container's own -/// `time_scale`/`time_offset`), the same `&dyn Scope` reference is handed to -/// every node in this call — a declared `vars` name and a `node(...)` -/// reference both resolve against one shared, already-computed state for -/// the whole frame, not a per-node one. See -/// `rustmotion/src/engine/render/scene.rs`'s `EngineScope` for what -/// typically sits behind it (a `vars::VarScope` composed with an -/// `engine::deps::ResolvedFrame`) and why that composition happens one -/// level up rather than in this crate: this crate does not otherwise depend -/// on `rustmotion-core`'s `vars` or `engine::deps` modules by name, only on -/// the `Scope` trait object they both implement. pub fn build_scene_from_refs_with_scope<'a, I>( children: I, viewport: (f32, f32), @@ -179,18 +94,6 @@ where build_scene_from_refs_with_scope_quiet(children, viewport, root_css, anim, outer_scope, true) } -/// Full form of [`build_scene_from_refs_with_scope`]: `warn_unresolved` -/// gates [`resolve_computed_style`]'s stderr warning for an expression that -/// fails to resolve against `outer_scope`. `false` for a build whose only -/// purpose is to seed a not-yet-complete `outer_scope` (the throwaway first -/// pass `render_with_new_pipeline_iter` runs for a scene with a `node(...)` -/// reference — see that function's doc): a `node(...)` call there fails -/// *by construction*, not because anything is actually wrong, and is -/// corrected by the very next build; warning about it would be a stderr -/// line that doesn't describe the frame that actually gets painted. Every -/// other build (the common no-outer-scope path, and any build whose -/// `outer_scope` is already complete) keeps warning — an expression that -/// still fails there really does keep its pre-expression value on screen. pub fn build_scene_from_refs_with_scope_quiet<'a, I>( children: I, viewport: (f32, f32), @@ -204,7 +107,7 @@ where { let mut components: Vec> = vec![None]; let mut stagger_delays: Vec = vec![0.0]; - let mut time_params: Vec<(f64, f64)> = vec![(1.0, 0.0)]; // slot 0 = root (identity) + let mut time_params: Vec<(f64, f64)> = vec![(1.0, 0.0)]; let mut next_id: NodeId = 1; let mut child_boxes = Vec::new(); @@ -226,7 +129,6 @@ where )); } - // Force the root to viewport dimensions even if the caller didn't set them. root_css.width = Some(CSize::Length(CLP::Px(viewport.0))); root_css.height = Some(CSize::Length(CLP::Px(viewport.1))); @@ -248,21 +150,6 @@ where } } -/// Every declared `id` in this subtree, paired with the `node(...)` -/// references its own style expressions make — the -/// `(String, Vec)` shape `rustmotion_core::engine::deps::DepGraph::build` -/// wants. A node's references were already found once, at load (see -/// `rustmotion_core::css::computed::extract`'s `node_refs` field), so this -/// is a plain tree walk with no `Expr`/JSON work of its own — cheap enough -/// to call every frame, and callers on the hot per-frame path do (see -/// `rustmotion/src/engine/render/scene.rs`). -/// -/// Recurses into a container's children via [`container_children_of`], the -/// same `Component` variant match `container_children` uses (minus the -/// animation-context bookkeeping this walk doesn't need) to decide which -/// components nest children and under which field, so a component type -/// this file doesn't (yet) recurse into cannot silently diverge between -/// "what gets laid out" and "what gets scanned for references". pub fn collect_node_refs<'a, I>(children: I) -> Vec<(String, Vec)> where I: IntoIterator, @@ -286,12 +173,6 @@ fn collect_node_refs_into(child: &ChildComponent, out: &mut Vec<(String, Vec &[ChildComponent] { match component { Component::Container(c) => &c.children, @@ -299,17 +180,6 @@ fn container_children_of(component: &Component) -> &[ChildComponent] { } } -/// True when *any* node in this subtree — declared `id` or not — makes at -/// least one `node(...)` reference. Deliberately not derived from -/// [`collect_node_refs`]'s own result: that function only ever records a -/// node's references under *that node's own* entry, and only when the node -/// itself has a declared `id` (matching `DepGraph::build`'s contract, which -/// needs an entry only for a reference *target*, never for a plain -/// referencer). The common shape — a node with no `id` of its own reading -/// `node("otherId", ...)` — would then never surface in -/// `collect_node_refs`'s output at all, silently skipping the second build -/// pass its own reference needs. This walks every node regardless of -/// whether it declares an `id`. pub fn scene_uses_node_refs<'a, I>(children: I) -> bool where I: IntoIterator, @@ -336,10 +206,6 @@ fn default_root_css(viewport: (f32, f32)) -> CssStyle { } } -/// Detect motion_blur and trail configs from a merged effect list. -/// -/// Returns `(motion_blur, trail)`. Only the first occurrence of each type is -/// used; duplicate effects of the same kind are ignored. fn detect_ghost_effects( effects: &[AnimationEffect], ) -> (Option, Option) { @@ -355,25 +221,6 @@ fn detect_ghost_effects( (mb, tr) } -/// Build ghost `BoxNode`s for motion-blur or trail effects. -/// -/// Returns a `Vec` of ghosts to be prepended (painted underneath) -/// the principal node. Each ghost gets a fresh `NodeId` and its own slot in -/// `components`/`stagger_delays`, both pointing to the same `ChildComponent` -/// as the principal (v1 approximation: internal content uses frame time, not -/// ghost time). -/// -/// # Ghost CSS -/// -/// For **motion_blur**: the CSS is resolved at `t_ghost = t - i * (shutter/fps)/samples` -/// and opacity is `base_opacity / (samples + 1)`. The principal's opacity is -/// kept at its base value so the static (no-motion) case stays visually full. -/// -/// For **trail**: the CSS is resolved at `t_ghost = t - i * spacing` and -/// opacity is `base_opacity * falloff^i`. The principal is unchanged. -/// -/// Only one of `mb` / `tr` is used at a time; if both are present, motion_blur -/// takes priority. #[allow(clippy::too_many_arguments)] fn build_ghosts<'a>( child: &'a ChildComponent, @@ -389,7 +236,6 @@ fn build_ghosts<'a>( ) -> Vec { let (mb, tr) = detect_ghost_effects(effects); - // Choose the ghost generation strategy. enum Strategy { MotionBlur { samples: u32, @@ -404,7 +250,6 @@ fn build_ghosts<'a>( let strategy = if let Some(mc) = mb { let samples = mc.samples.clamp(1, 16); if samples <= 1 { - // Degenerate: no ghosts needed (ghost = principal position). return Vec::new(); } let shutter_window = mc.shutter / actx.fps.max(1) as f64; @@ -424,7 +269,6 @@ fn build_ghosts<'a>( }; let base_css_for_ghost = |ghost_time: f64, ghost_opacity_scale: f32| -> CssStyle { - // Start from the same base CSS as the principal. let mut css = component_css(&child.component); if let Some((x, y)) = child.absolute_position() { css.position = Some(Position::Absolute); @@ -434,19 +278,12 @@ fn build_ghosts<'a>( if let Some(z) = child.z_index { css.z_index = Some(z); } - // Cascade: a ghost is the same component as the principal, painted - // at a different sampled time, so it inherits from the same parent. rustmotion_core::css::cascade::inherit_from(parent_css, &mut css); - // Apply timeline style states at the ghost time. if let Some(animatable) = child.component.as_animatable() { let steps = animatable.timeline_steps(); if steps.iter().any(|s| s.style.is_some()) { let skip_opacity = css.transition.is_some(); apply_style_states(&mut css, steps, ghost_time - extra_delay, skip_opacity); - // Same `border-radius`/`background` smoothing as the - // principal path in `build_child`, sampled at `ghost_time` - // so a motion-blur/trail ghost mid-transition matches what - // the principal will look like at that same instant. let overrides = resolve_transition_css_overrides( child.component.as_styled().style_config(), steps, @@ -460,7 +297,6 @@ fn build_ghosts<'a>( } } } - // Resolve animation props at the *ghost* time. let ghost_actx = BuildAnimationCtx { time: ghost_time, scenario_time: actx.scenario_time, @@ -479,7 +315,6 @@ fn build_ghosts<'a>( apply_glow_effect(&mut css, &ghost_effects); carry_paint_pass_effects(&mut css, &ghost_effects); } - // Scale opacity: multiply the base opacity by the ghost opacity factor. let base_opacity = css.opacity.unwrap_or(1.0); css.opacity = Some((base_opacity * ghost_opacity_scale).clamp(0.0, 1.0)); css @@ -492,8 +327,6 @@ fn build_ghosts<'a>( samples, shutter_window, } => { - // Ghost opacity: 1 / (samples + 1) of base opacity. - // Principal stays at full base opacity (handled in build_child). let ghost_opacity_scale = 1.0 / (samples + 1) as f32; for i in 1..=samples { let ghost_time = actx.time - (i as f64 * shutter_window / samples as f64); @@ -501,7 +334,6 @@ fn build_ghosts<'a>( let ghost_id = *next_id; *next_id += 1; - // Register a slot so the dispatcher can look up the component. components.push(Some(child)); stagger_delays.push(extra_delay); time_params.push(time_remap); @@ -510,8 +342,8 @@ fn build_ghosts<'a>( id: ghost_id, kind: BoxKind::Ghost(Arc::new(ghost_id)), css: ghost_css, - children: Vec::new(), // v1: no child recursion in ghosts - intrinsic: None, // v1: ghosts have no layout-measured content + children: Vec::new(), + intrinsic: None, source_path: None, window: None, }); @@ -522,10 +354,6 @@ fn build_ghosts<'a>( spacing, falloff, } => { - // Ghosts painted from oldest (most-trailing) to newest (closest to principal). - // We build them in reverse order (copies → 1) so that index i=copies - // is the oldest ghost (lowest opacity), and we then reverse to get the - // correct under-to-over paint order. let mut trail_nodes = Vec::with_capacity(copies as usize); for i in 1..=copies { let ghost_time = actx.time - i as f64 * spacing; @@ -548,7 +376,6 @@ fn build_ghosts<'a>( window: None, }); } - // Oldest ghost (most-trailing) painted first → prepend in reverse. trail_nodes.reverse(); ghosts = trail_nodes; } @@ -557,25 +384,6 @@ fn build_ghosts<'a>( ghosts } -/// Convert a single `ChildComponent` into one or more `BoxNode`s. -/// -/// Returns a `Vec` whose elements are inserted in order into the parent's -/// `children`. The last element is the principal node; any preceding elements -/// are `BoxKind::Ghost` nodes inserted *before* (painted underneath) the -/// principal for motion-blur or trail effects. -/// -/// `stagger_delay` is the animation delay accumulated from ancestor -/// containers' `stagger` fields. -/// `time_remap` is the accumulated affine time transform `(scale, shift)` where -/// `t_local = scale * t_global + shift`. Default is `(1.0, 0.0)` (identity). -/// `viewport` feeds [`rustmotion_core::css::FrameClock`] for this node's own -/// `style.expr` (issue #338) — see this file's "Per-frame expressions" doc -/// section, above [`resolve_computed_style`]. `outer_scope` is the same -/// frame-global `Scope` [`build_scene_from_refs_with_scope`] received, -/// passed straight through every recursion (never remapped per node the way -/// `anim`/`time_remap` are — see that function's own doc). `warn_unresolved` -/// is [`build_scene_from_refs_with_scope_quiet`]'s own flag, passed through -/// unchanged. #[allow(clippy::too_many_arguments)] fn build_child<'a>( child: &'a ChildComponent, @@ -592,31 +400,16 @@ fn build_child<'a>( outer_scope: Option<&dyn Scope>, warn_unresolved: bool, ) -> Vec { - // Compute the local animation context for this node — remapped by the - // accumulated affine time transform from ancestor containers. - // `t_local = scale * t_global + shift` let local_actx = anim.map(|a| { let (scale, shift) = time_remap; BuildAnimationCtx { time: a.time * scale + shift, - // A container's `time_scale`/`time_offset` remaps the *animation* - // clock of its subtree. The audio is not part of that subtree — - // it plays at wall-clock speed regardless — so the scenario clock - // passes through unremapped. scenario_time: a.scenario_time, scene_duration: a.scene_duration, fps: a.fps, } }); - // A node's own `start_at` rebases its animation clock the same way an - // ancestor's `stagger` already does: both push out the instant the - // component's *own* first keyframe is considered reached. Without this, - // `start_at` only gated visibility — the effect list still resolved - // against the untouched scene clock, so an entrance already playing out - // by the time the node became visible snapped straight to its end state, - // and an exit whose own `delay` elapsed before `start_at` left the node - // painting nothing for its whole visible window. let anim_delay = stagger_delay + child .component @@ -624,9 +417,6 @@ fn build_child<'a>( .and_then(|t| t.timing().0) .unwrap_or(0.0); - // ── Ghost generation (motion_blur / trail) ─────────────────────────────── - // Must happen before allocating the principal's id so that ghost ids are - // lower (earlier in the slot table). The principal's id is allocated below. let mut ghosts: Vec = Vec::new(); if let Some(actx) = local_actx { if let Some(effects) = effective_effects(&child.component, anim_delay, actx.time) { @@ -653,7 +443,6 @@ fn build_child<'a>( let mut css = component_css(&child.component); - // Apply per-child position/z-index from the wrapper. if let Some((x, y)) = child.absolute_position() { css.position = Some(Position::Absolute); css.left = Some(CLP::Px(x)); @@ -663,33 +452,14 @@ fn build_child<'a>( css.z_index = Some(z); } - // CSS cascade (round 4 audit, lot LAYOUT, constat 2): propagate - // inheritable properties (color, font-*, text-align, white-space, ...) - // from the parent's already-cascaded style into any of this node's own - // unset properties — mirrors CSS's "specified value" resolution, which - // happens before state/animation overrides compute the final value. - // `crates/rustmotion-core/src/css/cascade.rs::inherit_from` existed but - // nothing called it until this fix. rustmotion_core::css::cascade::inherit_from(parent_css, &mut css); - // Timeline style states: merge every state whose (at + stagger) <= t - // into the box CSS. Opacity is excluded when a `transition` smooths it - // (the synthesized keyframes then own its whole history). States affect - // box-model properties (layout, background, border, opacity, transform, - // filter); painter-internal properties like text color flow through the - // keyframes path below instead. if let Some(animatable) = child.component.as_animatable() { let steps = animatable.timeline_steps(); if steps.iter().any(|s| s.style.is_some()) { let t = local_actx.map(|a| a.time).unwrap_or(0.0); let skip_opacity = css.transition.is_some(); apply_style_states(&mut css, steps, t - anim_delay, skip_opacity); - // `border-radius`/`background` (solid colour, uniform absolute - // px only — see `resolve_transition_css_overrides`'s doc - // comment) smooth the same way opacity does above, but land - // directly on `css` instead of through the generic effects - // pipeline: no `AnimatedProperties` field for them is ever read - // by a painter, so that pipeline is a dead end for these two. let overrides = resolve_transition_css_overrides( child.component.as_styled().style_config(), steps, @@ -704,12 +474,6 @@ fn build_child<'a>( } } - // Resolve animations and apply transform/opacity/filter overrides on the - // box's CSS. Paint-time properties (transform, opacity, filter, - // perspective) plus the box size (`width`/`height`, which taffy needs so a - // resize reflows its children instead of stretching pixels) flow into CSS - // — internal animations like draw_progress or char_animation remain on the - // `AnimatedProperties` legacy path. if let Some(actx) = local_actx { if let Some(effects) = effective_effects(&child.component, anim_delay, actx.time) { let props = resolve_props_for_effects(&effects, actx.time, actx.scene_duration); @@ -729,9 +493,6 @@ fn build_child<'a>( ); } - // ── Audio-reactive binding ──────────────────────────────────────────────── - // Reads the audio analysis cache and lerps the target CSS property between - // min and max. Cache-miss → value = min (deterministic fallback). if let Some(ar) = css.audio_reactive.take() { use rustmotion_core::css::style::{AudioReactiveProperty, AudioSource, AudioSourceTag}; use rustmotion_core::engine::renderer::audio_analysis::audio_analysis_cache; @@ -754,7 +515,7 @@ fn build_child<'a>( } } } else { - 0.0 // cache empty → use min + 0.0 }; let lerped = ar.min as f32 + raw * (ar.max - ar.min) as f32; @@ -784,13 +545,6 @@ fn build_child<'a>( } } - // Visibility window (start_at/end_at) — enforced by the paint pass. - // The stagger delay shifts the window too, so a hard-cut child appears - // in step with its staggered siblings. - // When there is an accumulated time remap, the window times (which are in - // local/remapped time) must be converted back to global time so the paint - // pass (which operates on global time) can apply them correctly. - // If `t_local = scale * t_global + shift`, then `t_global = (t_local - shift) / scale`. let window = child.component.as_timed().and_then(|t| { let (start, end) = t.timing(); (start.is_some() || end.is_some()).then_some({ @@ -836,59 +590,11 @@ fn build_child<'a>( window, }; - // Ghosts prepended (painted underneath the principal). let mut result = ghosts; result.push(principal); result } -// ── Per-frame expressions (issue #338) ────────────────────────────────────── -// -// `css.expr` (`rustmotion_core::css::ComputedStyle`) holds whatever `"= ..."` -// expressions `CssStyle`'s own `Deserialize` impl pulled off this node's -// style at load time — see that type's module doc. This is where the other -// half of the two-tier model in `rustmotion_core::expr`'s own doc happens: -// evaluating those expressions fresh every frame and applying the result -// through `apply_animated_props`, the exact same override path a resolved -// `style.animation` already goes through a few lines above this function's -// call site — an expression is a second source of the same kind of -// override, not a parallel application mechanism. -// -// `apply_animated_props` is called a second time (once for the resolved -// animation, once for this), so the two compose exactly the way it already -// composes an animation on top of a literal CSS value: `opacity` multiplies -// (both contribute — a literal, an animation, and an expression on the same -// node all multiply together), `width`/`height` last-write-wins (an -// expression overrides an animation's own resize, since this call runs -// after it), and the four covered `transform` leaves each append their own -// `TransformFn` (both contribute, associative — see -// `rustmotion_core::css::computed::extract`'s doc for why the neutral -// placeholder its extraction leaves behind makes that safe). -// -// The `Scope` used here composes `rustmotion_core::css::FrameClock` — the -// reserved scenario-clock names (`t`/`T`/`duration`/`W`/`H`/`fps`), built -// from data `BuildAnimationCtx` and `viewport` already carry down to this -// exact call site — with `outer_scope`, the frame-global `&dyn Scope` -// `build_scene_from_refs_with_scope` threads down unchanged through every -// recursive `build_child`/`container_children` call (see those functions' -// own doc for why it isn't remapped per node the way `anim`/`time_remap` -// are). `rustmotion_core::css::ComposedScope` is the composite: the clock's -// six reserved names always win (see that type's own doc for why), -// everything else — a declared `vars` name, a `node(...)` reference — falls -// through to `outer_scope`. `rustmotion/src/engine/render/scene.rs` is what -// actually builds one (its own `EngineScope`, composing a `vars::VarScope` -// with the `node(...)` dependency graph's `ResolvedFrame`) and passes it in -// as `outer_scope`; every other caller in this crate (tests, the studio hit -// probe, any scenario with no `vars` and no node `id`) passes `None`, which -// makes `ComposedScope` behave exactly like a bare `FrameClock` — see that -// type's doc for why that is byte-for-byte, not just "close enough". An -// expression naming a name neither the clock nor `outer_scope` answers -// still fails loudly and specifically (see below), never silently — except -// when `warn_unresolved` is `false` (a throwaway pass whose own build is -// about to be discarded/superseded; see -// `build_scene_from_refs_with_scope_quiet`'s doc), in which case the -// best-effort fallback still applies but stays silent, since the frame it -// would be describing is never the one that reaches the screen. fn resolve_computed_style( css: &mut CssStyle, path: &str, @@ -915,19 +621,6 @@ fn resolve_computed_style( match css.expr.resolve(&scope) { Ok(props) => apply_animated_props(css, &props), Err(e) => { - // Best-effort, same contract `deserialize_children` - // (`rustmotion/src/engine/render/scene.rs`) already uses for a - // single broken child: named and located (`e` carries the exact - // property and the underlying `ExprError`, `path` carries which - // node), but never a fatal error and never a silent zero — the - // property that failed simply keeps whatever value it already - // had (its literal, or whatever an animation already resolved - // it to) for this frame, instead of aborting or blanking the - // rest of this node's expressions too... except that it *does* - // abort the rest of *this* node's expressions this frame: - // `ComputedStyle::resolve` stops at the first failing property, - // so a later expression on the same node that would have - // succeeded is not evaluated either. See that type's own doc. if warn_unresolved { eprintln!( "warning: {path}: {e} — this property keeps its pre-expression value this frame" @@ -937,18 +630,6 @@ fn resolve_computed_style( } } -/// The full effect list for a component at paint time: `style.animation`, -/// plus the `timeline` steps whose `at` `t` has reached, shifted by their -/// `at`, plus keyframes synthesized from timeline style-state changes -/// (`style.transition`), plus `extra_delay` applied to everything — callers -/// fold in both the ancestor-stagger delay and the node's own `start_at` here, -/// so the effect list is agnostic to which one (or both) it's carrying. -/// Returns `None` when there is nothing to resolve, `Some(Cow::Borrowed)` on -/// the no-merge fast path. -/// -/// `t` is the component's own local time, the same clock -/// `resolve_props_for_effects` is called with, and the same one -/// `apply_style_states` gates a step's `style` on. pub fn effective_effects( component: &Component, extra_delay: f64, @@ -957,8 +638,6 @@ pub fn effective_effects( let animatable = component.as_animatable()?; let effects = animatable.animation_effects(); let steps = animatable.timeline_steps(); - // `color` keyframes drive AnimatedProperties.color, consumed by the - // text-like painters only. let smooth_color = matches!(component, Component::Text(_) | Component::Counter(_)); let synthesized = transition_keyframes(component.as_styled().style_config(), steps, smooth_color); @@ -982,10 +661,6 @@ pub fn effective_effects( (!merged.is_empty()).then_some(std::borrow::Cow::Owned(merged)) } -/// Merge every timeline style state whose `at <= t` into `css`, in `at` -/// order. Serialize-merge keeps this schema-complete; `null`s and the empty -/// `animation` array never erase existing values. `skip_opacity` leaves -/// opacity to the synthesized transition keyframes. pub(crate) fn apply_style_states( css: &mut CssStyle, steps: &[rustmotion_core::schema::TimelineStep], @@ -1024,14 +699,6 @@ pub(crate) fn apply_style_states( base.insert(k, v); } } - // `CssStyle::expr` (issue #338) is `#[serde(skip)]` — neither `css` - // above nor a `step.style` survives this serialize round trip with its - // expressions intact, so `merged.expr` comes back empty regardless of - // what either side held. Restore `css`'s own pre-merge expressions - // (`.prefer` rather than a bare overwrite, so this stays correct even - // if that serialize-skip ever narrows) — otherwise a node with both a - // `style.expr` and any `timeline` style state would silently lose its - // expression the first time a state became due. let saved_expr = css.expr.clone(); if let Ok(mut merged) = serde_json::from_value::(serde_json::Value::Object(base)) { merged.expr = merged.expr.prefer(saved_expr); @@ -1039,12 +706,6 @@ pub(crate) fn apply_style_states( } } -/// Synthesize `Keyframes` effects smoothing timeline style-state changes per -/// the component's `style.transition`. Supported: `opacity` (as ratios of the -/// base opacity — `apply_animated_props` multiplies) and, on text-like -/// components, `color` (absolute, via `AnimatedProperties.color`). Color -/// states without a transition still synthesize a near-instant ramp because -/// text painters only see color through this path. pub(crate) fn transition_keyframes( base: &CssStyle, steps: &[rustmotion_core::schema::TimelineStep], @@ -1060,8 +721,6 @@ pub(crate) fn transition_keyframes( } let (duration, easing) = match base.transition.as_ref() { Some(tr) if tr.duration() > 0.0 => (tr.duration(), tr.easing()), - // Color snaps still need the keyframes path (see doc above); a 1ms - // ramp is visually a hard cut. _ => (0.001, rustmotion_core::schema::EasingType::Linear), }; let smooth_opacity = base.transition.is_some(); @@ -1086,8 +745,6 @@ pub(crate) fn transition_keyframes( value: KeyframeValue::Color(c), easing: None, }; - // Keep keyframe times strictly ascending even when states overlap a - // still-running transition (the resolver walks ordered segments). let push_pair = |kfs: &mut Vec, at: f64, from: Keyframe, to: Keyframe| { let floor = kfs.last().map(|k| k.time + 1e-6).unwrap_or(f64::MIN); let start = at.max(floor); @@ -1155,44 +812,11 @@ pub(crate) fn transition_keyframes( out } -/// Resolved `style.transition` smoothing for `border-radius`/`background`, -/// ready to be written straight onto a `CssStyle`. pub(crate) struct TransitionCssOverrides { pub border_radius: Option, pub background: Option, } -/// CSS-native smoothing for `border-radius` (uniform, absolute-px only) and -/// `background` (solid colour only) timeline style-state changes. -/// -/// Unlike `opacity`/`color` in `transition_keyframes` above, neither -/// property has anywhere to land in `AnimatedProperties` that any painter or -/// the CSS bridge (`css/animation.rs::apply_animated_props`) actually reads -/// (see `KNOWN_ANIMATABLE_PROPERTIES`'s doc comment in `animator.rs`) — -/// every painter reads `css.border_radius`/`css.background` straight off -/// the node's own `CssStyle` (`paint_pass.rs`, frozen, already does this for -/// the static case). So instead of synthesizing an `AnimationEffect` for the -/// generic effects pipeline (a dead end for these two), this resolves the -/// interpolated value directly and returns it for the caller to write onto -/// the box's `CssStyle` by hand — reusing `animator::resolve_keyframe_track` -/// for the actual segment/easing/spring math rather than reinventing it. -/// -/// Gated on `style.transition` being set, mirroring `opacity`'s gate above -/// (not `color`'s forced near-instant ramp — nothing downstream *requires* -/// these two to smooth the way text painters require `color` to). Absent an -/// explicit `style.transition`, this returns an all-`None` result, so every -/// existing scenario without one renders byte-identical to before this -/// workstream. -/// -/// **"Unités mixtes" decision** (see workstream report): resolves only when -/// *both* the origin and the target value are the exact shape -/// `BorderRadius::absolute_px`/`Background::solid_hex` can resolve without a -/// `LengthContext` — uniform absolute px, solid colour. Anything else -/// (per-corner radii, `%`/`em`/`rem`/`vw`/`vh`, gradients, image layers) is -/// refused rather than guessed: that property just falls back to -/// `apply_style_states`'s existing snap. `validate_schema.rs` calls the -/// exact same two predicates so the diagnostic and the runtime can never -/// disagree about what's interpolable. pub(crate) fn resolve_transition_css_overrides( base: &CssStyle, steps: &[rustmotion_core::schema::TimelineStep], @@ -1228,10 +852,6 @@ pub(crate) fn resolve_transition_css_overrides( let mut radius_kfs: Vec = Vec::new(); let mut bg_kfs: Vec = Vec::new(); - // Same ascending-time bookkeeping as `transition_keyframes`'s - // `push_pair` above (kept local — a shared closure can't easily borrow - // two different `Vec`s across both loops below without upsetting the - // borrow checker for no real benefit at this size). let push_pair = |kfs: &mut Vec, at: f64, from: Keyframe, to: Keyframe| { let floor = kfs.last().map(|k| k.time + 1e-6).unwrap_or(f64::MIN); let start = at.max(floor); @@ -1268,11 +888,6 @@ pub(crate) fn resolve_transition_css_overrides( prev_radius = Some(target); } } - // Unresolvable shape (per-corner, %/em/rem/vw/vh) — lose the - // interpolation origin for *this* transition only; - // `validate_schema.rs` diagnoses this exact step, and a - // later resolvable value simply resumes smoothing from - // itself onward (see the doc comment above). None => prev_radius = None, } } @@ -1329,9 +944,6 @@ pub(crate) fn resolve_transition_css_overrides( out } -/// Quick gate: does this resolved `AnimatedProperties` carry any property -/// that we know how to translate to CSS? Avoids allocating a transform Vec -/// when there's nothing to apply. fn props_has_paint_overrides(p: &AnimatedProperties) -> bool { p.translate_x != 0.0 || p.translate_y != 0.0 @@ -1344,25 +956,10 @@ fn props_has_paint_overrides(p: &AnimatedProperties) -> bool { || p.blur > 0.0 || (p.glow_radius > 0.0 && p.glow_intensity > 0.0) || p.perspective > 0.0 - // An animated box size is a *layout* override rather than a paint one, - // but it travels through the same bridge, so the gate has to let it - // through or `apply_animated_props` never runs for a scenario whose - // only animated property is `width`/`height` (the card-resize case). - // -1.0 is the animator's "never animated" sentinel. || p.width >= 0.0 || p.height >= 0.0 } -/// Hand the effects that the *paint pass* resolves for itself down to the box -/// node, in their delay-shifted form. -/// -/// Everything else on this path is resolved here and lands on `css` as a -/// finished value. `shimmer` cannot be: it composites against the pixels the -/// node paints, which do not exist until the paint pass has run. So the paint -/// pass reads it off `css.animation` — and it has to read the *shifted* copy -/// (container stagger, `timeline` step `at`) rather than the author's raw -/// `style.animation`, or a shimmer inside a staggered list would sweep in step -/// with the list's first item instead of its own. fn carry_paint_pass_effects( css: &mut CssStyle, effects: &[rustmotion_core::schema::AnimationEffect], @@ -1380,29 +977,6 @@ fn carry_paint_pass_effects( } } -/// M3: apply the static `glow` animation effect (a coloured halo — not -/// time-varying, see `AnimationEffect::shift_delay`'s doc comment) as a CSS -/// `filter: drop-shadow(...)`. -/// -/// This is deliberately *not* routed through `resolve_props_for_effects` / -/// `AnimatedProperties::glow_radius`+`glow_intensity` even though those -/// fields exist: `AnimatedProperties`'s public shape is frozen for this -/// workstream (can't add a `glow_color` field), and the existing bridge that -/// *does* consume those two fields — `css::animation::apply_animated_props`, -/// in a file outside this workstream's scope — hardcodes `color: None` on -/// the `DropShadow` it builds, which resolves to black. Piping the `glow` -/// effect through that path would either still render black, or — if we did -/// find a way to set the color fields too — double up into two stacked -/// drop-shadows (one colourless from the frozen bridge, one coloured from -/// here). Building the filter directly here, from the raw `GlowConfig` -/// (found via `animator::find_glow_effect`), keeps it a single, correctly -/// coloured shadow and needs no change to any frozen file. -/// -/// The pre-existing `glow_radius`/`glow_intensity` numeric-property path -/// (animating those as arbitrary `keyframes` targets, unrelated to this -/// named `glow` effect) is untouched and still produces a colourless halo — -/// that is `css::animation.rs`'s DropShadow-with-`color:None` bug, out of -/// this workstream's file ownership. fn apply_glow_effect(css: &mut CssStyle, effects: &[rustmotion_core::schema::AnimationEffect]) { use rustmotion_core::css::style::{Color, FilterFn}; use rustmotion_core::css::units::Length; @@ -1429,18 +1003,6 @@ fn apply_glow_effect(css: &mut CssStyle, effects: &[rustmotion_core::schema::Ani css.filter.get_or_insert_with(Vec::new).push(shadow); } -/// Build an [`IntrinsicMeasure`] for components whose box size depends on -/// their content (text, codeblock, terminal, etc.). Returns `None` for -/// components with explicit dimensions or pure containers. -/// -/// `cascaded_css` is this node's own `CssStyle` after `cascade::inherit_from` -/// has already merged it against the parent, plus every subsequent overlay -/// (timeline states, animation) — the exact same value `LegacyPaintDispatcher` -/// receives at paint time. `Component::with_cascaded_style` folds it into -/// whichever component variants read inherited typography off their own -/// style before this function's match ever sees them, so the reserved box -/// always matches what those components' painters (which fold the same -/// cascade in at paint time) actually draw. fn component_intrinsic( component: &Component, cascaded_css: &CssStyle, @@ -1466,9 +1028,6 @@ fn component_intrinsic( )), Badge(b) => Some(Arc::new(crate::intrinsic::BadgeIntrinsic::from_badge(b))), Table(t) => Some(Arc::new(crate::intrinsic::TableIntrinsic::from_table(t))), - // M2: rich_text had no intrinsic measurer at all, so it laid out - // 0×0 and rendered nothing unless the author guessed an explicit - // width/height. RichText(rt) => Some(Arc::new( crate::intrinsic::RichTextIntrinsic::from_rich_text(rt), )), @@ -1476,12 +1035,6 @@ fn component_intrinsic( } } -/// If the component is a container, recurse into its children. Otherwise -/// return an empty Vec. A container's `stagger` adds `index * stagger` -/// to each child's inherited animation delay (cumulative across nesting). -/// `time_remap` is the accumulated affine time transform `(scale, shift)` for -/// this container node; the container's own `time_scale`/`time_offset` are -/// composed in to produce the remap for children. #[allow(clippy::too_many_arguments)] fn container_children<'a>( component: &'a Component, @@ -1509,22 +1062,13 @@ fn container_children<'a>( _ => return Vec::new(), }; - // Clamp scale defensively to avoid division-by-zero downstream. let child_scale = child_scale.max(1e-6); - // Compose the container's time remap with the inherited (accumulated) remap. - // Accumulated remap: `t_parent = scale_acc * t_global + shift_acc` - // Container formula: `t_child = (t_parent - child_offset) * child_scale` - // = child_scale * (scale_acc * t_global + shift_acc) - child_scale * child_offset - // = (child_scale * scale_acc) * t_global + child_scale * (shift_acc - child_offset) let (scale_acc, shift_acc) = time_remap; let new_scale = child_scale * scale_acc; let new_shift = child_scale * (shift_acc - child_offset); let child_remap = (new_scale, new_shift); - // `anim` stays GLOBAL all the way down the recursion — each `build_child` - // derives its node-local time from the accumulated `child_remap`. Passing - // a pre-remapped ctx here would double-apply the transform. let step = stagger.unwrap_or(0.0) as f64; let mut result = Vec::new(); for (j, c) in children.iter().enumerate() { @@ -1547,8 +1091,6 @@ fn container_children<'a>( result } -/// Pull the component's `CssStyle`, augmented with intrinsic `width`/`height` -/// for components that carry a fixed size. fn component_css(component: &Component) -> CssStyle { let mut css = component_style(component).clone(); apply_default_display(component, &mut css); @@ -1556,16 +1098,6 @@ fn component_css(component: &Component) -> CssStyle { css } -/// Set `display` on the container when the user didn't specify one. The -/// four former variants (`card`/`flex`/`grid`/`positioned`) are now a single -/// `Component::Container` with no field recording which spelling produced -/// it, so the old "which variant is this" dispatch can't tell `grid` apart -/// from the others anymore — the signal used instead is `grid-template-columns` -/// itself: a container that sets it (the only way `validate_schema`'s grid -/// checks let a scenario be valid in the first place) defaults to -/// `Display::Grid`; every other container defaults to `Display::Flex`. The -/// taffy bridge would otherwise default to `block`, silently ignoring -/// `flex-direction` & friends. fn apply_default_display(component: &Component, css: &mut CssStyle) { use rustmotion_core::css::style::Display; if css.display.is_some() { @@ -1580,12 +1112,6 @@ fn apply_default_display(component: &Component, css: &mut CssStyle) { } } -/// Measure a single line of text with the exact same Skia font metrics the -/// affected painters (`callout`, `tooltip`, `pill_nav`, `stepper`) already use -/// to draw it (`measure_text_with_fallback`), so a size computed here matches -/// the pixels those painters actually paint instead of guessing at an average -/// character width. Returns `0.0` if the font family can't be resolved — -/// matches those painters' own silent-return-on-font-load-failure behaviour. fn measure_text_line_width(text: &str, font_size: f32, family: &str, bold: bool) -> f32 { use rustmotion_core::engine::renderer::{ emoji_typeface, measure_text_with_fallback, typeface_with_fallback, @@ -1603,27 +1129,10 @@ fn measure_text_line_width(text: &str, font_size: f32, family: &str, bold: bool) measure_text_with_fallback(text, &font, &emoji_font, 0.0) } -/// Apply per-component CSS overrides for things that the legacy -/// `Widget::measure` derived from constraints (e.g. divider stretching to its -/// parent, line bounding box from its endpoints). fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) { use Component::*; match component { Text(t) => { - // M1: `white-space: nowrap|pre` must style the *content*, not - // silently resize the *box*. Without this, CSS's "automatic - // minimum size" (min-width: auto + the default overflow: - // visible) lets a nowrap text's own auto-width box grow to its - // full natural width inside a flex container — which can push - // or resize flex siblings the author never touched, a - // surprising failure mode for a tool whose whole model is - // authors declaring explicit positions/sizes. Forcing - // `min-width: 0` (only when the author hasn't set their own) - // keeps the box within whatever space its container gives it; - // the painter (`text.rs`) still draws the full unwrapped line - // regardless of that box width, so the text visibly bleeds past - // it exactly as `white-space: nowrap` should — it's the box - // that stays put, not the content. let nowrap = matches!( t.style.white_space, Some( @@ -1635,12 +1144,6 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) { css.min_width = Some(CSize::Length(CLP::Px(0.0))); } } - // M1 follow-up: same reasoning as the `Text` arm above — now that - // `gradient_text.rs` and `caption.rs` word-wrap and respect - // `white-space: nowrap|pre` too (see `intrinsic.rs`'s - // `GradientTextIntrinsic`/`CaptionIntrinsic`), their auto-width - // boxes can hit the same CSS automatic-minimum-size growth when - // nowrap/pre is set with no explicit width. GradientText(t) => { let nowrap = matches!( t.style.white_space, @@ -1667,8 +1170,6 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) { } Divider(d) => match d.direction { DividerDirection::Horizontal => { - // Stretch horizontally in flex row/column parents (cross-axis - // for column = horizontal). Width stays auto. if css.height.is_none() { css.height = Some(CSize::Length(CLP::Px(d.thickness))); } @@ -1695,8 +1196,6 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) { } }, Line(l) => { - // Line draws inside its bounding box at (x1,y1)→(x2,y2). Use the - // bounding box as the intrinsic size so taffy reserves enough room. let w = (l.x2 - l.x1).abs().max(1.0); let h = (l.y2 - l.y1).abs().max(1.0); if css.width.is_none() { @@ -1707,7 +1206,6 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) { } } Arrow(a) => { - // Endpoint bounding box + padding for the arrowhead/curve overshoot. let pad = ARROW_BBOX_PADDING + a.arrow_size.max(0.0); let w = (a.x2 - a.x1).abs().max(1.0) + pad; let h = (a.y2 - a.y1).abs().max(1.0) + pad; @@ -1730,7 +1228,6 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) { } } Cursor(cur) => { - // Fixed-size pointer; legacy measure returns (width, height). if css.width.is_none() { css.width = Some(CSize::Length(CLP::Px(cur.width))); } @@ -1739,16 +1236,9 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) { } } SuccessCheck(c) => { - // The halo is the box: the entrance scales *within* it (0.72→1), - // so the mark never needs more room than its own diameter. apply_default_size(css, c.size, c.size); } Pointer(p) => { - // The box is the arrow glyph, not the area it travels over: the - // waypoints translate the glyph away from this box, and the - // geometry checker exempts `pointer` for exactly that reason. - // Sizing the box to the travel instead would make the pointer - // shove its flex siblings around. if css.width.is_none() { css.width = Some(CSize::Length(CLP::Px(p.size * 0.6))); } @@ -1757,7 +1247,6 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) { } } Particle(_) => { - // Particles fill their parent (legacy returned the max constraints). if css.width.is_none() { css.width = Some(CSize::Length(CLP::String("100%".into()))); } @@ -1894,52 +1383,15 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) { } } - // ── Round 4 audit, lot LAYOUT, constat 4: the 23-components block - // below (`Callout` through `Lottie`) now routes every default size - // through `apply_default_size`, which honours an explicit - // `aspect-ratio` (see its own doc comment) instead of the two - // guards below reaching separate, aspect-ratio-blind defaults — - // `width: 400` + `aspect-ratio: 16/9` used to still get the - // component's unrelated hardcoded default height (e.g. `shape`'s - // 80px) instead of the 225px the ratio implies. - // ── #126 / W3: the 23 components with no size source ───────────── - // - // A card's default flex column gives every child its width via - // `align-items: stretch`, but height stays at the CSS auto-height - // default (0 for a leaf with no intrinsic measurer) — these 23 paint - // nothing. Like the arms above, every default here fires only when - // the author hasn't set the corresponding `style` property, so a - // component that already declares `width`/`height` keeps rendering - // exactly as before. Both width *and* height are always set (not - // just height) so the same defaults also work in a flex *row* (e.g. - // `stat` cards side by side), where width — not height — is the one - // that would otherwise collapse to 0. - // - // Text-bearing "bubble" components (callout/tooltip) and label-flow - // components (pill_nav/stepper) are measured with the exact same - // Skia metrics their own painters use (`measure_text_line_width`), - // so the box matches the ink. Everything else uses either a formula - // derived from the component's own fields (heatmap/skeleton/gauge/ - // marquee) or a fixed size justified by a documented convention - // already established in this project's own skill docs - // (`.claude/skills/rustmotion/rules/*.md`) or its own example - // scenarios (`examples/*.json`) — never a number picked by feel. Callout(t) => { - // Mirrors callout.rs's own `paint()`: 12px text padding, a - // `font_size * 1.4` line height, and the arrow eating into - // whichever axis it points along (width for Left/Right, height - // for Top/Bottom/default). Sized to a single line — since the - // box is fit exactly to the unwrapped text width, the painter's - // own `wrap_text(text, font, Some(text_area_w))` never has a - // reason to wrap, so painted output matches this box exactly. let font_size = t .style .font_size_px_ctx(&crate::intrinsic::measure_time_font_size_ctx(0.0), 16.0); let family = t.style.font_family_or("Inter"); let text_w = measure_text_line_width(&t.text, font_size, family, false); - let h_pad = 12.0; // callout.rs's own `let padding = 12.0;` - let v_pad = 16.0; // breathing room around the line, same order of magnitude as h_pad - let line_h = font_size * 1.4; // callout.rs's own `line_height = font_size * 1.4` + let h_pad = 12.0; + let v_pad = 16.0; + let line_h = font_size * 1.4; let (extra_w, extra_h) = match t.arrow_direction { CalloutArrowDirection::Left | CalloutArrowDirection::Right => (t.arrow_size, 0.0), CalloutArrowDirection::Top | CalloutArrowDirection::Bottom => (0.0, t.arrow_size), @@ -1951,9 +1403,6 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) { ); } Tooltip(t) => { - // Same shape as Callout above; padding value borrowed from - // callout.rs since tooltip.rs's own paint() centers text in the - // body with no defined constant of its own. let font_size = t.style.font_size_px_ctx( &crate::intrinsic::measure_time_font_size_ctx(0.0), t.font_size, @@ -1976,12 +1425,6 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) { ); } PillNav(p) => { - // `height` is already a declared field on the component (like - // Progress/Switch/Slider above) — just promote it to CSS. Width - // replicates pill_nav.rs's own private `compute_tab_layout()` - // formula (h_pad = font_size*1.2 per side, `gap` before/after/ - // between every pill) using the same public fields and the same - // `measure_text_with_fallback` call it makes internally. let font_size = p .style .font_size_px_ctx(&crate::intrinsic::measure_time_font_size_ctx(0.0), 14.0); @@ -1997,21 +1440,6 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) { apply_default_size(css, total_w, p.height); } Marquee(m) => { - // Marquee's whole purpose is to scroll unbounded content, so - // there's no natural content width. A percentage (mirroring - // `Particle`'s "fills its parent" default above) would be the - // obvious choice, but it resolves to 0 against an indefinite - // parent (a card that itself has no explicit width) — the same - // "card with height: auto" case this issue asks to fix, so a - // percentage default would still paint nothing in that case. A - // fixed width sidesteps that: 800px matches this project's own - // marquee usage (examples/mega-showcase.json and SKILL.md's own - // example both use `style.width: 800`, or scale up from there — - // mega-showcase's 1700px is that same scene's marquee spanning a - // much wider bleed banner). Height follows the font-size-to- - // height ratio both of those same real usages share: - // `font_size: 24` paired with `style.height: 48`, i.e. - // `2 × font_size`. let font_size = m.style.font_size_px_ctx( &crate::intrinsic::measure_time_font_size_ctx(0.0), m.font_size, @@ -2019,11 +1447,6 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) { apply_default_size(css, 800.0, font_size * 2.0); } Stepper(s) => { - // Same shape as `Timeline`'s formula above (r*2 + label metrics), - // adapted to stepper.rs's own layout constants: `cy = r + 4.0`, - // label offset `r + 12.0`, description offset - // `label_font_size + 4.0`, and its hardcoded label/description - // font sizes (14px / 11px — not fields, copied from paint()). let n = (s.steps.len().max(1)) as f32; let has_desc = s.steps.iter().any(|st| st.description.is_some()); const LABEL_FS: f32 = 14.0; @@ -2041,10 +1464,6 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) { .fold(0.0_f32, f32::max); match s.orientation { StepperOrientation::Horizontal => { - // Per-step allocation: the node needs ~3 diameters of - // breathing room (a common stepper-UI spacing - // convention), or enough for its longest label/desc, - // whichever is larger. let per_step = (s.node_size * 3.0).max(max_label_w.max(max_desc_w) + 24.0); let label_h = LABEL_FS * 1.3; let desc_h = if has_desc { DESC_FS * 1.3 + 4.0 } else { 0.0 }; @@ -2065,14 +1484,6 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) { } } TagCloud(tc) => { - // Replicates tag_cloud.rs's own per-tag metrics (bold Inter, - // weight-normalized font size between min/max_font_size, its - // hardcoded h_gap=12/v_gap=8) to sum a single-line content width, - // then wraps that at a conventional card-content width (matching - // this project's own tag_cloud usage in - // examples/mega-showcase.json) to estimate a line count and - // hence a height — an approximation of the real flow-wrap - // algorithm, not a re-implementation of it. let n = tc.tags.len(); if n > 0 { let min_w = tc.tags.iter().map(|t| t.weight).fold(f64::MAX, f64::min); @@ -2099,10 +1510,6 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) { } } Heatmap(h) => { - // Fully content-derived from heatmap.rs's own paint() formula: - // `step = cell_size + cell_gap`, cell (col,row) drawn at - // `(col*step, row*step)` sized `cell_size` — so the painted - // extent is exactly `(cols-1)*step + cell_size` per axis. let rows = h.data.len(); let cols = h.data.iter().map(|r| r.len()).max().unwrap_or(0); let step = h.cell_size + h.cell_gap; @@ -2111,65 +1518,26 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) { apply_default_size(css, w, hh); } Sparkline(_) => { - // "Sparkline: no axes, no labels, compact (120x40 default), - // inline use" — documented in - // .claude/skills/rustmotion/rules/data-viz-components.md. apply_default_size(css, 120.0, 40.0); } Stat(_) => { - // Documented default from - // .claude/skills/rustmotion/rules/stat-cards.md's own "GOOD" - // example: `style: { width: 280, height: 180 }`. This is also - // the fix for the issue's second bug: three `stat`s in a flex - // row with no explicit size rendered zero pixels because width - // (not just height) collapsed to 0 in a row context. apply_default_size(css, 280.0, 180.0); } Gauge(g) => { - // Square — gauge.rs's own paint() derives its ring radius from - // `min(w, h)/2 - track_width/2 - 4`. Solved backwards for a - // target radius of 88px (chosen so the value-text font-size — - // this same file's own `radius * 0.45` — comes out to ~40px, - // comfortably legible), so the box scales with the component's - // own `track_width` field rather than a size picked independent - // of it. At the default `track_width` (16px) this lands on - // exactly 200×200, which also matches the midpoint of the - // documented "hero icon" desktop range (160–200px) in - // icon-sizing-hierarchy.md. const TARGET_RADIUS: f32 = 88.0; let size = 2.0 * (TARGET_RADIUS + g.track_width / 2.0 + 4.0); apply_default_size(css, size, size); } DotMap(_) => { - // 2:1 — the standard aspect ratio for an equirectangular world - // map (360° longitude : 180° latitude), the projection - // dot_map.rs's own `geo_to_screen` implements. dot_map.rs always - // paints a full-box background rect first, so any positive size - // shows ink even with zero points. apply_default_size(css, 640.0, 320.0); } Comparison(_) => { - // No natural intrinsic size (the painter just splits whatever - // box it's given at the divider) — matches this project's own - // reference usage in examples/mega-showcase.json's `comparison` - // block. apply_default_size(css, 520.0, 280.0); } Treemap(_) => { - // Slice-and-dice treemap fills whatever box it's given — matches - // this project's own reference usage in - // examples/mega-showcase.json's `treemap` block (near-square, - // the conventional treemap aspect since its rectangles are area- - // proportional in both axes). apply_default_size(css, 416.0, 368.0); } Chart(c) => { - // Pie/donut/radar/radial_bar are inherently circular — a square - // box avoids wasting space on one axis or clipping into an - // ellipse. The other 8 chart types (bar/line/area/scatter/ - // funnel/waterfall/stacked_bar/horizontal_bar) read axis labels - // best in a landscape 4:3, per data-viz-components.md's guidance - // that charts are "larger, standalone" than a sparkline. let round = matches!( c.chart_type, ChartType::Pie | ChartType::Donut | ChartType::Radar | ChartType::RadialBar @@ -2181,30 +1549,16 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) { }; apply_default_size(css, dw, dh); } - Skeleton(s) => { - // `rectangle`: documented default from data-viz-components.md's - // own "GOOD" example (`{ "width": 400, "height": 200 }`). - // `circle`: matches this file's own Icon/Avatar-adjacent 64px - // convention (skeleton circles most commonly stand in for an - // avatar). `text`: fully derived from the component's own - // `lines`/`line_height`/`line_gap` fields, mirroring `List`'s - // formula above — matches skeleton.rs's own per-line paint loop - // (`y = i * (line_height + line_gap)`) exactly. - match s.variant { - SkeletonVariant::Rectangle => apply_default_size(css, 400.0, 200.0), - SkeletonVariant::Circle => apply_default_size(css, 64.0, 64.0), - SkeletonVariant::Text => { - let n = s.lines.max(1) as f32; - let h = n * s.line_height + (n - 1.0).max(0.0) * s.line_gap; - apply_default_size(css, 240.0, h); - } + Skeleton(s) => match s.variant { + SkeletonVariant::Rectangle => apply_default_size(css, 400.0, 200.0), + SkeletonVariant::Circle => apply_default_size(css, 64.0, 64.0), + SkeletonVariant::Text => { + let n = s.lines.max(1) as f32; + let h = n * s.line_height + (n - 1.0).max(0.0) * s.line_gap; + apply_default_size(css, 240.0, h); } - } + }, Mockup(m) => { - // Per-device aspect matches each device's real-world screen - // proportions: phones (iPhone/Android) ~9:19.5 (modern - // flagship aspect), laptop 16:10 (the common MacBook/ultrabook - // ratio), browser 16:9 (the standard desktop viewport ratio). let (dw, dh) = match m.device { MockupDevice::Iphone | MockupDevice::Android => (320.0, 690.0), MockupDevice::Laptop => (640.0, 400.0), @@ -2213,46 +1567,21 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) { apply_default_size(css, dw, dh); } Icon(_) => { - // 64×64 — the midpoint of the documented "card / feature icon" - // role across all three device classes in - // icon-sizing-hierarchy.md (desktop 40–56px, mobile 72–96px, - // square 60–80px), and a size icon asset systems near-universally - // ship as a default export (24/32/48/64 being the common family). apply_default_size(css, 64.0, 64.0); } Svg(_) => { - // 200×200 — square, since an arbitrary vector graphic (icon, - // diagram, or logo) has no single natural aspect; matches the - // common equal-aspect SVG viewBox convention and sits above - // Icon's 64px "card icon" role for the more elaborate content - // `svg` typically carries (illustrations/diagrams, not glyphs). apply_default_size(css, 200.0, 200.0); } Shape(_) => { - // 80×80 — matches the median of this project's own decorative - // (non full-bleed-background, non-divider-line) shape usages in - // examples/*.json, which cluster at 44–70px for accent shapes - // (26, 36, 44, 60, 70, 140 — median ~55, rounded up for - // visibility as a standalone default rather than a same-scene - // accent tuned against neighbours). apply_default_size(css, 80.0, 80.0); } Image(_) => { - // 4:3 (400×300) — the traditional default photo aspect ratio, - // distinct from Video/Gif's 16:9 below so a generic still image - // doesn't presume widescreen framing. apply_default_size(css, 400.0, 300.0); } Video(_) | Gif(_) => { - // 16:9 (400×225) — the industry-standard video aspect ratio - // (matches every render resolution this project documents: - // 1920×1080, 1280×720), scaled down to a card-sized default. apply_default_size(css, 400.0, 225.0); } Lottie(_) => { - // 300×300 — square, matching the aspect the vast majority of - // Lottie animation assets ship at (LottieFiles' own marketplace - // preview convention is a 1:1 canvas). apply_default_size(css, 300.0, 300.0); } @@ -2260,26 +1589,6 @@ fn apply_intrinsic_overrides(component: &Component, css: &mut CssStyle) { } } -/// Apply a component's natural default size (`dw` × `dh`) to `css`, honouring -/// an explicit `aspect-ratio` instead of always falling back to `dw`/`dh` -/// independently (round 4 audit, lot LAYOUT, constat 4 — the previous code -/// guarded each axis with its own `is_none()` check and never looked at -/// `aspect-ratio`, so `width: 400` + `aspect-ratio: 16/9` still got the -/// component's unrelated hardcoded default height instead of 225). -/// -/// - Both axes already set: untouched (the author fully specified the box). -/// - One axis set to a fixed pixel length, `aspect-ratio` present: the other -/// axis is derived from it (`h = w / ratio` or `w = h * ratio`) — the CSS -/// replaced-element sizing rule for a single definite axis plus a -/// preferred aspect ratio. -/// - Neither axis set: the natural default width is kept (there is no -/// author-declared axis to derive from), and height is derived from -/// `aspect-ratio` when present, the natural default height otherwise. -/// -/// `min-*`/`max-*` need no equivalent guard here: taffy clamps the final -/// used size against them at layout time regardless of what `size` resolves -/// to (`style.min_size`/`max_size` in `taffy_bridge::to_taffy_style`), so a -/// default below `min-width` is corrected downstream, not silently wrong. fn apply_default_size(css: &mut CssStyle, dw: f32, dh: f32) { let ratio = css.aspect_ratio.filter(|r| *r > 0.0); match (css.width.is_some(), css.height.is_some()) { @@ -2306,11 +1615,6 @@ fn apply_default_size(css: &mut CssStyle, dw: f32, dh: f32) { } } -/// Extract a fixed pixel value from a `Size`, if it resolves to one without a -/// `LengthContext` (only `Size::Length(LengthPercentage::Px(_))` — a bare -/// number or `"NNpx"`). `%`/`vw`/`vh`/`em`/`rem` and `auto` return `None`: -/// `apply_default_size` can't derive a ratio from a length it can't resolve -/// at build time, so it falls back to the component's hardcoded default. fn fixed_px(size: Option<&CSize>) -> Option { match size? { CSize::Length(lp) => match lp.try_parse()? { @@ -2321,7 +1625,6 @@ fn fixed_px(size: Option<&CSize>) -> Option { } } -/// Borrow the `CssStyle` from any component. fn component_style(c: &Component) -> &CssStyle { use Component::*; match c { @@ -2381,7 +1684,6 @@ fn component_style(c: &Component) -> &CssStyle { } } -/// Short kind label for a component (for studio selection / inspector display). pub fn component_kind(c: &Component) -> &'static str { use Component::*; match c { @@ -2435,11 +1737,6 @@ pub fn component_kind(c: &Component) -> &'static str { Timeline(_) => "timeline", Tooltip(_) => "tooltip", Treemap(_) => "treemap", - // The schema tag is `div` (`#[serde(rename = "div", alias = - // "container", alias = "card", alias = "flex", alias = "grid", alias - // = "positioned")]` on the enum in `lib.rs`) — the other five only - // survive as deserialize aliases, so naming this label anything else - // told an author to look for a tag their scenario cannot contain. Container(_) => "div", AudioSpectrum(_) => "audio_spectrum", Waveform(_) => "waveform", @@ -2450,11 +1747,6 @@ pub fn component_kind(c: &Component) -> &'static str { mod tests { use super::*; - /// Two timeline steps on one node. Each step is documented to trigger at - /// its own `at`, and `apply_style_states` already gates a step's `style` - /// that way — its `animation` half must obey the same rule, or a step - /// that has not begun still sets the value through the shared - /// last-effect-wins bucket. #[test] fn a_timeline_step_leaves_the_value_alone_until_its_at() { let component: Component = serde_json::from_value(json!({ @@ -2505,12 +1797,6 @@ mod tests { ); } - /// A `start_at`ed entrance must play from its own first keyframe, not - /// from wherever the unrebased scene clock already landed it. Measured - /// bug: a `fade_in_down` (0.6s) on a `start_at: 2.0` node resolved at - /// t=2.0 (the instant it becomes visible) to the animation's value at - /// scene time 2.0 — long past the 0.6s duration — so it appeared already - /// fully faded in instead of animating. #[test] fn start_at_rebases_the_entrance_animation_clock() { let scene = vec![ChildComponent { @@ -2560,12 +1846,6 @@ mod tests { ); } - /// Companion to the entrance case above, mirroring the measured `badge` - /// bug: an exit declared after the entrance (so it alone owns `opacity` - /// under last-declared-wins) carries its own `delay`. Unrebased, that - /// delay is measured from scene time zero, so the exit can finish before - /// `start_at` is even reached — the component then renders zero pixels - /// for its entire visible window. #[test] fn start_at_rebases_an_exit_animation_declared_after_the_entrance() { let scene = vec![ChildComponent { @@ -2689,7 +1969,7 @@ mod tests { fn empty_scene_has_only_root() { let built = build_scene(&[], (1920.0, 1080.0)); assert_eq!(built.root.children.len(), 0); - assert_eq!(built.components.len(), 1); // synthetic root slot + assert_eq!(built.components.len(), 1); } #[test] @@ -2764,7 +2044,6 @@ mod tests { let c2 = layout .get(card_box.children[1].id) .expect("shape 2 laid out"); - // Padding 20 from top, then first shape 50 high, gap 10 → 80. assert_eq!(c1.x, 20.0); assert_eq!(c1.y, 20.0); assert_eq!(c2.x, 20.0); @@ -2836,9 +2115,6 @@ mod tests { #[test] fn text_child_in_flex_card_gets_cosmic_intrinsic_size() { - // A flex column card with no fixed size — its children's intrinsic - // sizes should determine the card's width/height. The text child - // must be measured via cosmic-text, not collapse to 0×0. use crate::container::ContainerComponent; use crate::text::Text; @@ -2912,7 +2188,6 @@ mod tests { text_layout.height ); - // Card height should hug the text + 2×padding(20) = ~text_h + 40. let card_layout = layout.get(card_id).expect("card laid out"); assert!( card_layout.height >= text_layout.height + 40.0 - 1.0, @@ -2958,7 +2233,6 @@ mod tests { let l = layout .get(built.root.children[0].id) .expect("arrow laid out"); - // bbox 100×60 + (16 padding + 12 arrow_size) = 128×88. assert_eq!(l.width, 128.0); assert_eq!(l.height, 88.0); } @@ -2995,15 +2269,12 @@ mod tests { let l = layout .get(built.root.children[0].id) .expect("connector laid out"); - // bbox 100×50 + (16 + 10) = 126×76. assert_eq!(l.width, 126.0); assert_eq!(l.height, 76.0); } #[test] fn counter_intrinsic_size_reserves_space_for_max_value() { - // 1234 → 1234 → format with 0 decimals → measure largest absolute value. - // Expectation: width > 0 (cosmic-text didn't fail), height ≈ font_size × line_height. use crate::counter::Counter; use rustmotion_core::css::units::Length; @@ -3044,7 +2315,6 @@ mod tests { "counter width should be > 0, got {}", l.width ); - // line_height defaults to font_size × 1.3 = 83.2. Allow some slack. assert!( l.height >= 60.0, "counter height should be ≥ ~one line ({}), got {}", @@ -3055,8 +2325,6 @@ mod tests { #[test] fn badge_intrinsic_size_includes_padding_and_text() { - // Default size = Md → font_size 14, h_pad 12, v_pad 6, icon 18. - // Without an icon, height ≈ 6×2 + 14×1.3 ≈ 30.2. use crate::badge::{Badge, BadgeSize, BadgeVariant}; let badge = ChildComponent { @@ -3086,7 +2354,6 @@ mod tests { let layout = run_layout(&built.root, (400.0, 200.0), &ConversionContext::default()); let id = built.root.children[0].id; let l = layout.get(id).expect("badge laid out"); - // h_pad×2 = 24 alone, plus the text width. assert!( l.width > 24.0, "badge width should exceed padding alone, got {}", @@ -3131,12 +2398,8 @@ mod tests { assert_eq!(l.height, 60.0); } - // ─── M2: rich_text intrinsic wired into component_intrinsic ───────────── - #[test] fn rich_text_child_gets_a_non_zero_intrinsic_size() { - // Before the fix: rich_text had no `component_intrinsic` entry, so - // an auto-sized rich_text laid out at 0×0 and was invisible. use crate::rich_text::{RichText, RichTextSpan}; use rustmotion_core::css::units::Length; @@ -3195,8 +2458,6 @@ mod tests { ); } - // ─── M3: the `glow` effect renders as a coloured drop-shadow ──────────── - #[test] fn glow_effect_adds_a_coloured_drop_shadow_filter() { use rustmotion_core::css::style::{Color, FilterFn}; @@ -3237,9 +2498,6 @@ mod tests { } other => panic!("expected Some(Length::Px(12.0)) blur, got {:?}", other), } - // The defect this fixes: `color: None` resolves to black downstream - // (see `paint_pass.rs`'s `unwrap_or(SColor::BLACK)`). The colour must - // be `Some` and must match the configured glow colour, not black. match color { Some(Color::Rgba { r, g, b, a }) => { assert_eq!(*r, 0x5C); @@ -3259,7 +2517,7 @@ mod tests { let mut shape = make_shape(100.0, 100.0); if let Component::Shape(ref mut s) = shape.component { s.style.animation = vec![AnimationEffect::Glow(GlowConfig { - color: "#FFFFFFFF".to_string(), // opaque white + color: "#FFFFFFFF".to_string(), radius: 10.0, intensity: 0.5, })]; @@ -3304,11 +2562,6 @@ mod tests { assert!(built.root.children[0].css.filter.is_none()); } - // ─── #126 / W3: the 23 components with no size source ─────────────────── - - /// Build a `ChildComponent` from a JSON literal (matches the `type`-tagged - /// `Component` enum's own `Deserialize` impl) — much less error-prone than - /// a full struct literal for components with a dozen+ fields. fn child_from_json(json: serde_json::Value) -> ChildComponent { let component: Component = serde_json::from_value(json.clone()).unwrap_or_else(|e| panic!("{e}\n{json:#}")); @@ -3323,11 +2576,6 @@ mod tests { } } - /// Lay out a single unsized child inside a card and return its final - /// `(width, height)`. The card itself has no explicit width or height - /// either, so this also exercises acceptance criterion #3 ("a card with - /// height: auto sizes to the component instead of collapsing to - /// padding"). fn layout_in_auto_card(child_json: serde_json::Value) -> (f32, f32) { let card = make_card(vec![child_from_json(child_json)], CssStyle::default()); let scene = vec![ChildComponent { @@ -3346,13 +2594,6 @@ mod tests { (l.width, l.height) } - /// Every one of the 23 components #126 lists gets a positive width *and* - /// height with no `style` at all — before this file's fix they all laid - /// out at 0×0 inside a card (proven by rendering: see the workstream's - /// scratch `ink-measure` harness, which shows every one of these going - /// from 0 painted pixels to a positive count under the identical fixture). - /// A positive layout box is the necessary precondition for any of that - /// painted ink — this test is the fast, render-free regression guard. #[test] fn all_23_unsized_components_get_a_positive_box_in_a_card() { let cases: &[(&str, serde_json::Value)] = &[ @@ -3411,8 +2652,6 @@ mod tests { #[test] fn heatmap_intrinsic_size_matches_cell_grid_formula() { - // 2 rows x 3 cols, default cell_size=14, cell_gap=3. - // width = (3-1)*(14+3) + 14 = 48, height = (2-1)*17 + 14 = 31. let (w, h) = layout_in_auto_card(json!({"type":"heatmap","data":[[1.0,2.0,3.0],[4.0,5.0,6.0]]})); assert_eq!(w, 48.0); @@ -3421,8 +2660,6 @@ mod tests { #[test] fn sparkline_gets_the_documented_120x40_default() { - // .claude/skills/rustmotion/rules/data-viz-components.md: "Sparkline: - // ... compact (120x40 default), inline use." let (w, h) = layout_in_auto_card(json!({"type":"sparkline","data":[1.0,2.0,3.0]})); assert_eq!(w, 120.0); assert_eq!(h, 40.0); @@ -3430,7 +2667,6 @@ mod tests { #[test] fn stat_gets_the_documented_280x180_default() { - // .claude/skills/rustmotion/rules/stat-cards.md's own "GOOD" example. let (w, h) = layout_in_auto_card(json!({"type":"stat","value":"42"})); assert_eq!(w, 280.0); assert_eq!(h, 180.0); @@ -3445,17 +2681,12 @@ mod tests { #[test] fn pill_nav_height_promotes_its_own_declared_field() { - // `height` is already a field on PillNav (default 44.0) — the fix - // just promotes it to CSS, like Progress/Switch/Slider above. let (_, h) = layout_in_auto_card(json!({"type":"pill_nav","items":["Overview"]})); assert_eq!(h, 44.0); } #[test] fn callout_width_grows_with_its_own_text_content() { - // A longer text must produce a wider box (content-derived, not a - // fixed constant) — proves the measured-text path is actually wired - // up, not just a padding-only fallback. let (short_w, _) = layout_in_auto_card(json!({"type":"callout","text":"Hi"})); let (long_w, _) = layout_in_auto_card( json!({"type":"callout","text":"This is a much longer callout message"}), @@ -3468,10 +2699,6 @@ mod tests { #[test] fn three_stats_in_a_flex_row_all_get_a_positive_width() { - // #126's second bug: 3 `stat`s in a flex-row card with no explicit - // size rendered zero pixels because *width* (not height) collapsed - // to 0 — align-items:stretch only helps the cross axis, which is - // height in a row. let stats = vec![ child_from_json(json!({"type":"stat","value":"45.2K","label":"Users"})), child_from_json(json!({"type":"stat","value":"12%","label":"Growth"})), @@ -3512,11 +2739,6 @@ mod tests { } } - // ── Round 4 audit, lot LAYOUT, constat 2: the CSS cascade is wired ────── - // `crates/rustmotion-core/src/css/cascade.rs::inherit_from` existed but - // nothing called it — `color`/`font-*` set on a container never reached - // children lacking their own value. - #[test] fn card_color_cascades_to_text_child_with_no_color_of_its_own() { use rustmotion_core::css::style::Color; @@ -3583,9 +2805,6 @@ mod tests { #[test] fn card_display_does_not_cascade_to_text_child() { - // `display` is not an inheritable CSS property — only the documented - // inheritable list (color, font-*, text-align, white-space, ...) - // should propagate. let card = make_card( vec![make_text("hello", CssStyle::default())], CssStyle { @@ -3607,9 +2826,6 @@ mod tests { assert_eq!(text_box.css.display, None); } - // ── Round 4 audit, lot LAYOUT, constat 4: `apply_intrinsic_overrides`'s - // default size ignored an explicit `aspect-ratio`. ───────────────────── - fn make_aspect_shape(width: f32, aspect_ratio: f32) -> ChildComponent { ChildComponent { id: None, @@ -3637,9 +2853,6 @@ mod tests { #[test] fn explicit_width_with_aspect_ratio_derives_height_instead_of_the_hardcoded_default() { - // `shape`'s hardcoded default is 80×80 (see `apply_intrinsic_overrides`). - // `width: 400` + `aspect-ratio: 16/9` should derive height = 225, not - // fall back to the unrelated 80px default. let scene = vec![make_aspect_shape(400.0, 16.0 / 9.0)]; let built = build_scene(&scene, (1920.0, 1080.0)); let layout = run_layout(&built.root, (1920.0, 1080.0), &ConversionContext::default()); @@ -3660,9 +2873,6 @@ mod tests { #[test] fn neither_axis_set_with_aspect_ratio_derives_height_from_the_default_width() { - // No width/height at all: the natural default width (80 for shape) - // is kept, but height should come from the aspect-ratio, not the - // unrelated 80px default. let scene = vec![make_aspect_shape_no_width(2.0)]; let built = build_scene(&scene, (1920.0, 1080.0)); let layout = run_layout(&built.root, (1920.0, 1080.0), &ConversionContext::default()); diff --git a/crates/rustmotion-components/src/callout.rs b/crates/rustmotion-components/src/callout.rs index 81052ccb..3b756865 100644 --- a/crates/rustmotion-components/src/callout.rs +++ b/crates/rustmotion-components/src/callout.rs @@ -76,10 +76,6 @@ impl Callout { self.style.border_radius_px_or(8.0) } - /// Resolves `font-size` against a real per-frame viewport (`rem`/`vw`/ - /// `vh` now resolve instead of silently dropping to 0px — lot B, wave - /// S). `em`/`%` on `font-size` itself remain approximate — see - /// `crate::intrinsic::font_size_ctx`'s doc comment. fn font_size(&self, ctx: &PaintCtx) -> f32 { self.style.font_size_px_ctx( &crate::intrinsic::font_size_ctx(ctx.video_width as f32, ctx.video_height as f32, 0.0), @@ -99,7 +95,6 @@ impl Callout { fn arrow_path(&self, w: f32, h: f32) -> Path { let mut path = PathBuilder::new(); let a = self.arrow_size; - // Overlap the arrow base 1px into the bubble to eliminate anti-aliasing seam let overlap = 1.0; match self.arrow_direction { @@ -148,7 +143,6 @@ impl Callout { let radius = self.radius(); let font_size = self.font_size(ctx); - // Draw bubble body let bubble = self.bubble_rect(w, h); let rrect = RRect::new_rect_xy(bubble, radius, radius); let mut bg_paint = paint_from_hex(self.bg_color()); @@ -156,11 +150,9 @@ impl Callout { bg_paint.set_anti_alias(true); canvas.draw_rrect(rrect, &bg_paint); - // Draw arrow let arrow = self.arrow_path(w, h); canvas.draw_path(&arrow, &bg_paint); - // Draw text let font_style = skia_safe::FontStyle::normal(); let family = self.style.font_family.as_deref().unwrap_or("Inter"); let typeface = typeface_with_fallback(family, font_style)?; @@ -228,12 +220,8 @@ mod tests { } } - // ─── Lot B, wave S: relative `font-size` units ───────────────────────── - #[test] fn rem_font_size_paints_visible_ink() { - // Reproduction: `font-size: "2rem"` used to resolve to 0px via the - // context-free `font_size_px_or`. let callout = Callout { text: "hello".to_string(), arrow_direction: ArrowDirection::default(), @@ -271,9 +259,6 @@ mod tests { skia_safe::image::CachingHint::Disallow, ); assert!(ok, "pixel read should succeed"); - // Text is white (#FFFFFF default) on a dark #333333 bubble — probe - // for near-white ink specifically, since the bubble background - // paints regardless of font-size. let text_ink = buf .as_chunks::<4>() .0 diff --git a/crates/rustmotion-components/src/caption.rs b/crates/rustmotion-components/src/caption.rs index cf14d6a6..23f29767 100644 --- a/crates/rustmotion-components/src/caption.rs +++ b/crates/rustmotion-components/src/caption.rs @@ -57,13 +57,6 @@ rustmotion_core::impl_traits!(Caption { impl Caption { fn paint(&self, canvas: &Canvas, layout_width: f32, layout_height: f32, ctx: &PaintCtx) { let time = ctx.time; - // #9 / lot B (wave S): `font-size` itself now resolves through the - // same context-aware machinery as `letter-spacing`/`line-height` - // below — it used to stay on the context-free `font_size_px_or`, - // silently dropping `rem`/`vw`/`vh` font-size to 0px. `em`/`%` on - // `font-size` itself remain approximate (see - // `crate::intrinsic::font_size_ctx`'s doc comment) — cascade.rs - // doesn't track the real parent font-size. let base_ctx = crate::intrinsic::font_size_ctx( ctx.video_width as f32, ctx.video_height as f32, @@ -73,20 +66,11 @@ impl Caption { let color = self.style.color_str_or("#FFFFFF"); let font_family = self.style.font_family_or("Inter"); - // #9: `letter-spacing`/`line-height` `em`/`%` resolve against this - // element's own font-size (just above); `vw`/`vh` resolve against - // the real viewport, available here via `ctx` (mirrors - // `text.rs::paint`'s `type_ctx`). let type_ctx = LengthContext { font_size, ..base_ctx }; - // #9: derive weight/slant from `style.font-weight`/`font-style` - // instead of always painting bold. `CaptionIntrinsic` (via - // `TextIntrinsic`) measures at whatever weight the style declares - // (400/normal when unset) — painting an unconditional bold made the - // glyphs wider than the box that was centred/measured for them. let font_style = Self::resolve_font_style(&self.style); let Ok(typeface) = typeface_with_fallback(font_family, font_style) else { @@ -96,28 +80,9 @@ impl Caption { let font = Font::from_typeface(typeface, font_size); let emoji_font = emoji_typeface().map(|tf| Font::from_typeface(tf, font_size)); - // Every branch below draws text with its *baseline* at local y=0 and - // (for the pill-background presets) a highlight box extending up to - // `font_size + padding/2` above that baseline. Treated as the box's - // own coordinate space (y=0 = box top, as every other painter - // assumes), that put glyphs — and pills further still — above the - // assigned box: `CaptionIntrinsic` sizes the box for one line's - // ascent+descent starting at y=0, not for a baseline sitting at 0 - // with ascenders going negative. Shifting the whole paint down by a - // margin that covers the tallest pill (WordPop's `font_size*0.35` - // padding, ~1.175×font_size) puts the topmost ink at/after y=0 - // without touching any of the per-preset layout math below. let top_offset = font_size * 1.2; canvas.save(); canvas.translate((0.0, top_offset)); - // Safety net: even with the offset above, an unusually large pill - // padding combined with a short assigned box could still spill past - // the bottom edge. Clip vertically only (not horizontally) — a - // caption in `white-space: nowrap` mode is *meant* to bleed past - // its own width when `max_width` doesn't fit the line (see - // `box_builder.rs`'s nowrap comment and the geometry validator's - // `unwrappable_text_overflow`), so clipping width here would hide a - // condition the validator is supposed to catch instead. if layout_height > 0.0 { const HALF_PLANE: f32 = 1_000_000.0; canvas.clip_rect( @@ -172,13 +137,9 @@ impl Caption { measure_text_with_fallback(&word.text, &font, &emoji_font, 0.0); let cx = layout_width / 2.0; - // Spring-like pop: ease-out-back over the first 180ms - // of the word window (overshoots ~1.1 then settles). let t = (((time - word.start) / POP_DURATION).clamp(0.0, 1.0)) as f32; let scale = ease_out_back(t).max(0.01); - // Scale around the visual center of the word (the - // baseline sits at y=0, glyphs extend upward). let cy = -font_size * 0.35; canvas.save(); canvas.translate((cx, cy)); @@ -213,25 +174,10 @@ impl Caption { } } CaptionStyle::Highlight | CaptionStyle::Karaoke | CaptionStyle::KaraokePop => { - // M1: `white-space: nowrap|pre` keeps every word on one - // line — ignore `max_width` entirely so the line can bleed - // past it, same rule `text.rs` uses. (`WordByWord`/`WordPop` - // above show a single word at a time; wrapping is moot - // there, same as the existing kbd/counter/badge "atomic" - // components, so they don't need this branch.) let nowrap = matches!( self.style.white_space, Some(CssWhiteSpace::Nowrap | CssWhiteSpace::Pre) ); - // #1: when `max_width` is unset, wrap at the box `layout` - // actually gave this caption (matches `text.rs:442-451`) - // instead of never wrapping — `CaptionIntrinsic` measures - // (and taffy reserves a box) against that same width, so - // painting at `f32::MAX` here painted a single line far - // wider than the reserved box, bleeding past it and past - // the viewport with `validate` never seeing the mismatch - // (it re-measures via the same intrinsic, not this paint - // path). let max_width = if nowrap { f32::MAX } else if layout_width.is_finite() && layout_width > 0.0 { @@ -256,15 +202,6 @@ impl Caption { current_x += word_width + space_width; } - // #9: honour `style.line-height` like `CaptionIntrinsic` - // does (via `TextIntrinsic::from_parts` -> - // `line_height_for_ctx`) instead of a hardcoded 1.4 — the - // box taffy reserves is sized from the former, so painting - // with the latter drifted the line spacing away from what - // was measured (7.7% at the unset default, arbitrarily more - // with an explicit `line-height`), and the caption's own - // vertical clip (below in the outer `paint`) silently crops - // whatever spills past the mismatch. let line_height = self.style.line_height_for_ctx(font_size, &type_ctx); let cx = layout_width / 2.0; @@ -303,8 +240,6 @@ impl Caption { let paint = paint_from_hex(word_color); if pop { - // Active word scales up ~1.15x around its visual - // center, on top of a pill background. let wcx = x + word_width / 2.0; let wcy = y - font_size * 0.35; canvas.save(); @@ -347,19 +282,12 @@ impl Caption { } impl Caption { - /// Draws the rounded pill background used by the pop presets. fn draw_pill(&self, canvas: &Canvas, rect: Rect) { let radius = rect.height() / 2.0; let paint = paint_from_hex(self.pill_color.as_deref().unwrap_or(DEFAULT_PILL_COLOR)); canvas.draw_rrect(skia_safe::RRect::new_rect_xy(rect, radius, radius), &paint); } - /// #9: the Skia `FontStyle` to paint with, derived from `style.font- - /// weight`/`font-style` — mirrors `text.rs`'s weight/slant mapping and - /// `intrinsic.rs`'s `weight_to_u16` (used to measure the box), so the - /// weight the box was measured at and the weight painted into it always - /// agree. Pulled out as its own function so it's directly unit-testable - /// without needing to render anything. fn resolve_font_style(style: &CssStyle) -> FontStyle { let weight = match &style.font_weight { Some(CssFontWeight::Keyword(FontWeightKw::Bold | FontWeightKw::Bolder)) => { @@ -394,16 +322,12 @@ fn default_active_color() -> String { "#FFFF00".to_string() } -/// Black at 70% opacity — default pill background for the pop presets. const DEFAULT_PILL_COLOR: &str = "#000000B3"; -/// Duration (seconds) of the word_pop scale-in. const POP_DURATION: f64 = 0.18; -/// Scale factor applied to the active word in karaoke_pop. const KARAOKE_POP_SCALE: f32 = 1.15; -/// Ease-out-back easing: starts at 0, overshoots ~1.1, settles at 1. fn ease_out_back(t: f32) -> f32 { const C1: f32 = 1.70158; const C3: f32 = C1 + 1.0; @@ -418,9 +342,6 @@ mod tests { use rustmotion_core::css::Length; use rustmotion_core::schema::CaptionWord; - /// A `PaintCtx` for tests that don't care about frame/fps bookkeeping — - /// only `time` and, since #9, the viewport dims threaded into the - /// `LengthContext` used to resolve `vw`/`vh` typography units. fn test_ctx(time: f64) -> PaintCtx { PaintCtx { time, @@ -468,8 +389,6 @@ mod tests { } } - /// Bounding box (min_x, max_x, min_y, max_y) of every non-transparent - /// pixel on the surface, or `None` if nothing was painted. fn ink_bounds( surface: &mut skia_safe::Surface, w: i32, @@ -507,12 +426,6 @@ mod tests { #[test] fn ink_never_starts_above_the_box_top() { - // #127: every branch drew its baseline at local y=0 (the box's own - // top edge) — ascenders, and pill backgrounds further still, - // painted *above* y=0, bleeding out of whatever box the layout - // gave this caption (measured: assigned box top at y=124 in a - // card starting at y=100, but ink started at y≈85 — above the - // card itself, not just the box). let caption = make_caption("Hello world", None); const W: i32 = 400; const H: i32 = 200; @@ -528,9 +441,6 @@ mod tests { #[test] fn word_pop_pill_never_starts_above_the_box_top() { - // The pill-background presets pad further above the baseline than - // plain text (up to `font_size * 0.35` extra) — the worst case - // among the four rendering modes. let mut caption = make_caption("Hello", None); caption.mode = CaptionStyle::WordPop; caption.words[0].start = 0.0; @@ -549,9 +459,6 @@ mod tests { #[test] fn nowrap_paints_one_wide_line_instead_of_wrapping_at_max_width() { - // M1 render-level proof, caption (Highlight mode): `white-space: - // nowrap` keeps every word on one line — much wider than - // `max_width: 80`, and only one line tall — instead of wrapping. let caption = make_caption( "the quick brown fox jumps over the lazy dog", Some(CssWhiteSpace::Nowrap), @@ -605,21 +512,12 @@ mod tests { ); } - // ─── #1: wrap at the box's layout_width when max_width is unset ─────── - #[test] fn wraps_at_layout_width_when_max_width_is_unset() { - // Reproduction: no `max_width` on the caption (the common case — a - // caption's box comes from wherever it's placed, e.g. a card), but - // the layout pass still hands `paint` a real, finite `layout_width` - // (mirrors `CaptionIntrinsic`, which measures against exactly this - // width). Before the fix, `max_width.unwrap_or(f32::MAX)` ignored - // `layout_width` entirely and painted one line stretching far past - // the box — and past the viewport in the audit's repro. let caption = make_caption_with_max_width( "the quick brown fox jumps over the lazy dog again", None, - None, // no explicit max_width + None, ); const W: i32 = 1600; const H: i32 = 400; @@ -627,8 +525,6 @@ mod tests { { let canvas = surface.canvas(); canvas.translate((800.0, 200.0)); - // The box the layout pass assigned: 300px wide, well short of - // this sentence's unwrapped width at font-size 28. caption.paint(canvas, 300.0, H as f32, &test_ctx(0.5)); } let (minx, maxx, miny, maxy) = @@ -648,8 +544,6 @@ mod tests { #[test] fn nowrap_still_ignores_layout_width_when_max_width_is_unset() { - // Regression guard: the #1 fix must not touch `white-space: - // nowrap`'s existing "always ignore any width constraint" contract. let caption = make_caption_with_max_width( "the quick brown fox jumps over the lazy dog", Some(CssWhiteSpace::Nowrap), @@ -678,17 +572,8 @@ mod tests { ); } - // ─── #9: line-height / font-weight measure-vs-paint parity ──────────── - #[test] fn honours_style_line_height_instead_of_hardcoded_1_4() { - // Reproduction: `style.line-height: 0.9` must change the vertical - // gap between wrapped lines. Before the fix, the painter always - // used `font_size * 1.4` regardless of `style.line-height`, while - // `CaptionIntrinsic` (the box taffy reserves) honoured it — a - // caption author following rules/typography-readability.md's - // guidance to set `line-height` got a box sized for their value but - // glyphs painted at a fixed 1.4. let mut tight = make_caption_with_max_width( "one two three four five six seven eight", None, @@ -732,13 +617,8 @@ mod tests { ); } - // ─── Lot B, wave S: relative `font-size` units ───────────────────────── - #[test] fn rem_font_size_paints_visible_ink() { - // Reproduction: `font-size: "2rem"` used to resolve to 0px on the - // context-free `font_size_px_or` path — `CaptionIntrinsic` (via - // `TextIntrinsic`) measured a 0-height box and nothing painted. let mut caption = make_caption_with_max_width("hello world", None, Some(300.0)); caption.style.font_size = Some(Length::String("2rem".into())); const W: i32 = 400; @@ -755,22 +635,8 @@ mod tests { ); } - // `Caption::resolve_font_style` is the exact weight/slant computation - // `paint` uses; testing it directly is deterministic regardless of - // whether the system's resolved "bold" and "normal" typefaces happen to - // have visually/metrically distinct advance widths on this particular - // host (on this machine, Helvetica's bold and normal share identical - // glyph metrics — a pixel-width comparison would pass whether or not - // `paint` used the right weight, which isn't a real check of the fix). - #[test] fn resolve_font_style_defaults_to_normal_matching_the_intrinsic_measurement() { - // #9 (weight half): `CaptionIntrinsic` (via `TextIntrinsic`'s - // `weight_to_u16`) measures at weight 400 when `style.font-weight` - // is unset. Before the fix, `paint` ignored `style.font-weight` - // entirely and always painted `FontStyle::bold()` (weight 700) — a - // silent measure-vs-paint weight mismatch on every caption that - // doesn't set an explicit font-weight (the common case). let style = CssStyle::default(); let resolved = Caption::resolve_font_style(&style); assert_eq!( @@ -788,9 +654,6 @@ mod tests { }; assert_eq!(*Caption::resolve_font_style(&bold).weight(), 700); - // Below the >=600 "treat as bold" threshold (same threshold - // `text.rs`'s equivalent mapping uses), so the exact numeric value - // passes through unchanged. let numeric = CssStyle { font_weight: Some(CssFontWeight::Number(350)), ..Default::default() diff --git a/crates/rustmotion-components/src/chart/axes.rs b/crates/rustmotion-components/src/chart/axes.rs index 6307ad69..cc1855d0 100644 --- a/crates/rustmotion-components/src/chart/axes.rs +++ b/crates/rustmotion-components/src/chart/axes.rs @@ -7,10 +7,8 @@ use rustmotion_core::engine::renderer::{ use super::Chart; -/// Returns "#000000" or "#FFFFFF" depending on the perceived luminance of `hex`. pub(super) fn contrast_text_color(hex: &str) -> String { let (r, g, b, _) = parse_hex_color(hex); - // Relative luminance (sRGB) let lum = 0.299 * r as f64 + 0.587 * g as f64 + 0.114 * b as f64; if lum > 150.0 { "#000000".to_string() @@ -32,14 +30,6 @@ pub(super) fn format_number(val: f64) -> String { } impl Chart { - /// Draw grid lines and axis labels for cartesian charts. - /// - /// When `categorical` is true, x-labels are placed at the center of evenly - /// distributed slots (`(i+0.5)/n * chart_w`) — appropriate for bar charts - /// where labels belong to a discrete bar slot. When false, labels are - /// placed at proportional positions (`i/(n-1) * chart_w`), spanning the - /// full chart width — appropriate for line/scatter where labels mark - /// points on a continuous axis. pub(super) fn draw_axes( &self, canvas: &Canvas, @@ -60,7 +50,6 @@ impl Chart { let (_, metrics) = font.metrics(); let ascent = -metrics.ascent; - // Grid lines + Y labels let grid_steps = 5; let range = max_val - min_val; @@ -97,7 +86,6 @@ impl Chart { } } - // X labels if self.show_x_labels && !x_labels.is_empty() { let mut label_paint = paint_from_hex(&self.label_color); label_paint.set_anti_alias(true); @@ -112,11 +100,6 @@ impl Chart { chart_x + (i as f32 / (n - 1).max(1) as f32) * chart_w }; let label_w = measure_text_with_fallback(label, &font, &emoji_font, 0.0); - // Centring the first/last label on the axis end pushes half of - // it outside the component's own box (measured: the last label - // of a 5-point line chart bled 5px past the right edge). Clamp - // into [0, box_width] — `chart_margins()` always returns a - // right margin of 8, so the box ends at chart_x + chart_w + 8. let box_w = chart_x + chart_w + 8.0; let lx = (x - label_w / 2.0).clamp(0.0, (box_w - label_w).max(0.0)); let ly = chart_y + chart_h + ascent + 6.0; @@ -134,13 +117,6 @@ impl Chart { } } - /// Draw the *value* axis of a transposed (horizontal) cartesian chart: - /// vertical grid lines and value tick labels along the bottom. - /// - /// [`draw_axes`] assumes the value axis is vertical (grid lines - /// horizontal, values down the left gutter), which is wrong for - /// `horizontal_bar` — there the value axis runs left-to-right and the - /// category axis is the vertical one. pub(super) fn draw_value_axis_x( &self, canvas: &Canvas, diff --git a/crates/rustmotion-components/src/chart/bar.rs b/crates/rustmotion-components/src/chart/bar.rs index 05c59f9f..76d17cc4 100644 --- a/crates/rustmotion-components/src/chart/bar.rs +++ b/crates/rustmotion-components/src/chart/bar.rs @@ -14,20 +14,8 @@ impl Chart { let chart_w = w - ml - mr; let chart_h = h - mt - mb; - // A bar chart's baseline is zero, not the smallest datum, so the scale - // has to span [min(0, min), max(0, max)]. Scaling by `max` alone drew - // negative bars downward from the bottom edge, straight out of the - // component box (measured: values [10, -6, 4] in a 800x500 box put - // 58072 ink pixels outside it, reaching 243px past the bottom). - // All-positive data is unaffected: min_val stays 0 and the arithmetic - // reduces to the previous `value / max_val`. let (min_val, max_val, range) = self.value_extent(); - // One slot per data point, unlabeled points included as `""`. - // `draw_axes` positions label `i` at slot `i` of `n = x_labels.len()` - // — a `filter_map` that dropped unlabeled points compacted the list, - // so `n` no longer matched `self.data.len()` and every surviving - // label slid onto the wrong bar as soon as one point had no label. let x_labels: Vec = self .data .iter() @@ -56,7 +44,6 @@ impl Chart { let rect = Rect::from_xywh(x, y, bar_w, bar_h); let radius = (bar_w * 0.15).min(8.0); - // Round the end away from the baseline. let round = (radius, radius).into(); let square = (0.0, 0.0).into(); let radii = if negative { @@ -71,8 +58,6 @@ impl Chart { Ok(()) } - /// `(min, max, range)` for a zero-anchored value axis over `self.data`. - /// The range is never zero, so callers can divide by it unconditionally. pub(super) fn value_extent(&self) -> (f64, f64, f64) { let min_val = self .data @@ -105,10 +90,6 @@ impl Chart { let ascent = -metrics.ascent; let measure = |s: &str| measure_text_with_fallback(s, &font, &emoji_font, 0.0); - // `chart_margins()` sizes the left gutter for *numeric* tick labels - // (3.5em), which is the wrong axis here: on a horizontal bar chart the - // vertical axis is categorical. Size the gutter to the widest category - // name instead, and cap it so the bars keep the majority of the width. let mt = 8.0; let mr = 8.0; let mb = if self.show_x_labels { @@ -150,10 +131,6 @@ impl Chart { let y = mt + gap + i as f32 * (row_h + gap); let radius = (row_h * 0.15).min(8.0); - // Track behind every row. Without it a zero-valued row is simply - // absent — the defect that made a "6 vs 0" comparison unreadable — - // and short bars have nothing to be read against. Matches the - // track `radial_bar` already draws for the same reason. let mut track_paint = paint_from_hex("#FFFFFF"); track_paint.set_style(PaintStyle::Fill); track_paint.set_anti_alias(true); @@ -172,7 +149,6 @@ impl Chart { paint.set_anti_alias(true); let rect = Rect::from_xywh(x, y, bar_len, row_h); - // Round the end away from the baseline. let round = (radius, radius).into(); let square = (0.0, 0.0).into(); let radii = if negative { @@ -184,8 +160,6 @@ impl Chart { let baseline_y = y + row_h / 2.0 + ascent / 2.0; - // Value annotation, right-aligned in the track so it never depends - // on how long the bar happens to be. let mut value_left = ml + chart_w; if self.show_labels { let text = format_number(dp.value); @@ -214,8 +188,6 @@ impl Chart { let Some(label) = &dp.label else { continue }; if gutter > 0.0 { - // Category names live in the left gutter, right-aligned against - // the bars — the classic ranking layout. let mut label_paint = paint_from_hex(&self.label_color); label_paint.set_anti_alias(true); let lx = (gutter - measure(label)).max(0.0); @@ -230,10 +202,6 @@ impl Chart { &label_paint, ); } else if self.show_labels || self.show_x_labels { - // Inside the bar when it fits, otherwise just past its end in - // the label colour. Drawing it inside unconditionally left the - // text of a zero-length bar floating on the background in a - // colour picked for contrast against a bar that isn't there. let label_w = measure(label); let fits = bar_len >= label_w + 20.0; let (lx, color_hex) = if fits { @@ -241,7 +209,6 @@ impl Chart { } else { (x + bar_len + 10.0, self.label_color.clone()) }; - // Never run into the value annotation. if lx + label_w <= value_left - 6.0 || fits { let mut label_paint = paint_from_hex(&color_hex); label_paint.set_anti_alias(true); @@ -278,18 +245,6 @@ impl Chart { let chart_h = h - mt - mb; let n_cats = self.categories.len(); - // A stacked total can go negative — either every series is negative - // (e.g. an all-cost breakdown) or the series mix signs within one - // category (revenue vs. cost). Scale from the true signed extent — - // positive segments stack above zero, negative segments stack below - // — the same zero-anchored contract `value_extent` gives - // `render_bar`. Scaling by the largest *positive* total alone - // (previously `max_val = ... .max(0.001)`, floored to 0.001 when - // every total was negative) sent every segment's height through a - // near-zero divisor and painted it thousands of chart-heights - // outside the box; even a bounded mixed-sign case (revenue stacked - // as if `max_val` were the net total) pushed the positive segment - // taller than the whole chart. let totals: Vec<(f64, f64)> = (0..n_cats) .map(|ci| { self.series @@ -319,9 +274,6 @@ impl Chart { for ci in 0..n_cats { let x = ml + gap + ci as f32 * (bar_w + gap); - // Positive segments stack upward from `zero_y`; negative - // segments stack downward from it, independently — each - // direction has its own running edge. let mut pos_top = zero_y; let mut neg_bottom = zero_y; let last_pos_si = @@ -355,9 +307,6 @@ impl Chart { paint.set_anti_alias(true); let rect = Rect::from_xywh(x, y, bar_w, seg_h); - // Round the outer edge of each stack: the top of the - // topmost positive segment, the bottom of the bottommost - // negative segment. let is_outer_edge = if negative { Some(si) == last_neg_si } else { @@ -427,9 +376,6 @@ mod tests { } } - /// Bounding box (min_x, max_x, min_y, max_y) of every non-transparent - /// pixel on the surface, or `None` if nothing was painted. Mirrors the - /// helper `stat.rs`/`caption.rs` already use for the same kind of proof. fn ink_bounds( surface: &mut skia_safe::Surface, w: i32, @@ -465,9 +411,6 @@ mod tests { (minx <= maxx).then_some((minx, maxx, miny, maxy)) } - /// Column-wise ink centers: for each x with any ink, returns the - /// vertical midpoint of the ink found in that column. Used to find the - /// horizontal center of a run of colored pixels (a bar or a label). fn colored_columns( surface: &mut skia_safe::Surface, w: i32, @@ -507,18 +450,6 @@ mod tests { cols } - /// Paint a stacked-bar chart for a `box_w`x`box_h` box, but into a - /// canvas with `MARGIN` px of headroom above and below it, and return - /// the ink's vertical extent in *box-local* coordinates (0 = box top). - /// - /// A same-size canvas hides the bug this exists to catch: an - /// overflowing segment's `Rect::from_xywh` gets a huge *negative* - /// height, which skia normalizes when drawing, so a coincidental sliver - /// of the inverted rect can still land inside a same-size canvas even - /// though the segment as a whole is nowhere near the box. Margin on - /// both sides catches overflow in either direction; local coordinates - /// let the assertion read directly as "outside the box" without redoing - /// the offset math at every call site. fn stacked_bar_ink_local_range(chart: &Chart, box_w: i32, box_h: i32, time: f64) -> (i32, i32) { const MARGIN: i32 = 300; let surface_h = box_h + MARGIN * 2; @@ -538,10 +469,6 @@ mod tests { #[test] fn stacked_bar_with_all_negative_totals_stays_inside_the_box() { - // #2's exact repro: every category totals negative, so the old - // `max_val = ... .max(0.001)` floor made `seg_h` divide by a - // near-zero value and blew the segment thousands of chart-heights - // past the bottom of the box. let mut chart = base_chart(ChartType::StackedBar); chart.categories = vec!["Q1".to_string(), "Q2".to_string()]; chart.series = vec![ChartSeries { @@ -559,10 +486,6 @@ mod tests { #[test] fn stacked_bar_with_mixed_sign_series_stays_inside_the_box() { - // Revenue/cost style stack: mixed signs within the same category. - // `max_val` alone (the pre-fix scale) ignored the negative side - // entirely, so the cost segment was scaled as if it were tiny and - // painted far below the box. let mut chart = base_chart(ChartType::StackedBar); chart.categories = vec!["Q1".to_string(), "Q2".to_string()]; chart.series = vec![ @@ -587,10 +510,6 @@ mod tests { #[test] fn bar_x_labels_align_with_their_own_bar_when_some_points_are_unlabeled() { - // #5's exact repro: with `filter_map` compacting the label list, the - // 2 surviving labels ("AAA", "DDD") were spread across only 2 of the - // 4 slots `draw_axes` computes from `x_labels.len()`, sliding every - // label onto the wrong bar. let mut chart = base_chart(ChartType::Bar); chart.show_x_labels = true; chart.data = vec![ @@ -626,10 +545,6 @@ mod tests { .expect("paint must not error"); colored_columns(&mut surface, W, H, |r, g, b| r < 40 && g < 40 && b > 200) }; - // The bar (blue) columns split into 4 contiguous runs (gaps between - // them). The label (red) columns should center within a small - // distance of the *first* and *last* runs' centers, not drift onto - // neighboring slots. assert!(!bar_cols.is_empty(), "no bars painted"); let mut runs: Vec<(i32, i32)> = vec![]; for x in bar_cols { @@ -650,7 +565,6 @@ mod tests { .paint(canvas, W as f32, H as f32, 10.0) .expect("paint must not error"); colored_columns(&mut label_surface, W, H, |r, g, b| { - // label_color default #888888 (120..160).contains(&r) && (120..160).contains(&g) && (120..160).contains(&b) }) }; diff --git a/crates/rustmotion-components/src/chart/funnel.rs b/crates/rustmotion-components/src/chart/funnel.rs index edc0fea5..a86b76e5 100644 --- a/crates/rustmotion-components/src/chart/funnel.rs +++ b/crates/rustmotion-components/src/chart/funnel.rs @@ -27,7 +27,6 @@ impl Chart { fn render_funnel_vertical(&self, canvas: &Canvas, w: f32, h: f32, progress: f32) -> Result<()> { let n = self.data.len(); - // Clamp negatives: a negative ratio flipped the trapezoid inside out. let max_val = self .data .iter() @@ -108,7 +107,6 @@ impl Chart { progress: f32, ) -> Result<()> { let n = self.data.len(); - // Clamp negatives: a negative ratio flipped the trapezoid inside out. let max_val = self .data .iter() diff --git a/crates/rustmotion-components/src/chart/line.rs b/crates/rustmotion-components/src/chart/line.rs index 3230efb2..621e2bea 100644 --- a/crates/rustmotion-components/src/chart/line.rs +++ b/crates/rustmotion-components/src/chart/line.rs @@ -6,13 +6,6 @@ use rustmotion_core::engine::renderer::{paint_from_hex, parse_hex_color}; use super::Chart; -/// `(min, max, normalize)` for a min–max scaled series. -/// -/// `normalize` maps a value to `0.0..=1.0` (0 = bottom of the plot area). -/// When every value is identical the series is centred rather than pinned to -/// the axis: dividing by a `max(0.001)` floor mapped a constant series to 0, -/// so a flat line at, say, 7 rendered sitting exactly on the zero gridline and -/// read as a series of zeroes. pub(super) fn series_scale( values: impl Iterator + Clone, ) -> (f64, f64, impl Fn(f64) -> f32) { @@ -53,9 +46,6 @@ impl Chart { canvas, ml, mt, chart_w, chart_h, min_val, max_val, &x_labels, false, ); if n < 2 { - // A one-point series has no line to draw, but returning before the - // axes were painted made the whole component render nothing at all - // (measured: 0 ink pixels, and `validate` clean). Plot the point. if let Some(dp) = self.data.first() { let x = ml + chart_w / 2.0; let y = mt + chart_h - norm(dp.value) * chart_h; @@ -89,7 +79,6 @@ impl Chart { fill_path.line_to((last_x, mt + chart_h)); fill_path.close(); - // Clip for animation let clip_w = w * progress; canvas.save(); canvas.clip_rect( @@ -98,21 +87,18 @@ impl Chart { false, ); - // Fill under line let line_color = self.get_color(0); let mut fill_paint = paint_from_hex(line_color); fill_paint.set_style(PaintStyle::Fill); fill_paint.set_alpha_f(0.15); canvas.draw_path(&fill_path.detach(), &fill_paint); - // Line stroke let mut line_paint = paint_from_hex(line_color); line_paint.set_style(PaintStyle::Stroke); line_paint.set_stroke_width(2.5); line_paint.set_anti_alias(true); canvas.draw_path(&path.detach(), &line_paint); - // Dots for (i, dp) in self.data.iter().enumerate() { let x = ml + (i as f32 / (n - 1) as f32) * chart_w; let y = mt + chart_h - norm(dp.value) * chart_h; @@ -145,7 +131,6 @@ impl Chart { canvas, ml, mt, chart_w, chart_h, min_val, max_val, &x_labels, false, ); if n < 2 { - // See `render_line`: draw the lone point rather than nothing. if let Some(dp) = self.data.first() { let x = ml + chart_w / 2.0; let y = mt + chart_h - norm(dp.value) * chart_h; @@ -157,7 +142,6 @@ impl Chart { return Ok(()); } - // Compute points let pts: Vec<(f32, f32)> = self .data .iter() @@ -173,7 +157,6 @@ impl Chart { let mut fill_path = PathBuilder::new(); if self.smooth && pts.len() >= 3 { - // Catmull-Rom -> cubic bezier for smooth curves line_path.move_to(pts[0]); fill_path.move_to((pts[0].0, mt + chart_h)); fill_path.line_to(pts[0]); @@ -213,7 +196,6 @@ impl Chart { fill_path.line_to((last_x, mt + chart_h)); fill_path.close(); - // Clip for animation let clip_w = w * progress; canvas.save(); canvas.clip_rect( @@ -222,7 +204,6 @@ impl Chart { false, ); - // Gradient fill let line_color = self.get_color(0); let (r, g, b, _) = parse_hex_color(line_color); let top_color = Color::from_argb((self.fill_opacity * 255.0) as u8, r, g, b); @@ -245,14 +226,12 @@ impl Chart { canvas.draw_path(&fill_path.detach(), &fill_paint); } - // Line stroke let mut line_paint = paint_from_hex(line_color); line_paint.set_style(PaintStyle::Stroke); line_paint.set_stroke_width(2.5); line_paint.set_anti_alias(true); canvas.draw_path(&line_path.detach(), &line_paint); - // Dots for &(x, y) in &pts { let mut dot_paint = paint_from_hex(line_color); dot_paint.set_style(PaintStyle::Fill); diff --git a/crates/rustmotion-components/src/chart/mod.rs b/crates/rustmotion-components/src/chart/mod.rs index 643f33ae..eb097979 100644 --- a/crates/rustmotion-components/src/chart/mod.rs +++ b/crates/rustmotion-components/src/chart/mod.rs @@ -20,8 +20,6 @@ mod radial; mod scatter; mod waterfall; -/// The engine's default series palette. `pub` so the studio can prefill an -/// empty `colors` list with what the canvas actually renders. pub const DEFAULT_PALETTE: &[&str] = &[ "#3B82F6", "#EF4444", "#22C55E", "#F59E0B", "#8B5CF6", "#EC4899", "#06B6D4", "#F97316", ]; @@ -113,38 +111,31 @@ pub struct Chart { #[serde(default)] pub colors: Option>, - // Donut-specific #[serde(default = "default_inner_radius")] pub inner_radius: f64, - // Area-specific #[serde(default = "default_fill_opacity")] pub fill_opacity: f32, #[serde(default)] pub smooth: bool, - // Stacked bar #[serde(default)] pub categories: Vec, #[serde(default)] pub series: Vec, - // Radar #[serde(default)] pub axes: Vec, #[serde(default)] pub radar_data: Vec, - // Scatter #[serde(default)] pub points: Vec, - // Funnel direction /// Direction for funnel chart: "vertical" (default) or "horizontal". #[serde(default)] pub direction: Option, - // Axes, grid, labels #[serde(default)] pub show_grid: bool, #[serde(default)] @@ -218,14 +209,9 @@ impl Chart { if !self.animated { return 1.0; } - // Ramp measured from `start_at`, not from scene time zero — matches - // `Counter::ramp_progress`. A chart delayed with `start_at` used to - // read raw scene time, so it was already fully drawn on the very - // first frame it became visible. let start = self.timing.start_at.unwrap_or(0.0); let elapsed = (time - start).max(0.0); let p = (elapsed / self.animation_duration).clamp(0.0, 1.0) as f32; - // ease_out_cubic 1.0 - (1.0 - p).powi(3) } @@ -295,7 +281,6 @@ impl Chart { } } - /// Compute margins for axes/labels area. pub(super) fn chart_margins(&self) -> (f32, f32, f32, f32) { let left = if self.show_y_labels { self.label_font_size * 3.5 @@ -307,7 +292,6 @@ impl Chart { } else { 0.0 }; - // top, right, bottom, left (8.0, 8.0, bottom + 8.0, left + 8.0) } @@ -370,11 +354,6 @@ mod tests { #[test] fn progress_ramp_starts_at_start_at_not_at_scene_time_zero() { - // #3's exact repro: a chart delayed with `start_at: 2.0` and - // `animation_duration: 1.5` was already fully drawn (progress 1.0) - // on the very first frame it became visible, because the ramp read - // raw scene time instead of time-since-`start_at` — the same defect - // `Counter::ramp_progress` was fixed for. let mut chart = base_chart(); chart.animation_duration = 1.5; chart.timing = TimingConfig { diff --git a/crates/rustmotion-components/src/chart/pie.rs b/crates/rustmotion-components/src/chart/pie.rs index b055ea8f..57047fa2 100644 --- a/crates/rustmotion-components/src/chart/pie.rs +++ b/crates/rustmotion-components/src/chart/pie.rs @@ -5,11 +5,6 @@ use rustmotion_core::engine::renderer::paint_from_hex; use super::Chart; -/// Skia's `Path::arc_to` emits an *empty* arc when the sweep is exactly 360°, -/// so a slice that owns 100% of the total silently disappears at the end of -/// the animation (measured: a single-slice pie rendered 116336 ink pixels at -/// frame 44 and 0 at frame 45, the first frame where `progress` reaches 1.0). -/// Sweeps at or above this threshold are drawn as a full disc/ring instead. const FULL_SWEEP: f32 = 359.99; impl Chart { @@ -28,14 +23,6 @@ impl Chart { self.render_pie_ring(canvas, w, h, progress, inner) } - /// Paint the data as a disc (`inner_ratio == 0`) or a ring. - /// - /// The ring is built from annulus-sector paths rather than by painting a - /// full pie and punching the centre out with `BlendMode::Clear`: `Clear` - /// writes transparent pixels straight through the shared canvas, so the - /// hole erased the parent card *and* the video background — measured as - /// 19380 fully transparent pixels in a 158x157 box in the shipped - /// `examples/mega-showcase.json`. fn render_pie_ring( &self, canvas: &Canvas, @@ -44,9 +31,6 @@ impl Chart { progress: f32, inner_ratio: f32, ) -> Result<()> { - // A negative share is meaningless in a part-of-whole chart, and letting - // one through poisons `total` for every other slice (measured: values - // [10, -6] gave slice sweeps of 900 and -540 degrees). Clamp at 0. let values: Vec = self.data.iter().map(|d| d.value.max(0.0)).collect(); let total: f64 = values.iter().sum(); if total <= 0.0 { @@ -83,8 +67,6 @@ impl Chart { if sweep >= FULL_SWEEP { if inner_r > 0.0 { - // Stroking the mid-radius circle gives an exact annulus - // without relying on path winding rules. let mut ring = paint; ring.set_style(PaintStyle::Stroke); ring.set_stroke_width(outer_r - inner_r); @@ -95,7 +77,6 @@ impl Chart { } else { let mut path = PathBuilder::new(); if inner_r > 0.0 { - // Outer arc forward, inner arc back: a closed annulus sector. path.arc_to(outer, start_angle, sweep, false); path.arc_to(inner, start_angle + sweep, -sweep, false); } else { diff --git a/crates/rustmotion-components/src/chart/radar.rs b/crates/rustmotion-components/src/chart/radar.rs index 954ab5cf..bce8f5f7 100644 --- a/crates/rustmotion-components/src/chart/radar.rs +++ b/crates/rustmotion-components/src/chart/radar.rs @@ -26,7 +26,6 @@ impl Chart { let angle_step = std::f32::consts::TAU / n_axes as f32; - // Draw concentric grid polygons let grid_levels = 4; for level in 1..=grid_levels { let r = radius * (level as f32 / grid_levels as f32); @@ -53,7 +52,6 @@ impl Chart { canvas.draw_path(&grid_path.detach(), &grid_paint); } - // Draw axis lines for i in 0..n_axes { let angle = -std::f32::consts::FRAC_PI_2 + i as f32 * angle_step; let px = cx + radius * angle.cos(); @@ -65,7 +63,6 @@ impl Chart { canvas.draw_line((cx, cy), (px, py), &axis_paint); } - // Draw axis labels let Some(font) = self.make_label_font() else { return Ok(()); }; @@ -83,11 +80,6 @@ impl Chart { let mut label_paint = paint_from_hex(&self.label_color); label_paint.set_anti_alias(true); let label_w = measure_text_with_fallback(label, &font, &emoji_font, 0.0); - // Anchor by side — centring every label pushed the east/west ones - // out of the component box (measured: 389 ink pixels outside a - // 400x400 box, reaching 28px left and 45px right of it). Labels on - // the right start at the vertex, labels on the left end at it, and - // the result is clamped into the box as a backstop. let cos = angle.cos(); let anchored = if cos > 0.15 { px @@ -101,10 +93,6 @@ impl Chart { draw_text_with_fallback(canvas, label, &font, &emoji_font, 0.0, lx, ly, &label_paint); } - // One scale for every series. Normalising each polygon by its own max - // made a [3, 2, 1, 2] series fill nearly the same area as a [10, 8, 6, - // 9] one, so overlaid series could not be compared at all — the single - // thing a radar chart exists to do. let global_max = self .radar_data .iter() @@ -112,7 +100,6 @@ impl Chart { .fold(0.0_f64, f64::max) .max(0.001); - // Draw data polygons for (di, rd) in self.radar_data.iter().enumerate() { if rd.values.len() != n_axes { continue; @@ -135,14 +122,12 @@ impl Chart { } data_path.close(); - // Fill let mut fill_paint = paint_from_hex(color_str); fill_paint.set_style(PaintStyle::Fill); fill_paint.set_alpha_f(0.3); fill_paint.set_anti_alias(true); canvas.draw_path(&data_path.snapshot(), &fill_paint); - // Stroke let mut stroke_paint = paint_from_hex(color_str); stroke_paint.set_style(PaintStyle::Stroke); stroke_paint.set_stroke_width(2.0); diff --git a/crates/rustmotion-components/src/chart/radial.rs b/crates/rustmotion-components/src/chart/radial.rs index 4e173e33..eeb6494f 100644 --- a/crates/rustmotion-components/src/chart/radial.rs +++ b/crates/rustmotion-components/src/chart/radial.rs @@ -20,9 +20,6 @@ impl Chart { let track_width = (max_radius / (n as f32 * 1.5)).clamp(6.0, 20.0); let ring_gap = track_width * 0.5; - // Negative values are clamped: an arc can only sweep forward, so a - // negative one drew a ring backwards from 12 o'clock and read as a - // large positive value. let max_val = self .data .iter() @@ -38,7 +35,6 @@ impl Chart { let oval = Rect::from_xywh(cx - r, cy - r, r * 2.0, r * 2.0); - // Track (background) let mut track_paint = paint_from_hex("#333333"); track_paint.set_style(PaintStyle::Stroke); track_paint.set_stroke_width(track_width); @@ -47,7 +43,6 @@ impl Chart { track_paint.set_alpha_f(0.3); canvas.draw_arc(oval, -90.0, 360.0, false, &track_paint); - // Fill arc let color = dp.color.as_deref().unwrap_or_else(|| self.get_color(i)); let sweep = (dp.value.max(0.0) / max_val) as f32 * 360.0 * progress; diff --git a/crates/rustmotion-components/src/chart/scatter.rs b/crates/rustmotion-components/src/chart/scatter.rs index d8555d05..a5e5703d 100644 --- a/crates/rustmotion-components/src/chart/scatter.rs +++ b/crates/rustmotion-components/src/chart/scatter.rs @@ -26,12 +26,6 @@ impl Chart { let (min_x, max_x, norm_x) = series_scale(self.points.iter().map(|p| p.x)); let (min_y, max_y, norm_y) = series_scale(self.points.iter().map(|p| p.y)); - // `ScatterPoint` carries no label, so the previous empty slice meant - // `show_x_labels: true` reserved a bottom gutter and then drew nothing - // into it. A scatter's x axis is numeric: label it with ticks over - // min_x..max_x, at the same 5 fractions `draw_axes` uses for the y - // axis. Passing `categorical: false` places tick i at i/(n-1) of the - // width, which is exactly where those fractions fall. let x_labels: Vec = if self.show_x_labels { (0..=5) .map(|i| format_number(min_x + (max_x - min_x) * (i as f64 / 5.0))) diff --git a/crates/rustmotion-components/src/chart/waterfall.rs b/crates/rustmotion-components/src/chart/waterfall.rs index bda50902..8287ca48 100644 --- a/crates/rustmotion-components/src/chart/waterfall.rs +++ b/crates/rustmotion-components/src/chart/waterfall.rs @@ -17,7 +17,6 @@ impl Chart { let chart_w = w - ml - mr; let chart_h = h - mt - mb; - // Calculate cumulative values and find range let mut cumulative = Vec::with_capacity(self.data.len()); let mut running = 0.0_f64; for dp in &self.data { @@ -48,7 +47,6 @@ impl Chart { let gap = 6.0; let bar_w = (chart_w - gap * (n + 1) as f32) / n as f32; - // Zero baseline let _zero_y = mt + chart_h - ((0.0 - min_val) / range) as f32 * chart_h; for (i, dp) in self.data.iter().enumerate() { @@ -61,7 +59,6 @@ impl Chart { let bar_h = (bar_bottom - bar_top) * progress; let x = ml + gap + i as f32 * (bar_w + gap); - // Green for positive, red for negative let color = dp.color.as_deref().unwrap_or({ if dp.value >= 0.0 { "#22C55E" @@ -74,7 +71,6 @@ impl Chart { paint.set_style(PaintStyle::Fill); paint.set_anti_alias(true); - // Animate from the start position let animated_top = if dp.value >= 0.0 { y_start - bar_h } else { @@ -86,7 +82,6 @@ impl Chart { let rrect = skia_safe::RRect::new_rect_xy(rect, radius, radius); canvas.draw_rrect(rrect, &paint); - // Connector line to next bar if i + 1 < n { let next_x = ml + gap + (i + 1) as f32 * (bar_w + gap); let connector_y = if dp.value >= 0.0 { @@ -98,7 +93,6 @@ impl Chart { connector_paint.set_style(PaintStyle::Stroke); connector_paint.set_stroke_width(1.0); connector_paint.set_anti_alias(true); - // Dashed let intervals = [4.0_f32, 4.0]; if let Some(effect) = skia_safe::PathEffect::dash(&intervals, 0.0) { connector_paint.set_path_effect(effect); diff --git a/crates/rustmotion-components/src/comparison.rs b/crates/rustmotion-components/src/comparison.rs index da1b3615..7868ae7b 100644 --- a/crates/rustmotion-components/src/comparison.rs +++ b/crates/rustmotion-components/src/comparison.rs @@ -137,7 +137,6 @@ impl Comparison { let font_size = (h * 0.08).clamp(16.0, 36.0); let font_style = skia_safe::FontStyle::bold(); let Ok(typeface) = typeface_with_fallback("Inter", font_style) else { - // Balance the canvas.save() above before bailing out. canvas.restore(); return; }; diff --git a/crates/rustmotion-components/src/connector.rs b/crates/rustmotion-components/src/connector.rs index 68d4c580..3e43b29e 100644 --- a/crates/rustmotion-components/src/connector.rs +++ b/crates/rustmotion-components/src/connector.rs @@ -109,14 +109,12 @@ impl Connector { let len = (dx * dx + dy * dy).sqrt(); let mid_x = (x1 + x2) / 2.0; let mid_y = (y1 + y2) / 2.0; - // Perpendicular offset for curvature let perp_x = -dy / len * self.curvature * len * 0.3; let perp_y = dx / len * self.curvature * len * 0.3; path.quad_to((mid_x + perp_x, mid_y + perp_y), (x2, y2)); } RoutingMode::Elbow => { path.move_to((x1, y1)); - // Route: horizontal first, then vertical let mid_x = (x1 + x2) / 2.0; path.line_to((mid_x, y1)); path.line_to((mid_x, y2)); diff --git a/crates/rustmotion-components/src/countdown.rs b/crates/rustmotion-components/src/countdown.rs index 998c4b30..8247844a 100644 --- a/crates/rustmotion-components/src/countdown.rs +++ b/crates/rustmotion-components/src/countdown.rs @@ -165,11 +165,6 @@ impl From for Countdown { } } -/// Total ink width `paint()`'s cursor walk produces: each visible group is -/// two digit boxes plus one inner gap (`gap*0.3`), and every group after -/// the first is preceded by a separator that consumes a full `gap`. Shared -/// by `From` (to size the box) and `paint()`'s own `total_w` -/// so the two can never drift apart again. fn countdown_total_width( box_w: f32, gap: f32, @@ -214,7 +209,6 @@ impl Countdown { ) { let (box_w, box_h) = self.digit_box_size(); - // Background rounded rect let mut bg_paint = paint_from_hex(&self.digit_background); bg_paint.set_style(PaintStyle::Fill); bg_paint.set_anti_alias(true); @@ -222,13 +216,12 @@ impl Countdown { let rrect = RRect::new_rect_xy(rect, self.border_radius, self.border_radius); canvas.draw_rrect(rrect, &bg_paint); - // Flip-clock horizontal line across the middle let (r, g, b, _) = parse_hex_color(&self.digit_background); let mut line_paint = skia_safe::Paint::default(); line_paint.set_style(PaintStyle::Stroke); line_paint.set_stroke_width(1.0); line_paint.set_anti_alias(true); - line_paint.set_color(skia_safe::Color::from_argb(76, r, g, b)); // alpha ~0.3 + line_paint.set_color(skia_safe::Color::from_argb(76, r, g, b)); let mid_y = y + box_h / 2.0; canvas.draw_line( skia_safe::Point::new(x, mid_y), @@ -236,7 +229,6 @@ impl Countdown { &line_paint, ); - // Digit text centered in box let digit_str = digit.to_string(); let mut text_paint = paint_from_hex(&self.digit_color); text_paint.set_anti_alias(true); @@ -301,11 +293,6 @@ impl Countdown { let (box_w, _box_h) = self.digit_box_size(); - // Safety net: `style.width`/`style.height` on this struct are - // already sized (see `From`) to match this exact - // cursor walk, so this shouldn't ever clip in practice — it's a - // backstop against drift if the two formulas are ever edited - // separately again. canvas.save(); if layout.width > 0.0 && layout.height > 0.0 { canvas.clip_rect( @@ -395,16 +382,9 @@ mod tests { #[test] fn countdown_total_width_matches_paints_own_cursor_walk() { - // #127: box_builder.rs's width formula (`visible*2*box_w + - // (visible-1)*gap`) omits the small inner gap `paint()` inserts - // *within* each digit pair, so the box came out narrower than - // what actually got drawn (153×67 assigned vs. 187×68 painted). - // Recompute by hand here (independent of `countdown_total_width` - // itself) so this test would catch drift in either direction. - let box_w = 42.0_f32; // digit_size 56 * 0.75 + let box_w = 42.0_f32; let gap = default_gap(); let inner_gap = gap * 0.3; - // two visible groups (minutes, seconds): two pairs + one separator let by_hand = (box_w * 2.0 + inner_gap) * 2.0 + gap; let got = countdown_total_width(box_w, gap, false, true, true); assert!( @@ -428,8 +408,6 @@ mod tests { #[test] fn no_visible_groups_is_zero_not_negative() { - // Defensive: `(visible - 1.0)` must not go negative and blow up - // `separators` when nothing is shown. assert_eq!(countdown_total_width(42.0, 12.0, false, false, false), 0.0); } } diff --git a/crates/rustmotion-components/src/counter.rs b/crates/rustmotion-components/src/counter.rs index b4d3ccdc..4d14e40e 100644 --- a/crates/rustmotion-components/src/counter.rs +++ b/crates/rustmotion-components/src/counter.rs @@ -68,10 +68,6 @@ rustmotion_core::impl_traits!(Counter { }); impl Counter { - /// Where the count sits on its 0..1 ramp at `time`, before easing. - /// - /// The ramp starts at `start_at` and runs for `duration`, falling back to - /// the rest of the scene when no duration is given. fn ramp_progress(&self, time: f64, scene_duration: f64) -> f64 { let start = self.timing.start_at.unwrap_or(0.0); let elapsed = (time - start).max(0.0); @@ -97,20 +93,12 @@ impl Counter { ) -> Result<()> { use rustmotion_core::engine::animator::ease; - // Lot B (wave S): `font-size` now resolves through the same - // context-aware machinery `text-shadow` already used below (see - // `lctx`) — it used to stay on the context-free `font_size_px_or`, - // silently dropping `rem`/`vw`/`vh` font-size to 0px. `em`/`%` on - // `font-size` itself remain approximate — see - // `crate::intrinsic::font_size_ctx`'s doc comment. let base_ctx = crate::intrinsic::font_size_ctx( ctx.video_width as f32, ctx.video_height as f32, layout_width.max(0.0), ); let font_size = self.style.font_size_px_ctx(&base_ctx, 48.0); - // Animated color (timeline style-state transitions) overrides the - // static style color. let color = props .color .as_deref() @@ -164,9 +152,6 @@ impl Counter { let mut paint = paint_from_hex(color); paint.set_alpha_f(1.0); - // This element's own resolved font-size as the `em`/`%` base for - // `letter-spacing` and (below) `text-shadow` — same context reused - // for both instead of each rebuilding an equivalent one. let own_ctx = rustmotion_core::css::units::LengthContext { font_size, ..base_ctx @@ -176,10 +161,6 @@ impl Counter { let advance_width = measure_text_with_fallback(&content, &font, &emoji_font, letter_spacing); - // For center/right alignment, anchor positioning on the same `absmax` - // width that `measure()` reserved. This keeps the right edge (or - // bounding box midpoint) of the counter stable across frames instead - // of letting it shift sub-pixel as the digit count changes. let stable_width = if matches!(align, TextAlign::Center | TextAlign::Right) { let absmax = self.from.abs().max(self.to.abs()); let signed = if self.from < 0.0 || self.to < 0.0 { @@ -206,8 +187,6 @@ impl Counter { } TextAlign::Right => layout_width - advance_width, }; - // Snap to whole pixels to eliminate the sub-pixel jitter that the - // glyph rasterizer would otherwise introduce on a moving counter. let x = raw_x.round(); let (_, metrics) = font.metrics(); let line_height = font_size * 1.3; @@ -215,8 +194,6 @@ impl Counter { let descent = metrics.descent; let y = (line_height + ascent - descent) / 2.0; - // Draw shadows — component field wins, else the bridged CSS - // `style.text-shadow` list (reverse order: first shadow on top). let shadows: Vec = if let Some(s) = &self.text_shadow { vec![s.clone()] } else if let Some(list) = &self.style.text_shadow { @@ -248,7 +225,6 @@ impl Counter { ); } - // Draw stroke if let Some(ref stroke) = self.stroke { let mut sp = paint_from_hex(&stroke.color); sp.set_style(PaintStyle::Stroke); @@ -327,8 +303,6 @@ mod tests { #[test] fn without_duration_the_count_only_lands_on_the_last_frame() { - // The behaviour that made counters unreadable: nothing settles early, - // so the figure is still moving when the scene cuts away. let c = counter(None, None); assert!(c.ramp_progress(3.9, 4.0) < 1.0); assert_eq!(c.ramp_progress(4.0, 4.0), 1.0); @@ -338,7 +312,6 @@ mod tests { fn duration_makes_the_count_land_early_and_hold() { let c = counter(Some(1.5), None); assert_eq!(c.ramp_progress(1.5, 4.0), 1.0); - // Held for the rest of the scene, which is the point. assert_eq!(c.ramp_progress(3.0, 4.0), 1.0); assert!((c.ramp_progress(0.75, 4.0) - 0.5).abs() < 1e-9); } @@ -353,8 +326,6 @@ mod tests { #[test] fn a_duration_outlasting_the_scene_is_honoured_not_clamped() { - // Deliberate: the author asked for a slow count, and silently speeding - // it up would be a surprise. It simply never reaches `to`. let c = counter(Some(10.0), None); assert!(c.ramp_progress(4.0, 4.0) < 0.5); } @@ -365,12 +336,8 @@ mod tests { assert!((c.ramp_progress(2.0, 4.0) - 0.5).abs() < 1e-9); } - // ─── Lot B, wave S: relative `font-size` units ───────────────────────── - #[test] fn rem_font_size_paints_visible_ink() { - // Reproduction: `font-size: "2rem"` used to resolve to 0px on the - // context-free `font_size_px_or` path. let mut c = counter(None, None); c.style.font_size = Some(rustmotion_core::css::Length::String("2rem".into())); c.style.color = Some(rustmotion_core::css::style::Color::String("#FFFFFF".into())); diff --git a/crates/rustmotion-components/src/cursor.rs b/crates/rustmotion-components/src/cursor.rs index 0c3f64c7..a5ecd7fc 100644 --- a/crates/rustmotion-components/src/cursor.rs +++ b/crates/rustmotion-components/src/cursor.rs @@ -117,7 +117,6 @@ rustmotion_core::impl_traits!(Cursor { }); impl Cursor { - /// Get all click times (from click_at or auto_path waypoints). fn click_times(&self) -> Vec { if !self.auto_path.is_empty() { self.auto_path.iter().map(|w| w.time).collect() @@ -126,21 +125,11 @@ impl Cursor { } } - /// Compute cursor position offset from auto_path at the given time. - /// Returns (dx, dy) translation to apply. fn auto_path_offset(&self, time: f64) -> (f32, f32) { waypoint_offset(&self.auto_path, time, self.click_duration, self.path_easing) } } -/// Where a waypoint path sits at `time`: `(dx, dy)` from the component's own -/// origin. -/// -/// Shared by `cursor` (a text caret) and `pointer` (a mouse pointer). They -/// draw entirely different glyphs, but "hold at the first waypoint, glide to -/// each next one on a Catmull-Rom curve, pause for the click before moving -/// on" is the same walkthrough choreography in both, and it is worth having -/// exactly one implementation of it. pub(crate) fn waypoint_offset( waypoints: &[CursorWaypoint], time: f64, @@ -155,18 +144,15 @@ pub(crate) fn waypoint_offset( return (0.0, 0.0); } - // Before first waypoint: stay at first position if time <= waypoints[0].time { return (waypoints[0].x, waypoints[0].y); } - // After last waypoint: stay at last position if time >= waypoints[waypoints.len() - 1].time { let last = &waypoints[waypoints.len() - 1]; return (last.x, last.y); } - // Find which segment we're in let mut seg_idx = 0; for i in 0..waypoints.len() - 1 { if time >= waypoints[i].time && time < waypoints[i + 1].time { @@ -182,7 +168,6 @@ pub(crate) fn waypoint_offset( return (wp1.x, wp1.y); } - // Account for click pause: don't start moving until click animation finishes let click_end = wp0.time + click_duration as f64; let move_start = if seg_idx > 0 { click_end } else { wp0.time }; let move_duration = wp1.time - move_start; @@ -193,10 +178,7 @@ pub(crate) fn waypoint_offset( let raw_t = ((time - move_start) / move_duration).clamp(0.0, 1.0); - // Apply easing let t = match path_easing { - // Hold the departure point for the whole segment; the jump happens - // when `time` reaches the next waypoint and the segment changes. CursorPathEasing::Step => 0.0, CursorPathEasing::Linear => raw_t, CursorPathEasing::EaseOut => 1.0 - (1.0 - raw_t).powi(3), @@ -209,7 +191,6 @@ pub(crate) fn waypoint_offset( } } as f32; - // Catmull-Rom interpolation for smooth curves let p_prev = if seg_idx > 0 { &waypoints[seg_idx - 1] } else { @@ -228,7 +209,6 @@ pub(crate) fn waypoint_offset( } } -/// Catmull-Rom spline interpolation fn catmull_rom(t: f32, p0: f32, p1: f32, p2: f32, p3: f32) -> f32 { let t2 = t * t; let t3 = t2 * t; @@ -328,9 +308,6 @@ mod tests { .expect("cursor fixture") } - /// A caret jumps between positions; it must never be caught between two - /// fields. Every other easing interpolates, so `step` is the only way to - /// say that. #[test] fn step_easing_holds_the_departure_point_until_the_next_waypoint() { let c = caret(CursorPathEasing::Step); @@ -345,8 +322,6 @@ mod tests { assert_eq!(c.auto_path_offset(9.0), (500.0, 0.0)); } - /// The other easings keep gliding — `step` is additive, not a change of - /// default behaviour. #[test] fn linear_easing_still_interpolates() { let (x, _) = caret(CursorPathEasing::Linear).auto_path_offset(1.5); diff --git a/crates/rustmotion-components/src/dot_map.rs b/crates/rustmotion-components/src/dot_map.rs index 5e5680a9..aa4c077b 100644 --- a/crates/rustmotion-components/src/dot_map.rs +++ b/crates/rustmotion-components/src/dot_map.rs @@ -56,25 +56,21 @@ pub struct MapPoint { pub pulse: Option, } -// Visible latitude range (clip to habitable world, avoid empty polar regions) -const LAT_MAX: f64 = 85.0; // top of view -const LAT_MIN: f64 = -85.0; // bottom of view +const LAT_MAX: f64 = 85.0; +const LAT_MIN: f64 = -85.0; -/// Convert lat/lng to normalized 0-1 coordinates for rendering on screen. fn geo_to_screen(lat: f64, lng: f64) -> (f32, f32) { let x = ((lng + 180.0) / 360.0) as f32; let y = ((LAT_MAX - lat) / (LAT_MAX - LAT_MIN)) as f32; (x, y) } -/// Convert screen-normalized coords (0-1) back to lat/lng for bitmap lookup. fn screen_to_geo(nx: f32, ny: f32) -> (f64, f64) { let lng = nx as f64 * 360.0 - 180.0; let lat = LAT_MAX - ny as f64 * (LAT_MAX - LAT_MIN); (lat, lng) } -/// Check lat/lng against bitmap. fn geo_is_land(lat: f64, lng: f64) -> bool { let bitmap = super::world_bitmap::land_bitmap(); let col = ((lng + 180.0) / 360.0 * 180.0) as usize; @@ -122,7 +118,6 @@ rustmotion_core::impl_traits!(DotMap { Styled => style, }); -/// Check if a screen-normalized point (0-1) is land. fn is_land_screen(nx: f32, ny: f32) -> bool { let (lat, lng) = screen_to_geo(nx, ny); geo_is_land(lat, lng) @@ -133,8 +128,6 @@ impl DotMap { if !self.animated { return 1.0; } - // Measure from `start_at`, like every other animated component: driving - // the ramp off raw scene time makes a delayed map arrive already drawn. let start = self.timing.start_at.unwrap_or(0.0); let elapsed = (time - start).max(0.0); let p = (elapsed / self.animation_duration).clamp(0.0, 1.0) as f32; @@ -146,22 +139,16 @@ impl DotMap { let h = layout_h; let progress = self.progress_at(time); - // Draw background let mut bg_paint = paint_from_hex(&self.background_color); bg_paint.set_style(PaintStyle::Fill); bg_paint.set_anti_alias(true); canvas.draw_rect(Rect::from_xywh(0.0, 0.0, w, h), &bg_paint); - // Draw world map dots if self.show_world { let mut world_paint = paint_from_hex(&self.world_dot_color); world_paint.set_style(PaintStyle::Fill); world_paint.set_anti_alias(true); - // A spacing at or below zero makes the division +inf, and `inf as u32` - // saturates to u32::MAX in Rust — the nested loop below would then be - // scheduled for ~1.8e19 iterations and never return. The geometry pass - // rejects 0.01 but not 0, so the floor has to live here. let spacing = if self.dot_spacing.is_finite() && self.dot_spacing >= 1.0 { self.dot_spacing } else { @@ -170,8 +157,6 @@ impl DotMap { let radius = self.dot_radius; let margin = spacing; - // Belt and braces: even a legal spacing on a very large box should not - // be able to schedule an unbounded amount of work. const MAX_DOTS_PER_AXIS: u32 = 4096; let cols = (((w - margin * 2.0) / spacing) as u32).min(MAX_DOTS_PER_AXIS); let rows = (((h - margin * 2.0) / spacing) as u32).min(MAX_DOTS_PER_AXIS); @@ -181,7 +166,6 @@ impl DotMap { let px = margin + col as f32 * spacing; let py = margin + row as f32 * spacing; - // Normalize to 0-1 let nx = px / w; let ny = py / h; @@ -192,7 +176,6 @@ impl DotMap { } } - // Render data points let default_color = "#3B82F6"; let default_dot_size = 10.0_f32; let label_font_size = 12.0_f32; @@ -207,7 +190,6 @@ impl DotMap { emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, label_font_size)); for (i, point) in self.points.iter().enumerate() { - // Staggered animation let dot_alpha = if self.animated { let stagger_delay = if point_count > 1 { (i as f64 / point_count as f64) * 0.6 @@ -231,7 +213,6 @@ impl DotMap { let dot_size = point.size.unwrap_or(default_dot_size); let color_str = point.color.as_deref().unwrap_or(default_color); - // Pulse rings (expanding concentric circles) if point.pulse.unwrap_or(false) { let num_rings = 2; for ring in 0..num_rings { @@ -248,14 +229,12 @@ impl DotMap { } } - // Filled dot let mut dot_paint = paint_from_hex(color_str); dot_paint.set_style(PaintStyle::Fill); dot_paint.set_anti_alias(true); dot_paint.set_alpha_f(dot_alpha); canvas.draw_circle((px, py), dot_size / 2.0, &dot_paint); - // White border let mut border_paint = paint_from_hex("#FFFFFF"); border_paint.set_style(PaintStyle::Stroke); border_paint.set_stroke_width(1.5); @@ -263,7 +242,6 @@ impl DotMap { border_paint.set_alpha_f(dot_alpha * 0.6); canvas.draw_circle((px, py), dot_size / 2.0, &border_paint); - // Label if let Some(label) = &point.label { let mut label_paint = paint_from_hex("#FFFFFF"); label_paint.set_anti_alias(true); diff --git a/crates/rustmotion-components/src/gauge.rs b/crates/rustmotion-components/src/gauge.rs index 4137a43b..835039b6 100644 --- a/crates/rustmotion-components/src/gauge.rs +++ b/crates/rustmotion-components/src/gauge.rs @@ -105,8 +105,6 @@ impl Gauge { if !self.animated { return 1.0; } - // Measure from `start_at`, like every other animated component: driving - // the ramp off raw scene time makes a delayed gauge arrive already full. let start = self.timing.start_at.unwrap_or(0.0); let elapsed = (time - start).max(0.0); let p = (elapsed / self.animation_duration).clamp(0.0, 1.0) as f32; @@ -125,7 +123,6 @@ impl Gauge { let oval = Rect::from_xywh(cx - radius, cy - radius, radius * 2.0, radius * 2.0); let total_sweep = self.end_angle - self.start_angle; - // Track arc let mut track_paint = paint_from_hex(&self.track_color); track_paint.set_style(PaintStyle::Stroke); track_paint.set_stroke_width(self.track_width); @@ -133,7 +130,6 @@ impl Gauge { track_paint.set_anti_alias(true); canvas.draw_arc(oval, self.start_angle, total_sweep, false, &track_paint); - // Fill arc let range = (self.max - self.min).max(0.001); let value_ratio = ((self.value - self.min) / range).clamp(0.0, 1.0) as f32; let fill_sweep = total_sweep * value_ratio * progress; @@ -145,7 +141,6 @@ impl Gauge { fill_paint.set_anti_alias(true); canvas.draw_arc(oval, self.start_angle, fill_sweep, false, &fill_paint); - // Value text if self.show_value { let display_val = self.min + (self.value - self.min) * progress as f64; let text = format!("{}", display_val.round() as i64); @@ -179,7 +174,6 @@ impl Gauge { ); } - // Label text if let Some(label) = &self.label { let font_size = (radius * 0.18).max(10.0); let font_style = skia_safe::FontStyle::normal(); diff --git a/crates/rustmotion-components/src/gif.rs b/crates/rustmotion-components/src/gif.rs index 5a0d918d..b226726e 100644 --- a/crates/rustmotion-components/src/gif.rs +++ b/crates/rustmotion-components/src/gif.rs @@ -38,10 +38,6 @@ rustmotion_core::impl_traits!(Gif { Styled => style, }); -/// The frame's rectangle, clamped to the logical canvas. -/// -/// A malformed GIF can declare a sub-rectangle that runs past the canvas; -/// clamping keeps the blit in bounds instead of panicking on the slice. fn frame_rect(canvas_w: u32, canvas_h: u32, frame: &gif::Frame<'_>) -> (u32, u32, u32, u32) { let left = frame.left.min(canvas_w as u16) as u32; let top = frame.top.min(canvas_h as u16) as u32; @@ -50,18 +46,13 @@ fn frame_rect(canvas_w: u32, canvas_h: u32, frame: &gif::Frame<'_>) -> (u32, u32 (left, top, w, h) } -/// Composite one decoded frame onto the persistent canvas. -/// -/// GIF transparency is an index, not a channel: the decoder emits alpha 0 for -/// transparent pixels, and those must leave what is underneath alone — that is -/// the whole point of the sub-rectangle encoding. fn blit_frame(composed: &mut [u8], canvas_w: u32, canvas_h: u32, frame: &gif::Frame<'_>) { let (left, top, w, h) = frame_rect(canvas_w, canvas_h, frame); for y in 0..h { for x in 0..w { let src = ((y * frame.width as u32 + x) * 4) as usize; let Some(px) = frame.buffer.get(src..src + 4) else { - return; // truncated buffer: keep what we have rather than panic + return; }; if px[3] == 0 { continue; @@ -72,7 +63,6 @@ fn blit_frame(composed: &mut [u8], canvas_w: u32, canvas_h: u32, frame: &gif::Fr } } -/// `DisposalMethod::Background`: clear this frame's rectangle before the next. fn clear_rect(composed: &mut [u8], canvas_w: u32, canvas_h: u32, frame: &gif::Frame<'_>) { let (left, top, w, h) = frame_rect(canvas_w, canvas_h, frame); for y in 0..h { @@ -81,16 +71,8 @@ fn clear_rect(composed: &mut [u8], canvas_w: u32, canvas_h: u32, frame: &gif::Fr } } -/// One decoded GIF: full-canvas RGBA frames with their dimensions, the -/// cumulative end time of each, and the total duration. Mirrors what -/// `gif_cache` stores. type DecodedGif = (Vec<(Vec, u32, u32)>, Vec, f64); -/// Artificial per-decode stall, settable only from this file's own tests -/// (`DECODE_STALL_MS`) to open a deterministic race window around the -/// cache-miss branch without timing-dependent sleeps sprinkled through the -/// test itself. Zero by default, and compiled out entirely in a non-test -/// build — no production cost. #[cfg(test)] static DECODE_STALL_MS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); @@ -102,35 +84,10 @@ fn stall_decode_for_tests() { } } -/// Hard ceiling on one composed GIF canvas frame's byte size -/// (`width × height × 4`), independent of the render's own video dimensions. -/// A GIF's logical-screen descriptor is two `u16` fields straight from the -/// file header — up to 65535×65535, a 17.2 GiB single allocation — and -/// nothing validated them before this cap existed. const MAX_GIF_CANVAS_BYTES: u64 = 128 * 1024 * 1024; -/// Hard ceiling on the number of animation frames one GIF decodes into. -/// Real GIFs rarely exceed a few hundred; this keeps a maliciously (or just -/// accidentally) long frame count from growing the decoded frame list -/// without bound even when each individual frame is well under the canvas -/// budget above. const MAX_GIF_FRAMES: usize = 600; -/// Decode a GIF into full-canvas RGBA frames, their cumulative end times, and -/// the total duration. `max_canvas_w`/`max_canvas_h` are the render's own -/// video dimensions: a GIF canvas larger than that can never be usefully -/// drawn (it only ever gets scaled into the component's layout box, which is -/// at most the video frame), so it is rejected the same way an -/// over-budget canvas is. -/// -/// Every frame after the first is usually a *sub-rectangle* holding only the -/// pixels that changed, so frames must be composed onto a persistent canvas -/// rather than used as images in their own right. Storing `frame.buffer` with -/// the canvas dimensions produced a buffer shorter than `width * height * 4`; -/// `raster_from_data` then returned `None` and the paint was skipped — which is -/// why only the first frame ever appeared (issue #185). -/// -/// `None` means nothing can be drawn, and the reason has already been reported. fn decode_composed_frames(src: &str, max_canvas_w: u32, max_canvas_h: u32) -> Option { let file = match std::fs::File::open(src) { Ok(f) => f, @@ -188,8 +145,6 @@ fn decode_composed_frames(src: &str, max_canvas_w: u32, max_canvas_h: u32) -> Op break; } - // `Previous` disposal restores what was there before this frame, so it - // has to be captured before compositing. let restore = (frame.dispose == gif::DisposalMethod::Previous).then(|| composed.clone()); blit_frame(&mut composed, canvas_w, canvas_h, frame); @@ -207,7 +162,6 @@ fn decode_composed_frames(src: &str, max_canvas_w: u32, max_canvas_h: u32) -> Op composed = prev; } } - // `Any` and `Keep` both leave the canvas as it stands. _ => {} } } @@ -222,13 +176,6 @@ fn decode_composed_frames(src: &str, max_canvas_w: u32, max_canvas_h: u32) -> Op Some((frames, cumulative_times, accumulated)) } -/// Cache lookup with the actual decode folded in, single-flighted through -/// `DashMap::entry`: a vacant entry holds its shard's write lock for as long -/// as the closure runs, so a second caller racing the same cache-cold `src` -/// blocks on that lock instead of starting its own redundant decode. Decode -/// failure (`decode_composed_frames` returning `None`) leaves the entry -/// vacant — `or_try_insert_with` never calls `insert` on its `Err` path — -/// so a broken source is retried rather than permanently cached as absent. fn cached_decode(src: &str, max_canvas_w: u32, max_canvas_h: u32) -> Option> { gif_cache() .entry(src.to_string()) @@ -290,9 +237,6 @@ impl Painter for Gif { mod tests { use super::*; - /// Write a 4x2 GIF: frame 0 fills the canvas red, frame 1 paints a 2x2 blue - /// sub-rectangle at (2,0) and keeps what is under it — the shape every - /// optimising encoder produces, and the one that used to render nothing. fn write_two_frame_gif(path: &std::path::Path) { let palette: &[u8] = &[0xFF, 0x00, 0x00, 0x00, 0x00, 0xFF]; let mut file = std::fs::File::create(path).expect("create gif fixture"); @@ -315,8 +259,6 @@ mod tests { frame[i..i + 4].try_into().expect("pixel") } - /// The regression: an optimised GIF must yield as many drawable frames as - /// it has, all full-canvas, and they must differ. #[test] fn a_subrectangle_frame_is_composed_onto_the_previous_one() { let path = std::env::temp_dir().join(format!( @@ -341,8 +283,6 @@ mod tests { } assert_ne!(frames[0].0, frames[1].0, "the two frames must differ"); - // Frame 1 patches the right half and keeps the left: the whole point of - // composing rather than drawing the sub-rectangle alone. assert_eq!(px(&frames[1].0, 4, 0, 0), [0xFF, 0x00, 0x00, 0xFF], "kept"); assert_eq!( px(&frames[1].0, 4, 3, 0), @@ -358,7 +298,6 @@ mod tests { fn a_missing_file_reports_instead_of_returning_nothing() { let missing = std::env::temp_dir().join("rustmotion_gif_absent_xyz.gif"); assert!(decode_composed_frames(missing.to_str().expect("utf-8"), 1920, 1080).is_none()); - // The warn-once slot must have been claimed — silence is the bug. assert!( !crate::warn_once_for(&format!("gif-open:{}", missing.to_str().expect("utf-8"))), "the open failure must have reported once" @@ -373,9 +312,6 @@ mod tests { assert_eq!(frame_rect(4, 4, &frame), (3, 3, 1, 1)); } - /// Releases `DECODE_STALL_MS` back to zero even if the test body - /// panics mid-assertion, so a failing run never leaks a stall into - /// whatever other test in this binary decodes a GIF next. struct StallGuard; impl Drop for StallGuard { @@ -384,14 +320,6 @@ mod tests { } } - /// N callers racing a cache-cold `src` must decode it once, not N - /// times. A 120ms stall (`DECODE_STALL_MS`) right after the header is - /// read opens a race window wide enough that every thread reaches the - /// cache-miss branch before any of them can finish decoding and insert — - /// pre-fix, that means eight independent decodes, each producing its own - /// `Arc` allocation. Comparing pointers (not content — a deterministic - /// decode produces byte-identical content either way) is what proves - /// only one of the eight actually ran. #[test] fn concurrent_paints_of_the_same_uncached_gif_decode_exactly_once() { let _guard = StallGuard; @@ -438,10 +366,6 @@ mod tests { } } - /// Writes a GIF whose logical-screen descriptor declares `w`×`h` but - /// whose only frame is 1×1 — the crafted-header shape a decompression - /// bomb takes: a file of a few hundred bytes that asks the decoder to commit to a - /// canvas orders of magnitude larger than anything it actually encodes. fn write_oversized_header_gif(path: &std::path::Path, w: u16, h: u16) { let palette: &[u8] = &[0, 0, 0, 255, 255, 255]; let mut file = std::fs::File::create(path).expect("create gif fixture"); @@ -450,14 +374,6 @@ mod tests { encoder.write_frame(&frame).expect("write frame"); } - /// A 6000×6000 canvas is 144 MiB per frame — comfortably over - /// `MAX_GIF_CANVAS_BYTES` (128 MiB) regardless of how large the render's - /// own video is, so passing generous `max_w`/`max_h` here isolates the - /// byte-budget check from the video-dimensions check exercised by the - /// next test. 144 MiB is deliberately far short of the 65535×65535 - /// (~17 GiB) header the audit's own crafted file could declare — large - /// enough to prove the budget check fires, small enough that running - /// this test never risks the allocation it is asserting never happens. #[test] fn a_canvas_over_the_byte_budget_is_rejected_without_allocating_it() { let path = std::env::temp_dir().join(format!( @@ -483,11 +399,6 @@ mod tests { ); } - /// The other half of the same guard: a canvas larger than the render's - /// own video dimensions can never be usefully drawn either. A - /// 3000×2000 canvas is only 24 MiB (well under the byte budget alone) - /// but bigger than the render's own 1920×1080 video, so this isolates - /// the video-dimensions check from the byte-budget one above. #[test] fn a_canvas_larger_than_the_video_is_rejected_even_under_the_byte_budget() { let path = std::env::temp_dir().join(format!( @@ -509,8 +420,6 @@ mod tests { ); } - /// Writes `count` tiny (2×2) frames — cheap regardless of `count`, so - /// the frame-count cap can be tested without the per-frame size mattering. fn write_many_frame_gif(path: &std::path::Path, count: u32) { let palette: &[u8] = &[0, 0, 0, 255, 255, 255]; let mut file = std::fs::File::create(path).expect("create gif fixture"); @@ -522,11 +431,6 @@ mod tests { } } - /// The decode loop had no frame-count limit at all — a - /// small canvas with a pathologically large frame count still grows the - /// decoded animation without bound. `MAX_GIF_FRAMES` truncates it - /// instead of rejecting the whole GIF: a real, merely-too-long animation - /// still plays (up to the cap), it just doesn't keep growing memory. #[test] fn frame_count_beyond_the_cap_is_truncated_not_unbounded() { let path = std::env::temp_dir().join(format!( diff --git a/crates/rustmotion-components/src/gradient_text.rs b/crates/rustmotion-components/src/gradient_text.rs index fcfadc2e..15a91553 100644 --- a/crates/rustmotion-components/src/gradient_text.rs +++ b/crates/rustmotion-components/src/gradient_text.rs @@ -57,11 +57,6 @@ rustmotion_core::impl_traits!(GradientText { }); impl GradientText { - /// Family/weight/style resolution only, independent of `font_size` — so - /// `text-autofit` (below) can build a fresh `Font` at each candidate - /// size from the same resolved typeface without re-resolving the - /// family/weight/style lookup per candidate. `paint` builds the actual - /// `Font` itself once the final (possibly autofit-shrunk) size is known. fn resolve_typeface(&self) -> Option { let font_family = self.style.font_family_or("Inter"); @@ -96,12 +91,6 @@ impl GradientText { return; } - // `font-size` itself now resolves through `LengthContext` too (lot B, - // wave S) — it used to stay context-free (`font_size_px_or`), - // silently dropping `rem`/`vw`/`vh` font-size to 0px. `em`/`%` on - // `font-size` itself remain approximate — see - // `crate::intrinsic::font_size_ctx`'s doc comment (cascade.rs - // doesn't track the real parent font-size). let base_ctx = crate::intrinsic::font_size_ctx( ctx.video_width as f32, ctx.video_height as f32, @@ -111,16 +100,6 @@ impl GradientText { let Some(typeface) = self.resolve_typeface() else { return; }; - // `letter-spacing`/`line-height`'s `em`/`%` are relative to this - // element's own font-size, no cascade dependency — a real - // `LengthContext` is available here, so use the context-aware - // resolvers (issue #125 §2: correctly handles `vw`/`vh`/`rem`/ - // line-height-`%`). `letter_spacing` itself: this component never - // read `style.letter-spacing` before this fix — the wrap/measure/ - // draw calls below always tracked at 0.0 regardless of what was - // set, which is the same class of measure/paint disagreement issue - // #125 §1 describes elsewhere. It's threaded through consistently - // now. let type_ctx = rustmotion_core::css::units::LengthContext { font_size, ..base_ctx @@ -128,11 +107,6 @@ impl GradientText { let mut line_height_val = self.style.line_height_for_ctx(font_size, &type_ctx); let mut letter_spacing = self.style.letter_spacing_px_ctx(&type_ctx); - // M1: `white-space: nowrap|pre` keeps the whole content on one line - // even past `layout_width` (it bleeds); anything else word-wraps at - // the box width — same rule `text.rs` uses. `gradient_text` has no - // `max_width` field of its own (unlike `text`/`caption`), so its box - // width is simply the resolved layout width, if any. let nowrap = matches!( self.style.white_space, Some(CssWhiteSpace::Nowrap | CssWhiteSpace::Pre) @@ -140,11 +114,6 @@ impl GradientText { let box_width = (layout_width.is_finite() && layout_width > 0.0).then_some(layout_width); let wrap_at = if nowrap { None } else { box_width }; - // `text-autofit` — see `text.rs::Text::paint`'s identical step and - // `resolve_text_autofit`'s doc comment for the shared-computation - // parity argument with `TextIntrinsic::measure`. Resolved before - // building the real `Font` below so the rest of this function draws - // at the (possibly shrunk) resolved size. if matches!(self.style.text_autofit, Some(true)) { let declared_height = content_height.filter(|h| *h > 0.0 && h.is_finite()); let (fs, ls, lh) = crate::intrinsic::resolve_text_autofit( @@ -165,15 +134,9 @@ impl GradientText { let font = Font::from_typeface(typeface, font_size); let emoji_font = emoji_typeface().map(|tf| Font::from_typeface(tf, font_size)); - // Tracking-aware wrap (issue #125 §1), consistent with the - // real-tracking measurement/draw below. let lines = wrap_text_with_tracking(&self.content, &font, &emoji_font, wrap_at, letter_spacing); - // Measure the overall (possibly multi-line) bounding box. The - // gradient is defined once across this whole block rather than - // per-line, so wrapping reflows the text without fragmenting the - // colour transition. let (_, metrics) = font.metrics(); let ascent = -metrics.ascent; let descent = metrics.descent; @@ -183,12 +146,6 @@ impl GradientText { .fold(0.0f32, f32::max); let text_h = (lines.len().max(1) - 1) as f32 * line_height_val + ascent + descent; - // `text-align`, on the same rule `text.rs` applies: each line is - // placed within the box width, and `Start`/`Justify` fall back to the - // left edge like every other left-ish value. Without this the draw - // loop below passed a literal `x = 0.0`, so a `gradient_text` and a - // `text` sharing a box and a `text-align: center` disagreed — the - // plain one centred, the gradient one sat at the left edge (#337). let align_width = if layout_width.is_finite() && layout_width > 0.0 { layout_width } else { @@ -200,22 +157,14 @@ impl GradientText { CssTextAlign::Right | CssTextAlign::End => align_width - advance, _ => 0.0, }; - // The gradient is defined once across the whole block, so its span - // has to travel with the aligned block instead of staying pinned to - // the box's left edge: a centred block's glyphs would otherwise run - // past the shader's end stop and all pick up the clamped last - // colour. The block occupies `text_w` — the widest line — so that - // line's own offset is what the endpoints shift by. let block_x = line_x(text_w); - // Compute angle (possibly animated) let angle = if self.animate_angle { self.angle + time as f32 * self.speed * 360.0 } else { self.angle }; - // Compute gradient endpoints from angle, spanning the full block. let angle_rad = angle * std::f32::consts::PI / 180.0; let cx = block_x + text_w / 2.0; let cy = text_h / 2.0; @@ -229,7 +178,6 @@ impl GradientText { cy + angle_rad.sin() * half_diag, ); - // Parse gradient colors let skia_colors: Vec = self .colors .iter() @@ -239,7 +187,6 @@ impl GradientText { }) .collect(); - // Build linear gradient shader let positions: Option<&[f32]> = None; let colors4f: Vec = skia_colors.iter().map(|c| Color4f::from(*c)).collect(); let stops = Colors::new(&colors4f, positions, skia_safe::TileMode::Clamp, None); @@ -254,7 +201,6 @@ impl GradientText { p } None => { - // Fallback: draw with the first color, no gradient. let mut p = paint_from_hex(&self.colors[0]); p.set_anti_alias(true); p @@ -294,8 +240,6 @@ impl Painter for GradientText { _props: &AnimatedProperties, ctx: &PaintCtx, ) { - // See `Text::paint_content`'s identical step for why `None` here - // means "nothing to fit against" rather than "fit to zero". let (_, _, _, content_height) = layout.content_box(); let content_height = (content_height > 0.0 && content_height.is_finite()).then_some(content_height); @@ -373,7 +317,6 @@ mod tests { false } - /// Horizontal span of every painted pixel, as `(first_x, last_x)`. fn ink_x_span(grid: &[u8], surface_width: i32, height: i32) -> Option<(i32, i32)> { let mut span: Option<(i32, i32)> = None; for y in 0..height { @@ -391,11 +334,6 @@ mod tests { #[test] fn text_align_center_centres_the_line_in_the_box() { - // #337: the draw loop passed a literal `x = 0.0`, so a `gradient_text` - // and a `text` sharing a box and a `text-align: center` disagreed -- - // the plain one centred, the gradient one sat at the left edge. - // Measured on pixels rather than on the offset the code computes: the - // painted span's own centre must land on the box's centre. const W: i32 = 700; const H: i32 = 80; const BOX_W: f32 = 600.0; @@ -444,9 +382,6 @@ mod tests { #[test] fn no_text_align_still_starts_at_the_box_left_edge() { - // The default must not move: every scenario written before #337 read - // `gradient_text` as left-aligned whatever `text-align` said, and a - // file that never set it has to render identically. const W: i32 = 700; const H: i32 = 80; @@ -464,11 +399,6 @@ mod tests { #[test] fn centring_keeps_the_gradient_over_the_glyphs() { - // The shader spans the block, so it has to travel with the aligned - // block: pinned at the box's left edge it would end before a centred - // block's last glyphs, which would all come out the clamped end - // colour. Compare the last glyph's colour centred against - // left-aligned -- same glyph, same place in the block, same colour. const W: i32 = 700; const H: i32 = 80; const BOX_W: f32 = 600.0; @@ -497,7 +427,6 @@ mod tests { )); let alpha: Vec = (0..(W * H) as usize).map(|i| buf[i * 4 + 3]).collect(); let (_, hi) = ink_x_span(&alpha, W, H).expect("must paint"); - // The densest opaque pixel on the last glyph's column. let (mut best_y, mut best_a) = (0i32, 0u8); for y in 0..H { let a = alpha[(y * W + hi) as usize]; @@ -522,8 +451,6 @@ mod tests { #[test] fn nowrap_paints_a_single_line_past_the_layout_width() { - // M1 render-level proof, gradient_text: `white-space: nowrap` stays - // on one line and bleeds past `layout_width`. let gt = make_gradient_text( "the quick brown fox jumps over the lazy dog", Some(CssWhiteSpace::Nowrap), @@ -567,9 +494,6 @@ mod tests { #[test] fn rem_font_size_paints_visible_ink() { - // Reproduction: `font-size: "2rem"` used to resolve to 0px via the - // context-free `font_size_px_or`, so `resolve_font` built a 0px font - // and nothing measurable painted. let gt = GradientText { content: "HELLO".into(), colors: default_colors(), @@ -597,8 +521,6 @@ mod tests { ); } - // ─── text-autofit ─────────────────────────────────────────────────── - fn autofit_gradient_text( content: &str, font_size: f32, @@ -633,7 +555,7 @@ mod tests { 90.0, Some(CssWhiteSpace::Nowrap), ); - const BOX_W: f32 = 300.0; // clears this sentence's floor-fit width + const BOX_W: f32 = 300.0; let (measured_w, _) = GradientTextIntrinsic::from_gradient_text(>).measure( (None, None), @@ -666,9 +588,6 @@ mod tests { #[test] fn without_text_autofit_nowrap_still_bleeds_past_the_box_exactly_as_before() { - // Backward compatibility twin of `nowrap_paints_a_single_line_past_the_layout_width`, - // now also passing a real `content_height` — proves that alone - // doesn't trigger shrinking without the flag. let gt = make_gradient_text( "the quick brown fox jumps over the lazy dog", Some(CssWhiteSpace::Nowrap), @@ -688,9 +607,6 @@ mod tests { #[test] fn autofit_is_stable_across_frames_for_fixed_content() { - // Trap #2: `angle` can animate over time via `animate_angle`, but - // here it's static — the resolved font size must not depend on - // `time` at all for fixed content in a fixed box. let gt = autofit_gradient_text( "the quick brown fox jumps over the lazy dog", 90.0, diff --git a/crates/rustmotion-components/src/heatmap.rs b/crates/rustmotion-components/src/heatmap.rs index 93ddae91..4ea2095f 100644 --- a/crates/rustmotion-components/src/heatmap.rs +++ b/crates/rustmotion-components/src/heatmap.rs @@ -98,11 +98,6 @@ fn interpolate_color(scale: &[String], t: f32) -> (u8, u8, u8) { } let n = scale.len() - 1; let scaled = t * n as f32; - // Clamp the segment index (t=1.0 lands exactly on `n`, one past the - // last valid segment), but re-derive `local_t` from the *clamped* - // segment rather than reusing the unclamped one — otherwise t=1.0 - // computed local_t=0.0 against the clamped (second-to-last) segment and - // resolved to the second-to-last color instead of the last one. let segment = (scaled.floor() as usize).min(n - 1); let local_t = (scaled - segment as f32).clamp(0.0, 1.0); let (r1, g1, b1, _) = parse_hex_color(&scale[segment]); @@ -119,10 +114,6 @@ impl Heatmap { if !self.animated { return 1.0; } - // Ramp measured from `start_at`, not from scene time zero — matches - // `Counter::ramp_progress`. A heatmap delayed with `start_at` used - // to read raw scene time, so it was already fully revealed on the - // very first frame it became visible. let start = self.timing.start_at.unwrap_or(0.0); let elapsed = (time - start).max(0.0); let p = (elapsed / self.animation_duration).clamp(0.0, 1.0) as f32; @@ -139,7 +130,6 @@ impl Heatmap { let progress = self.progress_at(time); - // Animation: clip rect expanding from left to right let clip_w = w * progress; canvas.save(); canvas.clip_rect( @@ -152,14 +142,6 @@ impl Heatmap { for (row_idx, row) in self.data.iter().enumerate() { for (col_idx, &val) in row.iter().enumerate() { - // `color_scale` documents an absolute 0.0-1.0 semantic - // (SKILL.md: "2D array of f64, values 0.0-1.0"), not a - // per-render min-max scale. Renormalizing meant a grid of - // constant values (or any subrange, e.g. [0.8, 0.9, 1.0]) - // painted identically to a grid of zeros — a flat or - // uniformly-high grid is not the same fact as "nothing - // happened". Clamp into the documented range instead of - // rescaling to whatever the data happens to span. let normalized = (val as f32).clamp(0.0, 1.0); let (r, g, b) = interpolate_color(&self.color_scale, normalized); @@ -230,7 +212,6 @@ mod tests { None, ); let mut buf = [0u8; 4]; - // Sample the middle of the top-left cell. let x = (heatmap.cell_size / 2.0) as i32; let y = (heatmap.cell_size / 2.0) as i32; snapshot.read_pixels( @@ -245,11 +226,6 @@ mod tests { #[test] fn a_uniformly_low_grid_is_not_identical_to_an_all_zero_grid() { - // #6's exact repro: `color_scale` is documented (SKILL.md) as an - // *absolute* 0.0-1.0 scale, but the painter renormalized min→max — - // so a grid of constant 5.0s (or any other constant) rendered - // pixel-for-pixel identical to a grid of constant 0.0s, both - // collapsing to the scale's first (lowest) color. let uniform = base_heatmap(vec![vec![5.0, 5.0, 5.0], vec![5.0, 5.0, 5.0]]); let zero = base_heatmap(vec![vec![0.0, 0.0, 0.0], vec![0.0, 0.0, 0.0]]); let uniform_color = cell_color(&uniform, 200, 100, 10.0); @@ -262,9 +238,6 @@ mod tests { #[test] fn absolute_values_are_not_renormalized_to_the_data_subrange() { - // A grid whose values happen to span [0.8, 1.0] must not stretch - // that subrange to fill the whole color scale — 0.8 reads as - // "mostly full", not as "the bottom of whatever this grid contains". let high = base_heatmap(vec![vec![0.8, 0.9, 1.0]]); let low = base_heatmap(vec![vec![0.0, 0.1, 0.2]]); let high_first_cell = cell_color(&high, 200, 100, 10.0); @@ -277,11 +250,6 @@ mod tests { #[test] fn interpolate_color_at_the_top_of_the_scale_returns_the_last_color() { - // Surfaced while chasing #6: the clamped segment index was reused - // for `local_t` too, so t=1.0 exactly computed `local_t = 0.0` for - // the *clamped* (second-to-last) segment instead of `local_t = 1.0` - // — landing on the second-to-last color rather than the last - // (brightest) one. let scale = default_color_scale(); let (r, g, b) = interpolate_color(&scale, 1.0); let (er, eg, eb, _) = parse_hex_color(scale.last().unwrap()); diff --git a/crates/rustmotion-components/src/icon.rs b/crates/rustmotion-components/src/icon.rs index 812f53c1..7ed54904 100644 --- a/crates/rustmotion-components/src/icon.rs +++ b/crates/rustmotion-components/src/icon.rs @@ -40,12 +40,6 @@ impl Painter for Icon { let color = self.style.color_str_or("#FFFFFF"); let target_w = (layout.width as u32).max(1); let target_h = (layout.height as u32).max(1); - // Oversampling (crisp edges under sub-pixel positioning / scale - // animation) and the cache key are computed together by - // `icon_cache_key` — see its doc for why that matters (issue #166): - // this used to be a local `const OVERSAMPLE` here plus an - // independent `format!` in `preload.rs`'s prefetcher, and the two - // could never agree. let (render_w, render_h, cache_key) = icon_cache_key(&self.icon, color, target_w, target_h); let cache = asset_cache(); @@ -53,14 +47,6 @@ impl Painter for Icon { cached.clone() } else { let Ok(svg_data) = fetch_icon_svg(&self.icon, color, render_w, render_h) else { - // Preload (issue #167 item 2) already hard-fails when an - // icon cannot be resolved via disk cache or network, so this - // branch is defense in depth (paint_content can run without - // a preceding prefetch, or the layout-derived target size - // here can differ from the preloader's style-based - // estimate, producing a genuine cache miss). Guarded so a - // single offline/typo'd icon does not spam once per frame - // over a render that can be 1000+ frames long. if crate::warn_once_for(&format!("icon-fetch-failed:{}", self.icon)) { eprintln!( "Warning: icon '{}' could not be loaded (checked the disk cache and \ @@ -148,12 +134,6 @@ mod tests { skia_safe::images::raster_from_data(&img_info, skia_data, 2 * 4).expect("sentinel image") } - /// Regression for issue #166: proves the painter's cache-key formula is - /// literally `icon_cache_key`. Pre-populate `asset_cache()` under the - /// exact key `preload.rs`'s prefetcher now computes for a 40×40 target, - /// then confirm the painter finds and paints it — instead of falling - /// through to `fetch_icon_svg` for a nonsense icon id (which pre-fix, on - /// a key mismatch, is exactly what would have happened every time). #[test] fn painter_finds_the_entry_preload_would_have_written() { let icon = Icon { @@ -184,8 +164,6 @@ mod tests { icon.paint_content(canvas, &layout, &props, &ctx); } - // Cleanup so this entry does not leak into other tests sharing the - // process-global asset_cache. asset_cache().remove(&key); let snapshot = surface.image_snapshot(); diff --git a/crates/rustmotion-components/src/intrinsic.rs b/crates/rustmotion-components/src/intrinsic.rs index 1c15ab07..858c1b65 100644 --- a/crates/rustmotion-components/src/intrinsic.rs +++ b/crates/rustmotion-components/src/intrinsic.rs @@ -1,10 +1,3 @@ -//! Intrinsic measurers for components whose box size depends on content. -//! -//! Uses the same Skia metrics that the painter uses, so the box reserved by -//! taffy matches the pixels actually drawn — measure-vs-paint mismatches -//! would otherwise cause text to wrap onto an extra line at paint time and -//! overflow into the next sibling. - use skia_safe::{Font, FontStyle as SkFontStyle, Typeface}; use rustmotion_core::css::style::{ @@ -25,44 +18,8 @@ use crate::gradient_text::GradientText; use crate::kbd::Kbd; use crate::text::Text; -// ─── Shared `font-size` context resolution (deployment of `font_size_px_ctx` -// / `typography_px_ctx`, css/style.rs, across every component that still -// resolved `font-size` with the context-free `font_size_px_or`) ─────────── -// -// `font_size_px_or`/`.px()` cannot resolve `%`/`em`/`rem`/`vw`/`vh` — for a -// `Some(Length::String(_))` that parses as one of those units, `.px()` warns -// and returns `0.0`, and since the field itself is `Some`, the `_or` -// fallback default never kicks in either. A `text` with `"font-size": -// "2rem"` therefore measured *and* painted at 0px: `validate` passed (only -// warnings), but the rendered frame had no visible text (paint_pass's -// `height <= 0.0` guard skips the node once the intrinsic measures it at -// zero). -// -// `rustmotion_core::css::style::CssStyle::font_size_px_ctx` (and -// `typography_px_ctx`, which resolves `font-size`, `letter-spacing`, and -// `line-height` together, honouring CSS's two different `em` bases) already -// exist and are tested — nothing in the engine called them. These two -// helpers build the `LengthContext` every call site below feeds them, -// so the context-building logic lives in exactly one place instead of being -// copied into ~15 components. use rustmotion_core::css::units::LengthContext; -/// `LengthContext` for resolving `font-size` (and, through -/// [`CssStyle::typography_px_ctx`], `letter-spacing`/`line-height` derived -/// from it) against a real, per-frame viewport. Use from `Painter:: -/// paint_content` and friends, which have a real `PaintCtx` (`video_width`/ -/// `video_height`) on hand. -/// -/// `rem`/`vw`/`vh` resolve correctly through this. `em`/`%` on `font-size` -/// itself do not: per CSS they're relative to the *parent's* computed -/// font-size, but `cascade.rs` inherits `font-size` down the tree as a raw, -/// unresolved `Length`, not a resolved px value (see the module note above -/// `CssStyle::font_size_px_ctx`) — no caller in this workstream's scope can -/// supply the real cascaded value. `font_size: 16.0` here is the CSS root -/// default used as the best available stand-in; it makes `em`/`%` on -/// `font-size` *resolve* (no longer silently drop to 0px) without making -/// them *correct* against an actual parent font-size. Fixing that fully -/// needs a `cascade.rs` change, out of scope here. pub fn font_size_ctx(viewport_width: f32, viewport_height: f32, parent_size: f32) -> LengthContext { LengthContext { viewport_width, @@ -73,28 +30,10 @@ pub fn font_size_ctx(viewport_width: f32, viewport_height: f32, parent_size: f32 } } -/// Same as [`font_size_ctx`], for the `Intrinsic` measurers in this module: -/// they run at `box_builder`/`geometry` construction time, before layout, so -/// there is no real per-frame viewport to hand (see the pre-existing note on -/// `TextIntrinsic::from_parts`, which has the same limitation for -/// `letter-spacing`/`line-height`). Falls back to the engine-wide default -/// 1920×1080 (same as `LengthContext::default()`) so `rem` — which does not -/// depend on the viewport at all — still resolves exactly, and `vw`/`vh` get -/// a reasonable non-zero approximation instead of silently dropping to 0. -/// This can diverge from what `Painter::paint_content` resolves via -/// [`font_size_ctx`] for `vw`/`vh` specifically, on videos that aren't -/// 1920×1080 — closing that fully needs the real `VideoConfig` threaded -/// through `box_builder.rs`/`geometry.rs`, both outside this workstream. pub fn measure_time_font_size_ctx(parent_size: f32) -> LengthContext { font_size_ctx(1920.0, 1080.0, parent_size) } -/// Skia-backed intrinsic measurer for [`Text`] (audit #10: despite the name -/// this module's doc header suggests, this uses `skia_safe::Font:: -/// measure_str` via `engine::renderer::text`'s fallback-aware helpers — the -/// same primitives `Text::paint` draws with — not `engine::text::cosmic`, -/// which has no callers on the real render path at all; see that module's -/// doc comment). pub struct TextIntrinsic { content: String, font_family: Option, @@ -105,40 +44,16 @@ pub struct TextIntrinsic { letter_spacing: f32, max_width: Option, wrap: bool, - /// `style.text-align`, resolved to the three horizontal keywords that - /// matter for placing a line inside its box (`right`/`end` collapse to - /// `Right`, everything else — including `justify`, which this engine - /// doesn't implement — collapses to `Left`). Only consulted by - /// [`Self::text_metrics`] (issue #328): line placement for painting is - /// `Text`'s own concern and already reads `style.text_align` directly. text_align: CssTextAlign, - /// `style.text-autofit == Some(true)`, but only ever set by - /// [`Self::from_text`] / [`GradientTextIntrinsic::from_gradient_text`] — - /// see [`Self::with_autofit`]'s doc comment for why `from_parts`/ - /// `from_parts_with_wrap` (shared by `Caption`/`Kbd`/`Badge`/`Counter`, - /// none of whose painters read `text-autofit`) must never set this from - /// `style` directly. text_autofit: bool, } impl TextIntrinsic { - /// M1: `white-space: nowrap|pre` disables wrapping — the geometry - /// validator's `unwrappable_text_overflow`/`ContentOverflowsBox` checks - /// (crates/rustmotion/src/cli/commands/geometry.rs) already branch on - /// exactly this pair of variants and re-measure via this same - /// `TextIntrinsic`, so the wrap decision here must match theirs exactly - /// or the validator's assumption about what the renderer produces is - /// false. pub fn from_text(text: &Text) -> Self { let wrap = !matches!( text.style.white_space, Some(WhiteSpace::Nowrap | WhiteSpace::Pre) ); - // Measure the *longest* label the text can ever show, not just the - // first: a box sized for "Saved" would be overrun the moment a - // `states` entry swapped in "Saving draft…", and the geometry - // validator — which measures through here — would have signed off on - // the overflow. let widest = text .all_labels() .max_by_key(|label| label.chars().count()) @@ -147,51 +62,12 @@ impl TextIntrinsic { .with_autofit(matches!(text.style.text_autofit, Some(true))) } - /// Opt this instance into `text-autofit`. Deliberately a separate, - /// explicit step rather than something `from_parts`/`from_parts_with_wrap` - /// read off `style` themselves: those two constructors are shared by - /// every atomic/synthetic-style caller in this file (`Caption`, `Kbd`, - /// `Badge`, `Counter`) whose *painters* have no idea `text-autofit` - /// exists — if the flag leaked in through the shared style, `measure()` - /// would shrink the reserved box for one of those while the painter - /// went on drawing at the full requested size, which is exactly the - /// measure-vs-paint divergence this feature exists to prevent, not - /// reintroduce elsewhere. Only [`Self::from_text`] and - /// [`GradientTextIntrinsic::from_gradient_text`] call this, matching the - /// two painters (`Text`, `GradientText`) that actually implement it. pub fn with_autofit(mut self, on: bool) -> Self { self.text_autofit = on; self } - /// Generic constructor shared by [`GradientText`]/[`Caption`] intrinsics, - /// whose painters don't (yet) implement `white-space: nowrap` — kept - /// wrap:true unconditionally so their measured size still matches what - /// those painters actually draw. pub fn from_parts(content: &str, style: &CssStyle, max_width: Option) -> Self { - // No *real* `LengthContext` (real viewport, real parent width) is - // reachable here without changing this constructor's signature — - // its only callers are `box_builder.rs` and - // `rustmotion/src/cli/commands/geometry.rs`, both outside this - // workstream's scope (box_builder.rs is a sibling's live file this - // wave; the geometry validator re-measures via this exact type and - // must keep agreeing with it byte-for-byte, so changing what it - // needs to pass in is not a call to make unilaterally here). - // `measure_time_font_size_ctx` falls back to the engine-wide default - // viewport (1920×1080) for this reason — see its doc comment. - // - // `font-size` itself, and `letter-spacing`/`line-height`'s `em`/`%` - // (relative to this element's *own*, just-resolved font-size — CSS - // spec, also documented on `CssStyle::letter_spacing_px_ctx`/ - // `line_height_for_ctx`) are resolved together by - // `typography_px_ctx`, which re-derives the right context between - // the two steps. `Text`/`Caption`'s painters resolve the same three - // properties with the real `PaintCtx`'s viewport (lot B, wave S), so - // `rem` (viewport-independent) always agrees between measure and - // paint; `vw`/`vh` can diverge on videos that aren't 1920×1080 — - // closing that fully needs the real `VideoConfig` plumbed through - // `box_builder.rs`/`geometry.rs`, still out of scope for the reasons - // above. let base_ctx = measure_time_font_size_ctx(0.0); let (font_size, letter_spacing, line_height_resolved) = style.typography_px_ctx(&base_ctx, 48.0); @@ -215,8 +91,6 @@ impl TextIntrinsic { } } - /// Build with an explicit `wrap` override (used by atomic components like - /// counter, kbd, badge that never wrap). pub fn from_parts_with_wrap( content: &str, style: &CssStyle, @@ -268,15 +142,6 @@ impl IntrinsicMeasure for TextIntrinsic { return (base_w, base_h); } - // Height target: mirrors the `known`/`available` merge above for - // width — taffy hands a leaf its own `known`/`available` height - // already padding/border-subtracted (content-box space) whenever - // the node's own box resolves to a *definite* height, exactly the - // same protocol it uses for width. No separate hand-rolled read of - // `style.height` here: reusing this signal is what guarantees this - // agrees with `Text::paint`'s `content_height` (from the *same* - // taffy-resolved `BoxLayout::content_box()`, post-layout) — see - // `CssStyle::text_autofit`'s doc comment. let target_height = match known.1 { Some(h) => Some(h), None => match available.1 { @@ -328,26 +193,6 @@ impl TextIntrinsic { typeface_with_fallback(family, self.sk_font_style()).ok() } - /// Text/glyph metrics for a `node("id", ...)` expression read (issue - /// #328) — the `TextMetrics`/`Glyphs` families of - /// `crate::engine::deps::ResolvedNode::prop`. `content_box_width` is the - /// node's own resolved content-box width, post-`layout_pass` - /// (`BoxLayout::content_box().2`) — the same width `self.measure` - /// itself would receive as `known.0`/`available.0`, which is why this - /// can only run *after* layout, unlike [`Self::sk_font_style`]. - /// - /// `capHeight`/`ascender`/`baseline` are plain font metrics (wrap- - /// independent); `textWidth` and the glyph run depend on how `content` - /// wraps at `content_box_width`, computed the same way - /// [`Self::measure`] does (`wrap_text_with_tracking`, same font/tracking - /// inputs), so the box an expression reads back always agrees with the - /// box `layout_pass` actually reserved. Does not account for - /// `text-autofit`'s shrunk font size — this reports metrics at the - /// *requested* size regardless of whether autofit later shrinks it, a - /// known simplification (autofit and `node(...)` reads are an unusual - /// combination: autofit exists for content whose length can't be - /// predicted, which cuts against also anchoring another node to its - /// exact rendered size). pub fn text_metrics(&self, content_box_width: f32) -> Option { let typeface = self.typeface()?; let font = Font::from_typeface(typeface, self.font_size); @@ -375,9 +220,6 @@ impl TextIntrinsic { for line in &lines { let advance = measure_text_with_fallback(line, &font, &emoji_font, self.letter_spacing); text_width = text_width.max(advance); - // `self.text_align` is already normalised to Left/Center/Right - // at construction (see `from_parts`) — no other variant is ever - // stored here. let line_x = match self.text_align { CssTextAlign::Center => (content_box_width - advance) / 2.0, CssTextAlign::Right => content_box_width - advance, @@ -393,9 +235,6 @@ impl TextIntrinsic { let (_, metrics) = font.metrics(); let ascender = -metrics.ascent; let descender = metrics.descent; - // Mirrors `text.rs::paint`'s own `baseline_offset` formula exactly - // (centers the em box within the line box) so `baseline` describes - // where the glyphs this same struct paints actually sit. let baseline = (self.line_height_resolved + ascender - descender) / 2.0; Some(TextMetrics { @@ -408,18 +247,6 @@ impl TextIntrinsic { } } -/// [`TextMetricsProvider`] implementation for the two components whose -/// intrinsic measurer is [`TextIntrinsic`]-backed and expose plain text -/// content (`Text`, `GradientText`) — the bridge -/// `rustmotion_core::engine::deps` needs from this crate to resolve the -/// `TextMetrics`/`Glyphs` families without `rustmotion-core` knowing either -/// concrete type (see that module's doc comment on why this is a trait -/// rather than a direct call). -/// -/// `Caption`/`Counter`/`Kbd`/`Badge`/`RichText` also measure through -/// `TextIntrinsic`-shaped helpers but are not wired in here — extending -/// this is a matter of downcasting to each and calling the same -/// `TextIntrinsic::text_metrics`, not new measurement logic. pub struct ComponentTextMetrics; impl TextMetricsProvider for ComponentTextMetrics { @@ -440,12 +267,6 @@ impl TextMetricsProvider for ComponentTextMetrics { } } -/// Wrap `content` at `font_size` (with `letter_spacing`/`line_height` -/// already resolved for that size) and return its `(max_line_width, -/// total_height)` — the single wrap+measure routine `TextIntrinsic::measure` -/// calls for both its base (requested-size) and, when `text-autofit` shrinks -/// it, its final (resolved-size) measurement, so the two never drift apart -/// from hand-duplicated logic. fn wrap_and_measure( content: &str, typeface: &Typeface, @@ -456,10 +277,6 @@ fn wrap_and_measure( ) -> (f32, f32) { let font = Font::from_typeface(typeface.clone(), font_size); let emoji_font = emoji_typeface().map(|tf| Font::from_typeface(tf, font_size)); - // Tracking-aware wrap (issue #125 §1): matches the real `letter_spacing` - // used to measure each line's width just below, so the box this - // measurer reserves and what the painter (also tracking-aware) actually - // paints agree on line count. let lines = wrap_text_with_tracking(content, &font, &emoji_font, wrap_at, letter_spacing); let mut max_w = 0.0f32; for line in &lines { @@ -474,40 +291,6 @@ fn wrap_and_measure( (max_w, line_count * line_height) } -/// `text-autofit`'s shared shrink resolution — the single computation -/// `TextIntrinsic::measure` and `Text`/`GradientText`'s painters all call -/// with identical inputs, so the resolved size can never disagree between -/// the box taffy reserves and the pixels actually painted into it (see -/// `CssStyle::text_autofit`'s doc comment — this exact class of bug is what -/// this workstream exists to close, not reopen). -/// -/// Pure and stateless: the same `(content, typeface, requested_font_size, -/// requested_letter_spacing, requested_line_height, wrap, box_width, -/// declared_height)` always produces the same `(font_size, letter_spacing, -/// line_height)`. That purity is *why* calling this fresh every paint call -/// (frame) is stable rather than something that needs caching — see the two -/// call sites' comments for what does and does not change frame to frame. -/// The one input this deliberately never sees is the paint-time typewriter -/// reveal (`AnimatedProperties::visible_chars_progress`): both call sites -/// pass the full, untruncated content, so a reveal-in-progress can't make -/// the resolved size drift as more characters become visible. -/// -/// `letter_spacing`/`line_height` are rescaled proportionally with the -/// chosen font size (`requested * chosen/requested`) rather than -/// re-resolved from the original CSS declaration at each candidate size. -/// This matches CSS exactly for the common declarations (a unitless -/// `line-height` number, `%`/`em` line-height, or the engine's `1.3×` -/// default all scale linearly with font-size by definition) and is a -/// deliberate approximation for the rare case of an absolute -/// (`px`/`rem`/`vw`/`vh`) `line-height`/`letter-spacing`, which CSS says -/// should stay fixed regardless of font-size — getting that exactly right -/// needs threading the full `CssStyle` (not just its already-resolved -/// scalars) through both call sites, out of scope here. -/// -/// `box_width`/`declared_height`: `None` means nothing to fit against on -/// that axis (an unconstrained box cannot overflow); returns -/// `requested_font_size` unchanged, without measuring anything, when both -/// are `None`. #[allow(clippy::too_many_arguments)] pub fn resolve_text_autofit( content: &str, @@ -562,21 +345,6 @@ pub fn resolve_text_autofit( } } -/// Binary-search the largest font size in `[floor_px, requested_font_size]` -/// whose `measure_at(size)` fits within `(target_width, target_height)` -/// (either bound `None` = no constraint on that axis). Assumes `measure_at` -/// is monotonically non-increasing as `size` shrinks — true for real text: -/// smaller glyphs measure narrower, and a fixed pixel wrap width can only -/// need the same or fewer lines as glyphs get smaller. 16 halvings of the -/// search range give sub-0.01px precision for any realistic font size — this -/// is a visual convenience, not a geometry-critical value, so that precision -/// is far more than needed. -/// -/// Never returns below `floor_px`: if the content doesn't fit there either, -/// `floor_px` is returned anyway — illegible-but-smallest beats an even -/// larger overflow — and the caller's own overflow signal (the geometry -/// validator's `ContentOverflowsBox`) is left to fire. This function never -/// silences that; it only tries to make it unnecessary. fn shrink_to_fit( requested_font_size: f32, floor_px: f32, @@ -602,8 +370,6 @@ fn shrink_to_fit( let (mut lo, mut hi) = (floor_px, requested_font_size); let (w_floor, h_floor) = measure_at(lo); if !fits(w_floor, h_floor) { - // Doesn't fit even at the floor — stop there and let the caller's - // own overflow check fire; see this function's doc comment. return lo; } for _ in 0..16 { @@ -628,23 +394,16 @@ fn weight_to_u16(w: Option<&CssFontWeight>) -> u16 { } } -/// Cosmic-text–backed intrinsic measurer for [`GradientText`] — same content -/// model as [`Text`] (a single string + style); the gradient is purely a -/// paint-time concern and doesn't change box dimensions. pub struct GradientTextIntrinsic(TextIntrinsic); impl GradientTextIntrinsic { pub fn from_gradient_text(t: &GradientText) -> Self { - // max_width comes from CSS style.width if set as a fixed pixel value use rustmotion_core::css::style::Size as CSize; use rustmotion_core::css::units::LengthPercentage; let max_width = match &t.style.width { Some(CSize::Length(LengthPercentage::Px(v))) => Some(*v), _ => None, }; - // M1 follow-up (issue #109 review): gradient_text now word-wraps - // like `text` (see `gradient_text.rs::paint`) — mirror the same - // white-space: nowrap|pre rule here so measure and paint agree. let wrap = !matches!( t.style.white_space, Some(WhiteSpace::Nowrap | WhiteSpace::Pre) @@ -666,8 +425,6 @@ impl IntrinsicMeasure for GradientTextIntrinsic { } } -/// Intrinsic measurer for [`Caption`]. Concatenates the words with single -/// spaces and measures the result like a regular text run. pub struct CaptionIntrinsic(TextIntrinsic); impl CaptionIntrinsic { @@ -678,12 +435,6 @@ impl CaptionIntrinsic { .map(|w| w.text.as_str()) .collect::>() .join(" "); - // M1 follow-up: `Highlight`/`Karaoke`/`KaraokePop` word-wrap all - // words (see `caption.rs::paint`); `white-space: nowrap|pre` now - // forces them onto one line there too — mirror it here. (`WordByWord` - // /`WordPop` show one word at a time; wrapping is moot for those, - // same as the existing kbd/counter/badge "atomic, never wraps" - // components, so this doesn't need a mode-specific branch.) let wrap = !matches!( c.style.white_space, Some(WhiteSpace::Nowrap | WhiteSpace::Pre) @@ -707,9 +458,6 @@ impl IntrinsicMeasure for CaptionIntrinsic { } } -/// Intrinsic measurer for [`Kbd`] — measures the key text plus the legacy -/// keyboard-cap padding (h ≈ font_size × 0.7, v ≈ font_size × 0.4) and -/// enforces a min-width of `font_size × 1.8`. pub struct KbdIntrinsic { text: TextIntrinsic, h_padding: f32, @@ -745,8 +493,6 @@ impl IntrinsicMeasure for KbdIntrinsic { } } -/// Intrinsic measurer for [`Counter`] — reserves space for the largest absolute -/// value the counter will display so layout never reflows during animation. pub struct CounterIntrinsic(TextIntrinsic); impl CounterIntrinsic { @@ -758,7 +504,6 @@ impl CounterIntrinsic { absmax }; let display = format_counter_value(signed, c.decimals, &c.separator, &c.prefix, &c.suffix); - // Counter is atomic: it never wraps. Self(TextIntrinsic::from_parts_with_wrap( &display, &c.style, None, false, )) @@ -775,14 +520,6 @@ impl IntrinsicMeasure for CounterIntrinsic { } } -/// Intrinsic measurer for [`crate::number_wheel::NumberWheel`]. -/// -/// Every digit column is as wide as the *widest* digit, because that is how -/// the painter lays the reels out — otherwise a figure that lands on `111` -/// would reserve a narrow box and then overflow it while a `0` rolls past. -/// Measuring the value once per possible digit and keeping the largest gives -/// exactly the painter's own `max over digits` per column, separators -/// included, without duplicating its layout arithmetic here. pub struct NumberWheelIntrinsic(TextIntrinsic); impl NumberWheelIntrinsic { @@ -809,7 +546,6 @@ impl NumberWheelIntrinsic { .unwrap_or(std::cmp::Ordering::Equal) }) .unwrap_or_else(|| w.value.clone()); - // A wheel is atomic: it never wraps. Self(TextIntrinsic::from_parts_with_wrap( &widest, &w.style, None, false, )) @@ -826,8 +562,6 @@ impl IntrinsicMeasure for NumberWheelIntrinsic { } } -/// Intrinsic measurer for [`Badge`] — measures the label text plus icon, gap, -/// and the size-derived horizontal/vertical padding. pub struct BadgeIntrinsic { text: TextIntrinsic, h_padding: f32, @@ -877,7 +611,6 @@ impl IntrinsicMeasure for BadgeIntrinsic { } fn badge_size_params(s: &BadgeSize) -> (f32, f32, f32, f32) { - // (font_size, h_padding, v_padding, icon_size) — matches badge.rs::params match s { BadgeSize::Sm => (12.0, 8.0, 4.0, 14.0), BadgeSize::Md => (14.0, 12.0, 6.0, 18.0), @@ -885,8 +618,6 @@ fn badge_size_params(s: &BadgeSize) -> (f32, f32, f32, f32) { } } -/// Build a CssStyle for text measurement carrying just the typography fields -/// from `src`, with a forced `font-size` and `font-family` fallback. fn synthesize_text_style(src: &CssStyle, font_size: f32, default_family: &str) -> CssStyle { use rustmotion_core::css::Length; let family = src @@ -904,30 +635,14 @@ fn synthesize_text_style(src: &CssStyle, font_size: f32, default_family: &str) - } } -// Compatibility shim: keep an unused fn so old callers that referenced -// `LineHeight::Number` style helpers compile cleanly. #[allow(dead_code)] fn _line_height_unused(_: Option<&LineHeight>) {} -// ───────────────────────────────────────────────────────────────────────────── -// Table intrinsic measurer -// ───────────────────────────────────────────────────────────────────────────── - use crate::table::{Table, DEFAULT_FONT_SIZE as TABLE_FONT_SIZE, DEFAULT_ROW_HEIGHT_RATIO}; -/// Intrinsic measurer for [`Table`]. -/// -/// Natural size formula (matches the painter exactly, since both now read -/// the same per-column distribution — see [`Table::natural_column_widths`]): -/// - `row_height = font_size × DEFAULT_ROW_HEIGHT_RATIO` -/// - `height = (1 + row_count) × row_height` (header + data rows) -/// - `width`: if `column_widths` are provided, their sum; otherwise the sum -/// of `Table::natural_column_widths`, which the painter's own -/// `resolve_column_widths` scales proportionally to whatever width the -/// box actually laid out at. pub struct TableIntrinsic { row_height: f32, - row_count: usize, // data rows only; header adds 1 + row_count: usize, total_width: f32, } @@ -971,23 +686,8 @@ impl IntrinsicMeasure for TableIntrinsic { } } -// ───────────────────────────────────────────────────────────────────────────── -// RichText intrinsic measurer -// ───────────────────────────────────────────────────────────────────────────── - use crate::rich_text::{RichText, RichTextSpan}; -/// Intrinsic measurer for [`RichText`]. -/// -/// M2: previously absent from `component_intrinsic` entirely, so a -/// `rich_text` with no explicit `width`/`height` laid out 0×0 and rendered -/// nothing. Reuses `RichText::compute_layout` — the exact same word-wrapped -/// line-breaking algorithm the painter uses — so the box taffy reserves -/// always matches what gets painted (same measure/paint-parity rationale as -/// [`TextIntrinsic`]). -/// -/// Always measures the full (untruncated) content — a `visible_chars` -/// typewriter animation must not reflow layout as it plays. pub struct RichTextIntrinsic { spans: Vec, style: CssStyle, @@ -1136,8 +836,6 @@ mod tests { assert!(h > 0.0); } - // ─── M1: white-space: nowrap/pre ──────────────────────────────────────── - fn nowrap_text(content: &str, white_space: Option) -> Text { Text { content: content.into(), @@ -1206,8 +904,6 @@ mod tests { #[test] fn normal_white_space_still_wraps_at_a_constrained_width() { - // Regression guard: making `nowrap`/`pre` real must not touch the - // default (`normal`/unset) wrapping path. let wrapped = nowrap_text( "the quick brown fox jumps over the lazy dog", Some(WhiteSpace::Normal), @@ -1227,8 +923,6 @@ mod tests { } } - // ─── M2: rich_text intrinsic ───────────────────────────────────────────── - fn span(text: &str) -> RichTextSpan { RichTextSpan { text: text.into(), @@ -1243,8 +937,6 @@ mod tests { #[test] fn rich_text_intrinsic_is_non_zero_without_explicit_size() { - // M2's core defect: rich_text had no `component_intrinsic` entry at - // all, so it measured 0×0 unless the author guessed a width/height. let spans = vec![span("Hello "), span("world")]; let style = CssStyle { font_size: Some(Length::Px(32.0)), @@ -1265,8 +957,6 @@ mod tests { #[test] fn rich_text_intrinsic_wraps_a_single_long_span_internally() { - // M2's second ask: a long single span must wrap like any other text, - // not just break at span boundaries (there is only one span here). let spans = vec![span( "the quick brown fox jumps over the lazy dog and keeps going", )]; @@ -1302,9 +992,6 @@ mod tests { #[test] fn rich_text_intrinsic_matches_compute_layout_used_by_the_painter() { - // Measure/paint parity: the intrinsic must reuse the exact same - // layout algorithm the painter does, or taffy could reserve a box - // that doesn't match what gets drawn. let spans = vec![span("Total: "), span("42"), span(" items")]; let style = CssStyle::default(); let intrinsic = RichTextIntrinsic { @@ -1321,8 +1008,6 @@ mod tests { assert_eq!(h, layout.lines.len().max(1) as f32 * layout.line_height); } - // ─── M1 follow-up: gradient_text / caption honor white-space too ─────── - #[test] fn gradient_text_intrinsic_ignores_constrained_width_when_nowrap() { let gt = GradientText { @@ -1350,7 +1035,6 @@ mod tests { "nowrap gradient_text must ignore the 80px constraint, got {}", w ); - // Single line: height should be one line, not several. let (_, h_unconstrained) = m.measure( (None, None), (AvailableSpace::MaxContent, AvailableSpace::MaxContent), @@ -1423,8 +1107,6 @@ mod tests { ); } - // ─── #2 / #5: em/% typography resolve against own font-size, not 0 ──── - fn text_with_style(content: &str, style: CssStyle) -> Text { Text { content: content.into(), @@ -1444,12 +1126,6 @@ mod tests { #[test] fn line_height_percent_no_longer_collapses_the_box_to_zero_height() { - // #2 reproduction: `line-height: "150%"` went through the - // context-free `line_height_for`, which cannot resolve `%` and - // silently fell back to 0 — the intrinsic then reported a - // `line_count * 0.0 = 0` height, so `paint_pass.rs`'s `if height <= - // 0.0 { return }` guard skipped painting the node (and its - // subtree) entirely, even though `validate` reported success. use rustmotion_core::css::units::LengthPercentage; let text = text_with_style( "VISIBLE?", @@ -1491,8 +1167,6 @@ mod tests { (h - 90.0).abs() < 0.5, "line-height: 1.5em of a 60px font-size must resolve to 90px, got {h}" ); - // Sanity: matches the already-correct unitless-number form exactly, - // proving em and the bare-number multiplier agree. let numeric = text_with_style( "VISIBLE?", CssStyle { @@ -1510,13 +1184,6 @@ mod tests { #[test] fn letter_spacing_em_matches_the_equivalent_px_measurement() { - // #5 reproduction: `letter-spacing: "1.2em"` at font-size 200 - // (=240px) went through the context-free `letter_spacing_px`, which - // returns 0 for `em` — the intrinsic reserved a box as if tracking - // were 0 while `Text::paint` (which already uses the `_ctx` - // resolver) painted with the real 240px tracking, so `validate`'s - // `unwrappable_text_overflow`/viewport checks (which re-measure via - // this same intrinsic) never saw the real, wider painted width. let em_style = CssStyle { font_size: Some(Length::Px(200.0)), letter_spacing: Some(Length::String("1.2em".into())), @@ -1546,9 +1213,6 @@ mod tests { "letter-spacing: 1.2em (font-size 200) must measure the same as the equivalent \ 240px value: em={w_em}, px={w_px}" ); - // And it must differ from the old (broken) zero-tracking width — - // otherwise this test would pass vacuously even if em still - // resolved to 0. let w_zero_tracking = TextIntrinsic::from_text(&text_with_style( "TRACKING", CssStyle { @@ -1569,8 +1233,6 @@ mod tests { ); } - // ─── text-autofit: `shrink_to_fit` (pure binary search, no Skia) ─────── - #[test] fn shrink_to_fit_is_a_noop_when_content_already_fits() { let calls = std::cell::RefCell::new(Vec::new()); @@ -1588,22 +1250,16 @@ mod tests { #[test] fn shrink_to_fit_is_a_noop_when_nothing_to_fit_against() { - // Both axes unconstrained: no target to shrink for, regardless of - // what `measure_at` reports. let size = shrink_to_fit(48.0, 12.0, None, None, |_| (99999.0, 99999.0)); assert_eq!(size, 48.0); } #[test] fn shrink_to_fit_finds_a_size_that_fits_the_width_target() { - // Fake linear model (width = size * 2), matching how real glyph - // widths scale roughly linearly with font size. let target = 100.0; let size = shrink_to_fit(120.0, 5.0, Some(target), None, |s| (s * 2.0, 10.0)); assert!(size < 120.0, "must have shrunk, got {size}"); assert!(size * 2.0 <= target + 0.5, "resolved size must fit: {size}"); - // And it's close to the true boundary, not grossly under-shrunk: one - // more px would no longer fit. assert!( (size + 1.0) * 2.0 > target + 0.5, "resolved size should be close to the fitting boundary, got {size}" @@ -1612,8 +1268,6 @@ mod tests { #[test] fn shrink_to_fit_respects_both_axes_jointly() { - // Width alone would allow a much bigger size than height alone — - // the chosen size must satisfy the tighter of the two. let size = shrink_to_fit(100.0, 5.0, Some(1000.0), Some(20.0), |s| (s, s * 2.0)); assert!(size * 2.0 <= 20.5, "must respect the height target: {size}"); assert!( @@ -1624,26 +1278,18 @@ mod tests { #[test] fn shrink_to_fit_never_returns_below_the_floor() { - // Content that never fits even at the floor: must stop exactly - // there, not silence the overflow by continuing to shrink. let size = shrink_to_fit(120.0, 20.0, Some(10.0), None, |s| (s * 5.0, 10.0)); assert_eq!(size, 20.0, "must stop exactly at the floor, not lower"); } #[test] fn shrink_to_fit_is_deterministic_across_repeated_calls() { - // Same inputs, same deterministic binary search → same output every - // time. This is the purity property the temporal-stability argument - // (see `resolve_text_autofit`'s doc comment) rests on: nothing here - // depends on when or how many times it's called. let run = || shrink_to_fit(90.0, 10.0, Some(137.0), Some(64.0), |s| (s * 1.7, s * 0.9)); let a = run(); let b = run(); assert_eq!(a, b); } - // ─── text-autofit: `resolve_text_autofit` (real Skia fonts) ──────────── - fn inter_typeface() -> Typeface { typeface_with_fallback("Inter", SkFontStyle::normal()).expect("Inter resolves in tests") } @@ -1660,7 +1306,7 @@ mod tests { requested, 0.0, requested * 1.3, - false, // nowrap: single line + false, Some(box_width), None, ); @@ -1669,8 +1315,6 @@ mod tests { fs >= TEXT_AUTOFIT_MIN_FONT_PX - 0.01, "must not shrink past the calibrated floor, got {fs}" ); - // Prove the resolved size actually fits when wrapped/measured the - // same way the caller will — not just that a smaller number came out. let (w, _) = wrap_and_measure(content, &typeface, fs, None, ls, lh); assert!( w <= box_width + 0.5, @@ -1699,8 +1343,6 @@ mod tests { #[test] fn resolve_text_autofit_never_goes_below_the_calibrated_floor() { let typeface = inter_typeface(); - // Absurdly small box: even the floor doesn't fit, but the function - // must still stop exactly at the floor. let (fs, _, _) = resolve_text_autofit( "This sentence is far too long for a ten pixel wide box", &typeface, @@ -1736,8 +1378,6 @@ mod tests { assert!((lh - 130.0 * ratio).abs() < 1e-3); } - // ─── text-autofit: `TextIntrinsic` end to end ────────────────────────── - fn autofit_text(content: &str, font_size: f32) -> Text { Text { content: content.into(), @@ -1768,14 +1408,6 @@ mod tests { (None, None), (AvailableSpace::MaxContent, AvailableSpace::MaxContent), ); - // A box at half the natural width needs roughly a ~50% size - // reduction — comfortably above the legibility floor for a 60px - // request, so this exercises the "shrinks and fits" path distinctly - // from `..._still_overflows_when_even_the_floor_does_not_fit` below - // (which drives it all the way to the floor on purpose). Derived - // from the actual measured natural width rather than a hardcoded - // px guess, so it isn't sensitive to exactly which glyph widths - // this font ships. let target = w_unconstrained / 2.0; let (w_constrained, _) = m.measure( (None, None), @@ -1825,11 +1457,6 @@ mod tests { #[test] fn caption_intrinsic_never_autofits_even_if_style_declares_it() { - // Regression guard for the leak this feature must not reintroduce: - // `Caption`'s painter has no idea `text-autofit` exists (only - // `Text`/`GradientText`'s do), so its intrinsic must never shrink - // because of it, even if the field is present in `style` — see - // `TextIntrinsic::with_autofit`'s doc comment. let caption = Caption { words: "the quick brown fox jumps over the lazy dog" .split_whitespace() @@ -1868,8 +1495,6 @@ mod tests { ); } - // ─── `text_metrics` / `ComponentTextMetrics` (issue #328) ────────────── - fn plain_text(content: &str, font_size: f32) -> Text { Text { content: content.into(), @@ -1904,9 +1529,6 @@ mod tests { #[test] fn text_metrics_last_glyph_sits_before_where_a_detached_char_would_go() { - // The issue #328 worked example: the author writes the sentence - // without its trailing `?` and anchors a separate node to the last - // glyph's right edge. let text = plain_text("Sentence", 32.0); let metrics = TextIntrinsic::from_text(&text).text_metrics(500.0).unwrap(); let last = *metrics.glyphs.last().unwrap(); @@ -1985,8 +1607,6 @@ mod tests { #[test] fn component_text_metrics_returns_none_for_a_non_text_component() { - // Any non-text payload (a bare `i32` stands in for one here) must - // fall through cleanly rather than panicking on a failed downcast. let payload: &(dyn std::any::Any + Send + Sync) = &42i32; assert!(ComponentTextMetrics.text_metrics(payload, 500.0).is_none()); } diff --git a/crates/rustmotion-components/src/kbd.rs b/crates/rustmotion-components/src/kbd.rs index 79f4b317..652033fb 100644 --- a/crates/rustmotion-components/src/kbd.rs +++ b/crates/rustmotion-components/src/kbd.rs @@ -73,9 +73,6 @@ fn default_kbd_style() -> CssStyle { } } -/// Deserializes `style` normally, then defaults `align-self` to -/// `flex-start` when the author didn't set it explicitly — see the doc -/// comment on [`Kbd::style`]. fn deserialize_no_stretch_style<'de, D>(deserializer: D) -> Result where D: serde::Deserializer<'de>, @@ -94,10 +91,6 @@ rustmotion_core::impl_traits!(Kbd { }); impl Kbd { - /// Resolves `font-size` against a real per-frame viewport (`rem`/`vw`/ - /// `vh` now resolve instead of silently dropping to 0px — lot B, wave - /// S). `em`/`%` on `font-size` itself remain approximate — see - /// `crate::intrinsic::font_size_ctx`'s doc comment. fn resolved_font_size(&self, ctx: &PaintCtx) -> f32 { self.style.font_size_px_ctx( &crate::intrinsic::font_size_ctx(ctx.video_width as f32, ctx.video_height as f32, 0.0), @@ -125,12 +118,6 @@ impl Kbd { .background_color_str() .unwrap_or(&self.background_color); - // Shadow (bottom edge to simulate physical key depth), and the key - // face sitting on top of it. Both are inset to `h - shadow_h` tall - // so the "3D lip" the shadow peeks out from under the face stays - // inside the assigned box — the face used to be drawn full-height - // with the shadow offset *below* it, bleeding `shadow_h` px past - // the box bottom every frame. let shadow_h = 3.0_f32.min(h); let cap_h = (h - shadow_h).max(0.0); let shadow_rect = Rect::from_xywh(0.0, shadow_h, w, cap_h); @@ -140,7 +127,6 @@ impl Kbd { shadow_paint.set_anti_alias(true); canvas.draw_rrect(shadow_rrect, &shadow_paint); - // Key face background let face_rect = Rect::from_xywh(0.0, 0.0, w, cap_h); let face_rrect = skia_safe::RRect::new_rect_xy(face_rect, radius, radius); let mut face_paint = paint_from_hex(bg_color); @@ -148,14 +134,12 @@ impl Kbd { face_paint.set_anti_alias(true); canvas.draw_rrect(face_rrect, &face_paint); - // Border let mut border_paint = paint_from_hex(&self.border_color); border_paint.set_style(PaintStyle::Stroke); border_paint.set_stroke_width(1.0); border_paint.set_anti_alias(true); canvas.draw_rrect(face_rrect, &border_paint); - // Text centered let Some(font) = self.make_font(ctx) else { return; }; @@ -219,9 +203,6 @@ mod tests { #[test] fn style_defaults_to_flex_start_when_absent() { - // #127: same fix as `badge` — a keycap must keep its intrinsic - // width (`KbdIntrinsic`) instead of stretching to the flex - // container's full cross-axis size. let kbd = parse(r#"{"type":"kbd","key":"K"}"#); assert_eq!(kbd.style.align_self, Some(AlignSelf::FlexStart)); } @@ -241,13 +222,6 @@ mod tests { #[test] fn shadow_never_extends_past_the_assigned_box() { - // The shadow "lip" used to be offset 3px *below* a full-height - // face (`shadow_rect` at y=3 with height=h), so its bottom edge - // sat at `h + 3` — 3px past whatever box the layout gave this - // component. `paint()` now insets the face to `h - shadow_h` and - // draws the shadow directly beneath it, so nothing should paint - // past `h` any more. Render at a tiny height where a 3px miss - // would be highly visible relative to the box. let kbd = Kbd { key: "K".to_string(), font_size: default_font_size(), @@ -262,7 +236,7 @@ mod tests { const W: i32 = 100; const H: i32 = 20; let box_w = 60.0_f32; - let box_h = 10.0_f32; // shorter than the 3px shadow inset would tolerate if unfixed by a wide margin + let box_h = 10.0_f32; let mut surface = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); { let canvas = surface.canvas(); @@ -285,8 +259,6 @@ mod tests { skia_safe::image::CachingHint::Disallow, ); assert!(ok, "pixel read should succeed"); - // Box bottom edge is at absolute y = 5 (translate) + 10 (box_h) = 15. - // Nothing painted should reach row 16 or beyond. let box_bottom = 15; for y in (box_bottom + 1)..H { for x in 0..W { @@ -299,12 +271,8 @@ mod tests { } } - // ─── Lot B, wave S: relative `font-size` units ───────────────────────── - #[test] fn rem_font_size_paints_visible_ink() { - // Reproduction: `font-size: "2rem"` used to resolve to 0px via the - // context-free `font_size_px_or`. let kbd = Kbd { key: "K".to_string(), font_size: default_font_size(), @@ -342,9 +310,6 @@ mod tests { skia_safe::image::CachingHint::Disallow, ); assert!(ok, "pixel read should succeed"); - // Text is near-white (#E2E8F0 default `text_color`) on a dark key - // face — probe for near-white ink specifically, since the face/ - // border/shadow paint regardless of font-size. let text_ink = buf .as_chunks::<4>() .0 diff --git a/crates/rustmotion-components/src/legacy_dispatch.rs b/crates/rustmotion-components/src/legacy_dispatch.rs index 3f259a2c..94016ac1 100644 --- a/crates/rustmotion-components/src/legacy_dispatch.rs +++ b/crates/rustmotion-components/src/legacy_dispatch.rs @@ -1,24 +1,3 @@ -//! Paint dispatcher for the new `paint_tree` pipeline. Bridges from the -//! taffy-laid-out `BoxNode` tree back to component-typed `Painter` impls. -//! -//! Naming kept as "legacy" for now to avoid churn in callers; this is the -//! sole dispatcher in use since every component implements `Painter`. -//! -//! The container component (`Component::Container` — tagged `div`, aliased -//! `container`/`card`/`flex`/`grid`/`positioned`) is intentionally skipped: -//! paint_pass already paints its box decorations and recurses into -//! children — so calling the container's own `paint_content` would do nothing -//! anyway, and we save a no-op call. -//! -//! `dispatch` receives the node's cascaded `CssStyle` (`css` below) from -//! `paint_pass`, but `Painter::paint_content` has no `CssStyle` parameter — -//! its signature is frozen — and every painter reads typography off its own -//! `self.style` instead. `Component::with_cascaded_style` rebuilds the -//! subset of components that read inherited typography off their own style -//! with `css` folded in before painting, the same call -//! `box_builder::component_intrinsic` makes for the intrinsic measurers so -//! measure and paint agree. - use rustmotion_core::css::CssStyle; use rustmotion_core::engine::animator::{resolve_props_for_effects, AnimatedProperties}; use rustmotion_core::engine::box_tree::NodeId; @@ -29,19 +8,9 @@ use skia_safe::Canvas; use crate::{ChildComponent, Component}; -/// Maps NodeIds to `ChildComponent`s and dispatches paint to their -/// `Painter::paint_content` impls. pub struct LegacyPaintDispatcher<'a> { - /// `components[id as usize]` is the component for `id`. Slot 0 is the - /// synthetic root and is always `None`. components: &'a [Option<&'a ChildComponent>], - /// Per-node animation delay — ancestor-stagger plus the node's own - /// `start_at` (indexed like `components`); empty when the caller doesn't - /// carry that information. stagger_delays: &'a [f64], - /// Per-node accumulated affine time remap `(scale, shift)` from ancestor - /// containers' `time_scale`/`time_offset` (indexed like `components`); - /// `t_local = scale * t_global + shift`. Empty → identity everywhere. time_params: &'a [(f64, f64)], } @@ -54,11 +23,6 @@ impl<'a> LegacyPaintDispatcher<'a> { } } - /// Build from a [`BuiltScene`], carrying its per-node animation delays - /// (stagger plus `start_at`) so internal animations shift by the same - /// amount as the CSS overrides, and its per-node time remaps so internal - /// animations (counter, draw_in, typewriter…) advance at the same local - /// time as the CSS overrides. pub fn for_scene(built: &'a crate::box_builder::BuiltScene<'a>) -> Self { Self { components: &built.components, @@ -89,31 +53,15 @@ impl<'a> PaintDispatcher for LegacyPaintDispatcher<'a> { return; }; - // Containers paint nothing of their own here — children are handled - // recursively by paint_tree, and decorations were already painted. if is_container(&child.component) { return; } - // Resolve animations for this leaf. Outer transforms (translate / - // scale / rotate / opacity / blur) are applied by `paint_pass` via - // the CSS overrides injected at box-tree build time, so we don't - // wrap the canvas here. `props` is still needed for internal-only - // fields like `draw_progress`, `stroke_width`, `visible_chars*`, - // and `char_animation`. Timeline steps and the node's accumulated - // delay (ancestor stagger plus its own `start_at`) are folded in so - // those internal animations shift exactly like the CSS overrides do. let stagger_delay = self .stagger_delays .get(*node_id as usize) .copied() .unwrap_or(0.0); - // Ancestor `time_scale`/`time_offset` remap the time seen by this - // node's whole animation surface: internal effect resolution below - // AND `PaintCtx.time` (a counter or a typewriter inside a slowed - // container must advance at local time). `scene_duration` stays - // GLOBAL — it describes the physical scene window, not the remapped - // timeline, so duration-relative effects keep their real-time span. let (t_scale, t_shift) = self .time_params .get(*node_id as usize) @@ -138,14 +86,6 @@ impl<'a> PaintDispatcher for LegacyPaintDispatcher<'a> { return; }; - // The `Painter` contract (traits/painter.rs, rules/paint-context.md) - // promises the canvas is already translated to the CONTENT-box - // origin, with `layout` describing the content box — padding - // reserved by taffy is consumed here, not left for the painter to - // rediscover. `Codeblock` used to be a deliberate, documented - // exception (it painted from the untranslated BORDER-box origin); - // it has been deleted, so every remaining `Painter` now honors the - // general contract uniformly. canvas.save(); let (cx, cy, cw, ch) = layout.content_box(); canvas.translate((cx, cy)); @@ -218,16 +158,6 @@ mod tests { #[test] fn leaf_painter_content_is_inset_by_padding() { - // A 100x80 red shape at (0,0) with `padding: 20`. The Painter - // contract (traits/painter.rs, rules/paint-context.md) promises the - // canvas is already translated to the CONTENT-box origin — so the - // shape's own fill (which just paints (0,0)..(layout.width, - // layout.height)) should only cover the 60x40 content box (20,20) - // to (80,60), leaving the 20px padding ring showing the (empty/ - // background) canvas underneath. Bug: the dispatcher translated to - // the BORDER-box origin and handed the painter the full border-box - // dimensions, so the fill ignored padding entirely and covered the - // whole (0,0)-(100,80) box. use rustmotion_core::css::style::Edges; let mut scene = vec![shape_child(100.0, 80.0, 0.0, 0.0)]; @@ -278,14 +208,12 @@ mod tests { buf }; - // Inside the padding ring (5,5): must NOT be red after the fix. let padding_zone = read(5, 5); assert!( !(padding_zone[0] > 200 && padding_zone[1] < 50 && padding_zone[2] < 50), "padding ring must not be painted by the leaf's own fill, got {:?}", padding_zone ); - // Deep inside the content box (50,40): must be red either way. let content_zone = read(50, 40); assert!( content_zone[0] > 200 && content_zone[1] < 50 && content_zone[2] < 50, @@ -300,12 +228,9 @@ mod tests { let built = build_scene(&scene, (200.0, 200.0)); let layout = run_layout(&built.root, (200.0, 200.0), &ConversionContext::default()); - // Sanity: the only component slot at idx 1 points to the shape. assert!(built.components[0].is_none()); assert!(built.components[1].is_some()); - // Build a Skia raster surface and run a paint pass against the - // dispatcher — this just exercises the dispatcher hook end-to-end. let mut surface = skia_safe::surfaces::raster_n32_premul((200, 200)).expect("raster surface"); let canvas = surface.canvas(); @@ -328,8 +253,6 @@ mod tests { &dispatcher, ); - // Read back the pixel at the centre of the shape (10+25, 20+15)=(35,35) - // and assert it's red-ish — confirms paint_content painted. let snapshot = surface.image_snapshot(); let mut buf = [0u8; 4]; let info = skia_safe::ImageInfo::new( @@ -346,7 +269,6 @@ mod tests { skia_safe::image::CachingHint::Disallow, ); assert!(read_ok, "pixel read should succeed"); - // Red channel should dominate (#ff0000). assert!(buf[0] > 200, "expected red, got rgba {:?}", buf); assert!(buf[1] < 50, "green should be low, got rgba {:?}", buf); assert!(buf[2] < 50, "blue should be low, got rgba {:?}", buf); @@ -354,8 +276,6 @@ mod tests { #[test] fn card_background_painted_with_red_shape_inside() { - // Card 100×80 at (40,30), green background, contains a red 30×20 shape - // absolutely positioned at (10,10) inside the card. use crate::container::ContainerComponent; use rustmotion_core::css::style::{Background, Color}; @@ -451,14 +371,10 @@ mod tests { buf }; - // Card background area: bottom-right corner of the card (well away - // from the red shape at (10,10)+(30,20)). Card spans x∈[40,140], - // y∈[30,110]. Pick (130, 100) — green. let bg = read(130, 100); assert!(bg[1] > 200, "expected green card bg, got {:?}", bg); assert!(bg[0] < 50, "red should be low at bg, got {:?}", bg); - // Red shape area: shape spans (50,40)→(80,60). Pick centre (65, 50). let fg = read(65, 50); assert!(fg[0] > 200, "expected red shape, got {:?}", fg); assert!(fg[1] < 50, "green should be low at shape, got {:?}", fg); @@ -466,11 +382,6 @@ mod tests { #[test] fn fade_in_preset_drives_alpha_through_dispatcher() { - // A red shape with a `FadeIn` preset over 0.5s, sampled at two points: - // t=0.05s — early in the curve, opacity should be near zero - // t=0.5s — at the end of the curve, opacity should be ~1 - // The dispatcher must wire animator output into the canvas alpha, - // otherwise both samples render fully opaque and the test fails. use crate::shape::Shape; use rustmotion_core::schema::{AnimationEffect, AnimationTiming, ShapeType}; @@ -506,9 +417,6 @@ mod tests { let sample_red_at = |time: f64| -> u8 { let scene = make_scene(); - // Build the box tree with an animation context so the FadeIn - // preset is resolved into CSS overrides (transform/opacity) on - // each box. paint_pass then applies those during painting. let built = crate::box_builder::build_scene_with_anim( &scene, (200.0, 200.0), @@ -604,11 +512,8 @@ mod tests { scene_duration: 1.0, camera: None, }; - // Wrong payload type — must not panic. let bogus: Arc = Arc::new(42i64); - // Reach into the dispatcher trait method. - // (BoxKind::Component is just a marker here; we drive dispatch directly.) dispatcher.dispatch(canvas, bogus.as_ref(), &css, &layout, &frame); - let _ = BoxKind::Container; // touch import + let _ = BoxKind::Container; } } diff --git a/crates/rustmotion-components/src/lib.rs b/crates/rustmotion-components/src/lib.rs index 8639ef79..6f9fc032 100644 --- a/crates/rustmotion-components/src/lib.rs +++ b/crates/rustmotion-components/src/lib.rs @@ -1,27 +1,3 @@ -// Issue #333 (phase B): the twenty-seven frozen-composition components -// (`stat`, `badge`, `gauge`, ... — see each type's own `#[deprecated]` note -// for its replacement recipe) carry a `#[deprecated]` attribute so a Rust -// consumer of this crate who writes `Badge { .. }`/`Stat { .. }`/etc. by hand -// is told, at their own call site, what to compose instead. -// -// That attribute also fires for every internal reference to the same type — -// the struct's own derive-generated impls, its `impl Painter`, every field -// read in `box_builder`'s intrinsic-sizing/style-extraction match arms, and -// the `Component` enum's own tagged-variant plumbing in this file — because -// deprecating a struct deprecates its fields too, and Rust does not -// distinguish "the engine implementing this component" from "an author -// constructing one". None of that internal traffic is an authoring site: -// rendering an existing `badge`/`stat`/... scenario byte-identically -// requires touching every one of those fields exactly as before, and a JSON -// scenario is deserialized through this crate's own generated -// `Deserialize` impls, never through hand-written Rust at the call site — so -// `serde_json::from_str::(..)` in `rustmotion`'s render path -// never lints here regardless of this attribute. This single crate-root -// allow silences only that internal noise; it does not extend to any other -// crate, so a hand-written construction in `rustmotion-html`, -// the studio, or this crate's own `tests/` integration suite (each -// a separate compilation unit) still warns. Verified empirically before -// relying on it: see the phase-B report for issue #333. #![allow(deprecated)] pub mod box_builder; pub mod intrinsic; @@ -142,19 +118,6 @@ pub use treemap::Treemap; pub use video::Video; pub use waveform::Waveform; -// --- Position mode --- - -/// Constat #8: `PositionMode::Named(String)` accepts any string, but -/// [`ChildComponent::absolute_position`] only ever treats the literal -/// `"absolute"` specially — every other value (including the CSS-legitimate -/// `"relative"`/`"static"`, which an LLM reasoning in CSS terms naturally -/// reaches for) silently drops `x`/`y`: the component is taken out of flow -/// (`is_flow()` is false for any `Some(position)`) but never receives an -/// absolute offset either, since only `"absolute"` is matched. `x`/`y` are -/// top-level sibling fields on `ChildComponent`, not on `PositionMode` -/// itself, so this can't detect *whether* they were actually set — only -/// that, if they were, they are about to be silently ignored. `"absolute"` -/// stays completely silent (the common, correct case); anything else warns. pub fn is_recognized_position_name(s: &str) -> bool { s == "absolute" } @@ -196,14 +159,6 @@ impl<'de> Deserialize<'de> for PositionMode { } } -/// True the first time this exact `position` value is seen, false afterwards. -/// -/// `render_scene_frame` calls `prepare_scene` — and therefore re-runs this -/// `Deserialize` over the whole scene tree — once **per frame**. An unguarded -/// warning here would print the same line once per offending component per -/// frame: over a thousand times on a 1200-frame render, drowning out anything -/// else on stderr. Keyed by the value rather than a plain `Once` so a scenario -/// with several distinct bad values still hears about each of them. pub(crate) fn warn_once_for(value: &str) -> bool { use std::collections::HashSet; use std::sync::{Mutex, OnceLock}; @@ -224,8 +179,6 @@ impl Default for PositionMode { mod position_mode_tests { use super::*; - // ---- constat #8 (RED first) ---- - #[test] fn absolute_is_recognized() { assert!(is_recognized_position_name("absolute")); @@ -259,14 +212,6 @@ mod position_mode_tests { #[test] fn relative_still_parses_but_drops_x_y_and_the_helper_flags_it() { - // The legitimate-CSS trap named in constat #8: an LLM writes - // `"position": "relative"` (valid CSS) with `x`/`y` alongside it, - // expecting a positioned element. The parse must not fail — this is - // legitimate JSON per the schema's own untagged catch-all — but the - // coordinates are provably dropped (`absolute_position()` is - // `None`), and `is_recognized_position_name` is the named, - // independently testable signal the warning path uses to detect - // this instead of staying silent. let json = r#"{ "position": "relative", "x": 5.0, "y": 7.0, "type": "shape", "shape": "circle" }"#; let child: ChildComponent = serde_json::from_str(json).unwrap(); @@ -280,13 +225,9 @@ mod position_mode_tests { "x/y are indeed dropped for a non-\"absolute\" position — this is the silent \ behaviour being made loud, not a new regression" ); - // The component is still taken out of flow, same as before. assert!(!child.is_flow()); } - /// `prepare_scene` re-runs this `Deserialize` over the whole scene tree - /// once per frame, so the warning must be deduplicated or a 1200-frame - /// render prints it 1200 times. Distinct values still each get a line. #[test] fn the_warning_fires_once_per_distinct_value_not_once_per_frame() { let value = "position-value-used-only-by-this-test"; @@ -312,8 +253,6 @@ mod position_mode_tests { } } -// --- Child wrapper --- - #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct ChildComponent { #[serde(flatten)] @@ -370,8 +309,6 @@ impl ChildComponent { } } -// --- Component enum --- - #[derive(Debug, Serialize, Deserialize, JsonSchema)] #[serde(tag = "type", rename_all = "snake_case")] pub enum Component { @@ -439,8 +376,6 @@ pub enum Component { Waveform(Waveform), } -// --- Dispatch helpers --- - impl Component { pub fn as_animatable(&self) -> Option<&dyn Animatable> { match self { @@ -616,11 +551,6 @@ impl Component { } } - /// Returns the Painter trait. Every `Component` variant is migrated to - /// the new pipeline; the dispatcher always uses Painter::paint_content. - /// `tests/audit_ws_i.rs::cascaded_components_match_the_verified_set` - /// exercises every variant — not a count here, which would just go - /// stale again the next time a component is added. pub fn as_painter(&self) -> Option<&dyn Painter> { match self { Component::AudioSpectrum(c) => Some(c), @@ -679,43 +609,6 @@ impl Component { } } - /// `Some(clone)` for the components whose `Painter`/intrinsic measurer - /// read inherited typography (`color`, `font-*`, `text-align`, - /// `white-space`, ...) off their own `style` field with `resolved`'s - /// twelve `cascade::inherit_from` properties folded in; `None` for every - /// other component, since the cascade cannot affect anything they draw. - /// `resolved` is the caller's own `CssStyle` post-cascade (`box_builder` - /// and `LegacyPaintDispatcher` both already have it on hand). - /// - /// The `true` set below was built by reading every component's own - /// `paint`/`paint_content` (and, where one exists, its `*Intrinsic` - /// measurer) for a direct `self.style.color`/`font_family`/`font_size`/ - /// `font_weight`/`font_style` read with no cascade in between — not by - /// guessing from the component's name. Several read only `font-size`/ - /// `font-family` and keep their own dedicated field for text colour - /// (`Kbd::text_color`, `PillNav::text_color`) — still members, since - /// those two properties alone are enough for the same defect: a - /// `font-size` set on a card never reaching the child. - /// - /// Exhaustive on purpose, no wildcard arm: adding a new `Component` - /// variant is a compile error here until this match says whether it - /// belongs to that set — the same completeness `as_painter`/ - /// `as_animatable`/`as_timed`/`as_styled` above already enforce for - /// their own questions, extended to this one. - /// `tests/audit_ws_i.rs::cascaded_components_match_the_verified_set` - /// pins the current membership directly, so a variant silently - /// reclassified here still fails a test even though the compiler has - /// nothing to object to. - /// - /// Builds the clone via a `serde_json` round-trip rather than `Clone`: - /// `Component`'s inner types are already `Serialize + Deserialize` (the - /// whole scenario tree is built that way), but not every one of them is - /// `Clone` — `Caption`'s `CaptionWord`/`CaptionStyle` (`rustmotion-core`) - /// are not, and adding it there is outside this crate. The round trip - /// costs more than a field-wise clone would, but only for the - /// components in the `Some` arm, and only once per box-tree build / - /// paint call — the same per-frame cost class `box_builder` already - /// pays elsewhere. pub fn with_cascaded_style(&self, resolved: &CssStyle) -> Option { let is_typographic = match self { Component::Text(_) diff --git a/crates/rustmotion-components/src/line.rs b/crates/rustmotion-components/src/line.rs index 78d8d3bb..7d73a8af 100644 --- a/crates/rustmotion-components/src/line.rs +++ b/crates/rustmotion-components/src/line.rs @@ -56,7 +56,6 @@ impl Line { paint.set_anti_alias(true); paint.set_stroke_cap(skia_safe::PaintCap::Round); - // Apply dashed style if let Some(ref intervals) = self.dashed { if intervals.len() >= 2 { if let Some(dash) = skia_safe::PathEffect::dash(intervals, 0.0) { @@ -65,7 +64,6 @@ impl Line { } } - // Apply draw_progress if props.draw_progress >= 0.0 && props.draw_progress < 1.0 { let dx = self.x2 - self.x1; let dy = self.y2 - self.y1; diff --git a/crates/rustmotion-components/src/list.rs b/crates/rustmotion-components/src/list.rs index 9036cefb..783f0e6c 100644 --- a/crates/rustmotion-components/src/list.rs +++ b/crates/rustmotion-components/src/list.rs @@ -87,10 +87,6 @@ rustmotion_core::impl_traits!(List { }); impl List { - /// Resolves `font-size` against a real per-frame viewport (`rem`/`vw`/ - /// `vh` now resolve instead of silently dropping to 0px — lot B, wave - /// S). `em`/`%` on `font-size` itself remain approximate — see - /// `crate::intrinsic::font_size_ctx`'s doc comment. fn resolved_font_size(&self, ctx: &PaintCtx) -> f32 { self.style.font_size_px_ctx( &crate::intrinsic::font_size_ctx(ctx.video_width as f32, ctx.video_height as f32, 0.0), @@ -290,12 +286,8 @@ mod tests { } } - // ─── Lot B, wave S: relative `font-size` units ───────────────────────── - #[test] fn rem_font_size_paints_visible_ink() { - // Reproduction: `font-size: "2rem"` used to resolve to 0px via the - // context-free `font_size_px_or`. let list = List { items: vec![ListItem { text: "hello".to_string(), diff --git a/crates/rustmotion-components/src/lottie.rs b/crates/rustmotion-components/src/lottie.rs index 6fee0988..cdfed9fb 100644 --- a/crates/rustmotion-components/src/lottie.rs +++ b/crates/rustmotion-components/src/lottie.rs @@ -62,7 +62,6 @@ rustmotion_core::impl_traits!(Lottie { }); impl Lottie { - /// Parse Lottie JSON metadata (fr, ip, op, w, h). fn parse_metadata(&self) -> Result<(f64, usize, f64, f32, f32)> { let json_str = if let Some(ref src) = self.src { std::fs::read_to_string(src).map_err(|e| RustmotionError::LottieRead { @@ -87,13 +86,11 @@ impl Lottie { Ok((fr, total_frames, duration, w, h)) } - /// Get the cache key for a specific frame (frames_dir path). fn cache_key(&self, frame: usize) -> String { let src = self.src.as_deref().unwrap_or("inline"); format!("lottie:{}:frame:{}", src, frame) } - /// Load a pre-rendered frame from frames_dir. fn load_frame_from_dir(&self, frames_dir: &str, frame: usize) -> Result { let frame_path = format!("{}/{:04}.png", frames_dir, frame); let data = std::fs::read(&frame_path).map_err(|e| RustmotionError::LottieFrameRead { @@ -125,8 +122,6 @@ impl Lottie { } } -// ─── Native ThorVG path ────────────────────────────────────────────────────── - #[cfg(feature = "lottie-native")] mod native { use std::hash::Hash; @@ -134,14 +129,10 @@ mod native { use dashmap::DashMap; - /// Maximum number of cached Lottie frames across all sources. - /// When exceeded the cache is cleared (naïve eviction). const CACHE_MAX_ENTRIES: usize = 128; - /// Composite key for a rendered Lottie frame. #[derive(Clone, PartialEq, Eq, Hash, Debug)] pub(super) struct FrameKey { - /// FNV-1a hash of the raw Lottie JSON bytes. pub src_hash: u64, pub frame_index: u32, pub width: u32, @@ -171,15 +162,12 @@ mod native { type NativeCacheMap = Arc>>>; - /// Global frame cache: `FrameKey` → RGBA bytes (width * height * 4). static NATIVE_LOTTIE_CACHE: OnceLock = OnceLock::new(); pub(super) fn native_lottie_cache() -> &'static NativeCacheMap { NATIVE_LOTTIE_CACHE.get_or_init(|| Arc::new(DashMap::new())) } - // Thread-local ThorVG engine — one instance per rayon worker thread. - // `Thorvg` is !Send + !Sync; thread_local! is the correct container. thread_local! { static THORVG_ENGINE: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; @@ -198,29 +186,12 @@ mod native { }) } - // Set of source hashes that failed to load (to suppress per-frame warnings). static FAILED_SOURCES: OnceLock> = OnceLock::new(); fn failed_sources() -> &'static DashMap { FAILED_SOURCES.get_or_init(DashMap::new) } - /// Render a single Lottie frame to RGBA bytes using ThorVG's software rasterizer. - /// - /// # Byte order - /// - /// ThorVG `ColorSpace::ABGR8888` stores pixels as `u32` values where: - /// - bits 0– 7 = R (least-significant byte in little-endian memory) - /// - bits 8–15 = G - /// - bits 16–23 = B - /// - bits 24–31 = A - /// - /// When the `Vec` buffer is reinterpreted as `&[u8]`, the byte layout - /// is R, G, B, A — which matches Skia's `RGBA8888` pixel format exactly. - /// - /// Proof: the pixel test in `#[cfg(test)]` verifies that a fully-red Lottie - /// shape (Lottie fill `[1, 0, 0, 1]`) produces a dominant red channel (byte 0) - /// and near-zero blue (byte 2) when the buffer is interpreted as RGBA. pub(super) fn render_frame( json_bytes: &[u8], frame_index: u32, @@ -229,7 +200,6 @@ mod native { ) -> Option>> { use thorvg::{ColorSpace, EngineOption}; - // Check cache first (before touching the engine). let key = FrameKey::new(json_bytes, frame_index, width, height); let cache = native_lottie_cache(); if let Some(cached) = cache.get(&key) { @@ -238,7 +208,6 @@ mod native { let src_hash = key.src_hash; - // Guard: skip sources that previously failed to avoid per-frame spam. if failed_sources().contains_key(&src_hash) { return None; } @@ -249,8 +218,6 @@ mod native { let mut buffer = vec![0u32; (width * height) as usize]; let mut canvas = engine.sw_canvas(EngineOption::Default).ok()?; - // SAFETY: `buffer` is alive for the duration of this closure; canvas - // borrows it until `canvas.sync()` is called. unsafe { canvas .set_target(&mut buffer, width, width, height, ColorSpace::ABGR8888) @@ -266,21 +233,15 @@ mod native { return None; } let clamped = (frame_index as f32).min(total - 1.0).max(0.0); - // set_frame returns Err(InsufficientCondition) when the frame didn't change - // (diff < 0.001). That's fine — we still draw what's already set. let _ = anim.set_frame(clamped); - // duplicate() returns Option — no .ok() needed. let dup = anim.picture().duplicate()?; canvas.add(dup).ok()?; canvas.draw(true).ok()?; canvas.sync().ok()?; - // Reinterpret Vec as Vec (R,G,B,A layout — see doc above). let byte_len = buffer.len() * 4; let mut out = Vec::with_capacity(byte_len); - // SAFETY: u32 has no uninitialized padding; the full slice is valid - // for any byte reinterpretation. let byte_slice = unsafe { std::slice::from_raw_parts(buffer.as_ptr() as *const u8, byte_len) }; out.extend_from_slice(byte_slice); @@ -299,7 +260,6 @@ mod native { } Some(bytes) => { let arc = Arc::new(bytes); - // Naïve eviction: clear when the cache grows too large. if cache.len() >= CACHE_MAX_ENTRIES { cache.clear(); } @@ -309,8 +269,6 @@ mod native { } } - /// Compute the frame index from elapsed time `t` (seconds) with `speed`, `repeat` - /// flag, and Lottie metadata `(fr, total_frames, duration)`. pub(super) fn frame_at_time( t: f64, speed: f32, @@ -332,8 +290,6 @@ mod native { f.min(total_frames.saturating_sub(1)) as u32 } - /// Resolve the raw JSON bytes from a `Lottie` component. - /// Returns `None` and prints a one-time warning on file-read failure. pub(super) fn resolve_json(lottie: &super::Lottie) -> Option> { if let Some(ref data) = lottie.data { return Some(data.as_bytes().to_vec()); @@ -420,8 +376,6 @@ mod native { pub(super) use resolve_json as test_resolve_json; } -// ─── Painter ───────────────────────────────────────────────────────────────── - impl Painter for Lottie { fn paint_content( &self, @@ -447,7 +401,6 @@ impl Painter for Lottie { }; let frame = ((effective_time * fr) as usize).min(total_frames.saturating_sub(1)); - // frames_dir path — backward-compatible, takes priority over native. if let Some(ref frames_dir) = self.frames_dir { let cache_key = self.cache_key(frame); let cache = asset_cache(); @@ -468,7 +421,6 @@ impl Painter for Lottie { return; } - // Native thorvg path. #[cfg(feature = "lottie-native")] { native::paint_native(self, canvas, layout, ctx); @@ -476,14 +428,10 @@ impl Painter for Lottie { } } -// ─── Tests ─────────────────────────────────────────────────────────────────── - #[cfg(all(test, feature = "lottie-native"))] mod tests { use super::native::{test_cache, test_frame_at_time, test_render_frame}; - /// Minimal valid Lottie: 30 fps, 30 frames (1 second), one red 80×80 rect layer. - /// Taken verbatim from the spike at lottie-spike-thorvg/src/main.rs. const RED_LOTTIE: &str = r#"{ "v": "5.7.4", "fr": 30, "ip": 0, "op": 30, "w": 100, "h": 100, "layers": [{ @@ -504,7 +452,6 @@ mod tests { const W: u32 = 100; const H: u32 = 100; - /// Helper: render frame 15 (mid-point of the 30-frame animation). fn render_mid() -> Vec { test_render_frame(RED_LOTTIE.as_bytes(), 15, W, H) .expect("render_mid must succeed") @@ -512,12 +459,6 @@ mod tests { .clone() } - // ── Test 1: pixel / byte-order ──────────────────────────────────────────── - // - // ThorVG ABGR8888: u32 low byte = R (little-endian). - // Reinterpreted as &[u8]: byte layout = [R, G, B, A] = Skia RGBA8888. - // A red lottie must produce dominant red channel (byte 0 of each pixel), - // near-zero blue (byte 2). #[test] fn pixel_byte_order_red_lottie() { let buf = render_mid(); @@ -535,8 +476,6 @@ mod tests { blue_sum += b; } } - // The 80×80 red rect covers most of the 100×100 canvas. - // We expect significant red and near-zero blue. assert!( red_sum > 200_000, "expected dominant red, got red_sum={red_sum} blue_sum={blue_sum}" @@ -547,14 +486,8 @@ mod tests { ); } - // ── Test 2: repeat ──────────────────────────────────────────────────────── - // - // Animation: 30 frames at 30fps → 1 second. - // With repeat=true, t=1.5 wraps to t=0.5 → same frame as t=0.5. - // With repeat=false, t=1.5 clamps to last frame. #[test] fn repeat_true_wraps_to_same_frame() { - // 30 fps, 30 total frames, 1.0s duration let fr = 30.0f64; let total = 30usize; let dur = 1.0f64; @@ -563,12 +496,10 @@ mod tests { let fi_wrap = test_frame_at_time(1.5, 1.0, true, fr, total, dur); assert_eq!(fi_half, fi_wrap, "repeat=true: t=1.5 should wrap to t=0.5"); - // Pixel buffers at those frames should be identical. let buf_half = test_render_frame(RED_LOTTIE.as_bytes(), fi_half, W, H).expect("render half"); let buf_wrap = test_render_frame(RED_LOTTIE.as_bytes(), fi_wrap, W, H).expect("render wrap"); - // They're the same frame index, so definitely the same buffer. assert_eq!(*buf_half, *buf_wrap); } @@ -580,14 +511,10 @@ mod tests { let fi_clamped = test_frame_at_time(1.5, 1.0, false, fr, total, dur); let fi_last = test_frame_at_time(1.0, 1.0, false, fr, total, dur); - // Both should land on the last frame (index 29). assert_eq!(fi_clamped, 29); assert_eq!(fi_last, 29); } - // ── Test 3: speed ───────────────────────────────────────────────────────── - // - // speed=2.0 at t=0.25 should equal speed=1.0 at t=0.5 (same frame). #[test] fn speed_multiplier_equivalent_frame() { let fr = 30.0f64; @@ -608,11 +535,9 @@ mod tests { assert_eq!(*buf_fast, *buf_normal); } - // ── Test 4: invalid JSON → no panic, zero pixels ────────────────────────── #[test] fn invalid_json_no_panic_zero_pixels() { let result = test_render_frame(b"not valid json at all!!!", 0, W, H); - // Must not panic. Either returns None or returns Some with all-zero pixels. if let Some(buf) = result { let nonzero = buf.iter().any(|&b| b != 0); assert!( @@ -620,26 +545,14 @@ mod tests { "invalid JSON should produce zero-pixel output, got non-zero pixels" ); } - // None is also acceptable. } - // ── Test 5: frames_dir priority ─────────────────────────────────────────── - // - // When frames_dir is Some (even if the path does not exist), the native - // path must NOT be taken. We verify this by checking that no cache entry - // is written for a key derived from the inline data, when frames_dir wins. - // - // Implementation note: `paint_content` returns early after the frames_dir - // branch (success or failure). The native module's cache will not contain - // any entry whose src_hash matches the red lottie if frames_dir was set. #[test] fn frames_dir_priority_no_native_cache_entry() { use super::{native, Lottie}; - // Clear the cache so we start clean. test_cache().clear(); - // Build a Lottie with both frames_dir (non-existent) and inline data. let lottie = Lottie { src: None, data: Some(RED_LOTTIE.to_string()), @@ -652,21 +565,11 @@ mod tests { stagger: None, }; - // Compute the hash that would be used if the native path were taken. use super::native::FrameKey; let expected_key = FrameKey::new(RED_LOTTIE.as_bytes(), 0, 100, 100); - // Call paint_content via the native resolution path directly, simulating - // what would happen if paint_content were called with a valid context. - // Since we can't call paint_content (needs a Canvas), we verify the - // invariant: frames_dir branch returns early → native cache stays empty. - // - // We confirm that the frames_dir path is taken by calling `resolve_json` - // (which doesn't touch the cache) and checking the cache is still empty. let _ = native::test_resolve_json(&lottie); - // The important check: native cache has no entry for this lottie. - // If frames_dir priority is broken and native was called, an entry would appear. assert!( !test_cache().contains_key(&expected_key), "native cache must not be populated when frames_dir takes priority" diff --git a/crates/rustmotion-components/src/marquee.rs b/crates/rustmotion-components/src/marquee.rs index c3982dca..8b1fe674 100644 --- a/crates/rustmotion-components/src/marquee.rs +++ b/crates/rustmotion-components/src/marquee.rs @@ -83,10 +83,6 @@ impl Marquee { let w = layout_w; let h = layout_h; - // Resolved against a real per-frame viewport (`rem`/`vw`/`vh` now - // resolve instead of silently dropping to 0px — lot B, wave S). - // `em`/`%` on `font-size` itself remain approximate — see - // `crate::intrinsic::font_size_ctx`'s doc comment. let fs = self.style.font_size_px_ctx( &crate::intrinsic::font_size_ctx(ctx.video_width as f32, ctx.video_height as f32, 0.0), self.font_size, @@ -113,14 +109,12 @@ impl Marquee { let ascent = -metrics.ascent; let text_y = (h + ascent) / 2.0; - // Calculate offset based on time and direction let offset = (time as f32 * self.speed) % text_w; let start_x = match self.direction { MarqueeDirection::Left => -offset, MarqueeDirection::Right => offset - text_w, }; - // Clip to bounds canvas.save(); canvas.clip_rect( Rect::from_xywh(0.0, 0.0, w, h), @@ -128,7 +122,6 @@ impl Marquee { false, ); - // Draw enough copies to fill the width let copies = ((w / text_w).ceil() as i32 + 2).max(2); for i in 0..copies { let x = start_x + i as f32 * text_w; @@ -186,12 +179,8 @@ mod tests { } } - // ─── Lot B, wave S: relative `font-size` units ───────────────────────── - #[test] fn rem_font_size_paints_visible_ink() { - // Reproduction: `font-size: "2rem"` used to resolve to 0px via the - // context-free `font_size_px_or`. let marquee = Marquee { content: "hello world".to_string(), speed: default_speed(), diff --git a/crates/rustmotion-components/src/mockup.rs b/crates/rustmotion-components/src/mockup.rs index 738a7de6..e4556b15 100644 --- a/crates/rustmotion-components/src/mockup.rs +++ b/crates/rustmotion-components/src/mockup.rs @@ -142,7 +142,6 @@ impl Mockup { let sw = screen_rect.width(); let sh = screen_rect.height(); - // Cover fit let scale = (sw / img_w).max(sh / img_h); let draw_w = img_w * scale; let draw_h = img_h * scale; @@ -159,7 +158,6 @@ impl Mockup { } fn render_phone(&self, canvas: &Canvas, w: f32, h: f32, m: &DeviceMetrics) -> Result<()> { - // Device body let body_rect = Rect::from_xywh(0.0, 0.0, w, h); let body_rrect = RRect::new_rect_xy(body_rect, m.corner_radius, m.corner_radius); let mut body_paint = paint_from_hex(self.theme.bezel_color()); @@ -167,7 +165,6 @@ impl Mockup { body_paint.set_anti_alias(true); canvas.draw_rrect(body_rrect, &body_paint); - // Screen area let screen_rect = Rect::from_xywh( m.bezel_side, m.bezel_top, @@ -175,7 +172,6 @@ impl Mockup { h - m.bezel_top - m.bezel_bottom, ); - // Screen background let mut screen_bg = paint_from_hex(self.theme.screen_bg()); screen_bg.set_style(PaintStyle::Fill); let screen_radius = m.corner_radius - m.bezel_side; @@ -183,13 +179,11 @@ impl Mockup { RRect::new_rect_xy(screen_rect, screen_radius.max(0.0), screen_radius.max(0.0)); canvas.draw_rrect(screen_rrect, &screen_bg); - // Content image canvas.save(); canvas.clip_rrect(screen_rrect, skia_safe::ClipOp::Intersect, true); self.draw_content_image(canvas, screen_rect)?; canvas.restore(); - // iPhone notch if matches!(self.device, MockupDevice::Iphone) { let notch_w = w * 0.35; let notch_h = 28.0; @@ -213,7 +207,6 @@ impl Mockup { canvas.draw_rrect(notch_rrect, ¬ch_paint); } - // Android punch-hole camera if matches!(self.device, MockupDevice::Android) { let mut cam_paint = paint_from_hex(self.theme.bezel_color()); cam_paint.set_style(PaintStyle::Fill); @@ -221,7 +214,6 @@ impl Mockup { canvas.draw_circle((w / 2.0, m.bezel_top / 2.0), 5.0, &cam_paint); } - // Home indicator (iPhone) if matches!(self.device, MockupDevice::Iphone) { let indicator_w = w * 0.35; let indicator_h = 5.0; @@ -246,7 +238,6 @@ impl Mockup { let screen_h = h * 0.85; let _base_h = h - screen_h; - // Screen lid let lid_rect = Rect::from_xywh(0.0, 0.0, w, screen_h); let lid_rrect = RRect::new_rect_xy(lid_rect, m.corner_radius, m.corner_radius); let mut lid_paint = paint_from_hex(self.theme.bezel_color()); @@ -254,7 +245,6 @@ impl Mockup { lid_paint.set_anti_alias(true); canvas.draw_rrect(lid_rrect, &lid_paint); - // Screen area let screen_rect = Rect::from_xywh( m.bezel_side, m.bezel_top, @@ -266,15 +256,12 @@ impl Mockup { screen_bg.set_style(PaintStyle::Fill); canvas.draw_rect(screen_rect, &screen_bg); - // Content self.draw_content_image(canvas, screen_rect)?; - // Camera dot let mut cam_paint = paint_from_hex("#333333"); cam_paint.set_anti_alias(true); canvas.draw_circle((w / 2.0, m.bezel_top / 2.0), 3.0, &cam_paint); - // Base (trapezoid) let base_y = screen_h; let inset = w * 0.05; let mut base_path = PathBuilder::new(); @@ -293,7 +280,6 @@ impl Mockup { } fn render_browser(&self, canvas: &Canvas, w: f32, h: f32, m: &DeviceMetrics) -> Result<()> { - // Window background let body_rect = Rect::from_xywh(0.0, 0.0, w, h); let body_rrect = RRect::new_rect_xy(body_rect, m.corner_radius, m.corner_radius); let mut body_paint = paint_from_hex(self.theme.bezel_color()); @@ -301,7 +287,6 @@ impl Mockup { body_paint.set_anti_alias(true); canvas.draw_rrect(body_rrect, &body_paint); - // Title bar let chrome_color = match self.theme { MockupTheme::Dark => "#2D2D2D", MockupTheme::Light => "#F3F3F3", @@ -314,7 +299,6 @@ impl Mockup { canvas.draw_rect(Rect::from_xywh(0.0, 0.0, w, m.bezel_top), &chrome_paint); canvas.restore(); - // Traffic lights let dot_colors = ["#FF5F57", "#FEBC2E", "#28C840"]; let dot_y = m.bezel_top / 2.0; for (i, color) in dot_colors.iter().enumerate() { @@ -325,7 +309,6 @@ impl Mockup { canvas.draw_circle((dot_x, dot_y), 6.0, &dot_paint); } - // URL bar let url_bar_color = match self.theme { MockupTheme::Dark => "#404040", MockupTheme::Light => "#FFFFFF", @@ -341,7 +324,6 @@ impl Mockup { url_paint.set_anti_alias(true); canvas.draw_rrect(url_rrect, &url_paint); - // Content area let screen_rect = Rect::from_xywh(0.0, m.bezel_top, w, h - m.bezel_top); let mut screen_bg = paint_from_hex(self.theme.screen_bg()); @@ -351,7 +333,6 @@ impl Mockup { canvas.draw_rect(screen_rect, &screen_bg); canvas.restore(); - // Content image canvas.save(); canvas.clip_rrect(body_rrect, skia_safe::ClipOp::Intersect, true); self.draw_content_image(canvas, screen_rect)?; diff --git a/crates/rustmotion-components/src/number_wheel.rs b/crates/rustmotion-components/src/number_wheel.rs index ec78622b..16e65656 100644 --- a/crates/rustmotion-components/src/number_wheel.rs +++ b/crates/rustmotion-components/src/number_wheel.rs @@ -1,13 +1,3 @@ -//! A rolling-digit counter: each digit column spins through 0-9 like a -//! mechanical odometer reel before landing on its target. -//! -//! Distinct from [`crate::counter::Counter`], which interpolates a *value* -//! and re-renders the number each frame — its digits change by arithmetic, -//! and the glyphs jump. Here the digits are physically on a strip that -//! travels: what lands is the number you asked for, and what you watch is the -//! travel. Reels land left-to-right, which is what makes the final digit read -//! as the one that settles the figure. - use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use skia_safe::{Canvas, ClipOp, Font, FontStyle, Rect}; @@ -109,11 +99,8 @@ rustmotion_core::impl_traits!(NumberWheel { Styled => style, }); -/// One character of the figure: a rolling reel, or a fixed glyph. pub(crate) enum Cell { - /// A digit reel landing on this value. Digit(u32), - /// A separator, sign or unit, painted as-is. Fixed(char), } @@ -128,11 +115,6 @@ impl NumberWheel { .collect() } - /// How far reel `column` has travelled at `time`, in cells. - /// - /// Cell 0 is the digit `0`; the reel counts upward through 0-9, wrapping, - /// and stops exactly on `revolutions * 10 + target` so that the landing - /// is on the requested digit rather than near it. pub(crate) fn reel_position(&self, column: usize, target: u32, time: f64) -> f32 { let start = self.delay + column as f64 * self.stagger_per_column; let raw = if self.duration <= 0.0 { @@ -145,8 +127,6 @@ impl NumberWheel { travel * p } - /// The digit-column width: the widest digit's advance, so the reels line - /// up in a column instead of jittering as a 1 rolls past an 8. fn digit_advance(font: &Font, emoji: &Option, letter_spacing: f32) -> f32 { (0..10) .map(|d| measure_text_with_fallback(&d.to_string(), font, emoji, letter_spacing)) @@ -173,7 +153,7 @@ impl NumberWheel { FontWeight::Normal => skia_safe::font_style::Weight::NORMAL, FontWeight::Weight(w) => skia_safe::font_style::Weight::from(w as i32), }; - let _ = FontStyleType::Normal; // keep the schema import meaningful + let _ = FontStyleType::Normal; let typeface = typeface_with_fallback( font_family, FontStyle::new(weight, skia_safe::font_style::Width::NORMAL, slant), @@ -217,8 +197,6 @@ impl Painter for NumberWheel { let (_, metrics) = font.metrics(); let ascent = -metrics.ascent; let descent = metrics.descent; - // One cell is a full line box: the strip advances by exactly this, so - // consecutive digits never overlap inside the clip. let cell_h = ascent + descent; let baseline = ascent; @@ -250,16 +228,12 @@ impl Painter for NumberWheel { let frac = pos - whole; canvas.save(); - // The clip is the window in the odometer's housing: it is - // what turns a long strip of digits into one visible one. canvas.clip_rect( Rect::from_xywh(x, 0.0, digit_w, cell_h), ClipOp::Intersect, false, ); - // Outgoing digit sliding up and out, incoming one rising - // into its place from below. for (step, offset) in [(0.0f32, -frac * cell_h), (1.0, (1.0 - frac) * cell_h)] { let digit = ((whole + step) as i64).rem_euclid(10); let s = digit.to_string(); @@ -270,8 +244,6 @@ impl Painter for NumberWheel { &font, &emoji_font, letter_spacing, - // Digits are centred in the column so a 1 does not - // sit off to one side of the reel it shares with an 8. x + (digit_w - w) / 2.0, baseline + offset, &paint, @@ -307,8 +279,6 @@ mod tests { #[test] fn a_reel_lands_exactly_on_its_target_digit() { - // Landing "near" the digit is the failure mode worth pinning: the - // whole point of a reel over a fade is that it stops on the figure. let w = wheel(serde_json::json!({ "value": "7", "duration": 1.0, "stagger_per_column": 0.0 })); let landed = w.reel_position(0, 7, 5.0); @@ -333,12 +303,10 @@ mod tests { let single = wheel(serde_json::json!({ "value": "3", "spin": "single" })); let triple = wheel(serde_json::json!({ "value": "3", "spin": "triple" })); - // Mid-travel the triple is further along... assert!( triple.reel_position(0, 3, 0.4) > single.reel_position(0, 3, 0.4), "a triple spin covers more ground in the same time" ); - // ...but both come to rest on the same digit. assert!( (single.reel_position(0, 3, 9.0) % 10.0 - 3.0).abs() < 1e-4 && (triple.reel_position(0, 3, 9.0) % 10.0 - 3.0).abs() < 1e-4, @@ -351,7 +319,6 @@ mod tests { let w = wheel(serde_json::json!({ "value": "99", "duration": 0.5, "stagger_per_column": 0.25 })); - // At the moment the first reel has landed, the second is still moving. let first = w.reel_position(0, 9, 0.5); let second = w.reel_position(1, 9, 0.5); assert!( diff --git a/crates/rustmotion-components/src/particle.rs b/crates/rustmotion-components/src/particle.rs index 8ff93a5f..14b64cad 100644 --- a/crates/rustmotion-components/src/particle.rs +++ b/crates/rustmotion-components/src/particle.rs @@ -83,7 +83,6 @@ rustmotion_core::impl_traits!(Particle { Styled => style, }); -/// Deterministic pseudo-random number generator (splitmix64 for good distribution) fn prng(seed: u64) -> (f64, u64) { let mut s = seed.wrapping_add(0x9E3779B97F4A7C15); s = (s ^ (s >> 30)).wrapping_mul(0xBF58476D1CE4E5B9); @@ -93,7 +92,6 @@ fn prng(seed: u64) -> (f64, u64) { (value.abs(), s) } -/// Generate N random values from a seed fn rand_values(seed: u64, n: usize) -> Vec { let mut values = Vec::with_capacity(n); let mut s = seed; diff --git a/crates/rustmotion-components/src/pill_nav.rs b/crates/rustmotion-components/src/pill_nav.rs index cf2cd35f..0cb4c353 100644 --- a/crates/rustmotion-components/src/pill_nav.rs +++ b/crates/rustmotion-components/src/pill_nav.rs @@ -91,10 +91,6 @@ rustmotion_core::impl_traits!(PillNav { }); impl PillNav { - /// Resolves `font-size` against a real per-frame viewport (`rem`/`vw`/ - /// `vh` now resolve instead of silently dropping to 0px — lot B, wave - /// S). `em`/`%` on `font-size` itself remain approximate — see - /// `crate::intrinsic::font_size_ctx`'s doc comment. fn resolved_font_size(&self, ctx: &PaintCtx) -> f32 { self.style.font_size_px_ctx( &crate::intrinsic::font_size_ctx(ctx.video_width as f32, ctx.video_height as f32, 0.0), @@ -157,7 +153,6 @@ impl PillNav { } let end_time = tr.at + self.transition_duration; if time < end_time { - // Mid-transition let progress = (time - tr.at) / self.transition_duration; _prev_index = current; current = tr.to; @@ -181,7 +176,6 @@ impl PillNav { let w = layout_w; let h = layout_h; - // Outer container let outer_rect = Rect::from_xywh(0.0, 0.0, w, h); let outer_rrect = RRect::new_rect_xy(outer_rect, self.border_radius, self.border_radius); let mut bg_paint = paint_from_hex(&self.background_color); @@ -194,7 +188,6 @@ impl PillNav { }; let (active, transition_info) = self.active_at_time(time); - // Pill indicator let pill_h = h - self.gap * 2.0; let pill_y = self.gap; let pill_radius = self.border_radius - self.gap; @@ -220,7 +213,6 @@ impl PillNav { pill_paint.set_anti_alias(true); canvas.draw_rrect(pill_rrect, &pill_paint); - // Tab labels let Some(font) = self.make_font(false, ctx) else { return; }; @@ -287,12 +279,8 @@ mod tests { } } - // ─── Lot B, wave S: relative `font-size` units ───────────────────────── - #[test] fn rem_font_size_paints_visible_ink() { - // Reproduction: `font-size: "2rem"` used to resolve to 0px via the - // context-free `font_size_px_or`. let nav = PillNav { items: vec!["Home".to_string(), "About".to_string()], active_index: 0, diff --git a/crates/rustmotion-components/src/pointer.rs b/crates/rustmotion-components/src/pointer.rs index 1b1fb199..a68c98fa 100644 --- a/crates/rustmotion-components/src/pointer.rs +++ b/crates/rustmotion-components/src/pointer.rs @@ -1,14 +1,3 @@ -//! A simulated mouse pointer — the arrow, plus the ring that pulses out of -//! its tip when it clicks. -//! -//! Not to be confused with [`crate::cursor::Cursor`], which is a *text* caret -//! (a blinking bar). Its `cursor_style: "pointer"` field has never drawn -//! anything but that bar. Product walkthroughs and agent demos need the other -//! thing: an arrow that travels to a control and visibly clicks it. -//! -//! Waypoint choreography — hold, glide, pause on the click — is shared with -//! `cursor` via [`crate::cursor::waypoint_offset`], so the two stay in step. - use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use skia_safe::{Canvas, Paint, PaintStyle, Path, PathBuilder}; @@ -49,7 +38,6 @@ pub enum ClickRing { } impl ClickRing { - /// `(stroke width, travel)` as multiples of the pointer's `size`. fn metrics(self) -> Option<(f32, f32)> { match self { Self::Subtle => Some((0.05, 0.55)), @@ -132,15 +120,12 @@ impl Pointer { } } - /// The click running at `time`, as progress 0..1, if any. fn click_progress(&self, time: f64) -> Option { if self.click_duration <= 0.0 { return None; } self.click_times() .into_iter() - // The *last* qualifying click, so overlapping clicks resolve to - // the most recent rather than to whichever happens to be first. .rfind(|&t| time >= t && time < t + self.click_duration as f64) .map(|t| ((time - t) / self.click_duration as f64) as f32) } @@ -158,10 +143,6 @@ impl Pointer { ) } - /// The classic arrow — tip, left edge, tail notch, and back up the right - /// shoulder — drawn tip-first at the origin and scaled to `size`. - /// Coordinates are in units of the pointer's height, so the glyph keeps - /// its proportions at any size. fn arrow_path(size: f32) -> Path { const OUTLINE: [(f32, f32); 7] = [ (0.0, 0.0), @@ -205,9 +186,6 @@ impl Painter for Pointer { canvas.save(); canvas.translate((dx, dy)); - // The ring expands out of the tip and fades as it goes, so the eye - // reads the click as happening *at* the tip rather than around the - // whole pointer. Painted under the arrow so it never veils it. if let (Some(p), Some((stroke_f, travel_f))) = (click, self.click_ring.metrics()) { let (r, g, b, _) = parse_hex_color(self.ring_color.as_deref().unwrap_or(&fill)); let alpha = ((1.0 - p) * 200.0) as u8; @@ -221,9 +199,6 @@ impl Painter for Pointer { } } - // A small dip on the press, released as the click finishes — the - // arrow's own acknowledgement, independent of the ring (which - // `click_ring: "none"` can switch off). if let Some(p) = click { let scale = if p < 0.35 { 1.0 - 0.12 * (p / 0.35) @@ -268,8 +243,6 @@ mod tests { #[test] fn clicks_come_from_the_waypoints_when_a_path_is_given() { - // `click_at` is for a stationary pointer; a travelling one clicks on - // arrival, so listing both must not produce two overlapping sets. let p = pointer(serde_json::json!({ "click_at": [9.0], "path": [ @@ -301,8 +274,6 @@ mod tests { #[test] fn overlapping_clicks_resolve_to_the_most_recent() { - // Two clicks closer together than `click_duration`: the second must - // restart the animation, not be swallowed by the first still running. let p = pointer(serde_json::json!({ "click_at": [1.0, 1.2], "click_duration": 0.5 diff --git a/crates/rustmotion-components/src/progress.rs b/crates/rustmotion-components/src/progress.rs index fc4c691b..a78775da 100644 --- a/crates/rustmotion-components/src/progress.rs +++ b/crates/rustmotion-components/src/progress.rs @@ -101,14 +101,6 @@ impl Painter for Progress { _props: &AnimatedProperties, _ctx: &PaintCtx, ) { - // `self.width`/`self.height` only seed the *intrinsic* size in - // `box_builder` (promoted to CSS when `style.width`/`style.height` - // are absent) — the box taffy actually assigns can differ whenever - // an author sets `style.width`/`style.height` or a flex-grow - // idiom directly, which `html-css-mental-model.md` recommends. - // Painting at `self.width`/`self.height` regardless left the fill - // sized to whichever one happened to be smaller, filling only part - // of its own box (or overflowing it) instead of the box. let _ = self.paint(canvas, layout.width, layout.height); } } @@ -118,7 +110,6 @@ impl Progress { let radius = self.border_radius; let progress = self.progress.clamp(0.0, 1.0) as f32; - // Background let mut bg_paint = skia_safe::Paint::new(color4f_from_hex(&self.background_color), None); bg_paint.set_style(PaintStyle::Fill); bg_paint.set_anti_alias(true); @@ -127,7 +118,6 @@ impl Progress { let bg_rrect = RRect::new_rect_xy(bg_rect, radius, radius); canvas.draw_rrect(bg_rrect, &bg_paint); - // Fill (progress) if progress > 0.001 { let mut fill_paint = skia_safe::Paint::new(color4f_from_hex(&self.fill_color), None); fill_paint.set_style(PaintStyle::Fill); @@ -153,7 +143,6 @@ impl Progress { let radius = (cx.min(cy) - self.track_width / 2.0 - 2.0).max(0.0); let oval = Rect::from_xywh(cx - radius, cy - radius, radius * 2.0, radius * 2.0); - // Track let mut track_paint = paint_from_hex(&self.background_color); track_paint.set_style(PaintStyle::Stroke); track_paint.set_stroke_width(self.track_width); @@ -161,7 +150,6 @@ impl Progress { track_paint.set_anti_alias(true); canvas.draw_arc(oval, 0.0, 360.0, false, &track_paint); - // Fill arc if progress > 0.001 { let sweep = 360.0 * progress; let mut fill_paint = paint_from_hex(&self.fill_color); @@ -172,7 +160,6 @@ impl Progress { canvas.draw_arc(oval, -90.0, sweep, false, &fill_paint); } - // Value text if self.show_value { let text = format!("{}%", (progress * 100.0).round() as i32); let font_size = (radius * 0.5).max(10.0); @@ -278,13 +265,6 @@ mod tests { #[test] fn linear_progress_fills_the_layout_box_not_its_own_width_height() { - // #4's exact repro: `render_linear` always drew at `self.width` x - // `self.height` (defaults 300x20) regardless of the box taffy - // actually assigned it. `box_builder` only promotes `c.width`/ - // `c.height` to CSS when `style.width`/`style.height` are absent — - // so a `progress` sized via `style.width: 800` (the project's - // CSS-first idiom) painted a 300px-wide bar sitting inside an - // 800px-wide box, filling only 37% of it at `progress: 0.5`. let progress = base_progress(ProgressVariant::Linear); const BOX_W: f32 = 800.0; const BOX_H: f32 = 24.0; @@ -304,9 +284,6 @@ mod tests { } let (_minx, maxx, _miny, _maxy) = ink_bounds(&mut surface, 900, 200).expect("progress must paint something"); - // At progress 0.5 the fill should reach roughly the middle of the - // 800px box (~400px), not the middle of the component's own - // `width` field (300px -> 150px). assert!( maxx as f32 > BOX_W * 0.4, "fill did not scale to the box's own width: max ink x = {maxx}, box width = {BOX_W}" @@ -334,10 +311,6 @@ mod tests { } let (minx, maxx, miny, maxy) = ink_bounds(&mut surface, 300, 300).expect("progress must paint something"); - // The ring must be centered on the 60x60 box's own center (30, 30), - // not on the component's own `width`/`height` fields' center - // (150, 10 for the 300x20 defaults) — the un-fixed painter puts the - // whole ring outside a small box entirely. let center_x = (minx + maxx) as f32 / 2.0; let center_y = (miny + maxy) as f32 / 2.0; assert!( diff --git a/crates/rustmotion-components/src/rich_text.rs b/crates/rustmotion-components/src/rich_text.rs index e2e3d4f5..2b434199 100644 --- a/crates/rustmotion-components/src/rich_text.rs +++ b/crates/rustmotion-components/src/rich_text.rs @@ -55,7 +55,6 @@ rustmotion_core::impl_traits!(RichText { Styled => style, }); -/// Resolve a font for a span, inheriting from parent style defaults. fn make_font( family: &str, weight: &FontWeight, @@ -77,24 +76,12 @@ fn make_font( Some(Font::from_typeface(typeface, size)) } -/// Resolved font/paint metadata for one span. struct SpanFontInfo { font: Font, color: String, letter_spacing: f32, } -/// Resolve each span's font/color/letter-spacing, falling back to the -/// component-level style for anything a span doesn't override. `None` at -/// index `i` means the span's font failed to load — that span is skipped -/// during tokenization (same behaviour as the original `filter_map`). -/// -/// `default_size` is resolved by the caller (once, against a real -/// `LengthContext` where one is available) rather than re-derived here — -/// this used to call the context-free `style.font_size_px_or(48.0)` -/// independently of `compute_layout`'s own resolution of the same value, a -/// duplicate computation that silently diverged for relative units (lot B, -/// wave S). fn resolve_span_fonts( spans: &[RichTextSpan], style: &CssStyle, @@ -132,7 +119,6 @@ fn resolve_span_fonts( .collect() } -/// One word-wrapped token ready to be measured or drawn. pub struct RichTextToken { pub span_idx: usize, pub text: String, @@ -140,15 +126,11 @@ pub struct RichTextToken { pub width: f32, } -/// One wrapped line of a [`RichText`] layout. pub struct RichTextLine { pub tokens: Vec, pub width: f32, } -/// Word-wrapped layout for a [`RichText`], shared by -/// [`crate::intrinsic::RichTextIntrinsic`] and the painter so the box taffy -/// reserves always matches what gets drawn. pub struct RichTextLayout { pub lines: Vec, pub max_width: f32, @@ -157,30 +139,6 @@ pub struct RichTextLayout { } impl RichText { - /// Word-wrap `spans` at `wrap_width` (`None` = unconstrained/natural - /// width). `visible_chars_progress >= 0.0` truncates the content for the - /// typewriter effect (same char-count semantics as `text`); `-1.0` shows - /// everything (used for intrinsic/natural-size measurement, which must - /// not shrink as the typewriter plays out — layout space is reserved for - /// the full content up front). - /// - /// Unlike the original implementation (which only ever broke a line at a - /// span boundary — a single long span never wrapped internally), this - /// tokenizes every span's text into words and packs them greedily across - /// span boundaries, so a long single span wraps like any other text. - /// Whitespace runs collapse to a single rendered space (CSS - /// `white-space: normal` semantics, matching `text`'s default wrap - /// behaviour); the exact inter-word spacing of source whitespace is not - /// preserved, matching how `wrap_text_with_fallback` already treats - /// plain `text` content. - /// - /// `viewport_width`/`viewport_height` resolve `rem`/`vw`/`vh` on - /// `style.font-size` (lot B, wave S — this used to go through the - /// context-free `font_size_px_or`, which silently resolved those units - /// to 0px). Callers with no real per-frame viewport (intrinsic - /// measurement, which runs before layout) should pass a stand-in — see - /// `intrinsic::measure_time_font_size_ctx`'s doc comment for why 0px is - /// worse than an approximation. pub fn compute_layout( spans: &[RichTextSpan], style: &CssStyle, @@ -199,9 +157,6 @@ impl RichText { let span_fonts = resolve_span_fonts(spans, style, default_size); let emoji_tf = emoji_typeface(); - // Typewriter truncation operates on each span's text by char count - // (mirrors `text`'s approach), producing a truncated copy that is - // then tokenized below exactly like the full content would be. let texts: Vec = if visible_chars_progress >= 0.0 { let total_chars: usize = spans.iter().map(|s| s.text.chars().count()).sum(); let visible = @@ -227,11 +182,6 @@ impl RichText { spans.iter().map(|s| s.text.clone()).collect() }; - // Tokenize into words, tracking whether a rendered space separates - // each token from the previous one (within a span: any whitespace - // run; across spans: only if either side's source text had - // whitespace at the boundary — otherwise the spans are "glued", e.g. - // `"Total: "` followed by `"42"` followed by `" items"`). struct Tok { span_idx: usize, text: String, @@ -261,9 +211,6 @@ impl RichText { prev_trailing_ws = text.chars().last().is_none_or(char::is_whitespace); } - // Greedy line packing, mirroring `wrap_text_with_fallback`'s rule: a - // token always fits on an otherwise-empty line, even if it alone - // exceeds `wrap_width` (never split a single word). let effective_wrap = wrap_width.unwrap_or(f32::INFINITY); let mut lines: Vec = vec![RichTextLine { tokens: Vec::new(), @@ -364,12 +311,6 @@ impl RichText { return; } - // Same `default_size` resolution `compute_layout` used above (real - // viewport, same `wrap_width`-derived parent size) — kept as a - // second call rather than threading `span_fonts` back out of - // `RichTextLayout`, but now via the same context-aware accessor so - // the two can no longer diverge on a relative `font-size` the way - // they structurally could before (lot B, wave S). let base_ctx = crate::intrinsic::font_size_ctx( ctx.video_width as f32, ctx.video_height as f32, @@ -457,8 +398,6 @@ mod tests { #[test] fn single_long_span_wraps_into_multiple_lines_at_constrained_width() { - // M2's second ask: line-breaking must not be limited to span - // boundaries — a single long span must wrap word-by-word. let spans = vec![span( "the quick brown fox jumps over the lazy dog and keeps going", )]; @@ -487,10 +426,6 @@ mod tests { #[test] fn spans_glue_without_extra_space_when_source_has_none() { - // "Total: " + "42" + " items" — no extra space should appear between - // "Total:" and "42" beyond the one already in the first span's text, - // and none at all between "42" and " items" beyond the leading space - // already in the third span. let glued = vec![span("Total:"), span("42"), span(" items")]; let s = style(20.0); let layout = RichText::compute_layout(&glued, &s, 1920.0, 1080.0, None, -1.0); @@ -500,12 +435,7 @@ mod tests { tokens.iter().map(|t| t.text.as_str()).collect::>(), vec!["Total:", "42", "items"] ); - // "Total:" is glued directly to "42" (no whitespace at the - // boundary), so token 1 starts exactly where token 0's glyphs end. assert_eq!(tokens[1].x, tokens[0].width, "no space between glued spans"); - // "42" and " items" DO have a boundary space (leading space in the - // third span's source text), so token 2 must start strictly after - // token 1 ends. assert!( tokens[2].x > tokens[1].x + tokens[1].width, "a space must separate '42' and 'items' (source had a leading space)" diff --git a/crates/rustmotion-components/src/shape.rs b/crates/rustmotion-components/src/shape.rs index fc58d762..78b22ae3 100644 --- a/crates/rustmotion-components/src/shape.rs +++ b/crates/rustmotion-components/src/shape.rs @@ -42,15 +42,6 @@ rustmotion_core::impl_traits!(Shape { Styled => style, }); -/// This node's frame clock, reused as the `Scope` for both `stroke. -/// dash_offset` and any `${...}` marker in `path.data` — the same reserved -/// names (`$t`/`$T`/`$duration`/`$W`/`$H`/`$fps`) `CssStyle`'s own `opacity`/ -/// `width`/`height` expressions already resolve against, via -/// `rustmotion_core::css::computed`'s doc. Neither `$i`/`$count`/`vars`/ -/// `node(...)` is answered here: those are resolved as plain text before -/// this component ever deserializes (`for-each`'s own binding pass, at -/// load), so a marker or `dash_offset` naming one of them either arrives -/// already substituted or was never valid in the first place. fn frame_clock(ctx: &PaintCtx) -> FrameClock { FrameClock { t: ctx.time, @@ -78,12 +69,6 @@ fn skia_line_join(join: LineJoin) -> skia_safe::PaintJoin { } } -/// Resolve `stroke.dash_offset` against this frame's `scope`. Absent -/// (`None`) and a failed expression both fall back to `0.0` — the same -/// phase every dashed stroke had before this field existed — rather than -/// aborting the whole shape over one bad phase value; see `render_shape_ -/// text` below for the same swallow-and-degrade convention already used in -/// this file, applied to a different `Result`. fn resolve_dash_offset(offset: &Option>, scope: &dyn Scope) -> f32 { match offset { None => 0.0, @@ -95,44 +80,6 @@ fn resolve_dash_offset(offset: &Option>, scope: &dyn Scope) -> f32 } } -/// Resolve every `${...}` marker in a string into a literal, each marker's -/// contents evaluated as an expression (`rustmotion_core::expr`) against -/// `scope`. Used for both `path.data` (`"M ${...} ${...} L ..."`) and a -/// `fill`/`stroke` hex color built from computed channels -/// (`"#${r|02x}${g|02x}${b|02x}"`) — same mechanism, two string shapes. -/// -/// Why markers, not the whole string as one `Computed`: both -/// `path.data` and a hex color are prose with numbers embedded in them, not -/// a single value, and an expression only ever produces one `f64` — folding -/// the *entire* string as `"= ..."` could at best replace it with one -/// number, never a multi-point path or a `#rrggbb` triplet. `${...}` scopes -/// the expression grammar down to exactly the substrings that are numbers, -/// leaving the surrounding syntax (path-command letters, the `#` and digit -/// grouping) untouched. -/// -/// A marker is `${expr}` (decimal, 4 places — what `path.data`'s -/// coordinates want) or `${expr|02x}` (the value rounded, clamped to -/// `0..=255`, and formatted as a zero-padded lowercase hex byte — what a -/// color channel wants). The `|` splits the two unambiguously: the -/// expression grammar's ternary is the only place it uses `:`, and it never -/// uses `|` at all. Any other format spec is a named failure, not a guess. -/// -/// A `for-each`'s own `$i`/`$count`/item-field substitution -/// (`crate::variables`/`crate::expand`) already ran, textually, over the -/// whole document before this component ever deserialized — so by the time -/// a marker reaches here it holds only arithmetic plus whatever -/// `$t`/`$T`/`$W`/`$H`/`$fps`/`$duration` it still names, which `scope` -/// (this node's `FrameClock`) answers. -/// -/// Returns `Ok(None)` when the string has no marker at all — the common, -/// zero-cost case; the caller keeps using it unchanged. Returns -/// `Ok(Some(resolved))` on success. Returns `Err(())` when a marker is -/// unterminated, its expression fails to parse or evaluate, or its format -/// spec is unrecognized: unlike `resolve_dash_offset`, this is not -/// swallowed to a placeholder, because splicing a wrong value into the -/// *middle* of path or color syntax can silently produce a -/// plausible-looking but wrong result (a stray point, an off-hue facet) -/// rather than an obviously-broken one — the caller skips painting instead. fn resolve_template(s: &str, scope: &dyn Scope) -> std::result::Result, ()> { if !s.contains("${") { return Ok(None); @@ -185,21 +132,12 @@ impl Painter for Shape { let h = layout.height; let corner_radius = self.style.border_radius_px(); - // `resolved_path` only ever holds something when `self.shape` is a - // `ShapeType::Path` whose `data` carries a `${...}` marker — see - // `resolve_template`'s own doc. Every other shape (and a `Path` - // with no marker) leaves it `None` and `shape` below is a plain - // reference to `self.shape`, unchanged from before this existed. let clock = frame_clock(ctx); let mut resolved_path = None; if let ShapeType::Path { data } = &self.shape { match resolve_template(data, &clock) { Ok(Some(resolved)) => resolved_path = Some(ShapeType::Path { data: resolved }), Ok(None) => {} - // A marker didn't parse or evaluate: nothing valid to paint - // this frame rather than a shape built from a half-spliced - // string — see `resolve_template`'s doc for why this is not - // swallowed to a placeholder the way `dash_offset` is. Err(()) => return, } } @@ -207,10 +145,6 @@ impl Painter for Shape { if let Some(fill) = &self.fill { let paint: Option = match fill { - // A `${...}`-marked solid color (e.g. a per-facet - // `"#${r|02x}${g|02x}${b|02x}"` computed from an angle) that - // fails to resolve just skips painting the fill — the - // stroke/text below are independent layers and still paint. Fill::Solid(color) => match resolve_template(color, &clock) { Ok(resolved) => Some(paint_from_hex(resolved.as_deref().unwrap_or(color))), Err(()) => None, @@ -221,10 +155,6 @@ impl Painter for Shape { .iter() .map(|c| color4f_from_hex(c)) .collect(); - // skia asserts `pos.len() == colors.len()` inside the gradient - // shader — a mismatch aborts the process instead of erroring. - // Nothing upstream enforces the pairing, so drop stops we - // cannot honour and let skia distribute the colours evenly. let stops: Option> = gradient .stops .as_ref() @@ -300,11 +230,6 @@ impl Painter for Shape { paint.set_stroke_cap(skia_line_cap(stroke.line_cap)); paint.set_stroke_join(skia_line_join(stroke.line_join)); - // An explicit `dashed` pattern is a deliberate authoring choice - // — honour it (plus its `dash_offset` phase) over the - // `draw_progress` reveal below, which would otherwise overwrite - // the same `Paint::path_effect` slot with its own synthetic - // two-interval dash and silently discard the author's pattern. if let Some(intervals) = stroke.dashed.as_ref().filter(|v| v.len() >= 2) { let phase = resolve_dash_offset(&stroke.dash_offset, &clock); if let Some(dash) = skia_safe::PathEffect::dash(intervals, phase) { @@ -372,12 +297,6 @@ fn render_shape_text( }; let letter_spacing = text.letter_spacing.unwrap_or(0.0); - // Tracking-aware wrap (issue #125 §1): the fit test measures with the - // same `letter_spacing` used below for `line_width`/`draw_text_with_ - // fallback`, so the wrap decision agrees with what's actually painted. - // `ShapeText`'s `font_size`/`letter_spacing`/`line_height` are plain - // `f32` (not `CssStyle`/`Length`), so issue #125 §2's relative-unit gap - // doesn't apply here — there is no unit string to resolve. let lines = wrap_text_with_tracking( &text.content, &font, diff --git a/crates/rustmotion-components/src/skeleton.rs b/crates/rustmotion-components/src/skeleton.rs index ea1f77a5..dedb48ea 100644 --- a/crates/rustmotion-components/src/skeleton.rs +++ b/crates/rustmotion-components/src/skeleton.rs @@ -97,16 +97,13 @@ impl Skeleton { fn draw_shimmer_rect(&self, canvas: &Canvas, rect: Rect, radius: f32, time: f64) { let w = rect.width(); - // Base color let mut base_paint = paint_from_hex(&self.base_color); base_paint.set_style(PaintStyle::Fill); base_paint.set_anti_alias(true); let rrect = skia_safe::RRect::new_rect_xy(rect, radius, radius); canvas.draw_rrect(rrect, &base_paint); - // Shimmer gradient sweep let cycle = (time as f32 / self.speed).fract(); - // The shimmer band sweeps from left to right let shimmer_w = w * 0.4; let shimmer_x = rect.left + (w + shimmer_w) * cycle - shimmer_w; diff --git a/crates/rustmotion-components/src/slider.rs b/crates/rustmotion-components/src/slider.rs index 34a7a9d2..929c7429 100644 --- a/crates/rustmotion-components/src/slider.rs +++ b/crates/rustmotion-components/src/slider.rs @@ -167,11 +167,6 @@ impl From for Slider { } } -/// Half-width of the widest value label ("100%") `paint()` can draw, -/// using its exact `font_size = (thumb_size*0.7).max(12.0)` formula — the -/// thumb can sit centered at either end of the track, so this (or the -/// thumb radius, whichever is larger) is how much horizontal margin the -/// box needs past the track on both sides. fn slider_value_label_half_width(thumb_size: f32) -> Option { let font_size = (thumb_size * 0.7).max(12.0); let typeface = typeface_with_fallback("Inter", skia_safe::FontStyle::normal()).ok()?; @@ -181,8 +176,6 @@ fn slider_value_label_half_width(thumb_size: f32) -> Option { Some(w / 2.0) } -/// Vertical room `paint()`'s value label needs above the thumb (ascent + -/// descent + the 4px gap it's drawn with), using the same font size. fn slider_value_label_line_height(thumb_size: f32) -> Option { let font_size = (thumb_size * 0.7).max(12.0); let typeface = typeface_with_fallback("Inter", skia_safe::FontStyle::normal()).ok()?; @@ -223,14 +216,6 @@ impl Slider { let radius = h / 2.0; let current = self.current_value_at(time).clamp(0.0, 1.0) as f32; - // The track used to be drawn flush with local (0,0) — the thumb - // (radius `thumb_r`, usually bigger than the thin track) and the - // `show_value` label both extend past that origin on every side - // (#127 measured a 396×7 assigned box vs. 417×31 painted). Offset - // everything by the same margins reserved on the `Slider` struct - // (see its `From` impl) so the whole thing — track, - // thumb at either end, and the widest possible "100%" label — sits - // inside `[0, layout.width] x [0, layout.height]`. let h_margin = slider_value_label_half_width(self.thumb_size) .unwrap_or(0.0) .max(thumb_r); @@ -356,10 +341,6 @@ mod tests { #[test] fn box_reserves_room_for_the_thumb_past_the_track() { - // #127: the thumb (`thumb_size`, usually bigger than the thin - // track) can be centered at either end of the track, so it always - // overhangs a box sized only to the track (396×7 assigned vs. - // 417×31 painted in the audit). let s = parse(r#"{"type":"slider","width":396,"height":7,"thumb_size":20}"#); let w = px_width(&s); assert!( diff --git a/crates/rustmotion-components/src/sparkline.rs b/crates/rustmotion-components/src/sparkline.rs index d3b405fa..a45aefda 100644 --- a/crates/rustmotion-components/src/sparkline.rs +++ b/crates/rustmotion-components/src/sparkline.rs @@ -69,13 +69,6 @@ rustmotion_core::impl_traits!(Sparkline { Styled => style, }); -/// `(min, max, normalize)` for a min-max scaled series, matching -/// `chart::line::series_scale`'s flat-series handling: a constant series is -/// centred (`0.5`) instead of collapsing to the bottom edge. Dividing by a -/// `max(0.001)` floor mapped a constant series to 0, so a flat series read -/// as "all zero" instead of "constant at some value". Duplicated locally — -/// `chart::line::series_scale` is `pub(super)` to the `chart` module, not -/// reachable from here. fn series_scale(values: impl Iterator + Clone) -> (f64, f64, impl Fn(f64) -> f32) { let min_val = values.clone().fold(f64::INFINITY, f64::min); let max_val = values.fold(f64::NEG_INFINITY, f64::max); @@ -101,10 +94,6 @@ impl Sparkline { if !self.animated { return 1.0; } - // Ramp measured from `start_at`, not from scene time zero — matches - // `Counter::ramp_progress`. A sparkline delayed with `start_at` used - // to read raw scene time, so it was already fully revealed on the - // very first frame it became visible. let start = self.timing.start_at.unwrap_or(0.0); let elapsed = (time - start).max(0.0); let p = (elapsed / self.animation_duration).clamp(0.0, 1.0) as f32; @@ -146,7 +135,6 @@ impl Sparkline { fill_path.line_to((last_x, h - pad)); fill_path.close(); - // Clip for animation let clip_w = w * progress; canvas.save(); canvas.clip_rect( @@ -155,7 +143,6 @@ impl Sparkline { false, ); - // Gradient fill if self.fill { let (r, g, b, _) = parse_hex_color(&self.color); let top_color = Color::from_argb((self.fill_opacity * 255.0) as u8, r, g, b); @@ -179,7 +166,6 @@ impl Sparkline { } } - // Line stroke let mut line_paint = paint_from_hex(&self.color); line_paint.set_style(PaintStyle::Stroke); line_paint.set_stroke_width(self.stroke_width); @@ -261,11 +247,6 @@ mod tests { #[test] fn a_flat_series_is_centered_not_pinned_to_the_bottom_edge() { - // #7's exact repro: with every value equal, `(val - min_val)` is - // 0 for every point, so the line was drawn on the bottom edge - // (`h - pad`) — reading as "a series of zeroes" instead of "a - // constant series at some value". `chart::line::series_scale` - // was fixed to center a flat series (0.5) for exactly this reason. const H: i32 = 40; let flat = base_sparkline(vec![7.0, 7.0, 7.0, 7.0, 7.0]); let mut surface = skia_safe::surfaces::raster_n32_premul((120, H)).expect("raster surface"); diff --git a/crates/rustmotion-components/src/stat.rs b/crates/rustmotion-components/src/stat.rs index 057325d0..747b37e5 100644 --- a/crates/rustmotion-components/src/stat.rs +++ b/crates/rustmotion-components/src/stat.rs @@ -101,15 +101,6 @@ impl Stat { return; } - // Hard containment: whatever the math below computes, nothing gets - // to paint past the box the layout gave this component (issue - // #127). `stat` used to walk a `y_cursor` from a hardcoded - // `pad = 20.0` — label, then value, then a sparkline with a forced - // `.max(20.0)` minimum height — never once consulting `h`. A short - // box (the audit measured 512×48) still got the same ~120px stack - // every time, overflowing 70+px onto the next flex sibling. The - // clip below is the backstop; the scaling further down is what - // keeps it from ever needing to bite in practice. canvas.save(); canvas.clip_rect( Rect::from_xywh(0.0, 0.0, w, h), @@ -117,7 +108,6 @@ impl Stat { true, ); - // Background if set if let Some(bg) = self.style.background_color_str() { let mut bg_paint = paint_from_hex(bg); bg_paint.set_style(PaintStyle::Fill); @@ -128,12 +118,6 @@ impl Stat { canvas.draw_rrect(rrect, &bg_paint); } - // `pad` scales down with the box instead of a fixed 20px, and the - // label/value font sizes are scaled by `content_scale` so the - // label+value stack actually fits in the room `h` gives them. Both - // stay at their authored size (pad=20, scale=1) whenever the box is - // tall enough — which is every existing example — and only shrink - // for a box shorter than the natural stack (like #127's 512×48). let pad = (h * 0.15).clamp(2.0, 20.0).min(w * 0.15).max(2.0); let content_h = (h - pad * 2.0).max(0.0); @@ -155,7 +139,6 @@ impl Stat { let mut y_cursor = pad; - // Label (top) if let Some(label) = &self.label { let font_style = skia_safe::FontStyle::normal(); let Ok(typeface) = typeface_with_fallback("Inter", font_style) else { @@ -184,7 +167,6 @@ impl Stat { y_cursor += eff_label_fs * 1.5; } - // Value (large) { let font_style = skia_safe::FontStyle::bold(); let Ok(typeface) = typeface_with_fallback("Inter", font_style) else { @@ -211,7 +193,6 @@ impl Stat { &val_paint, ); - // Trend inline after value if let Some(trend) = &self.trend { let val_w = measure_text_with_fallback(&self.value, &font, &emoji_font, 0.0); let trend_fs = eff_value_fs * 0.4; @@ -237,7 +218,6 @@ impl Stat { let mut tx = pad + val_w + 12.0; let ty = vy - eff_value_fs * 0.15 + trend_metrics.ascent * 0.2; - // Draw trend arrow icon let icon_id = match trend.direction { TrendDirection::Up => Some("lucide:trending-up"), TrendDirection::Down => Some("lucide:trending-down"), @@ -318,14 +298,6 @@ impl Stat { y_cursor += eff_value_fs * 1.2; } - // Sparkline (bottom) — only drawn if room is actually left. The - // previous `.max(20.0)` forced a sparkline into existence even when - // the label+value stack had already consumed the whole box, which - // is exactly what pushed ink onto the next flex sibling. `spark_y` - // itself eats 4px of gap below the value line, so that has to come - // out of the room budget too — the original formula measured room - // from `y_cursor`, not from `spark_y`, which let the sparkline's - // own bottom edge land 4px past `h - pad`. let spark_y = y_cursor + 4.0; let spark_room = (h - pad) - spark_y; if self.sparkline_data.len() >= 2 && spark_room >= 8.0 { @@ -334,10 +306,6 @@ impl Stat { let max_v = self.sparkline_data.iter().fold(f64::MIN, |a, &b| a.max(b)); let min_v = self.sparkline_data.iter().fold(f64::MAX, |a, &b| a.min(b)); - // A flat series has no span. Flooring the divisor instead normalises - // every point to 0, which glues the line to the bottom edge and reads - // as "collapsed to zero" rather than "unchanged" — centre it instead, - // matching `chart::line`'s handling of the same case. let span = max_v - min_v; let flat = span.abs() < f64::EPSILON; let n = self.sparkline_data.len(); @@ -364,7 +332,6 @@ impl Stat { fill_path.line_to((pad + spark_w, spark_y + spark_h)); fill_path.close(); - // Gradient fill let (r, g, b, _) = parse_hex_color(spark_color); let top_color = Color::from_argb(50, r, g, b); let bottom_color = Color::from_argb(0, r, g, b); @@ -435,9 +402,6 @@ mod tests { } } - /// Bounding box (min_x, max_x, min_y, max_y) of every non-transparent - /// pixel on the surface, or `None` if nothing was painted. Mirrors the - /// helper `caption.rs` already uses for the same kind of proof. fn ink_bounds( surface: &mut skia_safe::Surface, w: i32, @@ -475,12 +439,6 @@ mod tests { #[test] fn ink_stays_within_a_tiny_assigned_box() { - // #127's exact repro: a 512×48 box (the measured "assigned box" for - // this component in a 560×300 card) used to take ~120px of ink — - // label + value + a forced-minimum sparkline — 72px onto whatever - // sits below it in the flex column. With a solid background (the - // same way the audit made the assigned box visible), the painted - // background rect *is* the box, so no ink should land outside it. let stat = full_stat(); const W: i32 = 512; const H: i32 = 48; @@ -503,8 +461,6 @@ mod tests { #[test] fn ink_stays_within_box_even_without_a_background() { - // Same box, no `style.background` — the clip still has to hold - // even when there's no filled rect to visually anchor it to. let mut stat = full_stat(); stat.style = CssStyle::default(); const W: i32 = 512; @@ -521,10 +477,6 @@ mod tests { #[test] fn generous_box_keeps_the_original_full_size_layout() { - // A box as tall as the existing examples use (e.g. mega-showcase's - // 380×220 stat cards) must not be affected by the new scaling — - // `content_scale` should resolve to 1.0 and the value should still - // render at its authored `value_font_size`. let stat = full_stat(); const W: i32 = 380; const H: i32 = 220; diff --git a/crates/rustmotion-components/src/stepper.rs b/crates/rustmotion-components/src/stepper.rs index ebdcaa4f..db206b89 100644 --- a/crates/rustmotion-components/src/stepper.rs +++ b/crates/rustmotion-components/src/stepper.rs @@ -175,7 +175,6 @@ impl Stepper { }; let cy = r + 4.0; - // Draw connector lines between nodes for i in 0..(n - 1) { let x1 = padding + i as f32 * spacing + r; let x2 = padding + (i + 1) as f32 * spacing - r; @@ -200,41 +199,30 @@ impl Stepper { canvas.draw_line((x1, cy), (x2, cy), &line_paint); } - // Draw nodes and labels for (i, step) in self.steps.iter().enumerate() { let cx = padding + i as f32 * spacing; let step_f = i as f32; let (node_color, is_active) = if step_f < current.floor() { - // Completed (&self.completed_color, false) } else if (step_f - current).abs() < 0.5 { - // Active (&self.active_color, true) } else { - // Pending (&self.pending_color, false) }; - // Node circle let mut node_paint = paint_from_hex(node_color); node_paint.set_style(PaintStyle::Fill); node_paint.set_anti_alias(true); - if is_active { - // Filled circle for active - canvas.draw_circle((cx, cy), r, &node_paint); - } else if step_f < current.floor() { - // Filled circle for completed + if is_active || step_f < current.floor() { canvas.draw_circle((cx, cy), r, &node_paint); } else { - // Ring for pending node_paint.set_style(PaintStyle::Stroke); node_paint.set_stroke_width(2.0); canvas.draw_circle((cx, cy), r - 1.0, &node_paint); } - // Step number inside circle let num_text = format!("{}", i + 1); let num_w = measure_text_with_fallback(&num_text, &number_font, &emoji_number_font, 0.0); @@ -255,7 +243,6 @@ impl Stepper { &num_paint, ); - // Label below circle let label_w = measure_text_with_fallback(&step.label, &label_font, &emoji_label_font, 0.0); let label_x = cx - label_w / 2.0; @@ -275,7 +262,6 @@ impl Stepper { &label_paint, ); - // Description below label if let Some(desc) = &step.description { let desc_w = measure_text_with_fallback(desc, &desc_font, &emoji_desc_font, 0.0); @@ -297,7 +283,6 @@ impl Stepper { } } } else { - // Vertical layout let padding = r + 8.0; let available = h - padding * 2.0; let spacing = if n > 1 { @@ -307,7 +292,6 @@ impl Stepper { }; let cx = r + 4.0; - // Draw connector lines for i in 0..(n - 1) { let y1 = padding + i as f32 * spacing + r; let y2 = padding + (i + 1) as f32 * spacing - r; @@ -332,7 +316,6 @@ impl Stepper { canvas.draw_line((cx, y1), (cx, y2), &line_paint); } - // Draw nodes and labels for (i, step) in self.steps.iter().enumerate() { let cy = padding + i as f32 * spacing; let step_f = i as f32; @@ -357,7 +340,6 @@ impl Stepper { canvas.draw_circle((cx, cy), r - 1.0, &node_paint); } - // Step number let num_text = format!("{}", i + 1); let num_w = measure_text_with_fallback(&num_text, &number_font, &emoji_number_font, 0.0); @@ -378,7 +360,6 @@ impl Stepper { &num_paint, ); - // Label to the right let lx = cx + r + 12.0; let (_, lm) = label_font.metrics(); let ly = cy + (-lm.ascent - lm.descent) / 2.0; @@ -395,7 +376,6 @@ impl Stepper { &label_paint, ); - // Description below label if let Some(desc) = &step.description { let desc_y = ly + label_font_size + 2.0; let mut desc_paint = paint_from_hex("#8B949E"); diff --git a/crates/rustmotion-components/src/success_check.rs b/crates/rustmotion-components/src/success_check.rs index 4a1bb1f8..fd3cf74f 100644 --- a/crates/rustmotion-components/src/success_check.rs +++ b/crates/rustmotion-components/src/success_check.rs @@ -1,11 +1,3 @@ -//! The confirmation mark: a checkmark drawing itself inside a pale halo, -//! arriving with a small pop and a rotation it resolves as it lands. -//! -//! Assembling this out of a `shape` circle, an `svg` with `draw_in` and a -//! `scale_in` is possible, and was the only way before this existed — but the -//! three have to be kept in time with each other by hand, and the mark is the -//! single most repeated beat in a product video. One component, one timeline. - use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use skia_safe::{Canvas, PaintStyle, Path, PathBuilder}; @@ -92,18 +84,13 @@ rustmotion_core::impl_traits!(SuccessCheck { Styled => style, }); -/// Where the mark is in its arrival at a given instant. #[derive(Debug, Clone, Copy, PartialEq)] pub(crate) struct CheckPhase { - /// Eased 0..1 over the whole arrival — drives scale, rotation, opacity. pub arrival: f32, - /// Eased 0..1 over the stroke's own, later window. pub stroke: f32, } impl SuccessCheck { - /// The stroke starts once the halo has mostly landed: a mark that draws - /// itself while still flying in reads as two unrelated animations. const STROKE_START: f64 = 0.35; pub(crate) fn phase_at(&self, time: f64) -> CheckPhase { @@ -115,9 +102,6 @@ impl SuccessCheck { } let raw = ((time - self.delay) / self.duration).clamp(0.0, 1.0); if raw <= 0.0 { - // `ease_out_back(0)` is 0 only up to float residue (~2e-16), and - // "has it started?" is a question this type should answer - // exactly rather than approximately. return CheckPhase { arrival: 0.0, stroke: 0.0, @@ -130,7 +114,6 @@ impl SuccessCheck { } } - /// The checkmark itself, in units of `size`. pub(crate) fn check_path(size: f32) -> Path { let mut path = PathBuilder::new(); path.move_to((0.28 * size, 0.52 * size)); @@ -155,9 +138,6 @@ impl Painter for SuccessCheck { let size = self.size; let centre = size / 2.0; - // Scale up from 72% and unwind the entrance rotation. `ease_out_back` - // already overshoots past 1, so the mark settles by springing back - // rather than by decelerating into place. let scale = 0.72 + 0.28 * phase.arrival; let angle = -18.0 * self.spin * (1.0 - phase.arrival); @@ -167,7 +147,6 @@ impl Painter for SuccessCheck { canvas.rotate(angle, None); canvas.translate((-centre, -centre)); - // Halo if self.ring > 0.0 { let hex = self.ring_color.as_deref().unwrap_or(&self.tint); let (r, g, b, _) = parse_hex_color(hex); @@ -179,8 +158,6 @@ impl Painter for SuccessCheck { canvas.draw_circle((centre, centre), size * 0.5, &halo); } - // The mark, drawn on rather than faded in: a dash whose gap shrinks - // to nothing is what makes it read as being written. let path = Self::check_path(size); let mut stroke = paint_from_hex(&self.tint); stroke.set_style(PaintStyle::Stroke); @@ -225,8 +202,6 @@ mod tests { #[test] fn the_stroke_starts_after_the_halo_has_landed() { - // A mark that writes itself while still flying in reads as two - // animations fighting rather than as one gesture. let c = check(serde_json::json!({ "duration": 1.0 })); let early = c.phase_at(0.2); assert!( @@ -260,8 +235,6 @@ mod tests { #[test] fn the_mark_finishes_upright_whatever_the_spin() { - // `spin` scales the swing on the way in; it must never leave the - // finished mark tilted, or a still frame of the end state is wrong. for spin in [0.0, 1.0, 2.0, 5.0] { let c = check(serde_json::json!({ "spin": spin, "duration": 0.5 })); let settled = c.phase_at(2.0).arrival; diff --git a/crates/rustmotion-components/src/svg.rs b/crates/rustmotion-components/src/svg.rs index 7c86314f..5f487063 100644 --- a/crates/rustmotion-components/src/svg.rs +++ b/crates/rustmotion-components/src/svg.rs @@ -63,18 +63,7 @@ rustmotion_core::impl_traits!(Svg { Styled => style, }); -// ──────────────────────────────────────────────────────────────────────────── -// usvg → Skia path conversion -// ──────────────────────────────────────────────────────────────────────────── - -/// Convert a `tiny_skia::Path` (from usvg) with an `abs_transform` into a -/// `skia_safe::Path`, applying the transform inline. The resulting path is in -/// SVG-space coordinates (pre-layout-scale); callers apply the layout scale via -/// a canvas save/scale. fn tiny_path_to_skia(tsp: &tiny_skia::Path, abs_transform: tiny_skia::Transform) -> Path { - // Build the absolute-transform matrix for Skia. - // tiny_skia::Transform { sx, ky, kx, sy, tx, ty } (column-major) → Skia Matrix: - // new_all(scale_x, skew_x, trans_x, skew_y, scale_y, trans_y, pers0, pers1, pers2) let t = abs_transform; let matrix = Matrix::new_all(t.sx, t.kx, t.tx, t.ky, t.sy, t.ty, 0.0, 0.0, 1.0); @@ -108,8 +97,6 @@ fn tiny_path_to_skia(tsp: &tiny_skia::Path, abs_transform: tiny_skia::Transform) skia_path.detach() } -/// Recursively collect (skia_path, skia_color, stroke_width) for each visible -/// path in the usvg tree. fn collect_paths( group: &usvg::Group, draw_stroke_width: f32, @@ -125,8 +112,6 @@ fn collect_paths( continue; } let skia_path = tiny_path_to_skia(p.data(), p.abs_transform()); - // Determine stroke color and width: prefer the SVG stroke; fall - // back to the fill color with `draw_stroke_width`. let (color, sw) = if let Some(stroke) = p.stroke() { let sw = stroke.width().get(); let c = match stroke.paint() { @@ -134,7 +119,6 @@ fn collect_paths( let alpha = (stroke.opacity().get() * 255.0) as u8; skia_safe::Color::from_argb(alpha, col.red, col.green, col.blue) } - // Gradients/patterns: fall back to white _ => skia_safe::Color::WHITE, }; (c, sw) @@ -152,16 +136,11 @@ fn collect_paths( }; out.push((skia_path, color, sw)); } - // Image, Text and other node kinds are skipped in draw-on mode. _ => {} } } } -/// Recursively collect each visible path's geometry (with its SVG fill rule -/// applied), for use as a reveal mask in `reveal: fill` mode. Color/stroke -/// don't matter here: the mask only gates which pixels of the already -/// fully-painted raster (gradients included) get copied to the canvas. fn collect_paths_for_fill(group: &usvg::Group, out: &mut Vec) { for node in group.children() { match node { @@ -185,12 +164,6 @@ fn collect_paths_for_fill(group: &usvg::Group, out: &mut Vec) { } } -/// Reveal the SVG progressively at `draw_progress` (0..=1) by sweeping a clip -/// mask across each path's full, already fully-painted shape (`full_image`, -/// gradients and all) instead of tracing a stroked contour. Paths are -/// revealed one after another (or with overlap), using the same per-path -/// length-weighted windowing as `paint_draw_on` so the sequential ordering -/// matches the stroke mode. fn paint_fill_reveal( canvas: &Canvas, group: &usvg::Group, @@ -261,8 +234,6 @@ fn paint_fill_reveal( canvas.clip_path(path, None, true); if local_t < 1.0 { - // Sweep left-to-right: reveal a growing slice of this path's own - // bounding box, intersected with the path shape itself above. let bounds = path.bounds(); let revealed_w = bounds.width() * local_t; let sweep = Rect::from_ltrb( @@ -279,8 +250,6 @@ fn paint_fill_reveal( } } -/// Draw the SVG paths progressively at `draw_progress` (0..=1). -/// Uses a dash PathEffect to reveal each path sequentially (or with overlap). fn paint_draw_on( canvas: &Canvas, group: &usvg::Group, @@ -292,7 +261,6 @@ fn paint_draw_on( ) { let progress = progress.clamp(0.0, 1.0); - // Collect all paths with their colors. let mut paths_with_colors: Vec<(Path, skia_safe::Color, f32)> = Vec::new(); collect_paths(group, draw_stroke_width, &mut paths_with_colors); @@ -300,7 +268,6 @@ fn paint_draw_on( return; } - // Scale canvas from SVG coordinate space to layout box dimensions. let scale_x = if svg_size.width() > 0.0 { layout.width / svg_size.width() } else { @@ -315,8 +282,6 @@ fn paint_draw_on( canvas.save(); canvas.scale((scale_x, scale_y)); - // Measure path lengths in SVG space (paths already carry the abs_transform). - // We measure in SVG space, scaling the lengths to account for the canvas scale. let lengths: Vec = paths_with_colors .iter() .map(|(path, _, _)| { @@ -331,19 +296,8 @@ fn paint_draw_on( return; } - // overlap in [0,1]: 0 = sequential, 1 = all parallel. let overlap = draw_overlap.clamp(0.0, 1.0); - // Each path occupies a window [start_fraction, end_fraction] within [0,1]. - // Window size for path i (proportional to its length fraction): - // base_fraction[i] = lengths[i] / total_length - // With overlap: - // window_size[i] = base_fraction[i] + overlap * (1.0 - base_fraction[i]) - // = base_fraction[i] * (1 - overlap) + overlap - // The window start is placed so that at progress=1 all paths are fully drawn: - // start[i] = cumulative_fraction[i] * (1 - overlap) (cumulative before path i) - // end[i] = start[i] + window_size[i] - let mut cumulative = 0.0f32; for ((path, color, sw), length) in paths_with_colors.iter().zip(lengths.iter()) { let base_frac = length / total_length; @@ -351,8 +305,6 @@ fn paint_draw_on( let start_frac = cumulative * (1.0 - overlap); cumulative += base_frac; - // How much of this path is revealed: - // local_t = (progress - start_frac) / window_size, clamped to [0,1] let local_t = if window_size > 0.0 { ((progress - start_frac) / window_size).clamp(0.0, 1.0) } else { @@ -364,7 +316,6 @@ fn paint_draw_on( }; if local_t <= 0.0 { - // Nothing yet for this path. continue; } @@ -378,21 +329,12 @@ fn paint_draw_on( if local_t < 1.0 && draw_len > 0.0 { let remaining = length - draw_len; - // Add a tiny epsilon to avoid gap at exact end. let intervals = [draw_len, remaining + 0.01]; if let Some(dash) = skia_safe::PathEffect::dash(&intervals, 0.0) { paint.set_path_effect(dash); } } - // If local_t == 1.0, draw the full path with no dash effect. - - // Suppress scale effect on stroke width: we applied scale on the canvas, - // so the stroke width would be magnified. Compensate by dividing. - // Actually, skia already does local transform → stroke is in canvas units, - // not SVG units. The canvas is scaled by scale_x/scale_y, so the stroke - // rendered in canvas (pixel) space will be sw * scale_x. We want sw in - // pixel space, so we divide by scale here. - // Use the geometric mean for uniform compensation. + let scale_avg = (scale_x * scale_y).sqrt(); if scale_avg > 0.0 { paint.set_stroke_width(sw / scale_avg); @@ -415,11 +357,9 @@ impl Painter for Svg { let draw_active = self.draw || (props.draw_progress >= 0.0 && props.draw_progress < 1.0); if draw_active { - // Draw-on mode: walk the usvg tree and trace paths progressively. let progress = if props.draw_progress >= 0.0 { props.draw_progress } else { - // draw: true without animation → show complete trace (static) 1.0 }; @@ -446,7 +386,6 @@ impl Painter for Svg { let svg_size = tree.size(); if progress >= 1.0 { - // At completion, fall through to normal resvg render so fills are shown. self.paint_resvg(canvas, layout, &svg_data, &tree, svg_size); } else if self.reveal == SvgReveal::Fill { let Some(full_image) = self.cached_full_image(layout) else { @@ -473,14 +412,12 @@ impl Painter for Svg { ); } } else { - // Normal static mode: use cached resvg rasterization. self.paint_static(canvas, layout); } } } impl Svg { - /// Normal static render via cached resvg bitmap. fn paint_static(&self, canvas: &Canvas, layout: &BoxLayout) { let Some(img) = self.cached_full_image(layout) else { return; @@ -491,10 +428,6 @@ impl Svg { canvas.draw_image_rect(img, None, dst, &paint); } - /// Resolve (and cache) the fully rasterized SVG — fills, gradients and - /// all — at the layout's pixel size. Shared by `paint_static` and the - /// `reveal: fill` draw-on mode, which clips this same raster per path - /// instead of re-deriving flat per-path colors. fn cached_full_image(&self, layout: &BoxLayout) -> Option { let target_w_opt: Option = if layout.width > 0.0 { Some(layout.width as u32) @@ -567,7 +500,6 @@ impl Svg { Some(decoded) } - /// Render via resvg when draw-on completes (progress == 1.0). fn paint_resvg( &self, canvas: &Canvas, @@ -617,7 +549,7 @@ impl Svg { let paint = Paint::default(); canvas.draw_image_rect(img, None, dst, &paint); - let _ = svg_data; // only used to accept the lifetime; tree holds the parsed data + let _ = svg_data; } } @@ -696,9 +628,6 @@ mod tests { #[test] fn fill_reveal_paints_interior_pixels_at_partial_progress() { - // A fully-filled 80x80 rect with no stroke. At draw_progress = 0.5 the - // `fill` reveal mode must show painted interior pixels (a swept solid - // region), not just a thin traced outline. let svg = filled_square_svg(); let layout = test_layout(); let props = AnimatedProperties { @@ -713,9 +642,6 @@ mod tests { svg.paint_content(canvas, &layout, &props, &ctx); } - // x=30 is well inside the rect's left half (revealed at progress 0.5 - // under a left-to-right sweep) and far from the outline; a stroke-only - // trace would leave it fully transparent. let (r, g, b, a) = red_alpha_at(&mut surface, 30, 50); assert!( a > 200 && r > 200 && g < 50 && b < 50, @@ -725,9 +651,6 @@ mod tests { #[test] fn stroke_reveal_default_leaves_interior_unfilled_at_partial_progress() { - // The default `reveal: stroke` behavior must be unchanged: at partial - // draw_progress, only a thin traced outline is visible, so a deep - // interior pixel stays unpainted. let mut svg = filled_square_svg(); svg.reveal = SvgReveal::Stroke; let layout = test_layout(); diff --git a/crates/rustmotion-components/src/switch.rs b/crates/rustmotion-components/src/switch.rs index 067a222c..7a3b55e5 100644 --- a/crates/rustmotion-components/src/switch.rs +++ b/crates/rustmotion-components/src/switch.rs @@ -141,12 +141,6 @@ impl From for Switch { } } -/// Extra width (gap + label text) `paint()` needs past the track — mirrors -/// its `font_size = (h*0.5).max(12.0)` / `text_x = w + 8.0` exactly, so the -/// box reserved here always matches what gets drawn. `None` on font-load -/// failure (e.g. a headless test env with no fonts) — `style.width` is then -/// left unset and `box_builder.rs`'s plain `c.width` fallback applies, same -/// as before this fix. fn switch_label_extra_width(label: &str, height: f32) -> Option { let font_size = (height * 0.5).max(12.0); let typeface = typeface_with_fallback("Inter", skia_safe::FontStyle::normal()).ok()?; @@ -195,14 +189,12 @@ impl Switch { let radius = h / 2.0; let state = self.current_state_at(time) as f32; - // Interpolate track color let track_color = if state > 0.5 { &self.track_color_on } else { &self.track_color_off }; - // Track let mut track_paint = paint_from_hex(track_color); track_paint.set_style(PaintStyle::Fill); track_paint.set_anti_alias(true); @@ -211,7 +203,6 @@ impl Switch { let track_rrect = RRect::new_rect_xy(track_rect, radius, radius); canvas.draw_rrect(track_rrect, &track_paint); - // Thumb let thumb_radius = (h - 4.0) / 2.0; let thumb_x_min = 2.0 + thumb_radius; let thumb_x_max = w - 2.0 - thumb_radius; @@ -223,7 +214,6 @@ impl Switch { thumb_paint.set_anti_alias(true); canvas.draw_circle((thumb_cx, thumb_cy), thumb_radius, &thumb_paint); - // Label if let Some(label) = &self.label { let font_size = (h * 0.5).max(12.0); let font_style = skia_safe::FontStyle::normal(); @@ -278,18 +268,12 @@ mod tests { #[test] fn no_label_leaves_style_width_unset() { - // No extra room needed — box_builder.rs's plain `c.width` fallback - // should still apply, exactly as before this fix. let s = parse(r#"{"type":"switch"}"#); assert!(s.style.width.is_none()); } #[test] fn label_widens_style_width_past_the_track() { - // #127: `paint()` draws the label at `self.width + 8`, entirely - // outside a box sized only for the track (64×34 assigned vs. - // 155×34 painted in the audit). `style.width` must now reserve at - // least `width` (the track) plus *some* label room. let s = parse(r#"{"type":"switch","label":"Dark mode","width":64,"height":34}"#); let SzCheck::Length(rustmotion_core::css::LengthPercentage::Px(w)) = s.style.width.expect("label should reserve style.width") diff --git a/crates/rustmotion-components/src/table.rs b/crates/rustmotion-components/src/table.rs index eaa3c191..5faa9f0c 100644 --- a/crates/rustmotion-components/src/table.rs +++ b/crates/rustmotion-components/src/table.rs @@ -24,7 +24,6 @@ pub enum ColumnAlign { } pub(crate) const DEFAULT_FONT_SIZE: f32 = 14.0; -/// row_height = DEFAULT_FONT_SIZE * 2.5 pub(crate) const DEFAULT_ROW_HEIGHT_RATIO: f32 = 2.5; pub(crate) const DEFAULT_CELL_PADDING: f32 = 12.0; @@ -84,12 +83,6 @@ rustmotion_core::impl_traits!(Table { }); impl Table { - /// `font_size` is resolved once by the caller (`paint`, against a real - /// `LengthContext`) and passed in — this used to be a zero-argument - /// method independently re-deriving the value via the context-free - /// `font_size_px_or` at every call site (`row_height`, `make_font`, - /// `paint` itself), which is exactly the kind of duplicate computation - /// that let a relative unit silently diverge (lot B, wave S). fn row_height(&self, font_size: f32) -> f32 { font_size * 2.5 } @@ -106,18 +99,10 @@ impl Table { Some(skia_safe::Font::from_typeface(typeface, font_size)) } - /// Resolve column widths: explicit widths if provided (padded with an - /// equal share for any column left unspecified); otherwise - /// [`Self::natural_column_widths`] scaled proportionally so the columns - /// still sum to exactly `total_w`, whatever that box actually laid out - /// at (which — since taffy's intrinsic measurement and this painted - /// width can diverge, e.g. a `card` giving the table less room than its - /// natural size — is not always identical to the natural total). fn resolve_column_widths(&self, total_w: f32, font_size: f32) -> Vec { let col_count = self.headers.len().max(1); if let Some(widths) = &self.column_widths { let mut result: Vec = widths.to_vec(); - // Pad with equal-share for missing columns while result.len() < col_count { let remaining = total_w - result.iter().sum::(); let remaining_cols = col_count - result.len(); @@ -135,14 +120,6 @@ impl Table { natural.into_iter().map(|w| w * scale).collect() } - /// Per-column natural width: each column's own header/cell text - /// (measured with the bold header font, matching `paint`'s header row) - /// plus `2 × cell_padding`, indexed like `headers`. Shared by - /// `TableIntrinsic::from_table` (which sums this for the box's natural - /// total width) and `resolve_column_widths` above (which scales it to - /// whatever width the box actually laid out at) — a single source for - /// the per-column distribution keeps the two from drifting apart the - /// way an even split and a content-fitted sum used to. pub(crate) fn natural_column_widths(&self, font_size: f32) -> Vec { let col_count = self.headers.len().max(1); let font_style = skia_safe::FontStyle::bold(); @@ -172,7 +149,6 @@ impl Table { col_widths } - /// Get alignment for a specific column. fn get_align(&self, col: usize) -> &ColumnAlign { self.column_align .as_ref() @@ -180,7 +156,6 @@ impl Table { .unwrap_or(&ColumnAlign::Left) } - /// Compute the x position for text given alignment, cell x, cell width, text width, and padding. fn align_text_x(&self, col: usize, cell_x: f32, cell_w: f32, text_w: f32) -> f32 { let pad = self.cell_padding; match self.get_align(col) { @@ -194,10 +169,6 @@ impl Table { impl Table { fn paint(&self, canvas: &Canvas, layout_w: f32, layout_h: f32, ctx: &PaintCtx) { let w = layout_w; - // Resolved once, against the real per-frame viewport (`rem`/`vw`/ - // `vh` on `font-size` now resolve instead of silently dropping to - // 0px — lot B, wave S) and threaded through every call below that - // used to independently re-derive it via `font_size_px_or`. let font_size = self.style.font_size_px_ctx( &crate::intrinsic::font_size_ctx( ctx.video_width as f32, @@ -215,17 +186,12 @@ impl Table { let text_color = self.style.color_str_or("#FFFFFF"); let header_text_color = self.header_text_color.as_deref().unwrap_or("#FFFFFF"); let default_row_colors = vec!["#1F2937".to_string(), "#111827".to_string()]; - // `"row_colors": []` deserializes to Some(vec![]), not None — a generator - // writes it to mean "no striping". Row painting indexes this slice, so an - // empty one has to fall back rather than reach the painter. let row_colors = self .row_colors .as_ref() .filter(|c| !c.is_empty()) .unwrap_or(&default_row_colors); - // Resolve fonts before the optional clip below so an early return on - // font failure keeps canvas save/restore balanced. let Some(header_font) = self.make_font(true, font_size) else { return; }; @@ -233,7 +199,6 @@ impl Table { return; }; - // Clip to rounded rect if border-radius is set let radius_px = self.style.border_radius_px_or(0.0); let has_radius = radius_px > 0.0; if has_radius { @@ -243,7 +208,6 @@ impl Table { canvas.clip_rrect(rrect, skia_safe::ClipOp::Intersect, true); } - // Header row let emoji_font = emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, font_size)); let (_, header_metrics) = header_font.metrics(); let header_ascent = -header_metrics.ascent; @@ -275,7 +239,6 @@ impl Table { col_x += cw; } - // Data rows let (_, body_metrics) = body_font.metrics(); let body_ascent = -body_metrics.ascent; @@ -285,14 +248,12 @@ impl Table { for (row_idx, row) in self.rows.iter().enumerate() { let y_base = (row_idx + 1) as f32 * row_h; - // Row background let row_color_idx = row_idx % row_colors.len().max(1); let row_bg_color = &row_colors[row_color_idx]; let mut row_bg = paint_from_hex(row_bg_color); row_bg.set_style(PaintStyle::Fill); canvas.draw_rect(Rect::from_xywh(0.0, y_base, w, row_h), &row_bg); - // Cell text let mut cx = 0.0_f32; for (col_idx, cell) in row.iter().enumerate() { if col_idx >= col_count { @@ -317,7 +278,6 @@ impl Table { } } - // Grid lines (only if show_borders is true) if self.show_borders { let mut border_paint = paint_from_hex(border_color); border_paint.set_style(PaintStyle::Stroke); @@ -325,13 +285,11 @@ impl Table { let total_h = (1 + self.rows.len()) as f32 * row_h; - // Horizontal lines for i in 0..=(self.rows.len() + 1) { let y = i as f32 * row_h; canvas.draw_line((0.0, y), (w, y), &border_paint); } - // Vertical lines let mut vx = 0.0_f32; for i in 0..=col_count { canvas.draw_line((vx, 0.0), (vx, total_h), &border_paint); @@ -364,12 +322,8 @@ mod tests { use super::*; use rustmotion_core::css::Length; - // ─── Lot B, wave S: relative `font-size` units ───────────────────────── - #[test] fn rem_font_size_paints_visible_ink() { - // Reproduction: `font-size: "2rem"` used to resolve to 0px via the - // context-free `font_size_px_or`. let table = Table { headers: vec!["A".to_string(), "B".to_string()], rows: vec![vec!["1".to_string(), "2".to_string()]], @@ -422,9 +376,6 @@ mod tests { skia_safe::image::CachingHint::Disallow, ); assert!(ok, "pixel read should succeed"); - // Header text is white (#FFFFFF) on a #374151 header background — - // probe specifically for near-white text ink rather than any lit - // pixel (the header/row backgrounds paint regardless of font-size). let text_ink = buf .as_chunks::<4>() .0 diff --git a/crates/rustmotion-components/src/tag_cloud.rs b/crates/rustmotion-components/src/tag_cloud.rs index d56f61ba..aad10bd9 100644 --- a/crates/rustmotion-components/src/tag_cloud.rs +++ b/crates/rustmotion-components/src/tag_cloud.rs @@ -87,8 +87,6 @@ impl TagCloud { 1.0 - (1.0 - p).powi(3) } - /// Never empty: callers index into it with `%`, and `"colors": []` — which a - /// generator emits to mean "no custom palette" — otherwise divides by zero. fn palette(&self) -> Vec<&str> { match &self.colors { Some(colors) if !colors.is_empty() => colors.iter().map(|s| s.as_str()).collect(), @@ -115,11 +113,9 @@ impl TagCloud { let progress = self.progress_at(time); let palette = self.palette(); - // Collect weights let min_weight = self.tags.iter().map(|t| t.weight).fold(f64::MAX, f64::min); let max_weight = self.tags.iter().map(|t| t.weight).fold(f64::MIN, f64::max); - // Sort tags by weight descending (indices for stagger) let mut sorted_indices: Vec = (0..self.tags.len()).collect(); sorted_indices.sort_by(|&a, &b| { self.tags[b] @@ -132,7 +128,6 @@ impl TagCloud { let h_gap = 12.0_f32; let v_gap = 8.0_f32; - // Pre-compute tag metrics for flow layout struct TagMetrics { index: usize, font_size: f32, @@ -167,7 +162,6 @@ impl TagCloud { }); } - // Flow layout: place tags left-to-right, wrap when exceeding width struct PlacedTag { index: usize, x: f32, @@ -186,7 +180,6 @@ impl TagCloud { let item_width = m.text_width + h_gap; if cursor_x + m.text_width > w && cursor_x > 0.0 { - // Wrap to next line cursor_y += line_max_height + v_gap; cursor_x = 0.0; line_max_height = 0.0; @@ -208,7 +201,6 @@ impl TagCloud { cursor_x += item_width; } - // Compute total content height for vertical centering let total_height = if let Some(last) = placed.last() { last.y + placed.iter().fold(0.0_f32, |max_h, p| { @@ -223,11 +215,9 @@ impl TagCloud { }; let y_offset = ((h - total_height) / 2.0).max(0.0); - // Render each tag for (draw_order, pt) in placed.iter().enumerate() { let tag = &self.tags[pt.index]; - // Staggered opacity animation let tag_alpha = if self.animated { let stagger_delay = (draw_order as f64 / tag_count as f64) * 0.6; let tag_progress = ((time - stagger_delay) / (self.animation_duration * 0.4)) @@ -241,7 +231,6 @@ impl TagCloud { continue; } - // Pick color let color_str = tag .color .as_deref() diff --git a/crates/rustmotion-components/src/text.rs b/crates/rustmotion-components/src/text.rs index 61f317b3..5063c4b9 100644 --- a/crates/rustmotion-components/src/text.rs +++ b/crates/rustmotion-components/src/text.rs @@ -59,8 +59,6 @@ rustmotion_core::impl_traits!(Text { Styled => style, }); -/// Eased progress (0..1) of unit `idx` at `time`, honouring the config's -/// deterministic jitter. fn unit_progress(cfg: &ResolvedCharAnimation, idx: usize, time: f64) -> f32 { let unit_start = cfg.unit_start(idx); let unit_end = unit_start + cfg.duration as f64; @@ -74,11 +72,6 @@ fn unit_progress(cfg: &ResolvedCharAnimation, idx: usize, time: f64) -> f32 { ease(raw_t, &cfg.easing) as f32 } -/// The paint a unit draws with at progress `t`: the base paint, tinted from -/// `ink_from` towards the text's own colour when the config asks for it. -/// -/// `None` means "use the base paint unchanged" — worth keeping distinct from -/// a clone, since the caller may already be mutating its own copy. fn ink_paint(cfg: &ResolvedCharAnimation, paint: &Paint, t: f32) -> Option { let from = cfg.ink_from.as_deref()?; let start = paint_from_hex(from).color(); @@ -94,8 +87,6 @@ fn ink_paint(cfg: &ResolvedCharAnimation, paint: &Paint, t: f32) -> Option { - // 0→(1+overshoot) at 70%, then settle to 1.0 let scale = if overshoot > 0.001 { if t < 0.7 { let p = t / 0.7; @@ -200,7 +181,7 @@ fn apply_text_anim_preset( ); } CharAnimPreset::Bounce => { - let peak = 1.0 + overshoot.max(0.3); // bounce always overshoots, min 0.3 + let peak = 1.0 + overshoot.max(0.3); let scale = if t < 0.5 { t * 2.0 * peak } else { @@ -240,8 +221,6 @@ fn apply_text_anim_preset( ); } CharAnimPreset::SlideUp => { - // Despite the name, the travel axis is `direction`'s to choose — - // `up` (the default) is what the preset has always done. let travel = (1.0 - t) * font_size * 0.8 * cfg.distance; let (dx, dy) = cfg.direction.offset(travel); let mut p = paint.clone(); @@ -258,9 +237,6 @@ fn apply_text_anim_preset( ); } CharAnimPreset::BlurIn => { - // One continuous progress value `t` drives all three - // components at once (blur settle, upward drift, opacity - // ramp) rather than sequencing them as separate effects. let tt = t.clamp(0.0, 1.0); let travel = (1.0 - tt) * font_size * 0.12 * cfg.distance; let (dx, dy) = cfg.direction.offset(travel); @@ -291,7 +267,6 @@ fn apply_text_anim_preset( } } -/// Render text with per-character or per-word animation. fn render_char_animation( canvas: &Canvas, _content: &str, @@ -324,12 +299,10 @@ fn render_char_animation( let line_y = line_idx as f32 * line_height_val + baseline_offset; if is_word_mode { - // Per-word animation: split line into words and spaces let mut cursor_x = line_x; let mut chars = line.chars().peekable(); while chars.peek().is_some() { - // Collect leading spaces let mut spaces = String::new(); while let Some(&c) = chars.peek() { if c.is_whitespace() { @@ -342,14 +315,12 @@ fn render_char_animation( if !spaces.is_empty() { let space_w = measure_text_with_fallback(&spaces, font, emoji_font, letter_spacing); - // Draw spaces without animation draw_text_with_fallback( canvas, &spaces, font, emoji_font, 0.0, cursor_x, line_y, paint, ); cursor_x += space_w; } - // Collect the word let mut word = String::new(); while let Some(&c) = chars.peek() { if c.is_whitespace() { @@ -365,7 +336,6 @@ fn render_char_animation( let word_width = measure_text_with_fallback(&word, font, emoji_font, letter_spacing); - // Calculate animation progress for this word let t = unit_progress(char_anim, global_unit_idx, time); canvas.save(); @@ -391,7 +361,6 @@ fn render_char_animation( global_unit_idx += 1; } } else { - // Per-character animation (original behavior) let mut cursor_x = line_x; for ch in line.chars() { let ch_str = ch.to_string(); @@ -410,10 +379,6 @@ fn render_char_animation( cursor_x, line_y, ch_width, - // Single characters carry no internal tracking, so this is - // 0 by construction — passed explicitly rather than left - // to a default, since the word path above needs the real - // value and the two must not drift apart. 0.0, char_anim, t, @@ -431,18 +396,10 @@ fn render_char_animation( } impl Text { - /// Every label this text can display, in order — `content` followed by - /// each state's. - /// - /// Used for measurement: a box sized for the first label alone would be - /// overrun the moment the text swapped to a longer one, and the geometry - /// validator would have signed off on it. pub fn all_labels(&self) -> impl Iterator { std::iter::once(self.content.as_str()).chain(self.states.iter().map(|s| s.content.as_str())) } - /// The label showing at `time`: the last state whose `at` has passed, or - /// `content` before any of them. fn label_at(&self, time: f64) -> &str { self.states .iter() @@ -451,12 +408,6 @@ impl Text { .unwrap_or(&self.content) } - /// The swap in progress at `time`, if any. - /// - /// `None` when the text declares no `swap` config, even if it declares - /// `states`: the labels then simply cut over at each `at`, which is a - /// legitimate (if abrupt) choice and the one the field's absence asks - /// for. fn active_swap(&self, time: f64) -> Option { let cfg = self.swap.as_ref()?; if cfg.duration <= 0.0 { @@ -490,24 +441,6 @@ impl Text { props: &AnimatedProperties, ctx: &PaintCtx, ) -> Result<()> { - // `font-size` itself, plus `letter-spacing`/`line-height`'s `em`/`%` - // (relative to this element's *own*, just-resolved font-size) are - // now all resolved together against a real `LengthContext` (real - // viewport dims from `ctx`) via `typography_px_ctx`, which re-derives - // the right base between the two steps (lot B, wave S — this used to - // stop at the context-free `font_size_px_or`, so `rem`/`vw`/`vh` - // font-size silently fell back to 0px with only a loud warning). - // - // `em`/`%` *on `font-size` itself* are the one case this still - // doesn't get right: per CSS they're relative to the *parent's* - // actual computed font-size, but `cascade.rs` inherits `font-size` - // down the tree as a raw, unresolved `Length`, not a resolved px - // value (see the module note on `CssStyle::font_size_px_ctx`) — no - // caller here can supply the real cascaded value, so `base_ctx` - // below uses the CSS root default (16px) as the best available - // stand-in. `rem` (always relative to a fixed root, not a per- - // ancestor chain) and `vw`/`vh` (relative to the real viewport, - // available here via `ctx`) do not have this problem. let base_ctx = crate::intrinsic::font_size_ctx( ctx.video_width as f32, ctx.video_height as f32, @@ -515,8 +448,6 @@ impl Text { ); let (mut font_size, mut letter_spacing, mut line_height_val) = self.style.typography_px_ctx(&base_ctx, 48.0); - // Animated color (timeline style-state transitions) overrides the - // static style color. let color = props .color .as_deref() @@ -555,12 +486,6 @@ impl Text { let typeface = typeface_with_fallback(font_family, skia_font_style)?; - // The box's own resolved width — computed here (ahead of the - // `white-space: nowrap` wrap decision below) because `text-autofit` - // needs it as its width-fit target *regardless* of nowrap: a nowrap - // line still shrinks to fit this box once `text-autofit` is on (see - // `CssStyle::text_autofit`'s doc comment), it just never breaks - // across lines while doing it. let nowrap = matches!( self.style.white_space, Some(CssWhiteSpace::Nowrap | CssWhiteSpace::Pre) @@ -574,13 +499,6 @@ impl Text { self.max_width }; - // `text-autofit`: resolve the *actual* font-size/letter-spacing/ - // line-height used for the rest of this function — the identical - // computation `TextIntrinsic::measure` runs for this same node (see - // `resolve_text_autofit`'s doc comment for the parity argument). - // Must happen before `type_ctx`/the final `Font` are built below so - // both reflect the resolved (possibly shrunk) size, not the - // requested one. if matches!(self.style.text_autofit, Some(true)) { let declared_height = content_height.filter(|h| *h > 0.0 && h.is_finite()); let (fs, ls, lh) = crate::intrinsic::resolve_text_autofit( @@ -598,12 +516,6 @@ impl Text { line_height_val = lh; } - // This element's *own* resolved font-size as the `em`/`%` base — - // needed below for `text-shadow` (its blur/offset are relative to - // the shadow owner's own font-size, same rule as letter-spacing/ - // line-height, not the parent-proxy `base_ctx` above). Built from - // the post-autofit `font_size` so a shrunk headline's shadow shrinks - // with it instead of using the pre-shrink em/% base. let type_ctx = rustmotion_core::css::units::LengthContext { font_size, ..base_ctx @@ -614,17 +526,8 @@ impl Text { let mut paint = paint_from_hex(color); paint.set_alpha_f(1.0); - // Use the box width as the wrapping constraint (computed above, as - // `box_width`, ahead of the autofit step). M1: `white-space: - // nowrap|pre` disables wrapping entirely — the line may then exceed - // `layout_width`. That's the point: it makes the property mean - // something, and it's exactly the condition the geometry - // validator's `unwrappable_text_overflow` check (which re-measures - // via `TextIntrinsic::from_text`, now wrap-aware too) assumes the - // renderer can produce. let wrap_width = if nowrap { None } else { box_width }; - // Apply typewriter effect: limit visible characters based on animation progress let label = self.label_at(time); let content = if props.visible_chars_progress >= 0.0 { let chars: Vec = label.chars().collect(); @@ -633,20 +536,11 @@ impl Text { if visible == 0 && self.caret.is_none() { return Ok(()); } - // With a caret, an empty reveal still has something to paint: the - // caret itself, sitting where the first character is about to - // appear. Bailing out here would make it pop into existence - // alongside that character instead of waiting for it. chars[..visible].iter().collect::() } else { label.to_string() }; - // Tracking-aware wrap (issue #125 §1): the fit test now measures - // with this element's real `letter_spacing`, matching the - // measurements below (`align_width`, per-line `advance_width`) that - // already used it — the box this wraps for and the pixels painted - // into it now agree. let lines = wrap_text_with_tracking(&content, &font, &emoji_font, wrap_width, letter_spacing); let (_, metrics) = font.metrics(); @@ -654,9 +548,6 @@ impl Text { let descent = metrics.descent; let baseline_offset = (line_height_val + ascent - descent) / 2.0; - // Prepare optional shadow and stroke paints. The component-level - // `text-shadow` field wins; otherwise the CSS `style.text-shadow` - // list is bridged (it used to be parsed and silently dropped). let shadows: Vec = if let Some(s) = &self.text_shadow { vec![s.clone()] } else if let Some(list) = &self.style.text_shadow { @@ -689,7 +580,6 @@ impl Text { p }); - // Compute alignment width let align_width = if layout_width.is_finite() && layout_width > 0.0 { layout_width } else { @@ -701,10 +591,6 @@ impl Text { max_w }; - // Per-character animation mode (via style.animation char_* presets). - // All seven presets — `char_blur_in` included since it was routed - // through `extract_effects` like its siblings — arrive here already - // resolved, with container-level stagger folded into `delay`. if let Some(ref resolved) = props.char_animation { render_char_animation( canvas, @@ -724,10 +610,6 @@ impl Text { return Ok(()); } - // A state swap has two labels on screen at once, each with its own - // travel, blur and opacity. Everything above — font, alignment, - // metrics, decorations — is shared between them; only the label text - // and the motion differ. if let Some(swap) = self.active_swap(time) { for (label, offset_y, blur, alpha) in swap.labels() { let label_lines = @@ -781,8 +663,6 @@ impl Text { 0.0, ); - // The caret rides the reveal head: the end of the last line that has - // been revealed so far, which is where the next character will land. if let Some(caret) = &self.caret { let done = props.visible_chars_progress < 0.0 || props.visible_chars_progress >= 1.0; if !(done && caret.hide_when_done) { @@ -812,12 +692,6 @@ impl Text { } } -/// Draw already-wrapped `lines` with their background, shadows, stroke and -/// fill, shifted down by `offset_y`. -/// -/// Shared by the plain draw and by each half of a state swap, so a swapping -/// label keeps the decorations (`text-background`, `text-shadow`, `stroke`) -/// the same text has when it is not swapping. #[allow(clippy::too_many_arguments)] fn draw_text_lines( canvas: &Canvas, @@ -849,7 +723,6 @@ fn draw_text_lines( }; let y = i as f32 * line_height_val + baseline_offset + offset_y; - // Draw background highlight behind text if let Some(bg) = text_background { let bg_paint = paint_from_hex(&bg.color); let (_, font_rect) = font.measure_str(line, None); @@ -868,7 +741,6 @@ fn draw_text_lines( } } - // Draw shadows — reverse order so the first CSS shadow ends on top. for (sp, ox, oy) in shadow_paints.iter().rev() { draw_text_with_fallback( canvas, @@ -889,8 +761,6 @@ fn draw_text_lines( } } -/// A state swap in progress: the label leaving, the label arriving, and how -/// far through the crossing we are. struct ActiveSwap { from: String, to: String, @@ -900,11 +770,6 @@ struct ActiveSwap { } impl ActiveSwap { - /// `(label, offset_y, blur_sigma, alpha)` for each of the two labels. - /// - /// The outgoing label leaves upwards and the incoming one arrives from - /// below, so the pair reads as one value moving up a slot rather than as - /// two labels passing each other. fn labels(&self) -> [(String, f32, f32, f32); 2] { let p = self.progress.clamp(0.0, 1.0); [ @@ -924,7 +789,6 @@ impl ActiveSwap { } } -/// Paint a caret whose left edge sits at `x`, aligned to the text `baseline`. fn draw_caret( canvas: &Canvas, cfg: &CaretConfig, @@ -934,9 +798,6 @@ fn draw_caret( text_paint: &Paint, time: f64, ) { - // A blink is a square wave over one full period, so `blink: 1.0` reads as - // "on for half a second, off for half a second" rather than as a rate - // nobody can predict from the number. if cfg.blink > 0.0 { let phase = (time / cfg.blink as f64).rem_euclid(1.0); if phase >= 0.5 { @@ -950,10 +811,7 @@ fn draw_caret( let size = font.size(); let (width, gap) = match cfg.shape { - // Proportional to the type size: a 3px rule that reads as a caret at - // 24px is a hairline at 120px. CaretShape::Line => ((size * 0.07).max(1.5), size * 0.05), - // Roughly one character cell, the terminal look. CaretShape::Block => (size * 0.55, size * 0.04), }; @@ -963,8 +821,6 @@ fn draw_caret( }; paint.set_style(PaintStyle::Fill); paint.set_anti_alias(true); - // A caret is a solid mark, not a ghost: it must not inherit a stroke or - // image filter the text set up for itself. paint.set_image_filter(None); canvas.draw_rect( @@ -981,11 +837,6 @@ impl Painter for Text { props: &AnimatedProperties, ctx: &PaintCtx, ) { - // `text-autofit`'s height-fit target: the box's own content-box - // height, exactly as taffy resolved it for this frame's layout — - // `None` when it isn't a positive, finite number (an intrinsically- - // sized box that grew to fit its content, i.e. nothing to shrink - // for on this axis; see `CssStyle::text_autofit`'s doc comment). let (_, _, _, content_height) = layout.content_box(); let content_height = (content_height > 0.0 && content_height.is_finite()).then_some(content_height); @@ -1039,8 +890,6 @@ mod tests { } } - /// Reads back the full alpha channel of the surface as a `width × height` - /// row-major byte grid. fn alpha_grid(surface: &mut skia_safe::Surface, width: i32, height: i32) -> Vec { let snapshot = surface.image_snapshot(); let info = skia_safe::ImageInfo::new( @@ -1063,9 +912,6 @@ mod tests { .collect() } - /// Does any pixel in `[x0, x1) × [y0, y1)` have non-zero alpha? Scanning - /// a region rather than a single exact pixel avoids flaking on the gap - /// between two glyphs or on a space character. fn has_ink_in(grid: &[u8], surface_width: i32, x0: i32, x1: i32, y0: i32, y1: i32) -> bool { for y in y0..y1 { for x in x0..x1 { @@ -1079,9 +925,6 @@ mod tests { #[test] fn nowrap_paints_a_single_line_past_the_layout_width() { - // M1 render-level proof: a `white-space: nowrap` line stays on one - // line and its glyphs visibly extend past `layout_width` — the box - // it was allocated. let text = make_text( "the quick brown fox jumps over the lazy dog", Some(CssWhiteSpace::Nowrap), @@ -1096,16 +939,11 @@ mod tests { .expect("paint succeeds"); let grid = alpha_grid(&mut surface, W, H); - // Far past the 80px box, on the first line's height band: nowrap - // must have painted ink there (the line didn't break at 80px). assert!( has_ink_in(&grid, W, 300, W, 0, 45), "nowrap text must paint past its 80px box on line 1 (scanned x∈[300,600), y∈[0,45))" ); - // Nothing should be on a *second* line — nowrap never word-wraps - // (only literal newlines would start a new line, and there are - // none here), so all ink stays within the first line's height band. assert!( !has_ink_in(&grid, W, 0, W, 55, H), "nowrap text must stay on a single line; found ink on what would be line 2" @@ -1114,8 +952,6 @@ mod tests { #[test] fn normal_white_space_wraps_within_the_layout_width() { - // Contrast case: default wrapping keeps ink within the box on the - // first line, and instead spills onto additional lines below. let text = make_text("the quick brown fox jumps over the lazy dog", None); const W: i32 = 600; const H: i32 = 200; @@ -1137,15 +973,8 @@ mod tests { ); } - // ─── Lot B, wave S: relative `font-size` units ───────────────────────── - #[test] fn rem_font_size_paints_visible_ink() { - // Reproduction: `font-size: "2rem"` used to resolve to 0px (the - // context-free `font_size_px_or` cannot resolve `rem`), so - // `TextIntrinsic` measured a 0-height box and `paint_pass`'s - // `height <= 0.0` guard skipped painting this node entirely — - // `validate` reported success with only a warning. let text = Text { content: "HELLO".into(), max_width: None, @@ -1174,8 +1003,6 @@ mod tests { .expect("paint succeeds"); let grid = alpha_grid(&mut surface, W, H); - // 2rem against the 16px CSS root default = 32px — comfortably tall - // enough to show up in the first 60 rows. assert!( has_ink_in(&grid, W, 0, W, 0, 60), "font-size: 2rem must paint visible ink (32px glyphs), got none" @@ -1184,9 +1011,6 @@ mod tests { #[test] fn vh_font_size_paints_visible_ink_scaled_to_the_real_viewport() { - // `vh` needs the real per-frame viewport (`ctx.video_height`), not - // just a fixed root size — a different resolution path from `rem`. - // `test_ctx()` sets `video_height: 200`, so `20vh` = 40px. let text = Text { content: "HI".into(), max_width: None, @@ -1221,13 +1045,6 @@ mod tests { ); } - // ─── char_blur_in ─────────────────────────────────────────────────── - - /// Fraction of "inked" pixels (alpha > 0) in the region that are - /// partially transparent (0 < alpha < 250) rather than solid. A sharp - /// glyph is mostly solid fill with a thin antialiased edge, so this - /// fraction is low. A heavily blurred glyph is a soft gradient - /// wherever it has any ink at all, so this fraction is high. fn soft_pixel_fraction( grid: &[u8], surface_width: i32, @@ -1255,10 +1072,6 @@ mod tests { soft as f32 / inked as f32 } - /// Resolve `style.animation` the way the engine does before it paints, so - /// a char-animation test exercises the real wiring - /// (`extract_effects` → `props.char_animation`) instead of a - /// painter-private lookup. fn props_for(text: &Text) -> AnimatedProperties { AnimatedProperties { char_animation: rustmotion_core::engine::animator::extract_effects( @@ -1269,9 +1082,6 @@ mod tests { } } - /// Build the same `Font` the renderer would build for `family`/`px`, so - /// tests can measure exact word boundaries instead of guessing pixel - /// coordinates. fn inter_font(px: f32) -> Font { let style = FontStyle::new( skia_safe::font_style::Weight::NORMAL, @@ -1284,10 +1094,6 @@ mod tests { #[test] fn char_blur_in_word_is_blurred_mid_reveal_and_sharp_when_settled() { - // Render-level proof that char_blur_in actually blurs: a single - // word must read as measurably softer mid-reveal than once - // settled. Also exercises the DEFAULT_CHAR_BLUR_SIGMA fallback - // (`blur: None`). let mut text = make_text("BLUR", None); text.style.font_size = Some(Length::Px(100.0)); text.style.white_space = Some(CssWhiteSpace::Nowrap); @@ -1316,8 +1122,8 @@ mod tests { alpha_grid(&mut surface, W, H) }; - let early = render_at(0.15); // raw progress 0.15/0.5 = 0.3 into the reveal - let settled = render_at(1.0); // long past duration: sigma → 0, alpha → 1 + let early = render_at(0.15); + let settled = render_at(1.0); assert!( has_ink_in(&early, W, 0, W, 0, H), @@ -1341,23 +1147,8 @@ mod tests { #[test] fn char_blur_in_whitespace_gap_stays_empty_while_words_animate() { - // The word-mode char path draws inter-word whitespace unanimated - // at full opacity (see render_char_animation) — harmless for the - // six existing opacity-only presets since a space glyph has no - // ink. Pin down that this holds for char_blur_in too: each word's - // blur filter is scoped to that word's own draw call, so it must - // never smear ink into the gap between words. - // - // Note this is *not* the same as "a blurred word's own halo never - // reaches near the gap" — a Gaussian blur legitimately spreads a - // word's own ink a few sigma past its sharp glyph edge, which is - // correct behaviour, not smearing into whitespace. So this checks - // the gap's true center (rendering evidence: crates/.../issue-118 - // render proof measured the same distinction against real render - // output — a word's halo fades to background well within a third - // of a multi-space gap). const FONT_PX: f32 = 90.0; - let mut text = make_text("FIRST SECOND", None); // 15 spaces + let mut text = make_text("FIRST SECOND", None); text.style.font_size = Some(Length::Px(FONT_PX)); text.style.white_space = Some(CssWhiteSpace::Nowrap); text.style.animation = vec![AnimationEffect::CharBlurIn(CharAnimationTiming { @@ -1404,9 +1195,6 @@ mod tests { #[test] fn char_blur_in_honors_word_delay_and_stagger() { - // With a stagger larger than the per-word duration, word 2 must - // not have started at all while word 1 is mid-reveal, and nothing - // should paint before `delay` has elapsed either. const FONT_PX: f32 = 90.0; let mut text = make_text("ONE TWO", None); text.style.font_size = Some(Length::Px(FONT_PX)); @@ -1428,7 +1216,6 @@ mod tests { let font = inter_font(FONT_PX); let word1_end = measure_text_with_fallback("ONE", &font, &None, 0.0) as i32; - // Before `delay`, nothing should paint at all. let mut before = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); { let canvas = before.canvas(); @@ -1441,8 +1228,6 @@ mod tests { "nothing should paint before `delay` has elapsed" ); - // t=0.65s: word 1's local progress is (0.65-0.5)/0.3 = 0.5 (mid - // reveal), word 2 only starts at delay+stagger=1.1s. let mut mid = skia_safe::surfaces::raster_n32_premul((W, H)).expect("raster surface"); { let canvas = mid.canvas(); @@ -1454,18 +1239,12 @@ mod tests { has_ink_in(&mid_grid, W, 0, word1_end, 0, H), "word 1 should show ink by t=0.65 (mid-reveal)" ); - // Leave enough clearance past word 1's sharp-edge measurement for - // its *own* Gaussian halo (a real, expected effect of blurring - // that word — see the analogous note in the whitespace-gap test - // above), so this only catches an actual word-2 leak. assert!( !has_ink_in(&mid_grid, W, word1_end + 70, W, 0, H), "word 2 (starts at delay+stagger=1.1s) must still be fully invisible at t=0.65" ); } - // ─── text-autofit ─────────────────────────────────────────────────── - fn autofit_text(content: &str, font_size: f32, white_space: Option) -> Text { let mut t = make_text(content, white_space); t.style.font_size = Some(Length::Px(font_size)); @@ -1473,8 +1252,6 @@ mod tests { t } - /// Rightmost painted column across the whole surface — the horizontal - /// extent of whatever ink was actually drawn. fn max_ink_x(grid: &[u8], surface_width: i32, height: i32) -> Option { let mut max_x: Option = None; for y in 0..height { @@ -1488,8 +1265,6 @@ mod tests { max_x } - /// Bottommost painted row across the whole surface — the vertical - /// extent of whatever ink was actually drawn. fn max_ink_y(grid: &[u8], surface_width: i32, height: i32) -> Option { for y in (0..height).rev() { for x in 0..surface_width { @@ -1503,24 +1278,11 @@ mod tests { #[test] fn measure_and_paint_agree_on_a_shrunk_nowrap_line() { - // Trap #1 — the one the brief calls the only one that can ruin this - // work: `TextIntrinsic::measure` and `Text::paint` must resolve to - // the *same* font size for the same node, or the box the layout - // engine reserves stops matching what actually gets painted. Both - // delegate to `resolve_text_autofit` with identical inputs (see its - // doc comment); this proves that agreement operationally, on the - // real render path, not by re-deriving the expected size by hand - // (which would only test this test's own arithmetic). let text = autofit_text( "the quick brown fox jumps over the lazy dog", 90.0, Some(CssWhiteSpace::Nowrap), ); - // 300px comfortably clears this sentence's floor-fit width (~252px - // — this string never reads shorter than the calibrated legibility - // floor allows), so the box is reachable by shrinking alone, - // distinct from the separate floor-behaviour tests in - // `intrinsic.rs`. const BOX_W: f32 = 300.0; const BOX_H: f32 = 60.0; @@ -1531,8 +1293,6 @@ mod tests { AvailableSpace::Definite(BOX_H), ), ); - // Sanity: at 90px this line would never fit a 300px box unshrunk — - // proves the shrink path is actually exercised here. assert!( measured_w <= BOX_W + 0.5, "TextIntrinsic itself must report a fit once autofit is on, got {measured_w}" @@ -1567,9 +1327,6 @@ mod tests { #[test] fn measure_and_paint_agree_on_a_shrunk_wrapped_paragraph_height() { - // Same agreement proof as above, on the height axis with wrapping - // on: a paragraph whose box has an explicit height too short for - // its natural (unshrunk) line count. let text = autofit_text( "the quick brown fox jumps over the lazy dog and then keeps going for quite a while longer", 60.0, @@ -1612,9 +1369,6 @@ mod tests { #[test] fn autofit_size_is_stable_across_frames_for_fixed_content() { - // Trap #2: nothing in the resolution may depend on `ctx.time` for - // fixed content — rendering it at two different times, same box, - // must be byte-identical. let text = autofit_text( "the quick brown fox jumps over the lazy dog", 90.0, @@ -1647,14 +1401,6 @@ mod tests { #[test] fn autofit_size_does_not_drift_during_a_typewriter_reveal() { - // Trap #2's named example: a typewriter reveal - // (`visible_chars_progress`) must not make the resolved font size - // drift as more characters become visible — `resolve_text_autofit` - // is always fed the full, untruncated content, never the - // reveal-in-progress view (see its doc comment). Proof: the line's - // vertical footprint (driven by line-height, hence font size) must - // be identical at 30% and 100% reveal, even though the horizontal - // extent legitimately differs (fewer glyphs are visible yet). let text = autofit_text( "the quick brown fox jumps over the lazy dog", 90.0, @@ -1690,9 +1436,6 @@ mod tests { as the typewriter reveal progresses: early={early_bottom}, full={full_bottom}" ); - // And the visible width at 30% must be meaningfully narrower than - // the full line — otherwise this test would not actually be - // exercising a partial reveal at all. let early_right = max_ink_x(&early, W, H).expect("some ink at 30% reveal"); let full_right = max_ink_x(&full, W, H).expect("some ink at full reveal"); assert!( @@ -1704,11 +1447,6 @@ mod tests { #[test] fn without_text_autofit_nowrap_still_bleeds_past_the_box_exactly_as_before() { - // Backward compatibility: a scenario that does not declare - // `text-autofit` must render exactly as it did before this feature - // existed, even now that `content_height` is threaded through — - // the render-level twin of - // `intrinsic::tests::text_intrinsic_ignores_autofit_target_when_the_flag_is_off`. let text = make_text( "the quick brown fox jumps over the lazy dog", Some(CssWhiteSpace::Nowrap), @@ -1728,8 +1466,6 @@ mod tests { ); } - // ─── Text state swap ────────────────────────────────────────────────────── - fn swapping_text(swap: Option) -> Text { let mut text = make_text("Saving draft", Some(CssWhiteSpace::Nowrap)); text.style.font_size = Some(Length::Px(48.0)); @@ -1761,8 +1497,6 @@ mod tests { #[test] fn states_cut_over_at_their_own_time_without_a_swap_config() { - // `states` on its own is a hard cut: abrupt, but it is exactly what - // omitting `swap` asks for, and it must not silently animate. let text = swapping_text(None); let before = render_plain(&text, 0.5); let after = render_plain(&text, 1.5); @@ -1773,8 +1507,6 @@ mod tests { "\"Saving draft\" should be visibly wider than \"Saved\" — the label must actually \ have changed at t=1.0" ); - // A cut has exactly one label on screen at each instant, so the frame - // right after the boundary equals the settled one. assert_eq!( render_plain(&text, 1.01), render_plain(&text, 1.5), @@ -1786,9 +1518,6 @@ mod tests { fn a_swap_puts_both_labels_on_screen_at_once() { let text = swapping_text(Some(TextSwapConfig::default())); - // Just before, only the outgoing label; mid-window, both; well after, - // only the incoming one. "Both" shows up as ink covering more rows - // than either label alone occupies, since they are offset vertically. let rows_with_ink = |time: f64| -> usize { let grid = render_plain(&text, time); (0..CARET_H) @@ -1809,9 +1538,6 @@ mod tests { fn a_finished_swap_settles_on_the_incoming_label_alone() { let swapped = swapping_text(Some(TextSwapConfig::default())); let cut = swapping_text(None); - // Past `at + duration` the animated version must be indistinguishable - // from a plain cut — no residual offset, blur or ghost of the old - // label parked behind the new one. assert_eq!( render_plain(&swapped, 2.0), render_plain(&cut, 2.0), @@ -1821,9 +1547,6 @@ mod tests { #[test] fn the_box_is_measured_for_the_widest_label_not_the_first() { - // A box sized for "Saved" would be overrun the instant the text - // swapped back to "Saving draft" — and the geometry validator, which - // measures through this same intrinsic, would have signed off on it. let mut short_first = make_text("Saved", Some(CssWhiteSpace::Nowrap)); short_first.states = vec![TextState { at: 1.0, @@ -1849,8 +1572,6 @@ mod tests { ); } - // ─── Typewriter caret ───────────────────────────────────────────────────── - const CARET_W: i32 = 900; const CARET_H: i32 = 160; @@ -1861,7 +1582,6 @@ mod tests { text } - /// Render a `visible_chars_progress` reveal at `progress`, at `time`. fn render_reveal(text: &Text, progress: f32, time: f64) -> Vec { let props = AnimatedProperties { visible_chars_progress: progress, @@ -1879,17 +1599,12 @@ mod tests { #[test] fn the_caret_follows_the_reveal_head_instead_of_standing_still() { - // The whole reason this is a field on `text` rather than a separate - // `cursor` component placed next to it: a hand-placed caret stays - // put while the text grows out from under it. let text = typewriter_text(Some(CaretConfig { blink: 0.0, ..Default::default() })); let plain = typewriter_text(None); - // Rightmost ink, with and without the caret: the difference is the - // caret's own position. let caret_x = |progress: f32| -> i32 { let with = render_reveal(&text, progress, 0.0); let without = render_reveal(&plain, progress, 0.0); @@ -1922,9 +1637,6 @@ mod tests { let right_edge = |grid: &[u8]| max_ink_x(grid, CARET_W, CARET_H).unwrap_or(0); let baseline = right_edge(&render_reveal(&plain, 0.5, 0.0)); - // First half of the period: caret visible, so ink extends past the - // text. Second half: it must be gone, i.e. back to the text's own - // right edge. let on = right_edge(&render_reveal(&text, 0.5, 0.1)); let off = right_edge(&render_reveal(&text, 0.5, 0.6)); assert!(on > baseline, "caret should be visible at phase 0.1"); @@ -1965,9 +1677,6 @@ mod tests { #[test] fn the_caret_is_there_before_the_first_character_is() { - // At 0% reveal there is no text yet, but a typewriter that starts - // with a blank frame and pops both caret and first letter together - // reads as a glitch rather than as typing. let text = typewriter_text(Some(CaretConfig { blink: 0.0, ..Default::default() @@ -1979,12 +1688,9 @@ mod tests { ); } - // ─── Char-animation tuning (direction / distance / scale_from / ink_from) ── - const TUNING_W: i32 = 520; const TUNING_H: i32 = 360; - /// A single 90px word carrying `timing` as a `char_slide_up` effect. fn tuned_slide_up(timing: CharAnimationTiming) -> Text { let mut text = make_text("GO", None); text.style.font_size = Some(Length::Px(90.0)); @@ -2011,7 +1717,6 @@ mod tests { alpha_grid(&mut surface, TUNING_W, TUNING_H) } - /// Topmost inked row, i.e. how high on the canvas the glyphs sit. fn min_ink_y(grid: &[u8], surface_width: i32, height: i32) -> Option { (0..height) .find(|&y| (0..surface_width).any(|x| grid[(y * surface_width + x) as usize] > 0)) @@ -2019,10 +1724,6 @@ mod tests { #[test] fn direction_down_starts_the_unit_above_its_line_instead_of_below() { - // `char_slide_up` used to hardcode a downward starting offset. With - // `direction: "down"` the same preset has to start *above* the line - // and fall — the "letters cascading from the top" look. Sampled - // mid-travel, where the two are furthest apart. let base = || CharAnimationTiming { duration: 1.0, stagger: 0.0, @@ -2072,8 +1773,6 @@ mod tests { ..base() }); - // Same instant, same preset: the only difference is how far each has - // left to travel, which shows up as how far below its line it sits. let close_top = min_ink_y(&render_alpha(&close, 0.5), TUNING_W, TUNING_H).expect("close word paints"); let far_top = @@ -2088,9 +1787,6 @@ mod tests { #[test] fn settled_units_land_in_the_same_place_whatever_the_direction_and_distance() { - // Whatever route it took, a unit's resting position is its laid-out - // one — otherwise the tuning knobs would silently move the finished - // frame, which is the frame that has to match the layout. let settled = |timing: CharAnimationTiming| { let grid = render_alpha(&tuned_slide_up(timing), 5.0); min_ink_y(&grid, TUNING_W, TUNING_H).expect("settled word paints") @@ -2132,7 +1828,6 @@ mod tests { stagger: 0.0, granularity: TextAnimGranularity::Word, easing: EasingType::Linear, - // Isolate the scale: no travel to move the ink around. distance: Some(0.0), scale_from: Some(0.5), ..Default::default() @@ -2164,8 +1859,6 @@ mod tests { #[test] fn ink_from_starts_at_the_given_colour_and_settles_to_the_texts_own() { - // `char_scale_in` is the one preset that leaves alpha alone, so the - // measurement reads the colour ramp instead of a fade. let mut text = make_text("INK", None); text.style.font_size = Some(Length::Px(90.0)); text.style.white_space = Some(CssWhiteSpace::Nowrap); @@ -2176,12 +1869,10 @@ mod tests { granularity: TextAnimGranularity::Word, easing: EasingType::Linear, overshoot: Some(0.0), - // Pure red start → the green channel is the whole measurement. ink_from: Some("#FF0000".into()), ..Default::default() })]; - // Mean green over inked pixels: 0 at pure red, 255 once white. let mean_green = |time: f64| -> f32 { let mut surface = skia_safe::surfaces::raster_n32_premul((TUNING_W, TUNING_H)) .expect("raster surface"); @@ -2212,12 +1903,6 @@ mod tests { skia_safe::IPoint::new(0, 0), skia_safe::image::CachingHint::Disallow, )); - // Weight by alpha rather than requiring `alpha == 255`: the fill - // colour is uniform across a unit regardless of AA coverage, so - // this reads the same value it would with an opaque-only filter - // — but it stays correct once `char_scale_in` has shrunk the - // glyph enough (early in its ramp) that no single pixel is fully - // covered. let (weighted, alpha_sum) = (0..(TUNING_W * TUNING_H) as usize).fold((0u64, 0u64), |(s, a), i| { let alpha = buf[i * 4 + 3] as u64; diff --git a/crates/rustmotion-components/src/timeline.rs b/crates/rustmotion-components/src/timeline.rs index 5dab7590..b2258f7a 100644 --- a/crates/rustmotion-components/src/timeline.rs +++ b/crates/rustmotion-components/src/timeline.rs @@ -194,16 +194,8 @@ impl Timeline { ascent: f32, ) { let total_w = self.width; - let bar_y = r; // Center of nodes - - // Node 0 used to sit at local x=0 and node n-1 at x=total_w, so - // their circles (radius `r`) bled `r` px past both edges of the - // assigned box — and further still wherever a step's label was - // wider than the node itself (issue #127 measured ink starting - // 33px left of the box, 9px outside the card). Insetting every - // node by `max(r, widest_label / 2)` keeps both the circles and - // their centered labels inside `[0, total_w]` regardless of which - // step has the longest label. + let bar_y = r; + let max_label_half_w = self .steps .iter() @@ -217,7 +209,6 @@ impl Timeline { 0.0 }; - // Draw background bar let bar_rect = Rect::from_xywh(0.0, bar_y - self.bar_height / 2.0, total_w, self.bar_height); let mut bar_paint = paint_from_hex(&self.bar_color); @@ -229,7 +220,6 @@ impl Timeline { &bar_paint, ); - // Draw filled bar if fill_progress > 0.001 { let fill_w = total_w * fill_progress.clamp(0.0, 1.0); let fill_rect = @@ -251,7 +241,6 @@ impl Timeline { canvas.restore(); } - // Draw nodes and labels for (i, step) in self.steps.iter().enumerate() { let cx = if n > 1 { inset + i as f32 * spacing @@ -260,7 +249,6 @@ impl Timeline { }; let cy = bar_y; - // Determine if this step is "active" (filled bar has reached it) let step_progress = if n > 1 { i as f32 / (n - 1) as f32 } else { @@ -268,7 +256,6 @@ impl Timeline { }; let is_active = fill_progress >= step_progress; - // Node circle let node_color = if is_active { &step.color } else { @@ -279,7 +266,6 @@ impl Timeline { node_paint.set_anti_alias(true); canvas.draw_circle((cx, cy), r, &node_paint); - // Icon/text inside node if let Some(ref icon) = step.icon { let icon_w = measure_text_with_fallback(icon, icon_font, emoji_font, 0.0); let (_, icon_metrics) = icon_font.metrics(); @@ -301,7 +287,6 @@ impl Timeline { ); } - // Label below let label_w = measure_text_with_fallback(&step.label, font, &None, 0.0); let lx = cx - label_w / 2.0; let ly = cy + r + 8.0 + ascent; @@ -309,7 +294,6 @@ impl Timeline { label_paint.set_anti_alias(true); draw_text_with_fallback(canvas, &step.label, font, &None, 0.0, lx, ly, &label_paint); - // Sublabel if let Some(ref sublabel) = step.sublabel { let sub_w = measure_text_with_fallback(sublabel, sublabel_font, &None, 0.0); let sx = cx - sub_w / 2.0; @@ -345,15 +329,8 @@ impl Timeline { let spacing = 80.0; let bar_x = r; let total_h = if n > 1 { (n - 1) as f32 * spacing } else { 0.0 }; - // Node 0 used to sit at local y=0, so its circle (radius `r`) bled - // `r` px above the assigned box's top edge — same class of bug as - // the horizontal direction's left/right overflow. Insetting every - // node's `cy` by `r` keeps the whole column inside `[0, box height]` - // (box_builder.rs already reserves `r*2 + 64` px per step, well - // past what this needs). let top_inset = r; - // Background bar let bar_rect = Rect::from_xywh( bar_x - self.bar_height / 2.0, top_inset, @@ -369,7 +346,6 @@ impl Timeline { &bar_paint, ); - // Filled bar if fill_progress > 0.001 { let fill_h = total_h * fill_progress.clamp(0.0, 1.0); let fill_rect = Rect::from_xywh( @@ -430,7 +406,6 @@ impl Timeline { ); } - // Label to the right let lx = cx + r + 12.0; let (_, font_metrics) = font.metrics(); let ly = cy + (-font_metrics.ascent - font_metrics.descent) / 2.0; @@ -503,11 +478,6 @@ mod tests { #[test] fn horizontal_nodes_and_labels_stay_within_the_declared_width() { - // #127: node 0's circle used to be centered at local x=0 (and node - // n-1 at x=width), so the circle — and a wide first/last label — - // bled `node_radius` (or more) past both edges of the box the - // layout gave this component. A long first label and a long last - // label both push on the insets from either side. let tl = Timeline { steps: vec![ step("Design phase kickoff"), diff --git a/crates/rustmotion-components/src/tooltip.rs b/crates/rustmotion-components/src/tooltip.rs index 118864f1..b73d1249 100644 --- a/crates/rustmotion-components/src/tooltip.rs +++ b/crates/rustmotion-components/src/tooltip.rs @@ -80,10 +80,6 @@ rustmotion_core::impl_traits!(Tooltip { }); impl Tooltip { - /// Resolves `font-size` against a real per-frame viewport (`rem`/`vw`/ - /// `vh` now resolve instead of silently dropping to 0px — lot B, wave - /// S). `em`/`%` on `font-size` itself remain approximate — see - /// `crate::intrinsic::font_size_ctx`'s doc comment. fn resolved_font_size(&self, ctx: &PaintCtx) -> f32 { self.style.font_size_px_ctx( &crate::intrinsic::font_size_ctx(ctx.video_width as f32, ctx.video_height as f32, 0.0), @@ -111,7 +107,6 @@ impl Tooltip { let radius = self.style.border_radius_px_or(8.0); let arrow_sz = self.arrow_size; - // Compute body rect (excluding arrow area) let (body_x, body_y, body_w, body_h) = match self.arrow { TooltipArrow::Bottom => (0.0, 0.0, w, h - arrow_sz), TooltipArrow::Top => (0.0, arrow_sz, w, h - arrow_sz), @@ -120,7 +115,6 @@ impl Tooltip { TooltipArrow::None => (0.0, 0.0, w, h), }; - // Body rounded rect let body_rect = Rect::from_xywh(body_x, body_y, body_w, body_h); let body_rrect = skia_safe::RRect::new_rect_xy(body_rect, radius, radius); @@ -129,7 +123,6 @@ impl Tooltip { bg_paint.set_anti_alias(true); canvas.draw_rrect(body_rrect, &bg_paint); - // Border if let Some(bc) = &self.border_color { let mut border_paint = paint_from_hex(bc); border_paint.set_style(PaintStyle::Stroke); @@ -138,7 +131,6 @@ impl Tooltip { canvas.draw_rrect(body_rrect, &border_paint); } - // Arrow triangle if !matches!(self.arrow, TooltipArrow::None) { let mut arrow_path = PathBuilder::new(); match self.arrow { @@ -179,7 +171,6 @@ impl Tooltip { canvas.draw_path(&arrow_path.detach(), &bg_paint); } - // Text centered in body let Some(font) = self.make_font(ctx) else { return; }; @@ -239,12 +230,8 @@ mod tests { } } - // ─── Lot B, wave S: relative `font-size` units ───────────────────────── - #[test] fn rem_font_size_paints_visible_ink() { - // Reproduction: `font-size: "2rem"` used to resolve to 0px via the - // context-free `font_size_px_or`. let tooltip = Tooltip { text: "hello".to_string(), arrow: TooltipArrow::None, @@ -284,8 +271,6 @@ mod tests { skia_safe::image::CachingHint::Disallow, ); assert!(ok, "pixel read should succeed"); - // Text is near-white (#E2E8F0 default) on a dark #1E293B body — - // probe for near-white ink specifically. let text_ink = buf .as_chunks::<4>() .0 diff --git a/crates/rustmotion-components/src/treemap.rs b/crates/rustmotion-components/src/treemap.rs index fb7872b5..c3c0c123 100644 --- a/crates/rustmotion-components/src/treemap.rs +++ b/crates/rustmotion-components/src/treemap.rs @@ -113,10 +113,6 @@ impl Treemap { if !self.animated { return 1.0; } - // Ramp measured from `start_at`, not from scene time zero — matches - // `Counter::ramp_progress`. A treemap delayed with `start_at` used - // to read raw scene time, so it was already fully scaled in on the - // very first frame it became visible. let start = self.timing.start_at.unwrap_or(0.0); let elapsed = (time - start).max(0.0); let p = (elapsed / self.animation_duration).clamp(0.0, 1.0) as f32; @@ -133,7 +129,6 @@ impl Treemap { let progress = self.progress_at(time); - // Sort data by value descending, keeping original indices let mut sorted: Vec<(f64, usize)> = self .data .iter() @@ -153,7 +148,6 @@ impl Treemap { for (idx, rect) in &rects { let item = &self.data[*idx]; - // Apply gap by insetting the rect let inset = self.gap / 2.0; let inset_rect = Rect::from_xywh( rect.left + inset, @@ -166,7 +160,6 @@ impl Treemap { continue; } - // Animation: scale each rect from center let cx = inset_rect.left + inset_rect.width() / 2.0; let cy = inset_rect.top + inset_rect.height() / 2.0; let scaled_w = inset_rect.width() * progress; @@ -174,7 +167,6 @@ impl Treemap { let scaled_rect = Rect::from_xywh(cx - scaled_w / 2.0, cy - scaled_h / 2.0, scaled_w, scaled_h); - // Color let color_str = item .color .as_deref() @@ -186,7 +178,6 @@ impl Treemap { let rrect = RRect::new_rect_xy(scaled_rect, self.border_radius, self.border_radius); canvas.draw_rrect(rrect, &paint); - // Labels if self.show_labels || self.show_values { let mut text_parts: Vec = vec![]; if self.show_labels { @@ -203,13 +194,6 @@ impl Treemap { } let font_size = (scaled_rect.width() * 0.12).clamp(10.0, 24.0); - // `draw_text_with_fallback` builds a single-line `TextBlob` - // (renderer/text.rs) — joining label and value with "\n" - // never produced a line break, it fed the blob a literal - // control glyph. Each part now gets its own baseline, and - // the space each line needs (`20.0` per line, same floor - // the old single-line check used) is checked before - // drawing instead of after. let line_height = font_size * 1.2; if scaled_rect.width() < 30.0 || scaled_rect.height() < 20.0 * text_parts.len() as f32 @@ -304,20 +288,12 @@ mod tests { buf } - /// A pixel is "text ink" if it's opaque-ish and near-white — the fixed - /// `#FFFFFF` label/value color, distinct from the cell's own colored - /// (never white) `DEFAULT_PALETTE` background fill that would otherwise - /// dominate a naive alpha-only scan. fn is_text_ink(buf: &[u8], w: i32, x: i32, y: i32) -> bool { let idx = ((y * w + x) * 4) as usize; let (r, g, b, a) = (buf[idx], buf[idx + 1], buf[idx + 2], buf[idx + 3]); a > 40 && r > 200 && g > 200 && b > 200 } - /// Contiguous vertical bands (start_y, end_y) of text ink, merging rows - /// separated by a 1px anti-aliasing gap but splitting on anything - /// wider — used to tell "two stacked text lines" apart from "one line - /// of text". fn row_bands(buf: &[u8], w: i32, h: i32) -> Vec<(i32, i32)> { let mut bands: Vec<(i32, i32)> = vec![]; for y in 0..h { @@ -347,11 +323,6 @@ mod tests { #[test] fn label_and_value_render_on_two_separate_centered_lines() { - // #8's exact repro: `text_parts.join("\n")` fed a single-line - // `TextBlob` a literal "\n" glyph — the label and value landed side - // by side on the same baseline instead of stacked, and the whole - // (wrongly wide) string was centered as one block, decentering the - // label itself. const W: i32 = 300; const H: i32 = 200; let mut treemap = base_treemap(vec![TreemapItem { diff --git a/crates/rustmotion-components/src/video.rs b/crates/rustmotion-components/src/video.rs index cae7b018..e773df72 100644 --- a/crates/rustmotion-components/src/video.rs +++ b/crates/rustmotion-components/src/video.rs @@ -46,9 +46,6 @@ rustmotion_core::impl_traits!(Video { Styled => style, }); -/// The rectangle an `img_w`×`img_h` source draws into to honour `fit` inside -/// a `target_w`×`target_h` box — the same three CSS `object-fit` semantics -/// `image.rs`'s painter already implements for the `image` component. fn fit_rect(fit: &ImageFit, img_w: f32, img_h: f32, target_w: f32, target_h: f32) -> Rect { match fit { ImageFit::Fill => Rect::from_xywh(0.0, 0.0, target_w, target_h), @@ -67,8 +64,6 @@ fn fit_rect(fit: &ImageFit, img_w: f32, img_h: f32, target_w: f32, target_h: f32 } } -/// Draws `img` into `layout`'s box according to `fit`, clipping to the box -/// for `Cover` (the only mode whose fitted rectangle can extend past it). fn draw_fitted(canvas: &Canvas, img: skia_safe::Image, fit: &ImageFit, layout: &BoxLayout) { let dst = fit_rect( fit, @@ -92,13 +87,6 @@ fn draw_fitted(canvas: &Canvas, img: skia_safe::Image, fit: &ImageFit, layout: & } } -/// The source clip's own duration, probed via `ffprobe` and memoized per -/// `src` for the life of the process — `effective_source_time` below is -/// called once per painted frame, and re-probing on every one of them would -/// mean one subprocess spawn per frame for any looping video with no -/// explicit `trim_end`. `None` on a probe failure (no ffprobe on `PATH`, or -/// the source can't be read) is memoized too, so a broken source fails fast -/// on every subsequent frame instead of retrying the same failing probe. fn video_duration_secs(src: &str) -> Option { static CACHE: std::sync::OnceLock< std::sync::Mutex>>, @@ -121,16 +109,6 @@ fn video_duration_secs(src: &str) -> Option { } impl Video { - /// The timestamp to sample from the source clip for a given scene time. - /// - /// Honours `trim_end` on the picture the same way the audio track - /// already does: past `trim_end`, playback holds on the last in-window - /// frame instead of continuing to draw whatever the source contains - /// beyond the intended trim point. When `loop_video` is set, playback - /// wraps within `[trim_start, trim_end)` instead of clamping — falling - /// back to the source's own probed duration as the loop window only - /// when `trim_end` is absent, since that is the only case where the - /// window cannot otherwise be known at all. fn effective_source_time(&self, ctx_time: f64) -> f64 { let rate = self.playback_rate.unwrap_or(1.0); let trim_start = self.trim_start.unwrap_or(0.0); @@ -191,14 +169,6 @@ impl Painter for Video { let frame_data = match extract_video_frame(&self.src, source_time, width, height) { Ok(data) => data, Err(e) => { - // Item 3 (issue #167): decoding failures (ffmpeg missing, or - // this specific frame failing) used to be a silent `return` - // — a video component would render entirely blank with no - // trace anywhere. `paint_content` runs once per frame, so - // the warning is deduplicated per `src` via `warn_once_for` - // (the same guard `lib.rs` already uses for exactly this - // per-frame-call-site problem) instead of printing the same - // line a thousand times over a render. if crate::warn_once_for(&format!("video-frame:{}", self.src)) { eprintln!( "Warning: video '{}' could not be decoded: {e}. This component will \ @@ -236,11 +206,6 @@ mod tests { } } - /// A failed frame extraction (bad src, or no ffmpeg) must be reported, - /// not swallowed. Pre-fix, `paint_content` never calls `warn_once_for` - /// on this path at all, so the slot for this exact src stays unclaimed - /// ("first sighting" == true) forever — this is the observable half of - /// total silence we can assert on without capturing stderr. #[test] fn a_failed_frame_extraction_must_claim_its_warn_once_slot() { let missing_src = std::env::temp_dir().join(format!( diff --git a/crates/rustmotion-components/src/waveform.rs b/crates/rustmotion-components/src/waveform.rs index 07ff6bdb..94954d60 100644 --- a/crates/rustmotion-components/src/waveform.rs +++ b/crates/rustmotion-components/src/waveform.rs @@ -76,7 +76,6 @@ impl Painter for Waveform { }; let Some(analysis) = analysis else { - // Graceful degradation: flat line at mid-height let mut paint = Paint::default(); paint.set_color(color); paint.set_style(PaintStyle::Stroke); @@ -94,7 +93,6 @@ impl Painter for Waveform { let t_start = (ctx.scenario_time - half_window).max(0.0); let t_end = ctx.scenario_time + half_window; - // Sample N points along the window let n_points = w as usize; let mut points: Vec<(f32, f32)> = Vec::with_capacity(n_points); for i in 0..n_points { @@ -124,7 +122,6 @@ impl Painter for Waveform { canvas.draw_path(&path.detach(), &paint); } DrawStyle::Filled => { - // Filled area let (r2, g2, b2, _) = parse_hex_color(&self.color); let fill_color = Color::from_argb(100, r2, g2, b2); paint.set_style(PaintStyle::Fill); @@ -141,7 +138,6 @@ impl Painter for Waveform { fill_path.close(); canvas.draw_path(&fill_path.detach(), &paint); - // Outline paint.set_style(PaintStyle::Stroke); paint.set_stroke_width(1.5); paint.set_color(color); diff --git a/crates/rustmotion-components/src/world_bitmap.rs b/crates/rustmotion-components/src/world_bitmap.rs index 3a5b5c17..96e33ebd 100644 --- a/crates/rustmotion-components/src/world_bitmap.rs +++ b/crates/rustmotion-components/src/world_bitmap.rs @@ -1,638 +1,545 @@ -/// 180×90 bitmap of land (1) vs water (0), each cell = 2°×2°. -/// Row 0 = 90°N–88°N, Row 89 = 88°S–90°S. -/// Col 0 = 180°W–178°W, Col 179 = 178°E–180°E. pub fn land_bitmap() -> &'static [u8] { static BITMAP: [u8; 16200] = [ - // Row 0: 90°N to 88°N - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 1: 88°N to 86°N - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 2: 86°N to 84°N - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 3: 84°N to 82°N - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 4: 82°N to 80°N - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 5: 80°N to 78°N - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 6: 78°N to 76°N - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, + 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 7: 76°N to 74°N - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, + 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 8: 74°N to 72°N - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, - 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, - 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 9: 72°N to 70°N - 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, + 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, + 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, - // Row 10: 70°N to 68°N - 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, - 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, - // Row 11: 68°N to 66°N - 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, - 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, - // Row 12: 66°N to 64°N - 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, - 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, - // Row 13: 64°N to 62°N - 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, - 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, + 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, - // Row 14: 62°N to 60°N - 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, - 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, + 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 15: 60°N to 58°N - 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, + 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 16: 58°N to 56°N - 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, - 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, + 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 17: 56°N to 54°N - 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, - 0, 0, 0, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, + 0, 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 18: 54°N to 52°N - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 0, - 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, + 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 19: 52°N to 50°N - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, + 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 20: 50°N to 48°N - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 0, 0, 1, 1, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 21: 48°N to 46°N - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 22: 46°N to 44°N - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, + 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 0, 0, 1, 1, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, - 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 0, 0, 1, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 23: 44°N to 42°N - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, - 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 24: 42°N to 40°N - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, - 0, 0, 0, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, - 0, 1, 1, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 25: 40°N to 38°N - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, - 0, 0, 0, 1, 0, 0, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, 1, 0, - 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, - 0, 0, 1, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 26: 38°N to 36°N - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, - 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 1, 1, 1, 1, - 1, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, - 0, 0, 1, 1, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 27: 36°N to 34°N - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, + 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 0, 0, 1, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, - 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 1, 1, 1, + 1, 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, + 1, 0, 0, 0, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, + 1, 0, 0, 0, 1, 0, 0, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, 1, + 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, + 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, + 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, - 0, 0, 1, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 28: 34°N to 32°N - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, 1, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, - 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, - 1, 1, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, - 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 29: 32°N to 30°N - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, 1, 1, 1, - 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, - 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 30: 30°N to 28°N - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 1, 1, + 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 0, 0, 0, 1, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, + 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, + 1, 1, 1, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, + 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, 1, 1, + 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, + 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, 1, + 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 1, 0, 0, 1, 1, 1, 1, 1, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, + 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, 1, 1, - 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 31: 28°N to 26°N - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 1, 0, 0, 1, 1, 1, 1, 1, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0, 0, + 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, + 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, 1, - 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 32: 26°N to 24°N - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 1, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, + 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, + 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0, 0, 1, - 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 33: 24°N to 22°N - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, - 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 34: 22°N to 20°N - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, - 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 35: 20°N to 18°N - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 36: 18°N to 16°N - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 37: 16°N to 14°N - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 38: 14°N to 12°N - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, + 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 1, + 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 1, - 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 39: 12°N to 10°N - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, - 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 40: 10°N to 8°N - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, + 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 41: 8°N to 6°N - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 42: 6°N to 4°N - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 1, 1, 1, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 43: 4°N to 2°N - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 44: 2°N to 0° - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, + 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 1, 1, 1, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 1, 0, 0, 0, 1, 1, 1, 1, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 45: 0° to 2°S - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 1, 1, 1, 0, 0, 1, - 0, 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 46: 2°S to 4°S - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 1, 1, 1, 0, 0, 1, - 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 47: 4°S to 6°S - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 1, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 48: 6°S to 8°S - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, - 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 49: 8°S to 10°S - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 1, 0, 0, 0, 1, 1, 1, 1, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 50: 10°S to 12°S - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 1, 1, 1, 0, 0, + 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 1, 1, 1, 0, 0, + 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 1, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, + 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 51: 12°S to 14°S - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 52: 14°S to 16°S - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 53: 16°S to 18°S - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 54: 18°S to 20°S - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 55: 20°S to 22°S - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 56: 22°S to 24°S - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 57: 24°S to 26°S - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 58: 26°S to 28°S - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 59: 28°S to 30°S - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 60: 30°S to 32°S - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, + 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 61: 32°S to 34°S - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, + 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 62: 34°S to 36°S - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, + 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, - // Row 63: 36°S to 38°S - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, - // Row 64: 38°S to 40°S - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, - // Row 65: 40°S to 42°S - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, - // Row 66: 42°S to 44°S - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, - // Row 67: 44°S to 46°S - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, - // Row 68: 46°S to 48°S - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 69: 48°S to 50°S - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 70: 50°S to 52°S - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 71: 52°S to 54°S - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 72: 54°S to 56°S - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 73: 56°S to 58°S - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 74: 58°S to 60°S - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 75: 60°S to 62°S - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, + 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, + 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, + 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, + 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, + 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, + 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, + 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, + 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, + 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, + 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, + 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, + 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, + 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, + 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, + 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, + 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, + 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 76: 62°S to 64°S - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 77: 64°S to 66°S - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 78: 66°S to 68°S - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 79: 68°S to 70°S - 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, - 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, - 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, - 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, - 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, - 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, - // Row 80: 70°S to 72°S - 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, - 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, - 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, - 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, - 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, - 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, - // Row 81: 72°S to 74°S - 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, - 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, - 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, - 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, - 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, - 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, - // Row 82: 74°S to 76°S - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 83: 76°S to 78°S - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 84: 78°S to 80°S - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 85: 80°S to 82°S - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 86: 82°S to 84°S - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 87: 84°S to 86°S - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 88: 86°S to 88°S - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // Row 89: 88°S to 90°S - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ]; &BITMAP } diff --git a/crates/rustmotion-components/tests/audit_ws_d.rs b/crates/rustmotion-components/tests/audit_ws_d.rs index 7ac9dbd8..f4f5968f 100644 --- a/crates/rustmotion-components/tests/audit_ws_d.rs +++ b/crates/rustmotion-components/tests/audit_ws_d.rs @@ -1,14 +1,3 @@ -//! Regression tests for the `video` component's dead-field fixes: `fit`, -//! `trim_end`, `loop_video`, and the straight-vs-premultiplied alpha bug on -//! its cached-frame draw path. -//! -//! Every case populates `video_frame_cache()` directly with hand-built RGBA -//! frames rather than shelling out to a real ffmpeg decode: the field this -//! module exercises (`Video::paint_content`) is one call away from the -//! cache, and driving it that way keeps these tests hermetic and fast while -//! still going through the real, public `Painter` implementation — no -//! private items from `rustmotion-components` are touched. - use std::sync::Arc; use rustmotion_components::Video; @@ -74,9 +63,6 @@ fn solid_rgba(color: [u8; 4], w: u32, h: u32) -> Vec { buf } -/// Paints `video` into a fresh `w`×`h` surface (background transparent if -/// `transparent_bg`, opaque black otherwise) and reads the composited pixels -/// back as straight (unpremultiplied) RGBA. fn paint_and_read(video: &Video, ctx: &PaintCtx, w: i32, h: i32, transparent_bg: bool) -> Vec { let mut surface = skia_safe::surfaces::raster_n32_premul((w, h)).expect("raster surface"); let bg = if transparent_bg { @@ -110,13 +96,6 @@ fn px(buf: &[u8], w: i32, x: i32, y: i32) -> [u8; 4] { buf[i..i + 4].try_into().expect("pixel in bounds") } -// ─── `fit` was declared, documented, and never read ──────────────────────── - -/// A 10×20 source into a 40×40 box under `contain` must letterbox — scale -/// is `min(40/10, 40/40) = 1`, so the drawn region is 10 wide, centred with -/// a 15px empty margin on each side. Before the fix, the painter always -/// stretched to the full box regardless of `fit`, so every pixel — margins -/// included — came out opaque. #[test] fn contain_fit_letterboxes_instead_of_stretching() { let src = unique_src("fit-contain"); @@ -153,9 +132,6 @@ fn contain_fit_letterboxes_instead_of_stretching() { ); } -/// `fill` (the CSS default `object-fit: fill` behaviour) must still stretch -/// to cover the whole box exactly as before — the fix must not regress the -/// one mode that already matched the pre-fix behaviour. #[test] fn fill_fit_still_stretches_to_the_whole_box() { let src = unique_src("fit-fill"); @@ -187,15 +163,6 @@ fn fill_fit_still_stretches_to_the_whole_box() { ); } -// ─── `trim_end` was honoured only on the extracted audio ─────────────────── - -/// Frames beyond `trim_end` sit in the cache (simulating a preextraction -/// window, or a direct extraction, wider than the intended trim), so the -/// picture path must never pick one of them once `trim_end` is set: past -/// `trim_end`, playback holds on the last in-window frame. Before the fix, -/// `source_time` had no upper bound at all — querying past `trim_end` on a -/// cache/source that extends further would draw whatever sits further -/// along the source, not the frame at the trim boundary. #[test] fn trim_end_clamps_playback_instead_of_running_past_it() { let src = unique_src("trimend"); @@ -221,17 +188,6 @@ fn trim_end_clamps_playback_instead_of_running_past_it() { ); } -// ─── `loop_video` made neither the picture nor the audio loop ───────────── - -/// Cache frames only cover `[0.0, 1.0)`; `trim_end: Some(1.0)` gives -/// `loop_video` a window to wrap within without needing a real source file -/// to probe. Querying at `ctx.time = 2.1` (raw source time 2.1s, i.e. "2 -/// full loops plus 0.1s") must land near 0.1s once wrapped — nearest to -/// that among `{0.0, 0.25, 0.5, 0.75}` is red. Before this fix, the same -/// query — with the trim-end clamp from the previous test already in place -/// but no loop branch yet — clamped to `min(2.1, 1.0) = 1.0`, whose nearest -/// cached frame is yellow: a clearly different pixel, which is what proves -/// this test is exercising the loop path and not being masked by the clamp. #[test] fn loop_video_wraps_playback_within_the_trim_window() { let src = unique_src("loop"); @@ -256,9 +212,6 @@ fn loop_video_wraps_playback_within_the_trim_window() { ); } -/// Without `loop_video`, a `trim_end`-bounded video must still clamp -/// (unaffected by the loop branch existing) rather than wrap — the same -/// scenario as the wrap test above, minus the flag. #[test] fn without_loop_video_playback_still_clamps_not_wraps() { let src = unique_src("no-loop"); @@ -282,22 +235,6 @@ fn without_loop_video_playback_still_clamps_not_wraps() { ); } -// ─── cached-frame draw path mistagged straight alpha as premultiplied ────── - -/// ffmpeg's `-pix_fmt rgba` output — what fills the video-frame cache — is -/// straight (unpremultiplied) alpha. Tagging that buffer `AlphaType::Premul` -/// makes Skia treat the RGB channels as already scaled by alpha instead of -/// scaling them itself, which brightens (here: doubles) every -/// semi-transparent pixel's channels once composited. -/// -/// A straight-alpha (200, 100, 50, 128) pixel, composited over black: -/// correctly tagged `Unpremul`, Skia premultiplies it to -/// (200×128/255, 100×128/255, 50×128/255) ≈ (100, 50, 25) before compositing -/// over black, landing there almost exactly (the `(1 - alpha) * 0` background -/// term vanishes either way). Mistagged `Premul`, Skia uses the raw channel -/// values directly as if already scaled — (200, 100, 50) — composited over -/// black with no further scaling, landing at roughly double the correct -/// result. #[test] fn cached_frame_straight_alpha_composites_correctly_not_doubled() { let src = unique_src("alpha"); diff --git a/crates/rustmotion-components/tests/audit_ws_i.rs b/crates/rustmotion-components/tests/audit_ws_i.rs index ea66e2e1..6d95d173 100644 --- a/crates/rustmotion-components/tests/audit_ws_i.rs +++ b/crates/rustmotion-components/tests/audit_ws_i.rs @@ -1,15 +1,3 @@ -//! Regression tests for components and the CSS cascade: -//! -//! - `apply_intrinsic_overrides`'s default-size branches resolved -//! `font-size` through the context-free `font_size_px_or`, which returns -//! `0.0` for a relative unit (`rem`/`vw`/`vh`) instead of resolving it — -//! collapsing `marquee`/`list`/`callout`/`tooltip`/`pill_nav` to a 0px box. -//! - `cascade::inherit_from` computes inherited `color`/`font-*` but -//! nothing on the render path ever reads the result — every painter reads -//! its own component's un-cascaded `style` field instead. -//! - `TableIntrinsic` measures content-fitted per-column widths, but the -//! painter splits the box evenly across columns regardless. - use rustmotion_components::box_builder::{build_scene_with_anim, BuildAnimationCtx}; use rustmotion_components::legacy_dispatch::LegacyPaintDispatcher; use rustmotion_components::{ChildComponent, Component, PositionMode}; @@ -30,17 +18,8 @@ fn single_child_scene(json: serde_json::Value) -> ChildComponent { } } -// ─── relative font-size on box_builder's default-size branches ──────────── - #[test] fn marquee_with_relative_font_size_gets_a_positive_height() { - // Reproduction: `font_size_px_or` returns 0.0 for any relative unit - // (`.px()` can't resolve `%`/`em`/`rem`/`vw`/`vh`). Five sites in - // `apply_intrinsic_overrides` still called it — Marquee among them — - // so a marquee declaring `"font-size": "2rem"` and no explicit height - // used to collapse `apply_default_size(css, 800.0, 0.0)` to a 0px-tall - // box, and `paint_pass`'s `height <= 0.0` guard then skipped it - // entirely: the marquee never appeared on screen. let child = single_child_scene(serde_json::json!({ "type": "marquee", "content": "BREAKING NEWS", @@ -68,8 +47,6 @@ fn marquee_with_relative_font_size_gets_a_positive_height() { ); } -// ─── CSS cascade reaching the painter and the intrinsic measurer ────────── - const CASCADE_SCENE_W: i32 = 600; const CASCADE_SCENE_H: i32 = 500; @@ -144,14 +121,6 @@ fn count_dominant(pixels: &[u8], dominant: usize, muted: &[usize]) -> usize { #[test] fn card_color_and_font_size_cascade_to_painted_text_child() { - // `cascade::inherit_from` computes the twelve inheritable properties but - // `LegacyPaintDispatcher::dispatch` binds the resolved `CssStyle` to - // `_css` and drops it, and `component_intrinsic` builds `TextIntrinsic` - // from `&child.component` (the un-cascaded component) — so a card's - // `color`/`font-size` never reached a child `text` with no value of its - // own, at either paint time or measure time. The text rendered at the - // painter's own fallback (48px, #FFFFFF) instead of the card's (200px, - // red). let scene = paint_card_with_child(serde_json::json!({ "type": "card", "style": { @@ -184,11 +153,6 @@ fn card_color_and_font_size_cascade_to_painted_text_child() { #[test] fn text_own_color_wins_over_cascaded_card_color_at_paint_time() { - // The other half of the contract this fix must not break: a `text` - // that DOES declare its own `color` must keep winning over the parent's, - // not just at the box-tree level (already covered by - // `box_builder.rs::text_own_color_wins_over_inherited_card_color`) but - // in what actually gets painted. let scene = paint_card_with_child(serde_json::json!({ "type": "card", "style": { @@ -224,14 +188,6 @@ fn any_ink_above_black_below_y(pixels: &[u8], width: i32, y_threshold: i32) -> b #[test] fn card_font_size_cascades_to_measured_and_painted_gradient_text_child() { - // `gradient_text` was orphaned by the original Text-only special case in - // `LegacyPaintDispatcher::dispatch` — nobody owned this file, so the - // cascade fix for `text` never extended to it even though its painter - // and `GradientTextIntrinsic` read `font-size`/`font-family`/etc. off - // `self.style` exactly the same way. `gradient_text` paints its own - // `colors` gradient rather than `style.color`, so `font-size` (which - // affects both the measured box and the ink's vertical extent) is the - // observable half of the cascade here, not color. let scene = paint_card_with_child(serde_json::json!({ "type": "card", "style": { @@ -260,9 +216,6 @@ fn card_font_size_cascades_to_measured_and_painted_gradient_text_child() { #[test] fn card_color_cascades_to_painted_caption_child() { - // Same orphaned-file gap as gradient_text, for `caption`: its inactive - // words paint in `style.color_str_or("#FFFFFF")`, which never saw the - // card's cascaded color either. let scene = paint_card_with_child(serde_json::json!({ "type": "card", "style": { @@ -289,10 +242,6 @@ fn card_color_cascades_to_painted_caption_child() { #[test] fn card_color_cascades_to_painted_rich_text_child() { - // Same gap for `rich_text`: `resolve_span_fonts`'s `default_color` - // (used by any span that doesn't set its own `color`) reads - // `style.color_str_or("#FFFFFF")` on the component's own un-cascaded - // style. let scene = paint_card_with_child(serde_json::json!({ "type": "card", "style": { @@ -316,25 +265,6 @@ fn card_color_cascades_to_painted_rich_text_child() { #[test] fn cascaded_components_match_the_verified_set() { - // Pins the current membership of `Component::with_cascaded_style`'s - // exhaustive classifier as an executable fact, not just a convention. - // The match itself guarantees every *future* variant gets a decision — - // adding a `Component` variant without extending that match is a - // compile error, the same completeness `as_painter`/`as_animatable`/ - // `as_timed`/`as_styled` already enforce for their own questions. That - // guarantee says nothing about an *existing* variant silently drifting - // (a copy-paste that drops one into the wrong arm, or a "cleanup" that - // moves one back) — this test is what catches that, by covering all - // fifty-seven variants directly rather than a sample. - // - // The sixteen `true` cases (beyond `text`, already fixed by a prior - // pass) were found by reading every `impl Painter for X` in this crate - // for a direct `self.style.color`/`font_family`/`font_size`/ - // `font_weight`/`font_style` read with no cascade in between — not by - // trusting the component's name. `stat` was checked and ruled out: it - // reads only `self.style.background_color_str` (not inheritable) — its - // text colours come from its own dedicated `value_color`/`label_color` - // fields. use rustmotion_components::Component; use rustmotion_core::css::style::{Color, CssStyle as CoreCssStyle}; use rustmotion_core::css::Length; @@ -582,18 +512,8 @@ fn cascaded_components_match_the_verified_set() { } } -// ─── table column widths: painter vs. intrinsic measurer ────────────────── - #[test] fn table_columns_are_sized_by_content_not_split_evenly() { - // `TableIntrinsic::compute_width` measures each column's own natural - // width (header/cell text + padding) and sums them for the box's - // reserved size, but `Table::resolve_column_widths` (the *painter*'s own - // distribution) just divides the laid-out width evenly across columns - // regardless. A table whose columns have very different natural widths - // ("ID" vs. a long description) used to get a column border sitting at - // the arithmetic midpoint of the box, not near the natural split the - // measurer already computed. use rustmotion_components::Component; use rustmotion_core::engine::animator::AnimatedProperties; use rustmotion_core::engine::layout_pass::BoxLayout; @@ -672,15 +592,6 @@ fn table_columns_are_sized_by_content_not_split_evenly() { ); } -// ─── the cascade reaches every typographic component, not just `text` ───── -// -// `card_font_size_cascades_to_measured_and_painted_gradient_text_child`, -// `card_color_cascades_to_painted_caption_child` and -// `card_color_cascades_to_painted_rich_text_child` above cover the three -// components the brief named directly. The tests below cover the "own value -// still wins" half for `caption`, plus every other component this sweep -// found reading un-cascaded typography off its own `style`. - #[test] fn caption_own_color_wins_over_cascaded_card_color_at_paint_time() { let scene = paint_card_with_child(serde_json::json!({ @@ -709,16 +620,6 @@ fn caption_own_color_wins_over_cascaded_card_color_at_paint_time() { ); } -// ─── the fourteen other components that read `self.style` typography ────── -// -// Found by sweeping every `impl Painter for X` in this crate for a direct -// `self.style.color`/`font_family`/`font_size`/`font_weight`/`font_style` -// read with no cascade in between (the same shape as the three tested -// above), not by re-checking only the candidates the brief named. `stat` -// was checked and ruled out: it reads only `self.style.background_color_str` -// (not inheritable) — its text colours come from its own dedicated -// `value_color`/`label_color` fields. - #[test] fn newly_cascaded_components_inherit_unset_typography_from_the_parent() { use rustmotion_components::Component; @@ -813,12 +714,6 @@ fn table_own_color_wins_over_cascaded_card_color() { #[test] fn divider_color_cascades_from_card_to_painted_line() { - // `Divider::paint_content` reads `self.style.color_str_or("#FFFFFF")` - // directly for the line it draws — the same un-cascaded read as the - // text-bearing components above, just for a component with no text at - // all. A card's `color` is the CSS analogue of `border-color: - // currentColor`: a divider with no colour of its own should match the - // ambient text colour, not fall back to white. let scene = paint_card_with_child(serde_json::json!({ "type": "card", "style": { @@ -841,12 +736,6 @@ fn divider_color_cascades_from_card_to_painted_line() { #[test] fn table_color_and_font_size_cascade_to_painted_cells() { - // Complements `table_columns_are_sized_by_content_not_split_evenly` - // above (painter vs. intrinsic column widths) with the cascade half: - // `Table::paint`'s body-cell colour (`self.style.color_str_or`) and - // font-size both used to ignore the card's cascaded style. The header - // row keeps its own hardcoded `header_text_color` default regardless, - // so this only proves the body cells. let scene = paint_card_with_child(serde_json::json!({ "type": "card", "style": { diff --git a/crates/rustmotion-components/tests/caption_presets.rs b/crates/rustmotion-components/tests/caption_presets.rs index 4e71899e..bc68258f 100644 --- a/crates/rustmotion-components/tests/caption_presets.rs +++ b/crates/rustmotion-components/tests/caption_presets.rs @@ -1,11 +1,3 @@ -//! Pixel tests for the TikTok-style caption presets (`word_pop`, `karaoke_pop`). -//! -//! These route a caption component through the real pipeline -//! (box_builder + run_layout + paint_tree) and assert on the pixels that -//! come out: the active word must be painted, the pill background must be -//! visible (probed via its configurable color), and inactive words must be -//! absent in `word_pop` mode. - use rustmotion_components::box_builder::{build_scene_with_anim, BuildAnimationCtx}; use rustmotion_components::legacy_dispatch::LegacyPaintDispatcher; use rustmotion_components::{ChildComponent, Component, PositionMode}; @@ -16,14 +8,10 @@ use rustmotion_core::engine::paint_pass::{paint_tree, PaintFrame}; const W: u32 = 400; const H: u32 = 300; -/// Renders a single caption component (absolutely positioned at y=150 so the -/// baseline-anchored glyphs are fully on-canvas) and returns the RGBA buffer. fn render_caption(json: serde_json::Value, time: f64) -> Vec { render_caption_at(json, time, 150.0) } -/// Same as [`render_caption`] but with an explicit vertical position, for -/// tests whose caption spans more lines than fit below the default y=150. fn render_caption_at(json: serde_json::Value, time: f64, y: f32) -> Vec { let component: Component = serde_json::from_value(json).expect("deserialize caption"); let child = ChildComponent { @@ -94,19 +82,16 @@ fn lit_pixels(buf: &[u8]) -> usize { count_pixels(buf, |p| p[3] > 0) } -/// Magenta pill probe: high red + blue, low green. fn magenta_pixels(buf: &[u8]) -> usize { count_pixels(buf, |p| { p[0] > 180 && p[2] > 180 && p[1] < 100 && p[3] > 200 }) } -/// Yellow active-word probe: high red + green, low blue. fn yellow_pixels(buf: &[u8]) -> usize { count_pixels(buf, |p| p[0] > 180 && p[1] > 180 && p[2] < 100) } -/// White inactive-word probe: high red + green + blue. fn white_pixels(buf: &[u8]) -> usize { count_pixels(buf, |p| p[0] > 200 && p[1] > 200 && p[2] > 200) } @@ -127,9 +112,6 @@ fn word_pop_caption() -> serde_json::Value { #[test] fn word_pop_shows_active_word_with_pill() { - // At t=0.45 the first word is active and the pop-in animation has - // settled (scale=1). Both the magenta pill and the yellow word must - // be painted. let buf = render_caption(word_pop_caption(), 0.45); let pill = magenta_pixels(&buf); let word = yellow_pixels(&buf); @@ -139,8 +121,6 @@ fn word_pop_shows_active_word_with_pill() { #[test] fn word_pop_hides_inactive_words() { - // At t=1.5 no word window is active: word_pop must paint nothing at - // all (no lingering pill, no inactive words). let buf = render_caption(word_pop_caption(), 1.5); let lit = lit_pixels(&buf); assert!(lit < 50, "expected empty canvas, got {lit} lit pixels"); @@ -148,9 +128,6 @@ fn word_pop_hides_inactive_words() { #[test] fn word_pop_scales_in() { - // Right after the word starts (t=0.02) the spring-like scale-in has - // barely begun, so far fewer pixels are lit than once it has settled - // (t=0.45). let early = lit_pixels(&render_caption(word_pop_caption(), 0.02)); let settled = lit_pixels(&render_caption(word_pop_caption(), 0.45)); assert!( @@ -161,8 +138,6 @@ fn word_pop_scales_in() { #[test] fn word_pop_default_pill_is_translucent_black() { - // Without pill_color the pill defaults to black at 70% opacity: - // pixels that are dark and semi-transparent must exist. let mut json = word_pop_caption(); json.as_object_mut().unwrap().remove("pill_color"); let buf = render_caption(json, 0.45); @@ -177,8 +152,6 @@ fn word_pop_default_pill_is_translucent_black() { #[test] fn karaoke_pop_highlights_active_word_with_pill() { - // Full line stays visible (white inactive words), the active word - // takes active_color with a magenta pill behind it. let json = serde_json::json!({ "type": "caption", "mode": "karaoke_pop", @@ -207,14 +180,6 @@ fn karaoke_pop_highlights_active_word_with_pill() { ); } -/// Audit finding #1: a caption with no `max_width` set, given a box -/// narrower than its unwrapped content via `style.width` (mirrors a caption -/// placed inside a card, the documented use case), must wrap to fit that -/// box — not paint one wide line that bleeds out of it. Routed through the -/// real pipeline so `CaptionIntrinsic` (the box taffy reserves) and -/// `Caption::paint` (what actually gets drawn) are exercised together: this -/// is exactly the measure-vs-paint pairing the geometry validator depends -/// on to catch overflow, and before the fix the two disagreed silently. #[test] fn wraps_within_its_layout_box_when_max_width_is_unset() { let json = serde_json::json!({ @@ -231,8 +196,6 @@ fn wraps_within_its_layout_box_when_max_width_is_unset() { { "text": "lazy", "start": 0.0, "end": 100.0 }, { "text": "dog", "start": 0.0, "end": 100.0 } ], - // No `max_width` — the box comes entirely from `style.width` below, - // mirroring a caption inside a fixed-width card. "style": { "width": "150px", "font-size": 24, "color": "#FFFFFF" } }); let buf = render_caption_at(json, 0.5, 20.0); @@ -251,8 +214,6 @@ fn wraps_within_its_layout_box_when_max_width_is_unset() { ); } -/// Bounding box (min_x, max_x, min_y, max_y) of every non-transparent pixel -/// in an RGBA8888 `W`x`H` buffer, or `None` if nothing was painted. fn ink_bounds(buf: &[u8]) -> Option<(i32, i32, i32, i32)> { let (mut minx, mut maxx, mut miny, mut maxy) = (i32::MAX, i32::MIN, i32::MAX, i32::MIN); for y in 0..H as i32 { diff --git a/crates/rustmotion-components/tests/degenerate_inputs.rs b/crates/rustmotion-components/tests/degenerate_inputs.rs index f12ad6c6..43cdb6b7 100644 --- a/crates/rustmotion-components/tests/degenerate_inputs.rs +++ b/crates/rustmotion-components/tests/degenerate_inputs.rs @@ -1,14 +1,3 @@ -//! A painter must never panic — nor hang — on input the schema accepts. -//! -//! `rustmotion validate` is the documented gate before delivery, and it answers -//! "Valid scenario" for every component below. Each one was observed aborting or -//! wedging the renderer mid-frame, which kills the whole encode: the scenarios -//! here are the reproductions, verbatim, kept as a regression floor. -//! -//! Every case routes through the real pipeline — serde, box_builder, run_layout, -//! paint_tree — so a fix that only guards the painter while leaving the component -//! undeserialisable would still fail. - use std::sync::mpsc; use std::time::Duration; @@ -22,8 +11,6 @@ use rustmotion_core::engine::paint_pass::{paint_tree, PaintFrame}; const W: u32 = 400; const H: u32 = 300; -/// Deserialize one component and paint it at `time`. Panics propagate — that is -/// the point of the test. fn paint(json: serde_json::Value, time: f64) { let component: Component = serde_json::from_value(json).expect("component is schema-valid"); let children = vec![ChildComponent { @@ -70,9 +57,6 @@ fn paint(json: serde_json::Value, time: f64) { paint_tree(canvas, &built.root, &layout, &frame, &dispatcher); } -/// Paint on a worker so a runaway loop fails the test instead of wedging the -/// suite. `dot_spacing: 0` used to spin for billions of iterations; a plain -/// `paint()` call would hang CI rather than report. fn paint_within(json: serde_json::Value, time: f64, budget: Duration, what: &str) { let (tx, rx) = mpsc::channel(); let worker = std::thread::spawn(move || { @@ -87,8 +71,6 @@ fn paint_within(json: serde_json::Value, time: f64, budget: Duration, what: &str #[test] fn shape_survives_a_gradient_whose_stops_do_not_match_its_colors() { - // skia asserts pos.len() == colors.len() inside the gradient shader, so a - // mismatched `stops` aborted the process rather than returning an error. for gradient_type in ["linear", "radial"] { paint( serde_json::json!({ @@ -108,8 +90,6 @@ fn shape_survives_a_gradient_whose_stops_do_not_match_its_colors() { #[test] fn table_survives_an_empty_row_colors_list() { - // `row_colors: []` deserializes to Some(vec![]), so the default palette was - // never substituted and the modulo guard still indexed an empty slice. paint( serde_json::json!({ "type": "table", @@ -124,8 +104,6 @@ fn table_survives_an_empty_row_colors_list() { #[test] fn tag_cloud_survives_an_empty_colors_list() { - // palette() returned the caller's empty vec, and the painter took - // `index % palette.len()` on it. paint( serde_json::json!({ "type": "tag_cloud", @@ -139,9 +117,6 @@ fn tag_cloud_survives_an_empty_colors_list() { #[test] fn dot_map_terminates_on_a_zero_dot_spacing() { - // (w - 0) / 0 is +inf, and `inf as u32` saturates to u32::MAX in Rust, so the - // nested loop was scheduled for ~1.8e19 iterations. The geometry pass catches - // dot_spacing: 0.01 but not 0. paint_within( serde_json::json!({ "type": "dot_map", diff --git a/crates/rustmotion-components/tests/html_css_mental_model_margin_padding.rs b/crates/rustmotion-components/tests/html_css_mental_model_margin_padding.rs index 3a2547e5..2b9e4da5 100644 --- a/crates/rustmotion-components/tests/html_css_mental_model_margin_padding.rs +++ b/crates/rustmotion-components/tests/html_css_mental_model_margin_padding.rs @@ -1,20 +1,3 @@ -//! Regression test — audit round 4, lot LAYOUT, constat 5. -//! -//! `.claude/skills/rustmotion/rules/html-css-mental-model.md` — a rules file -//! read by the LLMs that generate rustmotion scenarios — documented -//! `"margin-top"` / `"margin-left"` as valid per-child JSON keys, and -//! `"padding": [40, 60]` as a valid CSS-shorthand-style array. Neither is: -//! `CssStyle` only exposes `margin: Option` / `padding: Option` -//! (`#[serde(deny_unknown_fields)]`), and `Edges` is `Uniform(LengthPercentage)` -//! or a `{top, right, bottom, left}` object — never a per-key kebab-case field, -//! never an array. Following the old doc verbatim makes the whole component -//! fail typed deserialization, so it silently disappears from the render -//! instead of being spaced as intended. -//! -//! These tests pin both failure modes (so a future change can't quietly -//! re-introduce them) and confirm the corrected syntax the doc now teaches -//! actually round-trips. - use rustmotion_components::Component; fn component_json_parses(json: serde_json::Value) -> bool { @@ -23,8 +6,6 @@ fn component_json_parses(json: serde_json::Value) -> bool { #[test] fn old_doc_margin_top_shorthand_fails_to_deserialize() { - // What the doc used to teach (html-css-mental-model.md, old line 87): - // `"margin-top": 16` as a direct style key. let json = serde_json::json!({ "type": "text", "content": "hi", @@ -41,8 +22,6 @@ fn old_doc_margin_top_shorthand_fails_to_deserialize() { #[test] fn old_doc_margin_left_auto_shorthand_fails_to_deserialize() { - // What the doc used to teach (html-css-mental-model.md, old line 90): - // `"margin-left": "auto"` as a direct style key. let json = serde_json::json!({ "type": "badge", "text": "NEW", @@ -56,9 +35,6 @@ fn old_doc_margin_left_auto_shorthand_fails_to_deserialize() { #[test] fn old_doc_padding_array_shorthand_fails_to_deserialize() { - // What the doc used to teach (html-css-mental-model.md, old line 177): - // `"padding": [40, 60]`, a CSS `padding: 40px 60px` -style array shorthand - // `Edges` does not implement. let json = serde_json::json!({ "type": "card", "style": { "padding": [40, 60] }, diff --git a/crates/rustmotion-components/tests/relative_font_size.rs b/crates/rustmotion-components/tests/relative_font_size.rs index 5aff28eb..ac9dac62 100644 --- a/crates/rustmotion-components/tests/relative_font_size.rs +++ b/crates/rustmotion-components/tests/relative_font_size.rs @@ -1,14 +1,3 @@ -//! Reproduction + regression test for relative `font-size` units (`rem`/ -//! `vw`/`vh`) resolving to 0px silently (Lot B, wave S). -//! -//! Routes a `text` component through the real pipeline (box_builder → -//! run_layout → paint_tree) so both the intrinsic measurement (the box taffy -//! reserves) and the painter (what actually gets drawn) are exercised -//! together — the geometry validator's overflow checks depend on the two -//! agreeing, so a fix that only touches one side is not a real fix (see -//! `crates/rustmotion-components/tests/caption_presets.rs` for the same -//! pipeline pattern). - use rustmotion_components::box_builder::{build_scene_with_anim, BuildAnimationCtx}; use rustmotion_components::legacy_dispatch::LegacyPaintDispatcher; use rustmotion_components::{ChildComponent, Component, PositionMode}; @@ -83,11 +72,6 @@ fn lit_pixels(buf: &[u8]) -> usize { #[test] fn text_with_rem_font_size_paints_visible_pixels() { - // Reproduction: `rustmotion validate` on this exact style passes (exit - // 0, "Valid scenario") with only warnings — `2rem` silently resolves to - // 0px, so `TextIntrinsic` measures a 0-height box, `paint_pass`'s - // `height <= 0.0` guard skips painting the node entirely, and the - // rendered frame has no text at all. let json = serde_json::json!({ "type": "text", "content": "HELLO", @@ -105,13 +89,9 @@ fn text_with_rem_font_size_paints_visible_pixels() { #[test] fn text_with_vh_font_size_paints_visible_pixels() { - // `vh` needs the real viewport (video_height), not just the root - // font-size — a separate resolution path from `rem` inside - // `LengthContext::resolve`. let json = serde_json::json!({ "type": "text", "content": "HELLO", - // 10vh of a 300px-tall frame = 30px. "style": { "font-size": "10vh", "color": "#FFFFFF" } }); let buf = render(json); diff --git a/crates/rustmotion-components/tests/text_autofit.rs b/crates/rustmotion-components/tests/text_autofit.rs index 14387a83..aed3af02 100644 --- a/crates/rustmotion-components/tests/text_autofit.rs +++ b/crates/rustmotion-components/tests/text_autofit.rs @@ -1,15 +1,3 @@ -//! End-to-end pixel tests for `text-autofit` through the *real* render -//! pipeline (box_builder → run_layout → paint_tree), not just the -//! `TextIntrinsic`/`Text::paint` unit-level tests in `intrinsic.rs`/ -//! `text.rs`. Routes a `text` component through the same -//! `build_scene_with_anim` → `run_layout` → `paint_tree` sequence -//! `render_with_new_pipeline_iter` runs once per rendered frame in the real -//! encoder. This is the strongest available proof that `TextIntrinsic:: -//! measure` (which determines the box `run_layout` reserves) and -//! `Text::paint` (which draws into whatever box that pass assigned) agree -//! on the real render path, and that nothing about the resolved size drifts -//! frame to frame. - use rustmotion_components::box_builder::{build_scene_with_anim, BuildAnimationCtx}; use rustmotion_components::legacy_dispatch::LegacyPaintDispatcher; use rustmotion_components::{ChildComponent, Component, PositionMode}; @@ -99,8 +87,6 @@ fn render_at(content: &str, autofit: bool, time: f64) -> Vec { pixels } -/// Rightmost column (absolute, full-surface coordinates) with any painted -/// (non-zero-alpha) ink. fn max_ink_x(pixels: &[u8]) -> Option { let mut max_x: Option = None; for y in 0..H as i32 { @@ -117,9 +103,6 @@ fn max_ink_x(pixels: &[u8]) -> Option { #[test] fn without_autofit_the_nowrap_line_bleeds_past_the_box_through_the_real_pipeline() { - // Control: a 90px `white-space: nowrap` line in a 300px-wide box, no - // `text-autofit` — must bleed well past the box's right edge, exactly - // as it always has (nowrap's existing, unmodified contract). let pixels = render_at("the quick brown fox jumps over the lazy dog", false, 0.0); let ink_right = max_ink_x(&pixels).expect("text must paint some ink"); let box_right = (BOX_X + BOX_W) as i32; @@ -132,12 +115,6 @@ fn without_autofit_the_nowrap_line_bleeds_past_the_box_through_the_real_pipeline #[test] fn with_autofit_the_same_line_stays_inside_the_box_through_the_real_pipeline() { - // Same fixture as the control above, `text-autofit: true` added. The - // box `run_layout` reserves (via `TextIntrinsic::measure`, invoked by - // taffy inside `run_layout`) and the pixels `paint_tree` actually draws - // (via `Text::paint`, invoked by `LegacyPaintDispatcher`) must agree — - // if they disagreed, the box would still be 300px wide but the ink - // would still bleed past it exactly like the control test above. let pixels = render_at("the quick brown fox jumps over the lazy dog", true, 0.0); let ink_right = max_ink_x(&pixels).expect("text must paint some ink"); let box_right = (BOX_X + BOX_W) as i32; @@ -150,10 +127,6 @@ fn with_autofit_the_same_line_stays_inside_the_box_through_the_real_pipeline() { #[test] fn autofit_end_to_end_is_stable_across_frames_for_fixed_content() { - // Temporal stability through the real per-frame pipeline (the box tree - // and layout are rebuilt fresh every frame in the real encoder, exactly - // like this test does via `build_scene_with_anim` at two different - // `time`s): static content, static box → must be pixel-identical. let frame_a = render_at("the quick brown fox jumps over the lazy dog", true, 0.0); let frame_b = render_at("the quick brown fox jumps over the lazy dog", true, 2.0); assert_eq!( diff --git a/crates/rustmotion-components/tests/transition_interpolation.rs b/crates/rustmotion-components/tests/transition_interpolation.rs index 29972618..c5052daf 100644 --- a/crates/rustmotion-components/tests/transition_interpolation.rs +++ b/crates/rustmotion-components/tests/transition_interpolation.rs @@ -1,19 +1,3 @@ -//! Generic interpolation of `timeline`/`style.transition` (issue: "generic -//! interpolation of any property"). Routes JSON straight through -//! `box_builder` (no layout/paint needed — the interpolated value is fully -//! decided in `CssStyle` before layout ever runs) and inspects the -//! resulting `BoxNode.css` at a sampled mid-transition time. -//! -//! Before this workstream, `transition_keyframes`/`apply_style_states` -//! (`box_builder.rs`) only smoothed `opacity` and `color` (text/counter -//! only) — every other property snapped straight to the target value the -//! instant `t >= step.at`, `style.transition` or not. The first test below -//! (`red_*`) pins that snap down with a mid-transition sample: at exactly -//! half the transition duration, a snapping property already equals its -//! *end* value, not something in between. That's the signature of a jump, -//! not an animation — a bounds-only test (checking only t=0 and t=1) would -//! pass even on a hard cut. - use rustmotion_components::box_builder::{build_scene_at_time, BuildAnimationCtx}; use rustmotion_components::{ChildComponent, Component, PositionMode}; use rustmotion_core::css::style::{Background, BorderRadius, CssStyle}; @@ -21,7 +5,7 @@ use rustmotion_core::css::style::{Background, BorderRadius, CssStyle}; const SCENE_DURATION: f64 = 4.0; const STEP_AT: f64 = 1.0; const DURATION: f64 = 1.0; -const MIDPOINT: f64 = STEP_AT + DURATION / 2.0; // 1.5 +const MIDPOINT: f64 = STEP_AT + DURATION / 2.0; fn div_with_transition( from_radius: f64, @@ -82,11 +66,6 @@ fn bg_hex(css: &CssStyle) -> String { } } -/// GREEN (after the fix): `border-radius` is a *paint-only* CSS property -/// (it decorates a box `paint_pass.rs` already laid out; it never feeds -/// `run_layout`), so it's safe to interpolate at `box_builder` time. -/// Mid-transition (t=1.5, halfway through a 1s ease-in-out-in transition -/// from 0 to 40) must land strictly between the two endpoints. #[test] fn border_radius_interpolates_at_midpoint() { let json = div_with_transition(0.0, 40.0, "#000000", "#000000"); @@ -107,8 +86,6 @@ fn border_radius_interpolates_at_midpoint() { ); } -/// Same proof for `background` (solid-colour only): the interpolated hex at -/// the midpoint must differ from both the `from` and `to` colours. #[test] fn background_color_interpolates_at_midpoint() { let json = div_with_transition(0.0, 0.0, "#000000", "#ffffff"); @@ -130,15 +107,6 @@ fn background_color_interpolates_at_midpoint() { ); } -/// `width` is a *layout* property (it must reach `run_layout`, which -/// `box_builder` cannot do on its own — the piège this workstream's brief -/// calls out explicitly). It is deliberately NOT interpolated: it must keep -/// snapping exactly like every other unhandled property, `style.transition` -/// or not. This pins that "signal the gap, don't fake the fix" contract: -/// `validate` growing a diagnostic here is fine; the renderer silently -/// getting this "right" by some accident would not be — it would mean a -/// layout property was interpolated purely at paint time, decoupled from -/// the box the geometry validator actually measured. #[test] fn width_still_snaps_because_it_is_a_layout_property() { let json = serde_json::json!({ @@ -153,8 +121,6 @@ fn width_still_snaps_because_it_is_a_layout_property() { }); let mid = css_at(json.clone(), MIDPOINT).width.expect("width set"); - // Snap semantics: at the midpoint the value already equals the *target* - // (300px), not something in between (e.g. ~200px). let px = match mid { rustmotion_core::css::style::Size::Length(lp) => lp.px(), other => panic!("expected a length, got {other:?}"), diff --git a/crates/rustmotion-core/src/audio/dsp.rs b/crates/rustmotion-core/src/audio/dsp.rs index 019c05e1..68d15d89 100644 --- a/crates/rustmotion-core/src/audio/dsp.rs +++ b/crates/rustmotion-core/src/audio/dsp.rs @@ -1,19 +1,10 @@ -//! Low-level, dependency-free DSP primitives: the four oscillator -//! waveforms, a white-noise generator, a biquad filter (RBJ "Audio EQ -//! Cookbook" coefficients), an ADSR envelope, and the two master-bus -//! processors (compressor, limiter). Nothing here knows about [`super::voices::Voice`] -//! or [`super::score::Score`] — those layer the JSON schema and the -//! event timeline on top of these plain functions. - use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -/// One period of phase, `[0, 1)`, mapped to a bipolar sample in `[-1, 1]`. pub fn sine(phase: f32) -> f32 { (phase * std::f32::consts::TAU).sin() } -/// 50% duty cycle: `+1` for the first half of the period, `-1` for the second. pub fn square(phase: f32) -> f32 { if phase < 0.5 { 1.0 @@ -22,15 +13,10 @@ pub fn square(phase: f32) -> f32 { } } -/// A rising ramp from `-1` to `+1` across the period. Naive (not -/// band-limited) — acceptable aliasing for a small synth with no -/// broadcast-grade anti-aliasing requirement. pub fn saw(phase: f32) -> f32 { 2.0 * phase - 1.0 } -/// `-1` at `phase = 0`, rising linearly to `+1` at `phase = 0.5`, falling -/// back to `-1` at `phase = 1`. pub fn triangle(phase: f32) -> f32 { if phase < 0.5 { 4.0 * phase - 1.0 @@ -39,19 +25,12 @@ pub fn triangle(phase: f32) -> f32 { } } -/// Deterministic white-noise source: a 32-bit xorshift PRNG (Marsaglia), -/// seeded explicitly so two renders of the same score reproduce the exact -/// same noise samples — a `rand`-style thread-seeded generator would break -/// the "two renders are byte-identical" guarantee this whole module exists -/// to uphold. #[derive(Debug, Clone, Copy)] pub struct Xorshift32 { state: u32, } impl Xorshift32 { - /// A seed of `0` is a fixed point of xorshift (it would only ever - /// produce `0`), so it is remapped to a fixed nonzero constant. pub fn new(seed: u32) -> Self { Xorshift32 { state: if seed == 0 { 0x9E37_79B9 } else { seed }, @@ -67,7 +46,6 @@ impl Xorshift32 { x } - /// A sample uniformly distributed in `[-1, 1]`. pub fn next_f32(&mut self) -> f32 { (self.next_u32() as f32 / u32::MAX as f32) * 2.0 - 1.0 } @@ -99,12 +77,6 @@ pub enum FilterKind { Notch, } -/// A biquad's per-sample memory (Direct Form I): the last two inputs and -/// the last two outputs. Separate from [`BiquadCoeffs`] so one coefficient -/// set could in principle drive several independent states — not needed -/// today (each [`super::voices::Voice`] owns exactly one filter), but it -/// keeps "what changes per sample" and "what is fixed for the voice" -/// apart. #[derive(Debug, Clone, Copy, Default)] pub struct BiquadState { x1: f32, @@ -113,8 +85,6 @@ pub struct BiquadState { y2: f32, } -/// Normalized (`a0 = 1`) biquad coefficients for one of [`FilterKind`]'s -/// four topologies, computed from the RBJ "Audio EQ Cookbook" formulas. #[derive(Debug, Clone, Copy)] pub struct BiquadCoeffs { b0: f32, @@ -125,12 +95,6 @@ pub struct BiquadCoeffs { } impl BiquadCoeffs { - /// `freq` is clamped below Nyquist and `q` away from zero so a - /// carelessly authored score (a filter freq at or above half the - /// sample rate, or `q: 0`) cannot divide by zero or fold the - /// coefficients into `NaN` — it is silently made safe rather than - /// rejected, the same posture [`super::voices::Voice::render_grain`] - /// takes for its own inputs. pub fn design(kind: FilterKind, freq: f32, q: f32, sample_rate: f32) -> Self { let freq = freq.clamp(1.0, sample_rate * 0.499); let q = q.max(0.01); @@ -170,7 +134,6 @@ impl BiquadCoeffs { } } - /// Direct Form I: `y[n] = b0*x[n] + b1*x[n-1] + b2*x[n-2] - a1*y[n-1] - a2*y[n-2]`. pub fn process(&self, state: &mut BiquadState, x: f32) -> f32 { let y = self.b0 * x + self.b1 * state.x1 + self.b2 * state.x2 - self.a1 * state.y1 @@ -183,16 +146,6 @@ impl BiquadCoeffs { } } -/// An attack/decay/sustain/hold/release envelope, in seconds (`sustain` is -/// a level, `0..=1`). There is no note-off in this synth's score model -/// (every score event is a trigger instant, never an on/off pair — see -/// `super::score::ScoreEvent`), so `hold` stands in for "how long to sit -/// at the sustain level" before `release` brings it back to zero: a voice -/// always finishes on its own, which is what lets -/// [`super::voices::Voice::render_grain`] size a finite buffer up front. -/// The common case (`sustain: 0`, `hold: 0`, `release: 0`, the drum-machine -/// defaults `super::voices::Voice` gives every field but `decay`) collapses -/// this to a plain attack/decay percussive shape. #[derive(Debug, Clone, Copy, PartialEq)] pub struct Adsr { pub attack: f32, @@ -207,7 +160,6 @@ impl Adsr { self.attack.max(0.0) + self.decay.max(0.0) + self.hold.max(0.0) + self.release.max(0.0) } - /// The envelope's linear gain at `t` seconds after the trigger. pub fn level_at(&self, t: f32) -> f32 { if t < 0.0 { return 0.0; @@ -238,11 +190,6 @@ impl Adsr { } } -/// `master.compressor` (deliverable #1's `{"threshold": -14, "ratio": 4}`): -/// a standard feedforward compressor, `threshold`/gain in dB, `ratio` as -/// `N:1`. `attack`/`release` are exposed as knobs but not part of the -/// issue's example — [`super::score::CompressorConfig`] defaults them to -/// 5ms/50ms. #[derive(Debug, Clone, Copy)] pub struct CompressorParams { pub threshold_db: f32, @@ -259,9 +206,6 @@ fn lin_to_db(lin: f32) -> f32 { 20.0 * lin.max(1e-9).log10() } -/// One-pole envelope-follower smoothing coefficient for a given time -/// constant: the classic `exp(-1 / (time_seconds * sample_rate))`. `ms -/// <= 0` is instantaneous (no smoothing at all). fn time_const_coeff(ms: f32, sample_rate: f32) -> f32 { if ms <= 0.0 { 0.0 @@ -270,10 +214,6 @@ fn time_const_coeff(ms: f32, sample_rate: f32) -> f32 { } } -/// Feedforward compressor, applied in place to a mono buffer. A peak -/// envelope follower (fast on the way up per `attack_ms`, slow on the way -/// down per `release_ms`) drives the gain-reduction curve above -/// `threshold_db`. pub fn apply_compressor(buffer: &mut [f32], params: CompressorParams, sample_rate: u32) { let sr = sample_rate as f32; let attack_coeff = time_const_coeff(params.attack_ms, sr); @@ -306,22 +246,8 @@ pub fn apply_compressor(buffer: &mut [f32], params: CompressorParams, sample_rat } } -/// The ceiling [`apply_limiter`] holds every sample under. Deliberately -/// below the issue's own "-0.1 dBTP" acceptance bound (not equal to it): -/// this is a sample-peak limiter, not a true-peak (oversampled) one, so a -/// reconstruction filter downstream can still overshoot a ceiling set -/// exactly at the bound. -0.3 dB gives that margin. pub const LIMITER_CEILING_DB: f32 = -0.3; -/// Master-bus peak limiter, applied in place to a mono buffer — deliverable -/// #4/#5: on by default, and the reason it exists at all (the SVG reel's -/// author found his first mix clipping at 0 dB only by reading `ffmpeg` -/// output after the fact). A fast attack / slower release envelope -/// follower drives a soft gain reduction, and every sample is *also* hard -/// clamped to `[-ceiling, ceiling]` afterwards — belt and braces: the -/// smoothed gain alone cannot guarantee zero overshoot on a sample that -/// jumps before the envelope catches up, and this function's entire -/// purpose is that guarantee, not an approximation of it. pub fn apply_limiter(buffer: &mut [f32], sample_rate: u32) { let ceiling = db_to_lin(LIMITER_CEILING_DB); let sr = sample_rate as f32; diff --git a/crates/rustmotion-core/src/audio/mod.rs b/crates/rustmotion-core/src/audio/mod.rs index 3a027e66..1344569c 100644 --- a/crates/rustmotion-core/src/audio/mod.rs +++ b/crates/rustmotion-core/src/audio/mod.rs @@ -1,25 +1,3 @@ -//! Declarative audio synthesis (issue #331): a scenario carries its own -//! soundtrack, synthesised from oscillators/noise/filters on the *same* -//! beat grid as the cuts — no audio file required. An LLM can author a -//! [`score::Score`] (a JSON object of `voices` + `score` + `master`); it -//! cannot hand over a WAV. -//! -//! - [`dsp`] — dependency-free primitives: oscillators, a biquad filter, -//! an ADSR envelope, a compressor, a limiter. -//! - [`voices`] — [`voices::Voice`], one instrument definition, and the -//! code that renders one trigger of it into a grain of samples. -//! - [`score`] — [`score::Score`]/[`score::ScoreEvent`], the JSON schema -//! for the timeline, and the [`crate::schema::time::TimePoint`] -//! resolution that expands a repeating event into concrete hit instants. -//! - [`synth`] — [`synth::render`], the single entry point that ties the -//! above together into a finished, mixed, mastered buffer. -//! -//! `schema::scenario::AudioConfig` (the object form of `Scenario::audio`) -//! is this module's only caller inside `rustmotion-core`; the offline -//! render-to-WAV-then-mux bridge lives in `rustmotion`'s `encode` crate, -//! which joins the synthesised buffer into the existing file-based -//! [`crate::schema::AudioTrack`] mixer rather than replacing it. - pub mod dsp; pub mod score; pub mod synth; diff --git a/crates/rustmotion-core/src/audio/score.rs b/crates/rustmotion-core/src/audio/score.rs index a7fe1356..29f1bf03 100644 --- a/crates/rustmotion-core/src/audio/score.rs +++ b/crates/rustmotion-core/src/audio/score.rs @@ -1,9 +1,3 @@ -//! [`Score`]: the declarative timeline — `voices` (see -//! [`super::voices::Voice`]) plus a list of [`ScoreEvent`]s that trigger -//! them, resolved against the scenario's own beat grid via -//! [`crate::schema::time::TimePoint`]/[`crate::schema::time::TimeCtx`] (the -//! frozen contract this whole workstream shares with `Scene::at`). - use std::collections::HashMap; use schemars::JsonSchema; @@ -117,8 +111,6 @@ pub struct Score { pub master: MasterBus, } -/// Everything that can go wrong turning a [`Score`] into a set of hit -/// instants. #[derive(Debug, Clone, PartialEq, thiserror::Error)] pub enum ScoreError { #[error("score event references unknown voice '{0}' (not declared in `voices`)")] @@ -142,9 +134,6 @@ pub enum ScoreError { }, } -/// Safety cap on how many times a single [`ScoreEvent`] may repeat, so a -/// typo like `"every": "1ms"` over a multi-minute scenario fails fast with -/// a named error instead of allocating a multi-gigabyte hit list. const MAX_HITS_PER_EVENT: usize = 100_000; impl Score { @@ -152,11 +141,6 @@ impl Score { self.voices.is_empty() && self.score.is_empty() } - /// Every event resolved into a flat list of absolute-scenario-seconds - /// hit instants, grouped by voice name. `scenario_duration` is the - /// default `to` for a repeating event that never names one (the `hat` - /// voice in the issue's example) — see the caller in `rustmotion`'s - /// `encode` crate for how that duration is computed. pub fn resolve_hits( &self, ctx: &TimeCtx, @@ -174,10 +158,6 @@ impl Score { } } -/// `at`/`from`/`to`: instants, resolved with the real `beat_offset` — -/// identical semantics to [`crate::schema::Scene::at`] (score events are -/// never nested inside a scene, so `ctx.scene_start` is always `0.0` and -/// `resolve_relative`/`resolve_absolute` agree). fn resolve_instant(tp: &TimePoint, ctx: &TimeCtx, voice: &str) -> Result { tp.resolve_relative(ctx).map_err(|source| ScoreError::Time { voice: voice.to_string(), @@ -185,15 +165,6 @@ fn resolve_instant(tp: &TimePoint, ctx: &TimeCtx, voice: &str) -> Result Result { let duration_ctx = TimeCtx { beat_offset: 0.0, @@ -278,8 +249,6 @@ mod tests { #[test] fn every_1b_is_one_beat_regardless_of_beat_offset() { - // bpm=120 -> one beat is 0.5s. A nonzero beat_offset must not leak - // into the *interval* length (only into where "from" lands). let c = ctx(Some(120.0), 2.2); let d = resolve_duration(&tp("1b"), &c, "kick").unwrap(); assert!( diff --git a/crates/rustmotion-core/src/audio/synth.rs b/crates/rustmotion-core/src/audio/synth.rs index 76a9c603..c6e67685 100644 --- a/crates/rustmotion-core/src/audio/synth.rs +++ b/crates/rustmotion-core/src/audio/synth.rs @@ -1,10 +1,3 @@ -//! [`render`]: the single entry point that turns a [`Score`] into a -//! finished, mixed, mastered stereo buffer — the "small, boring synth" -//! deliverable's top-level orchestration. Everything else in this module -//! (`dsp`, `voices`, `score`) is a building block this function assembles; -//! nothing outside `rustmotion-core::audio` needs to call anything but -//! this. - use std::collections::HashMap; use crate::schema::time::TimeCtx; @@ -12,28 +5,8 @@ use crate::schema::time::TimeCtx; use super::dsp; use super::score::{Score, ScoreError}; -/// Sample rate the synth renders at, per deliverable #2 ("rendered offline -/// to an f32 buffer at 48 kHz"). The file-based [`crate::schema::AudioTrack`] -/// mixer downstream (`rustmotion`'s `encode` crate) declares a different, -/// fixed rate for its own muxed PCM — the exact same resampling path an -/// ordinary 48kHz source file already goes through there carries this -/// buffer down to that rate, so nothing in this crate needs to duplicate -/// it. pub const SYNTH_SAMPLE_RATE: u32 = 48_000; -/// Renders `score` into an interleaved stereo `f32` buffer, `duration_secs` -/// long at [`SYNTH_SAMPLE_RATE`] (mono voices, duplicated to both -/// channels — this synth has no panning model). `ctx` is the scenario's own -/// [`TimeCtx`] (real `bpm`/`beat_offset`, `scene_start: 0.0` — a score is -/// never nested inside a scene): every `TimePoint` in `score.score` resolves -/// against it, which is what puts a kick on the same instant as a beat-grid -/// scene cut. -/// -/// Deterministic: same `score` + `ctx` + `duration_secs` always produces -/// the same bytes (no wall-clock or thread-seeded randomness anywhere in -/// this crate's synth — see [`dsp::Xorshift32`]'s doc). That is the -/// property `rustmotion`'s `--frames a-b` slicing and the "two renders are -/// byte-identical" acceptance criterion both depend on. pub fn render(score: &Score, ctx: TimeCtx, duration_secs: f64) -> Result, ScoreError> { let duration_secs = duration_secs.max(0.0); let num_samples = (duration_secs * SYNTH_SAMPLE_RATE as f64).ceil() as usize; @@ -41,19 +14,9 @@ pub fn render(score: &Score, ctx: TimeCtx, duration_secs: f64) -> Result = hits.keys().collect(); voice_names.sort(); - // Every trigger of a given voice renders to the identical grain (see - // `Voice::render_grain`'s doc) — render each voice once and stamp it - // onto the timeline per hit, rather than re-synthesizing per hit. let mut grains: HashMap<&str, Vec> = HashMap::new(); for name in &voice_names { if let Some(voice) = score.voices.get(*name) { diff --git a/crates/rustmotion-core/src/audio/voices.rs b/crates/rustmotion-core/src/audio/voices.rs index 208e2eae..eebd21b2 100644 --- a/crates/rustmotion-core/src/audio/voices.rs +++ b/crates/rustmotion-core/src/audio/voices.rs @@ -1,10 +1,3 @@ -//! [`Voice`]: one instrument definition in a [`super::score::Score`] — the -//! `"kick"`/`"hat"` entries of the issue's `voices` map. A `Voice` is -//! stateless data; [`Voice::render_grain`] is the only place it turns into -//! samples, producing one self-contained "grain" (attack through release, -//! silence before and after) that [`super::synth::render`] then stamps -//! onto the timeline once per resolved hit. - use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -25,9 +18,6 @@ pub enum FreqSpec { } fn default_q() -> f32 { - // Butterworth Q — maximally flat passband, the least surprising - // default when a score gives a filter's `freq` but not its `q` (the - // issue's `hat` example does exactly this). 0.707 } @@ -44,9 +34,6 @@ pub struct FilterSpec { } fn default_attack() -> f32 { - // 2ms: enough to avoid a hard-edge click on a one-shot trigger, - // short enough to still read as instant next to `decay` (tens/hundreds - // of ms) on a drum voice. 0.002 } @@ -111,11 +98,6 @@ impl Voice { } } - /// Renders exactly one trigger of this voice — a self-contained mono - /// grain, `envelope().total_duration()` seconds long at `sample_rate`, - /// starting and ending at silence. Identical every time (no dependency - /// on *when* it is triggered), so [`super::synth::render`] renders it - /// once per voice and reuses it for every resolved hit. pub fn render_grain(&self, sample_rate: u32) -> Vec { let envelope = self.envelope(); let sr = sample_rate as f32; diff --git a/crates/rustmotion-core/src/css/animation.rs b/crates/rustmotion-core/src/css/animation.rs index 4260e524..67f9d950 100644 --- a/crates/rustmotion-core/src/css/animation.rs +++ b/crates/rustmotion-core/src/css/animation.rs @@ -1,40 +1,9 @@ -//! Bridge from the legacy `AnimatedProperties` (Flutter-style) to CSS-style -//! overrides on `CssStyle`. Used by the new pipeline so animations resolved -//! through `animator::resolve_props_for_effects` flow into `transform`, -//! `opacity`, `filter`, etc. before layout/paint. -//! -//! This is a transitional module: once all animation surfaces are CSS-native, -//! the animator can produce `CssStyle` overrides directly and this bridge -//! disappears. -//! -//! # A second caller (issue #338) -//! -//! [`apply_animated_props`] has exactly one caller-shape requirement: an -//! [`AnimatedProperties`] whose non-default fields are the ones to apply. -//! `crate::css::computed::ComputedStyle::resolve` produces exactly that -//! shape from a node's per-frame expressions, so `box_builder.rs` calls this -//! same function a second time per node — once for a resolved animation, -//! once for resolved expressions — rather than this module growing a -//! parallel "apply an expression override" path. See that module's doc for -//! why an expression is "a second source of the same kind of override," and -//! `box_builder.rs` for the resulting precedence (expressions apply after -//! animations, so they compose the same way animations already compose with -//! a literal `CssStyle` value). - use crate::css::style::CssStyle; use crate::css::style::{FilterFn, Size, TransformFn}; use crate::css::units::{Length, LengthPercentage}; use crate::engine::animator::AnimatedProperties; -/// Apply the resolved animation properties as a partial CSS override on top -/// of an existing `CssStyle`. Only properties that the animator actually -/// touched (vs. their `Default` sentinels) are written. -/// -/// Order matters: the resulting `transform` list mirrors the legacy paint -/// order (translate → scale → rotate → 3D rotate). Matching `transform-origin` -/// is the box centre, which the paint pass already defaults to. pub fn apply_animated_props(css: &mut CssStyle, props: &AnimatedProperties) { - // ---- transform list ---- let mut tx: Vec = Vec::new(); if props.translate_x != 0.0 || props.translate_y != 0.0 { tx.push(TransformFn::Translate { @@ -63,23 +32,17 @@ pub fn apply_animated_props(css: &mut CssStyle, props: &AnimatedProperties) { }); } if !tx.is_empty() { - // Append rather than replace so a CSS-defined transform composes with - // the animation-derived one (CSS transforms are post-multiplied). match css.transform.as_mut() { Some(existing) => existing.extend(tx), None => css.transform = Some(tx), } } - // ---- opacity ---- - // Only write if it differs from the default (1.0). The animator sets - // `opacity = 1.0` as default, so any other value is meaningful. if (props.opacity - 1.0).abs() > 1e-4 { let base = css.opacity.unwrap_or(1.0); css.opacity = Some(base * props.opacity); } - // ---- filter (blur + glow) ---- let mut filters: Vec = Vec::new(); if props.blur > 0.0 { filters.push(FilterFn::Blur { @@ -101,31 +64,16 @@ pub fn apply_animated_props(css: &mut CssStyle, props: &AnimatedProperties) { } } - // ---- perspective ---- if props.perspective > 0.0 { css.perspective = Some(Length::Px(props.perspective)); } - // ---- box size ---- - // An animated `width`/`height` has to reach taffy, not just the painter: - // resizing a card is a *layout* change (its children reflow inside the new - // box), which is what separates it from a `scale` transform stretching the - // pixels it already had. The animator's sentinel for "never animated" is - // -1.0 (see `AnimatedProperties::default`), so a 0 is a real, authored 0. - // The layout pass runs per frame, so writing here is enough. if props.width >= 0.0 { css.width = Some(Size::Length(LengthPercentage::Px(props.width))); } if props.height >= 0.0 { css.height = Some(Size::Length(LengthPercentage::Px(props.height))); } - - // Note: `border_radius`, `font_size`, `gap`, `padding`, `stroke_width`, - // `shadow_blur`, `draw_progress`, `motion_progress`, `visible_chars*`, - // `char_animation`, `color` are NOT translated to CSS here. Those are - // component-internal animations and remain accessible through the legacy - // props on the dispatcher path. They will move into CSS once each - // component's painter is migrated. } #[cfg(test)] @@ -178,16 +126,6 @@ mod tests { assert!(css.perspective.is_none()); } - /// `motion_path` (`engine::animator::apply_motion_paths`) writes its - /// resolved position into `translate_x`/`translate_y` and its - /// tangent-derived orientation into `rotation` — the exact same fields - /// `orbit`/presets already write. This is the unit-level half of the - /// proof that a `motion_path` excursion reaches `css.transform` (and - /// therefore `--strict-anim`'s viewport check, which folds - /// `css.transform`): shape an `AnimatedProperties` the way that effect - /// would, at a moment where it has both moved *and* turned, and confirm - /// both land in the transform list in the documented translate→rotate - /// order — no new bridge, no separate channel. #[test] fn motion_path_shaped_translate_and_rotation_compose_into_one_transform_list() { let mut css = CssStyle::default(); diff --git a/crates/rustmotion-core/src/css/cascade.rs b/crates/rustmotion-core/src/css/cascade.rs index eaa5deec..59ee551e 100644 --- a/crates/rustmotion-core/src/css/cascade.rs +++ b/crates/rustmotion-core/src/css/cascade.rs @@ -1,30 +1,5 @@ -//! CSS cascade & inheritance. -//! -//! Walks the BoxTree and propagates inheritable properties from parent to -//! child when the child has not specified them. Properties not in the -//! inheritable list are left untouched (initial values applied at paint time). -//! -//! Inheritable properties (per CSS spec, restricted to our scope): -//! - `color` -//! - `font-family` -//! - `font-size` -//! - `font-weight` -//! - `font-style` -//! - `line-height` -//! - `letter-spacing` -//! - `text-align` -//! - `white-space` -//! - `overflow-wrap` -//! - `visibility` -//! - `text-decoration` (not strictly inheritable but used as such here) -//! -//! All other properties (background, padding, border, transform, etc.) are -//! NOT inherited. - use super::style::CssStyle; -/// Inherit unset child properties from the parent. `parent` is the already -/// resolved style (post-cascade) of the parent node. pub fn inherit_from(parent: &CssStyle, child: &mut CssStyle) { if child.color.is_none() { child.color = parent.color.clone(); @@ -105,7 +80,6 @@ mod tests { }; let mut child = CssStyle::default(); inherit_from(&parent, &mut child); - // display is not inherited assert!(child.display.is_none()); } diff --git a/crates/rustmotion-core/src/css/computed.rs b/crates/rustmotion-core/src/css/computed.rs index 876c593c..b228e339 100644 --- a/crates/rustmotion-core/src/css/computed.rs +++ b/crates/rustmotion-core/src/css/computed.rs @@ -1,53 +1,9 @@ -//! Where a `"= ..."` expression on a style property actually lands once it -//! survives past `rustmotion::loader::fold_static_expressions` — i.e. once -//! it reads something only known per frame (`$t`, `$T`, `$beat`, a declared -//! `vars` name, or a `node(...)` reference) and therefore cannot be folded -//! to a literal at load time. See [`crate::expr`]'s module doc for the full -//! two-tier model this is the dynamic half of (issue #338). -//! -//! [`Computed`](crate::expr::Computed) already solves "a JSON value is -//! either a literal `T` or an `"= ..."` expression" *per field*, but -//! retyping [`crate::css::CssStyle`]'s existing fields (`opacity: Option`, -//! ...) to `Computed` would change their Rust type for every one of the -//! many consumers across this workspace that read them directly — not this -//! workstream's call to make. [`ComputedStyle`] is the alternative: a -//! sibling, additive field on `CssStyle` that holds the *parsed* [`Expr`] -//! for whichever of a small, fixed set of properties carried one, leaving -//! every existing typed field exactly as it was (either a literal, or -//! simply unset when the author wrote an expression instead). See -//! [`extract`] for how a `"= ..."` string moves from the raw JSON into this -//! side channel, and `CssStyle`'s own hand-written `Deserialize` impl -//! (`style.rs`) for where that happens — exactly once, at load, never -//! per frame. - use serde_json::{Map, Value}; use crate::engine::animator::AnimatedProperties; use crate::engine::deps::NodeRef; use crate::expr::{Expr, ExprError, Scope}; -/// Parsed, not-yet-evaluated `"= ..."` expressions pulled off a -/// [`crate::css::CssStyle`] at deserialize time — one [`Expr`] per style -/// property that accepts one. A property is either a literal (its normal -/// typed field on `CssStyle`) or an expression (a field here), never both: -/// [`extract`] removes the `"= ..."` string from the JSON before the typed -/// field is deserialized, so the typed field is simply absent when this one -/// is populated. -/// -/// Every [`Expr`] here was parsed exactly once, by [`extract`], when the -/// scenario was loaded — [`Self::resolve`] only ever calls [`Expr::eval`] -/// on it afterwards, once per sampled frame. See [`crate::expr`]'s "Two -/// evaluation tiers" doc: this struct is that second tier's landing zone. -/// -/// # Coverage -/// -/// `opacity`, `width`, `height`, and — inside a `style.transform` array -/// entry — the `x`/`y` of `translate`/`translate-x`/`translate-y`/`scale`/ -/// `scale-x`/`scale-y` and the `deg` of `rotate`. Every other `CssStyle` -/// property (colors, other lengths, enums, `z`/3d transform functions, -/// `skew*`, `perspective`, `matrix`/`matrix3d`, ...) does not accept an -/// expression yet — a `"= ..."` string there still fails deserialization -/// exactly as it did before this module existed. #[derive(Debug, Clone, Default, PartialEq)] pub struct ComputedStyle { pub opacity: Option, @@ -58,30 +14,10 @@ pub struct ComputedStyle { pub scale_x: Option, pub scale_y: Option, pub rotate: Option, - /// Every `node("id", "prop")` call found across this node's own - /// populated expressions above — the `(String, Vec)` shape - /// [`crate::engine::deps::DepGraph::build`] wants, pre-computed once at - /// [`extract`] time (issue #328's join to #338). [`Expr`] itself - /// compiles the same information internally but does not expose it - /// (see `engine::deps`'s module doc, "Why text metrics are a trait" — - /// no, rather its `scan_node_refs` doc — for why this crate re-derives - /// it from the raw `"= ..."` source text instead of reaching into - /// `Expr`'s private fields), so [`extract`] runs - /// [`crate::engine::deps::scan_node_refs`] on each expression's source - /// the moment it has it in hand, right before that source is discarded. - /// Empty for the overwhelming common case (no expression at all, or an - /// expression that only reads `$name`s) — same zero-cost-when-unused - /// shape as every other field here. pub node_refs: Vec, } impl ComputedStyle { - /// True when this node's style carries no expression at all — the - /// common case. `box_builder.rs` checks this before building a - /// [`Scope`] or calling [`Self::resolve`], so a scenario that never - /// writes `"= ..."` on a style property pays nothing beyond this one - /// field-count check, per node, per frame — no `Expr::eval`, no `Scope` - /// construction, no [`AnimatedProperties`] built or applied. pub fn is_empty(&self) -> bool { self.opacity.is_none() && self.width.is_none() @@ -93,27 +29,7 @@ impl ComputedStyle { && self.rotate.is_none() } - /// `self`'s own populated fields win; `base`'s fill in whatever `self` - /// left `None`. Used by `box_builder.rs::apply_style_states`, whose - /// timeline-style-state merge round-trips `CssStyle` through - /// `Serialize`/`Deserialize` — `expr` is `#[serde(skip)]` on that round - /// trip (see `style.rs`), so the node's original expressions would - /// otherwise vanish the instant it has any `timeline` style state. A - /// state's own `style` block can itself carry a fresh expression on the - /// same property (rare, but not disallowed) — that one is `self` here, - /// and takes precedence. pub fn prefer(self, base: ComputedStyle) -> ComputedStyle { - // `node_refs` is a per-field-agnostic union rather than a - // per-field "self wins" pick like every field above: knowing - // *which* of the 8 slots each `NodeRef` came from would need - // per-field storage this struct doesn't keep (see the field's own - // doc). A union can only ever add a dependency-graph edge that a - // precise per-field pick wouldn't have — never drop a real one — - // so the worst case is an unnecessary ordering constraint, never a - // reference silently failing to resolve. Duplicate `NodeRef`s - // across `self`/`base` are harmless: `DepGraph::build` counts an - // edge once per occurrence on both sides of Kahn's algorithm, so a - // duplicate self-corrects instead of leaving a dangling count. let mut node_refs = self.node_refs; node_refs.extend(base.node_refs.iter().cloned()); ComputedStyle { @@ -129,20 +45,6 @@ impl ComputedStyle { } } - /// Evaluate every populated expression against `scope` and return the - /// result shaped as an [`AnimatedProperties`] — ready for - /// [`crate::css::apply_animated_props`], the exact same per-frame - /// override path a resolved animation already goes through (see - /// `css::animation`'s module doc and issue #338). `AnimatedProperties`'s - /// own `Default` already carries the correct neutral/identity value for - /// every field this touches (opacity 1.0, scale 1.0, translate/rotation - /// 0.0, width/height the `-1.0` "unset" sentinel — see that type's own - /// `Default` impl), so an unpopulated field here is simply left at that - /// default rather than needing its own sentinel logic. - /// - /// Stops at the first property whose expression fails to evaluate, - /// naming which one in [`ComputedError::property`] — never a silent - /// zero, never a panic (issue #338's deliverable #4). pub fn resolve(&self, scope: &dyn Scope) -> Result { let mut props = AnimatedProperties::default(); if let Some(e) = &self.opacity { @@ -180,12 +82,6 @@ fn eval_named(expr: &Expr, scope: &dyn Scope, property: &str) -> Result) -> Result { let mut node_refs = Vec::new(); let mut out = ComputedStyle { @@ -306,32 +182,9 @@ fn take_leaf( Ok(()) } -/// A minimal, self-contained [`Scope`] answering exactly the reserved -/// scenario-clock names [`crate::expr::eval`]'s own `is_dynamic_var_name` -/// treats specially (`t`, `T`, `duration` — see [`crate::expr`]'s "Two -/// evaluation tiers" doc) plus `W`/`H`/`fps`, all derivable from the same -/// per-frame context `box_builder.rs` already threads through the box tree -/// (`BuildAnimationCtx` plus the viewport size) — no `vars` table, no -/// `node(...)` dependency graph. `box_builder.rs` builds one of these for -/// every frame that has any expression to resolve at all, with zero new -/// parameters on any of its existing, publicly-called functions — see that -/// file's own doc for why that constraint mattered. -/// -/// `beat` is deliberately not answered here: nothing reaching this scope -/// knows the scenario's BPM. An expression naming `$beat` (or a declared -/// `vars` name, or a `node(...)` call) gets a real -/// [`crate::expr::ExprError::UnknownIdent`] back from [`Scope::var`]/ -/// [`Scope::node_prop`]'s default `None` — the same "not defined in this -/// scope" contract every [`Scope`] impl in this codebase already uses, not -/// a special case. A caller with a richer context (a `vars::VarScope`, a -/// `node(...)` dependency graph) answers those by composing its own `Scope` -/// impl instead of this one — see `vars::scope`'s module doc for exactly -/// this composition pattern. #[derive(Debug, Clone, Copy, PartialEq)] pub struct FrameClock { - /// Scene-local time in seconds — resets to 0 at the start of each scene. pub t: f64, - /// Scenario-absolute time in seconds — never resets. pub t_abs: f64, pub duration: f64, pub width: f64, @@ -353,32 +206,6 @@ impl Scope for FrameClock { } } -/// Composes [`FrameClock`] with a caller-supplied outer [`Scope`] — the -/// join issue #326's decomposition left open: a declared `vars` name and a -/// `node(...)` reference both answer through *some* richer `Scope`, but -/// nothing upstream of `box_builder.rs`'s per-node [`ComputedStyle::resolve`] -/// call had one to hand it. [`vars::scope`](crate::vars::scope)'s own module -/// doc explains why this is a struct holding both rather than one `Scope` -/// wrapping another: a `Scope` is only ever consumed behind `&dyn Scope`, -/// and trait objects don't nest. -/// -/// # Order -/// -/// [`Scope::var`] tries `clock` first, `outer` second. The six names -/// [`FrameClock`] answers (`t`, `T`, `duration`, `W`, `H`, `fps`) are -/// reserved — see that type's own doc — and must always win over a -/// same-named declared variable rather than being shadowable by one; trying -/// `clock` first is what makes that true regardless of what `outer` -/// happens to answer. [`Scope::node_prop`] only ever reaches `outer` — -/// `FrameClock` has no notion of another node's state and never will (it is -/// built fresh, per node, from data with no dependency-graph position of -/// its own). -/// -/// `outer` is `None` for a build that has no richer context to offer (no -/// declared `vars`, no node ids in the scene) — [`Scope::var`] then behaves -/// exactly as a bare [`FrameClock`] would, byte for byte, which is what -/// keeps an expression-free-of-`vars`-and-`node(...)` scenario's render -/// unaffected by this type existing at all. pub struct ComposedScope<'a> { pub clock: FrameClock, pub outer: Option<&'a dyn Scope>, @@ -592,7 +419,7 @@ mod tests { fn var(&self, name: &str) -> Option { match name { "fade" => Some(0.25), - "t" => Some(999.0), // must never win: `clock` is reserved. + "t" => Some(999.0), _ => None, } } diff --git a/crates/rustmotion-core/src/css/mod.rs b/crates/rustmotion-core/src/css/mod.rs index 37cbf59e..003a729d 100644 --- a/crates/rustmotion-core/src/css/mod.rs +++ b/crates/rustmotion-core/src/css/mod.rs @@ -1,14 +1,3 @@ -//! CSS-like styling system for the browser-mode renderer. -//! -//! Inspired by Remotion (React inline styles) and CSS box model. -//! Provides: -//! - `CssStyle`: a struct mirroring the CSS properties supported by the engine -//! - `units`: parsing/resolution of CSS lengths (px, %, em, rem, vw, vh, fr, auto) -//! - `cascade`: parent → child inheritance pass -//! - `taffy_bridge`: conversion `CssStyle` → `taffy::Style` for layout -//! -//! Layout is computed by [`taffy`]; paint is done by Skia in `engine::paint_pass`. - pub mod animation; pub mod cascade; pub mod computed; diff --git a/crates/rustmotion-core/src/css/style.rs b/crates/rustmotion-core/src/css/style.rs index 5a52698c..73700b92 100644 --- a/crates/rustmotion-core/src/css/style.rs +++ b/crates/rustmotion-core/src/css/style.rs @@ -1,62 +1,12 @@ -//! `CssStyle` — typed mirror of the CSS properties supported by the engine. -//! -//! Scope: Remotion-equivalent (Flex, Grid, Block, transforms 2D/3D, filters, -//! gradients, position absolute/relative, box-shadow, border-radius, opacity, -//! clip-path). Excludes: inline boxes, floats, tables, position sticky/fixed. - use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use super::computed; use super::units::{Length, LengthContext, LengthPercentage, ParsedLength}; -// `GradientBorder` / `InnerShadow` are reused from the schema layer rather -// than mirrored: same crate, same serde/JsonSchema derives, identical JSON -// shape either way — a css-local mirror would only duplicate the struct. use crate::schema::{deserialize_animation_effects, AnimationEffect, GradientBorder, InnerShadow}; -// ─── Legibility floor (relocated from `rustmotion/src/cli/commands/ -// geometry.rs`'s `check_legibility`, issue #110/#102 — moved here, not -// duplicated, so `text-autofit` below can shrink down to the exact same -// calibrated threshold instead of inventing a second one; `rustmotion` -// depends on `rustmotion-core`, never the other way around, so the shared -// value has to live on this side of that boundary) ───────────────────────── -// -// Threshold justification (rendered evidence, not a guess): a 1920×1080 -// scenario was rendered with the same sample line at 8/10/11/12/13/14/16/18/ -// 20/22/24/28px, then the frame was scaled down 50% (a realistic "not -// full-native" viewing size) to inspect. 8–13px degraded to an illegible -// grey smear at that scale; 14px was the first size that stayed readable. -// 0.012 (1.2% of output height) sits between those two bands — it equals -// ~13px on a 1080p frame — and clears every built-in component default -// already shipped (table/terminal/codeblock/pill_nav = 14px, badge `md` = -// 14px, kbd = 14px, tooltip = 13px), so it does not fire on scenarios that -// already validate clean today. Expressing it as a fraction of output -// height (rather than an absolute px count) makes the same *visual* size -// get flagged on a 4K or vertical-format canvas too. pub const MIN_LEGIBLE_FONT_RATIO: f32 = 0.012; -/// [`MIN_LEGIBLE_FONT_RATIO`] evaluated at a fixed 1920×1080 reference -/// canvas (≈12.96px) — `text-autofit`'s shrink floor. -/// -/// This is deliberately **not** `MIN_LEGIBLE_FONT_RATIO * scenario.video. -/// height`, unlike `check_legibility`'s own per-scenario check. Reason: -/// `text-autofit` must resolve to the *identical* px value wherever it's -/// computed (`TextIntrinsic::measure`, which runs pre-layout inside -/// `box_builder.rs`, and `Text`/`GradientText`'s painters, which run -/// post-layout with a real `PaintCtx`) — see the measure/paint parity -/// argument on `CssStyle::text_autofit`. `box_builder.rs` does not thread -/// the real `VideoConfig` down to where `TextIntrinsic` is constructed (out -/// of this workstream's file scope), so the painter side cannot be allowed -/// to use the real, more accurate `ctx.video_height` either — doing so would -/// silently reintroduce exactly the measure-vs-paint divergence this -/// workstream exists to prevent, just relocated from "the box" to "the -/// floor". Pinning both sides to the same fixed reference trades per-canvas -/// precision (a vertical 1080×2256 scenario's *true* 1.2%-of-height floor is -/// larger than this) for the non-negotiable guarantee that they agree. This -/// does not weaken `check_legibility` itself: that check still runs -/// independently, against the real canvas, on whatever `font-size` was -/// authored — it has no visibility into `text-autofit`'s runtime output -/// either way (see the workstream report's "non traité" list). pub const TEXT_AUTOFIT_MIN_FONT_PX: f32 = MIN_LEGIBLE_FONT_RATIO * 1080.0; /// Top-level CSS style block. All fields are optional; `None` means "not set" @@ -78,7 +28,6 @@ pub const TEXT_AUTOFIT_MIN_FONT_PX: f32 = MIN_LEGIBLE_FONT_RATIO * 1080.0; #[derive(Debug, Clone, Default, PartialEq, Serialize, JsonSchema)] #[serde(default, deny_unknown_fields, rename_all = "kebab-case")] pub struct CssStyle { - // ---- Layout / box ---- pub display: Option, pub position: Option, pub top: Option, @@ -99,7 +48,6 @@ pub struct CssStyle { pub box_sizing: Option, pub aspect_ratio: Option, - // ---- Flex ---- pub flex_direction: Option, pub flex_wrap: Option, pub justify_content: Option, @@ -112,7 +60,6 @@ pub struct CssStyle { pub flex_basis: Option, pub order: Option, - // ---- Grid ---- pub grid_template_columns: Option>, pub grid_template_rows: Option>, pub grid_column: Option, @@ -121,7 +68,6 @@ pub struct CssStyle { pub justify_items: Option, pub justify_self: Option, - // ---- Typography (most are inherited) ---- pub font_family: Option, pub font_size: Option, pub font_weight: Option, @@ -180,7 +126,6 @@ pub struct CssStyle { /// silence it. pub text_autofit: Option, - // ---- Visual ---- pub background: Option, pub border_radius: Option, pub box_shadow: Option>, @@ -193,23 +138,19 @@ pub struct CssStyle { /// convention as `background` linear gradients. pub gradient_border: Option, - // ---- Legacy compat (accepted, never rendered — validator warns) ---- /// Deprecated: use `backdrop-filter: [{ "fn": "blur", "radius": N }]`. pub backdrop_blur: Option, /// Deprecated: use `box-shadow` with `"inset": true`. pub inner_shadow: Option, - // ---- Filters / effects ---- pub filter: Option>, pub backdrop_filter: Option>, - // ---- Transform ---- pub transform: Option>, pub transform_origin: Option, pub perspective: Option, pub perspective_origin: Option, - // ---- Scene-camera parallax ---- /// Parallax plane depth for the scene camera (issue #90). 0 = locked /// plane (the camera does not affect it), 1 = normal plane (default), /// above 1 = amplified foreground. v1: effective on direct children of @@ -217,14 +158,12 @@ pub struct CssStyle { /// governs its whole subtree). Not inherited via cascade. pub depth: Option, - // ---- Overflow / stacking ---- pub overflow: Option, pub overflow_x: Option, pub overflow_y: Option, pub z_index: Option, pub visibility: Option, - // ---- Animation ---- #[serde(default, deserialize_with = "deserialize_animation_effects")] pub animation: Vec, /// Smoothing for `timeline` style-state changes. Supported properties: @@ -239,11 +178,9 @@ pub struct CssStyle { /// which property and why whenever it would otherwise snap silently. pub transition: Option, - // ---- Audio reactive binding ---- #[serde(default)] pub audio_reactive: Option, - // ---- Per-frame expression overrides (issue #338) ---- /// The `"= ..."` expressions [`computed::extract`] pulled off this /// node's `opacity`/`width`/`height`/covered `transform` leaves at /// deserialize time — never part of the wire format (`#[serde(skip)]`: @@ -255,15 +192,6 @@ pub struct CssStyle { pub expr: computed::ComputedStyle, } -/// Field-for-field mirror of [`CssStyle`], used only as a `#[serde(remote)]` -/// deserialization target — see [`CssStyle`]'s own doc, "`Deserialize` is -/// hand-written, not derived". Never constructed directly; the derive macro -/// generates `CssStyleWire::deserialize(d) -> Result` as -/// an inherent function, which is all [`CssStyle`]'s hand-written impl below -/// calls. Keep this in sync with [`CssStyle`]'s own field list — a field -/// added to one and not the other fails to compile (a `remote` mismatch is a -/// type error, never a silent divergence), so drift cannot survive `cargo -/// check`. #[derive(Deserialize, Default)] #[serde( remote = "CssStyle", @@ -366,15 +294,6 @@ struct CssStyleWire { } impl<'de> Deserialize<'de> for CssStyle { - /// Two steps, in order: (1) [`computed::extract`] peels any `"= ..."` - /// expression off `opacity`/`width`/`height`/a covered `transform` leaf, - /// mutating a `serde_json::Value` in place so the fields it touched are - /// left either absent or holding a neutral literal; (2) the cleaned - /// `Value` goes through [`CssStyleWire`]'s derived (ordinary, - /// `deny_unknown_fields`) logic for everything else. A style object that - /// isn't even a JSON object (malformed input) skips step 1 — there is - /// nothing to extract from — and step 2 then fails exactly as the old - /// derived impl would have. fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, @@ -407,8 +326,6 @@ fn default_transition_easing() -> crate::schema::EasingType { crate::schema::EasingType::EaseInOut } -// ---- Audio reactive binding ---- - /// Bind a CSS property to audio analysis data. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] @@ -472,24 +389,15 @@ impl StyleTransition { } } -// ---- Painter convenience accessors ---- -// -// These resolve raw CssStyle values to the simple primitives that component -// painters deal with: f32 px, &str hex colors, etc. They drop unsupported -// units (em/rem/% with no parent context). Painters that need full -// resolution should use `Length::resolve(&LengthContext)` directly. impl CssStyle { - /// `font-size` in px, falling back to `default` when unset. pub fn font_size_px_or(&self, default: f32) -> f32 { self.font_size.as_ref().map(|l| l.px()).unwrap_or(default) } - /// `font-size` in px if set as a length. pub fn font_size_px(&self) -> Option { self.font_size.as_ref().map(|l| l.px()) } - /// `color` as a hex string (only `Color::String` returns Some). pub fn color_str(&self) -> Option<&str> { match &self.color { Some(Color::String(s)) => Some(s.as_str()), @@ -497,29 +405,22 @@ impl CssStyle { } } - /// `color` as a hex string with default fallback. pub fn color_str_or<'a>(&'a self, default: &'a str) -> &'a str { self.color_str().unwrap_or(default) } - /// `font-family` string. pub fn font_family_str(&self) -> Option<&str> { self.font_family.as_deref() } - /// `font-family` string with default fallback. pub fn font_family_or<'a>(&'a self, default: &'a str) -> &'a str { self.font_family.as_deref().unwrap_or(default) } - /// `letter-spacing` in px, defaulting to 0. pub fn letter_spacing_px(&self) -> f32 { self.letter_spacing.as_ref().map(|l| l.px()).unwrap_or(0.0) } - /// `line-height` resolution. For `Number` (unitless) returns - /// `n * font_size`; for `Length(px)` returns the px value; otherwise - /// returns `1.3 * font_size`. pub fn line_height_for(&self, font_size: f32) -> f32 { match &self.line_height { Some(LineHeight::Number(n)) => n * font_size, @@ -528,61 +429,6 @@ impl CssStyle { } } - // ---- Context-aware typography resolution (issue #125 §2) ---- - // - // `font_size_px_or`/`letter_spacing_px`/`line_height_for` above are the - // context-free accessors ~50+ call sites across the engine use; they go - // through `Length::px()`, which cannot resolve `%`/`em`/`rem`/`vw`/`vh` - // (no `LengthContext` reaches them) and — as of this fix — warns loudly - // instead of silently dropping to `0px` when the value actually is one - // of those units (see `units::px_or_warn`). That is the "fail loudly" - // half of issue #125 §2. - // - // The methods below are the "or work" half: given a `LengthContext`, - // they resolve `%`/`em`/`rem`/`vw`/`vh` correctly for these three - // properties specifically, honouring the CSS rule that `em` means two - // different things depending on which property it's on: - // - on `font-size` itself, `em` is relative to the *parent's* computed - // font-size — i.e. `ctx.font_size` going in. - // - on `letter-spacing` / `line-height`, `em` is relative to the - // element's *own* (just-computed) font-size, not the parent's. - // `typography_px_ctx` below resolves all three together and gets this - // right by re-deriving the context between steps; the three individual - // methods are the building blocks for callers that need only one value, - // or that already have the right `ctx.font_size` for what they're - // resolving. - // - // What is NOT fixed by this, and is explicitly out of scope for this - // workstream (file allowlist: renderer/text.rs, css/units.rs, - // css/style.rs — not css/cascade.rs): `cascade::inherit_from` copies an - // inherited `font-size` down the tree as the raw, unresolved `Length` — - // not a resolved px value. So today nothing walks the tree computing - // "the actual parent font-size in px" to feed as `ctx.font_size` when - // resolving a child's `em` font-size; a caller that plugs in some other - // value (a default, the root font-size, whatever's convenient) gets a - // *technically* resolved but *semantically wrong* base for that one - // case. `rem` (always relative to a single scenario-wide root, not a - // per-ancestor chain) and `vw`/`vh` (relative to the real viewport) do - // NOT have this problem — they are fully correct via `ctx.root_font_size` - // / `ctx.viewport_*` regardless of cascade. `%` on `line-height` is - // special-cased below against the *own* font-size per CSS, not - // `ctx.parent_size`, so it isn't affected either. In short: `em`/`%` on - // `font-size` need a cascade.rs fix to be fully correct end-to-end; - // everything else these methods resolve is correct today. - // - // These are additive — nothing above changes signature, and nothing - // currently in the engine calls these yet, since every existing call - // site (`rustmotion-components/**`) is outside this workstream's file - // scope. Wiring a real `LengthContext` (viewport dims from `PaintCtx`, - // parent font-size from a resolved-cascade) into those call sites is the - // integration step a sibling workstream (or a follow-up PR) needs to do - // for relative units on type to actually reach rendered output. - - /// `font-size` resolved against `ctx`, correctly handling - /// `%`/`em`/`rem`/`vw`/`vh` — unlike [`Self::font_size_px_or`]. `em`/`%` - /// resolve against `ctx.font_size`, which the caller should set to the - /// parent's *actual computed* font-size in px for correctness (see the - /// module note above on why nothing does that yet). pub fn font_size_px_ctx(&self, ctx: &LengthContext, default: f32) -> f32 { self.font_size .as_ref() @@ -590,13 +436,6 @@ impl CssStyle { .unwrap_or(default) } - /// `letter-spacing` resolved against `ctx`, correctly handling - /// `%`/`em`/`rem`/`vw`/`vh` — unlike [`Self::letter_spacing_px`]. Per - /// CSS, `em` here means the element's *own* font-size, so pass a `ctx` - /// whose `font_size` is the already-resolved own font-size (e.g. via - /// [`Self::font_size_px_ctx`]), not the parent's — see - /// [`Self::typography_px_ctx`] for a helper that gets this right - /// automatically. pub fn letter_spacing_px_ctx(&self, ctx: &LengthContext) -> f32 { self.letter_spacing .as_ref() @@ -604,16 +443,6 @@ impl CssStyle { .unwrap_or(0.0) } - /// `line-height` resolved against `ctx`, correctly handling - /// `%`/`em`/`rem`/`vw`/`vh` — unlike [`Self::line_height_for`]. - /// `LineHeight::Number` (unitless, e.g. `1.5`) is unaffected — it always - /// means `n * font_size` regardless of any context. For - /// `LineHeight::Length`, `%` is special-cased to CSS's actual rule for - /// this property (relative to the element's *own* font-size, not - /// `ctx.parent_size` like `%` normally means): a generic - /// `ParsedLength::resolve` would silently resolve it against the wrong - /// base otherwise. Same own-vs-parent `em` caveat as - /// [`Self::letter_spacing_px_ctx`] applies. pub fn line_height_for_ctx(&self, font_size: f32, ctx: &LengthContext) -> f32 { match &self.line_height { Some(LineHeight::Number(n)) => n * font_size, @@ -625,13 +454,6 @@ impl CssStyle { } } - /// Resolve `font-size`, `letter-spacing`, and `line-height` together in - /// one call, honouring CSS's two different `em` bases (see the module - /// note above `font_size_px_ctx`): `font-size`'s own `em` resolves - /// against `ctx.font_size` (conventionally the parent's font-size), - /// while `letter-spacing`'s and `line-height`'s `em` resolve against the - /// just-computed *own* font-size, not `ctx.font_size` again. Returns - /// `(font_size_px, letter_spacing_px, line_height_px)`. pub fn typography_px_ctx( &self, ctx: &LengthContext, @@ -644,12 +466,10 @@ impl CssStyle { (font_size, letter_spacing, line_height) } - /// `opacity` with default 1.0. pub fn opacity_or(&self, default: f32) -> f32 { self.opacity.unwrap_or(default) } - /// `border-radius` resolved as a single uniform px value (drops per-corner). pub fn border_radius_px(&self) -> Option { match &self.border_radius { Some(BorderRadius::Uniform(lp)) => Some(lp.px()), @@ -658,22 +478,18 @@ impl CssStyle { } } - /// `border-radius` as px, defaulting to `default`. pub fn border_radius_px_or(&self, default: f32) -> f32 { self.border_radius_px().unwrap_or(default) } - /// Resolved padding tuple `(top, right, bottom, left)` in px. pub fn padding_px(&self) -> (f32, f32, f32, f32) { edges_px(self.padding.as_ref()) } - /// Resolved margin tuple `(top, right, bottom, left)` in px. pub fn margin_px(&self) -> (f32, f32, f32, f32) { edges_px(self.margin.as_ref()) } - /// `background` as a hex/keyword string when set as a plain color. pub fn background_color_str(&self) -> Option<&str> { match &self.background { Some(Background::Color(Color::String(s))) => Some(s.as_str()), @@ -698,8 +514,6 @@ fn edges_px(e: Option<&Edges>) -> (f32, f32, f32, f32) { } } -// ---- Layout enums ---- - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "kebab-case")] pub enum Display { @@ -891,18 +705,6 @@ pub enum BorderRadius { } impl BorderRadius { - /// The single uniform radius as an absolute pixel value, or `None` when - /// this isn't a shape a context-free (pre-layout) resolver can safely - /// interpolate: per-corner radii (which corner "wins" a 2-point - /// interpolation is undefined), or a unit that needs a - /// [`crate::css::units::LengthContext`] the caller doesn't have yet - /// (`%`/`em`/`rem`/`vw`/`vh` — see the "unités mixtes" decision in - /// `box_builder.rs`'s `resolve_transition_overrides`: resolved only - /// where both endpoints are unambiguous, refused otherwise rather than - /// guessed). Used by `box_builder.rs` (`style.transition` smoothing) and - /// `validate_schema.rs` (the matching diagnostic) — both must agree on - /// exactly which shapes are interpolable, which is why this lives here - /// once instead of being reimplemented on each side. pub fn absolute_px(&self) -> Option { match self { BorderRadius::Uniform(lp) => match lp.try_parse() { @@ -914,8 +716,6 @@ impl BorderRadius { } } -// ---- Flex ---- - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "kebab-case")] pub enum FlexDirection { @@ -996,8 +796,6 @@ pub enum Gap { }, } -// ---- Grid ---- - /// A single grid track (column or row) sizing function. /// /// Variant order matters here: this is `#[serde(untagged)]`, and serde tries @@ -1078,8 +876,6 @@ pub enum JustifySelf { Center, } -// ---- Typography ---- - #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(untagged)] pub enum FontWeight { @@ -1192,8 +988,6 @@ pub enum TextDecorationStyle { Wavy, } -// ---- Color ---- - /// Typed color. Strings are parsed lazily ("#rgb", "rgba(..)", named colors). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(untagged)] @@ -1213,7 +1007,6 @@ fn one_f32() -> f32 { } impl Color { - /// CSS-string form: pass strings through, format rgba as `#rrggbb[aa]`. pub fn to_css_string(&self) -> String { match self { Color::String(s) => s.clone(), @@ -1229,8 +1022,6 @@ impl Color { } } -// ---- Background ---- - #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(untagged)] pub enum Background { @@ -1240,13 +1031,6 @@ pub enum Background { } impl Background { - /// The background's hex/rgba string when it's a plain solid colour, or - /// `None` for anything else (gradients, image layers, multi-layer - /// stacks) — those need real paint-time compositing to interpolate - /// correctly, which is out of reach for a pre-layout `CssStyle` value. - /// Same shared-predicate rationale as [`BorderRadius::absolute_px`]: - /// `box_builder.rs`'s smoothing and `validate_schema.rs`'s diagnostic - /// both call this so they can never disagree about what's interpolable. pub fn solid_hex(&self) -> Option { match self { Background::Color(c) => Some(c.to_css_string()), @@ -1337,8 +1121,6 @@ pub enum BackgroundRepeat { Space, } -// ---- Shadows ---- - #[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)] #[serde(default, deny_unknown_fields, rename_all = "kebab-case")] pub struct BoxShadow { @@ -1360,7 +1142,6 @@ pub struct TextShadow { } impl TextShadow { - /// Resolve into the legacy schema shadow consumed by the text painters. pub fn to_schema(&self, ctx: &crate::css::units::LengthContext) -> crate::schema::TextShadow { crate::schema::TextShadow { color: self @@ -1375,8 +1156,6 @@ impl TextShadow { } } -// ---- Transform ---- - #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(tag = "fn", rename_all = "kebab-case")] pub enum TransformFn { @@ -1466,8 +1245,6 @@ pub struct TransformOrigin { pub z: Option, } -// ---- Filters ---- - #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(tag = "fn", rename_all = "kebab-case")] pub enum FilterFn { @@ -1526,8 +1303,6 @@ fn default_noise_seed() -> u64 { 42 } -// ---- Blend ---- - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "kebab-case")] pub enum BlendMode { @@ -1550,8 +1325,6 @@ pub enum BlendMode { PlusLighter, } -// ---- Clip-path ---- - #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(tag = "kind", rename_all = "kebab-case")] pub enum ClipPath { @@ -1607,8 +1380,6 @@ pub enum ClipPath { }, } -// ---- Tests ---- - #[cfg(test)] mod tests { use super::*; @@ -1681,12 +1452,6 @@ mod tests { assert_eq!(parsed.z_index, Some(10)); } - // ---- Grid track deserialization (issue #105) ---- - // - // `GridTrack` is `#[serde(untagged)]`; these lock in which variant a - // given JSON shape resolves to, since that resolution previously - // silently swallowed both `Fr` and `Keyword` into `Length`. - #[test] fn grid_track_bare_number_is_fr() { let json = r#"{ "grid-template-columns": [1, 1, 1] }"#; @@ -1755,8 +1520,6 @@ mod tests { } } - // ---- issue #125 §2: context-aware typography resolution ---- - fn style_with(font_size: &str, letter_spacing: &str, line_height: &str) -> CssStyle { let json = format!( r#"{{ "font-size": {font_size}, "letter-spacing": {letter_spacing}, "line-height": {line_height} }}"# @@ -1771,12 +1534,7 @@ mod tests { viewport_width: 1920.0, ..Default::default() }; - // The exact regression from issue #125 §2: `font-size: "15.6vw"` - // used to resolve to 0 via `.px()` (rendering nothing / a black - // frame). Through a LengthContext it resolves correctly. assert_eq!(s.font_size_px_ctx(&ctx, 48.0), 15.6 / 100.0 * 1920.0); - // The context-free accessor still can't do this — proving the two - // are genuinely different code paths, not the same thing renamed. assert_eq!(s.font_size_px_or(48.0), 0.0); } @@ -1787,9 +1545,6 @@ mod tests { root_font_size: 20.0, ..Default::default() }; - // rem is relative to a single scenario-wide root font-size, not a - // per-ancestor chain — no cascade.rs involvement needed for this to - // be correct. assert_eq!(s.font_size_px_ctx(&ctx, 48.0), 40.0); } @@ -1801,31 +1556,20 @@ mod tests { #[test] fn letter_spacing_px_ctx_resolves_own_em_not_parent_em() { - // letter-spacing's `em` is relative to the *element's own* - // font-size, not whatever `ctx.font_size` happened to be for - // resolving font-size itself. let s = style_with("300", r#""-0.03em""#, "1"); let own_ctx = LengthContext { - font_size: 300.0, // the element's own resolved font-size + font_size: 300.0, ..Default::default() }; assert!((s.letter_spacing_px_ctx(&own_ctx) - (-9.0)).abs() < 1e-4); - // Context-free path can't resolve this at all (issue #125 §2): it - // silently (now loudly, but still numerically) drops to 0, which is - // byte-identical to a deliberate zero tracking. assert_eq!(s.letter_spacing_px(), 0.0); } #[test] fn line_height_percent_resolves_against_own_font_size_not_parent_size() { - // CSS special case: `line-height: 50%` means 50% of the element's - // own font-size, NOT 50% of `ctx.parent_size` like `%` means for - // most other properties (width, padding, etc). let s = style_with("100", r#""50%""#, r#""50%""#); let ctx = LengthContext { - parent_size: 1000.0, // deliberately different from font_size, - // to prove `%` here does NOT fall through to the generic - // percent-of-parent resolution. + parent_size: 1000.0, ..Default::default() }; assert_eq!(s.line_height_for_ctx(100.0, &ctx), 50.0); @@ -1842,18 +1586,9 @@ mod tests { #[test] fn typography_px_ctx_resolves_all_three_with_correct_em_bases() { - // font-size: 1.5em against a 200px parent font-size -> 300px own - // font-size. letter-spacing/line-height's em must then use that - // 300px *own* size, not the 200px parent size passed in via ctx. - // line-height as a bare JSON number (unitless, `LineHeight::Number`) - // — a quoted `"0.85"` would instead deserialize as a `Length` - // string, which parses a bare numeric string as *pixels* - // (`ParsedLength::Px`), not as the unitless multiplier CSS means; - // that's an existing quirk of `LineHeight`'s untagged variants, - // unrelated to this fix. let s = style_with(r#""1.5em""#, r#""-0.03em""#, "0.85"); let ctx = LengthContext { - font_size: 200.0, // parent's font-size, for font-size's own em + font_size: 200.0, ..Default::default() }; let (font_size, letter_spacing, line_height) = s.typography_px_ctx(&ctx, 48.0); @@ -1865,19 +1600,8 @@ mod tests { assert_eq!(line_height, 300.0 * 0.85); } - // ---- constat #1: border-radius per-corner kebab-case (RED first) ---- - #[test] fn border_radius_corners_accepts_kebab_case() { - // This is the shape every sibling composite in this file uses - // (box-shadow -> offset-x/offset-y, transform-origin -> x/y, etc.) - // and the shape `rules/component-field-placement.md` teaches. Before - // the fix, `BorderRadius::Corners`'s fields are literally - // `top_left`/`top_right`/... with no kebab alias, so this kebab - // object fails to match `Corners` (unknown fields) and, being all - // `#[serde(default)]`, matches it anyway with every corner at 0 — - // the untagged enum never reports an error, it just silently - // produces radius 0. let json = r#"{ "border-radius": { "top-left": "12px", "top-right": "12px", "bottom-right": "4px", "bottom-left": "4px" } }"#; let s: CssStyle = serde_json::from_str(json).unwrap(); match s.border_radius { @@ -1898,8 +1622,6 @@ mod tests { #[test] fn border_radius_corners_still_accepts_legacy_snake_case() { - // Back-compat: any scenario already written with the old - // snake_case field names must keep working identically. let json = r#"{ "border-radius": { "top_left": "8px", "top_right": "8px", "bottom_right": "8px", "bottom_left": "8px" } }"#; let s: CssStyle = serde_json::from_str(json).unwrap(); assert_eq!(s.border_radius_px(), Some(8.0)); @@ -1907,8 +1629,6 @@ mod tests { #[test] fn border_radius_corners_typo_is_a_named_error_not_a_silent_zero() { - // A misspelled key must not silently resolve to Corners{0,0,0,0} — - // it must be reported. let json = r#"{ "border-radius": { "topleft": "12px" } }"#; let err = serde_json::from_str::(json).expect_err("typo must be rejected"); let msg = err.to_string(); @@ -1920,16 +1640,8 @@ mod tests { ); } - // ---- constat #2: `Edges` (padding/margin) rejects unknown shapes (RED first) ---- - #[test] fn edges_rejects_unknown_object_shape_instead_of_defaulting_to_zero() { - // `rules/margin-left-hack.md`-adjacent trap: an LLM reasoning in CSS - // terms writes `{"horizontal": 20}` instead of the supported - // `{"top":.., "right":.., "bottom":.., "left":..}` shape. Before the - // fix, `Edges::Sides`'s four fields are all `#[serde(default)]` with - // no `deny_unknown_fields`, so this object matches `Sides` anyway - // with every side at 0 — silent, wrong padding instead of an error. let json = r#"{ "padding": { "horizontal": 20 } }"#; let err = serde_json::from_str::(json) .expect_err("an unrecognised padding shape must be rejected, not silently zeroed"); @@ -1954,16 +1666,8 @@ mod tests { assert_eq!(s.padding_px(), (24.0, 24.0, 24.0, 24.0)); } - // ---- constat #6: `Size` untagged variant order (RED first) ---- - #[test] fn size_keyword_max_content_is_reachable() { - // `Size` is `#[serde(untagged)]`: Auto, Length, Keyword in that - // declared order (before the fix). `Length(LengthPercentage)`'s - // `String` fallback variant accepts *any* string, so it is tried - // (and succeeds) before `Keyword` is ever reached — `max-content` / - // `min-content` / `fit-content` are dead schema. After the fix, - // `Keyword` must be tried before the `Length` catch-all. for (kw, expected) in [ ("max-content", SizeKeyword::MaxContent), ("min-content", SizeKeyword::MinContent), @@ -1991,10 +1695,6 @@ mod tests { assert!(matches!(s.width, Some(Size::Length(_)))); } - // ---- extra: `LineHeight` has the same catch-all-before-specific shape - // as constat #6's `Size`, found while auditing this file for the same - // bug class. Fixed alongside it (see the doc comment on `LineHeight`). - #[test] fn line_height_keyword_normal_is_reachable() { let s: CssStyle = serde_json::from_str(r#"{ "line-height": "normal" }"#).unwrap(); @@ -2013,8 +1713,6 @@ mod tests { assert!(matches!(s.line_height, Some(LineHeight::Length(_)))); } - // ---- border-radius: kebab-case is the canonical wire form on output ---- - #[test] fn border_radius_corners_serializes_as_kebab_case() { let s = CssStyle { diff --git a/crates/rustmotion-core/src/css/taffy_bridge.rs b/crates/rustmotion-core/src/css/taffy_bridge.rs index f3c1fc5c..2d6ef82b 100644 --- a/crates/rustmotion-core/src/css/taffy_bridge.rs +++ b/crates/rustmotion-core/src/css/taffy_bridge.rs @@ -1,11 +1,3 @@ -//! `CssStyle` → `taffy::Style` converter. -//! -//! Inspired by `stylo_taffy` (Servo) but kept minimal: we only translate the -//! layout-affecting properties. Paint properties (color, background, transform, -//! filter, etc.) are read separately by the paint pass. -//! -//! Currently a stub — fleshed out in step 3 of the migration plan. - use taffy::prelude as tf; use super::style::{ @@ -21,16 +13,6 @@ pub struct ConversionContext { } impl ConversionContext { - /// Anchor `vw`/`vh` resolution to a real output viewport. - /// - /// [`ConversionContext::default()`] carries a 1920×1080 viewport, which is - /// only ever correct by coincidence. Any caller that knows the video's real - /// dimensions must build its context here instead: on a 1080×1920 vertical - /// render, the default resolves `50vw` to 960px where the truth is 540px, - /// and misses `vh` by the same margin in the other axis. - /// - /// `font-size` / `root-font-size` stay at the CSS initial 16px: nothing - /// upstream resolves and threads a root font-size through yet. pub fn for_viewport(viewport_width: f32, viewport_height: f32) -> Self { Self { length: LengthContext { @@ -42,29 +24,22 @@ impl ConversionContext { } } -/// Convert a [`CssStyle`] into a [`taffy::Style`]. Properties not relevant to -/// layout are ignored. Unsupported / unset properties fall back to taffy -/// defaults (which match CSS initial values). pub fn to_taffy_style(css: &CssStyle, ctx: &ConversionContext) -> tf::Style { let mut style = tf::Style::DEFAULT; - // Display style.display = match css.display { Some(Display::None) => tf::Display::None, Some(Display::Block) => tf::Display::Block, Some(Display::Flex) => tf::Display::Flex, Some(Display::Grid) => tf::Display::Grid, - // inline-block / contents → fall back to block in our scope _ => tf::Display::Block, }; - // Position style.position = match css.position { Some(Position::Absolute) => tf::Position::Absolute, _ => tf::Position::Relative, }; - // Inset style.inset = tf::Rect { top: lp_to_lp_auto(css.top.as_ref(), ctx), right: lp_to_lp_auto(css.right.as_ref(), ctx), @@ -72,7 +47,6 @@ pub fn to_taffy_style(css: &CssStyle, ctx: &ConversionContext) -> tf::Style { left: lp_to_lp_auto(css.left.as_ref(), ctx), }; - // Sizing style.size = tf::Size { width: size_to_dim(css.width.as_ref(), ctx), height: size_to_dim(css.height.as_ref(), ctx), @@ -87,11 +61,6 @@ pub fn to_taffy_style(css: &CssStyle, ctx: &ConversionContext) -> tf::Style { }; style.aspect_ratio = css.aspect_ratio; - // `box-sizing` (round 4 audit, lot LAYOUT, constat 3): taffy supports it - // natively (`Style::box_sizing`, default `BorderBox`) — schema-valid but - // untranslated before this fix, so `content-box` was silently ignored - // and every sized box behaved as `border-box` regardless of what the - // author declared. if let Some(bs) = css.box_sizing { style.box_sizing = match bs { BoxSizing::ContentBox => tf::BoxSizing::ContentBox, @@ -99,12 +68,10 @@ pub fn to_taffy_style(css: &CssStyle, ctx: &ConversionContext) -> tf::Style { }; } - // Margin / padding / border (border WIDTH only — border style/color are paint props) style.margin = edges_to_rect_lpa(css.margin.as_ref(), ctx); style.padding = edges_to_rect_lp(css.padding.as_ref(), ctx); style.border = border_widths(css.border.as_ref(), ctx); - // Flex style.flex_direction = match css.flex_direction { Some(FlexDirection::Row) => tf::FlexDirection::Row, Some(FlexDirection::RowReverse) => tf::FlexDirection::RowReverse, @@ -147,19 +114,6 @@ pub fn to_taffy_style(css: &CssStyle, ctx: &ConversionContext) -> tf::Style { if let Some(basis) = css.flex_basis.as_ref() { style.flex_basis = size_to_dim(Some(basis), ctx); } - // `order` (round 4 audit, lot LAYOUT, constat 3): schema-valid but has no - // taffy equivalent — taffy has no flex/grid item-reordering primitive - // (its internal `order` on `Layout` is source order, assigned during - // layout, not settable via `Style`). Translating is not possible, so — - // per the same "fail loud instead of a silent no-op" contract this - // module's `Length`/`LengthPercentage` parsing already uses (see - // `units.rs`'s `px_or_warn` / `parse_length_or_warn`) — warn instead of - // dropping it without a trace. Reorder the JSON `children` array itself - // to get the equivalent effect. - // Emitted at most once per process: `to_taffy_style` runs per node per - // layout pass, and layout runs per frame — an unguarded `eprintln!` here - // would print the same line a thousand times over a single render and - // slow it down while doing so. if css.order.is_some() { static WARNED_ORDER: std::sync::Once = std::sync::Once::new(); WARNED_ORDER.call_once(|| { @@ -171,7 +125,6 @@ pub fn to_taffy_style(css: &CssStyle, ctx: &ConversionContext) -> tf::Style { }); } - // Gap if let Some(gap) = css.gap.as_ref() { let (row, col) = match gap { Gap::Uniform(v) => (lp_to_lp(v, ctx), lp_to_lp(v, ctx)), @@ -183,7 +136,6 @@ pub fn to_taffy_style(css: &CssStyle, ctx: &ConversionContext) -> tf::Style { }; } - // Grid if let Some(tracks) = css.grid_template_columns.as_ref() { style.grid_template_columns = tracks .iter() @@ -210,11 +162,6 @@ pub fn to_taffy_style(css: &CssStyle, ctx: &ConversionContext) -> tf::Style { if let Some(gr) = css.grid_row.as_ref() { style.grid_row = grid_placement_line(gr); } - // `justify-items` / `justify-self` (round 4 audit, lot LAYOUT, constat 3): - // taffy supports both natively for grid children, reusing the same - // `AlignItems`/`AlignSelf` types as `align-items`/`align-self` (the - // block-axis equivalents) — same untranslated-but-schema-valid gap as - // `box-sizing` above. if let Some(ji) = css.justify_items { style.justify_items = Some(justify_items_to_taffy(ji)); } @@ -222,7 +169,6 @@ pub fn to_taffy_style(css: &CssStyle, ctx: &ConversionContext) -> tf::Style { style.justify_self = justify_self_to_taffy(js); } - // Overflow if let Some(o) = css.overflow { let v = overflow_to_taffy(o); style.overflow = taffy::Point { x: v, y: v }; @@ -237,18 +183,6 @@ pub fn to_taffy_style(css: &CssStyle, ctx: &ConversionContext) -> tf::Style { style } -/// Resolve `padding` + `border` width into a single content-box inset, in -/// px, per axis: `(horizontal, vertical)` i.e. `(left + right, top + -/// bottom)`. -/// -/// Mirrors what taffy 0.10.1's own `compute_leaf_layout` (`content_box_inset -/// = padding + border`) subtracts from a leaf's `available_space` before -/// handing it to the measure function — see [`crate::engine::layout_pass`], -/// which uses this to bring `known_dimensions` (still border-box) into that -/// same content-box space (RM-27). Percentage padding/border resolves -/// against `ctx.length.parent_size` like every other percentage in this -/// module; a leaf's own known/available width is not threaded here, so a -/// percentage inset on a leaf is only as accurate as that shared context. pub(crate) fn content_box_inset(css: &CssStyle, ctx: &ConversionContext) -> (f32, f32) { let (pt, pr, pb, pl) = css.padding.as_ref().map(Edges::resolve).unwrap_or_default(); let padding = ( @@ -325,19 +259,12 @@ fn justify_items_to_taffy(j: JustifyItems) -> tf::AlignItems { JustifyItems::Start => tf::AlignItems::Start, JustifyItems::End => tf::AlignItems::End, JustifyItems::Center => tf::AlignItems::Center, - // `legacy` (old CSS2-era grid keyword, only meaningful combined with - // `left`/`right`/`center` which this schema doesn't expose) has no - // taffy analog; `Start` is the closest normal-flow behaviour and - // matches this bridge's own `Auto`-ish fallbacks elsewhere. JustifyItems::Legacy => tf::AlignItems::Start, } } fn justify_self_to_taffy(j: JustifySelf) -> Option { Some(match j { - // `auto` computes to the parent's `justify-items` — `None` is - // exactly how this bridge already models `align-self: auto` - // inheriting `align-items` above. JustifySelf::Auto => return None, JustifySelf::Stretch => tf::AlignSelf::Stretch, JustifySelf::Start => tf::AlignSelf::Start, @@ -366,7 +293,6 @@ fn overflow_to_taffy(o: Overflow) -> taffy::Overflow { } } -/// Convert `LengthPercentage` → taffy `LengthPercentage`. fn lp_to_lp(v: &LengthPercentage, ctx: &ConversionContext) -> tf::LengthPercentage { match v.parse() { ParsedLength::Px(p) => tf::LengthPercentage::length(p), @@ -379,7 +305,6 @@ fn lp_to_lp(v: &LengthPercentage, ctx: &ConversionContext) -> tf::LengthPercenta } } -/// Convert `LengthPercentage` → taffy `LengthPercentageAuto`. None → auto. fn lp_to_lp_auto( v: Option<&LengthPercentage>, ctx: &ConversionContext, @@ -403,7 +328,6 @@ fn lp_to_lp_auto( } } -/// Convert `Size` → taffy `Dimension`. fn size_to_dim(s: Option<&Size>, ctx: &ConversionContext) -> tf::Dimension { let Some(s) = s else { return tf::Dimension::auto(); @@ -420,11 +344,7 @@ fn size_to_dim(s: Option<&Size>, ctx: &ConversionContext) -> tf::Dimension { ParsedLength::Vh(p) => tf::Dimension::length(p / 100.0 * ctx.length.viewport_height), ParsedLength::Fr(_) => tf::Dimension::auto(), }, - Size::Keyword(_) => { - // taffy 0.10 supports max-content / min-content / fit-content via Dimension. - // We map them to `auto` for now; refine later if needed. - tf::Dimension::auto() - } + Size::Keyword(_) => tf::Dimension::auto(), } } @@ -479,7 +399,6 @@ fn border_widths( left: tf::LengthPercentage::length(0.0), }; }; - // Per-side overrides take precedence over the uniform `width`. let uniform = b.width.as_ref().map(|e| e.resolve()); let pick_side = |side: Option<&super::style::BorderSide>, idx: usize| -> tf::LengthPercentage { if let Some(side) = side { @@ -508,12 +427,6 @@ fn border_widths( } } -/// Convert a [`GridTrack`] into a taffy `TrackSizingFunction` (used for both -/// the min and max sizing function, except `fr` which uses taffy's `flex()` -/// helper — `minmax(0, Nfr)`. This gives *exactly* evenly-sized tracks -/// regardless of child content, matching the `flex-direction: row` control -/// (`flex-grow: 1` siblings) rather than CSS's stricter `minmax(auto, Nfr)` -/// default, which lets content push a track wider than its fair share. fn grid_track_sizing(t: &GridTrack, ctx: &ConversionContext) -> tf::TrackSizingFunction { match t { GridTrack::Fr(n) => tf::flex(*n), @@ -546,9 +459,6 @@ fn grid_length_sizing(lp: &LengthPercentage, ctx: &ConversionContext) -> tf::Tra } } -/// `min` side of an explicit `minmax(min, max)`. `fr` is not a valid CSS -/// minimum sizing function, so it falls back to `auto` (matches taffy's own -/// `MaxTrackSizingFunction -> MinTrackSizingFunction` conversion for `fr`). fn grid_track_min(t: &GridTrack, ctx: &ConversionContext) -> tf::MinTrackSizingFunction { match t { GridTrack::Fr(_) => tf::auto(), @@ -556,8 +466,6 @@ fn grid_track_min(t: &GridTrack, ctx: &ConversionContext) -> tf::MinTrackSizingF GridTrack::Keyword(GridTrackKeyword::MinContent) => tf::min_content(), GridTrack::Keyword(GridTrackKeyword::MaxContent) => tf::max_content(), GridTrack::Length(lp) => grid_length_min(lp, ctx), - // A `minmax` nested inside a `minmax` isn't valid CSS; degrade - // gracefully by taking the inner track's own min side. GridTrack::Minmax { min, .. } => grid_track_min(min, ctx), } } @@ -574,7 +482,6 @@ fn grid_length_min(lp: &LengthPercentage, ctx: &ConversionContext) -> tf::MinTra } } -/// `max` side of an explicit `minmax(min, max)`. fn grid_track_max(t: &GridTrack, ctx: &ConversionContext) -> tf::MaxTrackSizingFunction { match t { GridTrack::Fr(n) => tf::fr(*n), @@ -599,9 +506,6 @@ fn grid_length_max(lp: &LengthPercentage, ctx: &ConversionContext) -> tf::MaxTra } } -/// Convert a `grid-column` / `grid-row` placement (`{ start, end, span }`) -/// into a taffy `Line`. Named lines / grid areas are out of -/// scope (issue #105) — only numeric line indices and spans are supported. fn grid_placement_line(g: &GridLine) -> tf::Line { let start = g.start.map(|GridLineEnd::Index(i)| i as i16); let end = g.end.map(|GridLineEnd::Index(i)| i as i16); @@ -743,8 +647,6 @@ mod tests { assert_eq!(s.overflow.y, taffy::Overflow::Hidden); } - // ---- Grid (issue #105) ---- - #[test] fn grid_display_translated() { let css = CssStyle { @@ -767,7 +669,6 @@ mod tests { #[test] fn grid_template_rows_string_fr_tracks_translated() { - // Same string-encoded form used by examples/mega-showcase.json. let css = CssStyle { grid_template_rows: Some(vec![ GridTrack::Length(LengthPercentage::String("1fr".into())), @@ -831,19 +732,10 @@ mod tests { assert_eq!(s.gap.height, tf::LengthPercentage::length(24.0)); } - /// The audit's core repro: a grid with three `1fr` columns must lay its - /// children out side-by-side (distinct x-offsets), matching a - /// `flex-direction: row` control — NOT stacked as three full-width rows, - /// which is what the engine did before `grid-template-columns` was wired - /// through to taffy (grid fell back to taffy's single-implicit-column - /// default because it never received any track definitions). #[test] fn grid_three_fr_columns_produce_distinct_x_offsets() { let root_css = CssStyle { display: Some(Display::Grid), - // The grid container needs a definite size: an `auto`-sized root - // (the CssStyle default) has no intrinsic content of its own, so - // it collapses to 0×0 and every column ends up at x=0. width: Some(Size::Length(LengthPercentage::Px(900.0))), height: Some(Size::Length(LengthPercentage::Px(300.0))), grid_template_columns: Some(vec![ @@ -896,9 +788,6 @@ mod tests { ); } - // ── Round 4 audit, lot LAYOUT, constat 3: box-sizing / justify-items / - // justify-self are schema-valid but were never translated to taffy. ──── - #[test] fn box_sizing_content_box_is_translated() { let css = CssStyle { @@ -939,9 +828,6 @@ mod tests { #[test] fn justify_self_auto_falls_back_to_parent_justify_items() { - // `auto` computes to the parent's `justify-items` — taffy models - // this the same way `align-self: auto` models inheriting - // `align-items`: `None`, not an explicit `Start`. let css = CssStyle { justify_self: Some(JustifySelf::Auto), ..Default::default() diff --git a/crates/rustmotion-core/src/css/units.rs b/crates/rustmotion-core/src/css/units.rs index 413bc68a..e41da445 100644 --- a/crates/rustmotion-core/src/css/units.rs +++ b/crates/rustmotion-core/src/css/units.rs @@ -1,12 +1,3 @@ -//! CSS length/percentage units. -//! -//! Supports: `px`, `%`, `em`, `rem`, `vw`, `vh`, `fr`, `auto`, plus a bare -//! number (interpreted as `px`). -//! -//! Resolution to absolute pixels happens through [`LengthContext`], which -//! carries the viewport dimensions, parent size (for `%`), and font sizes -//! (for `em` / `rem`). - use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -40,7 +31,6 @@ impl Default for LengthPercentage { } } -/// Internal parsed representation. Once parsed we know which unit it is. #[derive(Debug, Clone, Copy, PartialEq)] pub enum ParsedLength { Px(f32), @@ -75,7 +65,6 @@ impl Default for LengthContext { } impl ParsedLength { - /// Resolve to absolute pixels. Returns `None` for `Auto` / `Fr` (caller decides). pub fn resolve(&self, ctx: &LengthContext) -> Option { match self { Self::Px(v) => Some(*v), @@ -89,13 +78,6 @@ impl ParsedLength { } } - /// True for units whose absolute pixel value depends on a - /// [`LengthContext`] (`%`, `em`, `rem`, `vw`, `vh`) — i.e. everything - /// [`Length::px`] / [`LengthPercentage::px`] cannot resolve on their own - /// and silently (issue #125 §2) or loudly (post-fix) fall back to `0.0` - /// for. `Px` needs no context; `Auto`/`Fr` are not scalar lengths at all - /// (the caller decides what they mean) so they are not "relative" in - /// this sense. pub fn is_relative(&self) -> bool { matches!( self, @@ -104,12 +86,6 @@ impl ParsedLength { } } -/// Parse a CSS length/percentage string that may also contain transform-origin -/// axis keywords (`left`, `center`, `right`, `top`, `bottom`). -/// -/// Keywords are normalised to `ParsedLength::Percent` so that the regular -/// `resolve` machinery handles them — the caller must still choose the correct -/// axis dimension (width for x, height for y) in the `LengthContext`. pub fn parse_origin_component(s: &str) -> Option { let lower = s.trim().to_ascii_lowercase(); match lower.as_str() { @@ -121,7 +97,6 @@ pub fn parse_origin_component(s: &str) -> Option { parse_length(s) } -/// Parse a CSS length/percentage string. Whitespace tolerated. pub fn parse_length(s: &str) -> Option { let s = s.trim(); if s.eq_ignore_ascii_case("auto") { @@ -150,13 +125,6 @@ pub fn parse_length(s: &str) -> Option { } impl Length { - /// Parse into a resolved unit. Falls back to `Px(0.0)` on unparseable - /// input, for the many existing call sites across the codebase that - /// expect an infallible result — but unlike the previous behaviour, - /// this now logs a warning so the fallback is a detectable signal - /// instead of a silent, indistinguishable zero. Callers that can - /// surface the failure themselves (e.g. a validator) should prefer - /// [`Length::try_parse`], which returns `None` instead of guessing. pub fn parse(&self) -> ParsedLength { match self { Length::Px(v) => ParsedLength::Px(*v), @@ -164,10 +132,6 @@ impl Length { } } - /// Fallible counterpart of [`Length::parse`]: `None` on unparseable - /// input rather than a silent `Px(0.0)`, and never logs. Prefer this - /// when the failure can be reported to the caller (e.g. schema - /// validation) instead of swallowed. pub fn try_parse(&self) -> Option { match self { Length::Px(v) => Some(ParsedLength::Px(*v)), @@ -179,29 +143,12 @@ impl Length { self.parse().resolve(ctx).unwrap_or(0.0) } - /// Quick px resolution without context (treats em/rem/%/vw/vh as 0). Used - /// by painters that only need the px value of an explicit length and - /// have no [`LengthContext`] available (see [`CssStyle::font_size_px_or`] - /// / `letter_spacing_px` / `line_height_for` in `css::style`, all built - /// on this — issue #125 §2). - /// - /// A relative unit here cannot be resolved correctly no matter what: - /// `em`/`rem` need a font-size base, `vw`/`vh` need the real viewport, - /// `%` needs a parent box — none of which this context-free accessor - /// has. Rather than silently returning `0.0` indistinguishably from a - /// deliberate `0px` (the previous behaviour — the "15.6vw renders a - /// completely black frame with no signal" bug), this now warns loudly - /// when the dropped value was a relative unit, same fail-loud contract - /// as [`parse_length_or_warn`] uses for genuinely unparseable input. - /// Callers with a real `LengthContext` should call [`Length::resolve`] - /// instead, which resolves these correctly. pub fn px(&self) -> f32 { px_or_warn(self.parse()) } } impl LengthPercentage { - /// See [`Length::parse`] — same infallible-with-a-warning contract. pub fn parse(&self) -> ParsedLength { match self { LengthPercentage::Px(v) => ParsedLength::Px(*v), @@ -209,7 +156,6 @@ impl LengthPercentage { } } - /// See [`Length::try_parse`] — fallible, silent, `None` on failure. pub fn try_parse(&self) -> Option { match self { LengthPercentage::Px(v) => Some(ParsedLength::Px(*v)), @@ -221,22 +167,11 @@ impl LengthPercentage { self.parse().resolve(ctx).unwrap_or(0.0) } - /// Quick px resolution without context (treats em/rem/%/vw/vh as 0). See - /// [`Length::px`] — same fail-loud contract. pub fn px(&self) -> f32 { px_or_warn(self.parse()) } } -/// Shared by `Length::px` / `LengthPercentage::px`: return the pixel value -/// for `Px`, or `0.0` for anything else — but if the anything-else is a -/// *relative* unit (issue #125 §2: `%`/`em`/`rem`/`vw`/`vh`), warn loudly -/// first, since silently returning `0.0` here is indistinguishable from a -/// deliberate `0px` and was the exact defect reported (`font-size: "15.6vw"` -/// rendering a fully black frame with `render` exiting 0). `Auto`/`Fr` -/// aren't scalar lengths (the caller decides what they mean), so they don't -/// warn — a context-free accessor asking for their "px value" is a caller -/// bug, not a units bug, and predates this fix. fn px_or_warn(parsed: ParsedLength) -> f32 { match parsed { ParsedLength::Px(v) => v, @@ -255,12 +190,6 @@ fn px_or_warn(parsed: ParsedLength) -> f32 { } } -/// Shared fallback used by both `Length::parse` and `LengthPercentage::parse`: -/// parse `s`, or log a warning and return `Px(0.0)` if it doesn't match any -/// known length form. This is what closes the "silently fold into 0px with -/// no signal" gap — the previous `unwrap_or(ParsedLength::Px(0.0))` produced -/// an indistinguishable, unreported zero for a typo'd unit exactly like a -/// deliberate `0px`. fn parse_length_or_warn(s: &str) -> ParsedLength { match parse_length(s) { Some(p) => p, @@ -364,8 +293,6 @@ mod tests { assert_eq!(l.resolve(&LengthContext::default()), 10.0); } - // ---- unparseable length: detectable signal, not a silent 0px ---- - #[test] fn length_try_parse_returns_none_for_garbage() { let l = Length::String("not-a-length".into()); @@ -387,11 +314,6 @@ mod tests { #[test] fn length_parse_still_infallible_fallback_for_garbage() { - // `.parse()` keeps its infallible signature (existing call sites - // across the codebase depend on it) — it still resolves to 0px for - // unparseable input, but `try_parse` above proves the failure is - // now independently detectable rather than indistinguishable from - // a deliberate `0px`. let l = Length::String("not-a-length".into()); assert_eq!(l.parse(), ParsedLength::Px(0.0)); assert_eq!(l.resolve(&LengthContext::default()), 0.0); @@ -403,8 +325,6 @@ mod tests { assert_eq!(l.parse(), ParsedLength::Px(0.0)); } - // ---- issue #125 §2: relative units on the context-free `.px()` path ---- - #[test] fn is_relative_classifies_every_variant() { assert!(!ParsedLength::Px(1.0).is_relative()); @@ -424,15 +344,6 @@ mod tests { assert_eq!(LengthPercentage::String("24px".into()).px(), 24.0); } - /// `.px()` cannot correctly resolve relative units (no context is - /// available at this call site) — this locks in that it still falls - /// back to `0.0` for them post-fix, same numeric behaviour as before. - /// What changed is that this fallback is now loud (see - /// `px_or_warn`/`is_relative`): distinguishing "the value really is - /// relative, and got dropped" from "the value was genuinely `0px`" is - /// exactly what `is_relative` (tested above) exists to let a caller — - /// or the `px_or_warn` eprintln — detect, since asserting on stderr - /// text isn't practical from a unit test. #[test] fn px_falls_back_to_zero_for_every_relative_unit() { for s in ["50%", "1.5em", "2rem", "100vw", "50vh"] { @@ -442,19 +353,12 @@ mod tests { 0.0, "LengthPercentage::px() for {s:?}" ); - // And `is_relative` on the same parsed value proves *why*: a - // caller (or px_or_warn) can tell this apart from a real 0px. assert!(parse_length(s).unwrap().is_relative()); } } #[test] fn px_does_not_warn_for_auto_or_fr_context_free_use() { - // Auto/Fr aren't scalar lengths — a context-free `.px()` call on - // them is a caller bug predating this fix, not a relative-unit - // silent-zero. They fall back to 0.0 too, but `is_relative` is - // false for both so `px_or_warn` does not treat them as the - // issue #125 §2 case. assert!(!ParsedLength::Auto.is_relative()); assert!(!ParsedLength::Fr(2.0).is_relative()); assert_eq!(Length::String("auto".into()).px(), 0.0); diff --git a/crates/rustmotion-core/src/engine/animator.rs b/crates/rustmotion-core/src/engine/animator.rs index 715e3cc5..65f28f43 100644 --- a/crates/rustmotion-core/src/engine/animator.rs +++ b/crates/rustmotion-core/src/engine/animator.rs @@ -4,18 +4,8 @@ use crate::schema::{ TextAnimGranularity, WiggleConfig, }; -/// Default starting blur sigma (px) for `char_blur_in` when -/// `CharAnimationTiming.blur` is not set. Tuned against rendered output at -/// 120px display type (see issue #118's render proof): low enough that -/// individual letterforms stay ghost-legible at the start of a unit's -/// reveal (this is a *reveal*, not a smoke effect), high enough that the -/// blur is unmistakable next to the settled, sharp frame. pub const DEFAULT_CHAR_BLUR_SIGMA: f32 = 14.0; -/// Safe division that returns `fallback` when the denominator is too small to -/// produce a meaningful result (within 1e-9). Use this for any calculation -/// where a zero-or-near-zero duration could otherwise produce NaN/∞ that -/// silently propagates into transforms or opacity. #[inline] pub fn safe_div(num: f64, denom: f64, fallback: f64) -> f64 { if denom.abs() < 1e-9 { @@ -25,7 +15,6 @@ pub fn safe_div(num: f64, denom: f64, fallback: f64) -> f64 { } } -/// Same as `safe_div` but for f32. Useful in render-side hot paths. #[inline] pub fn safe_div_f32(num: f32, denom: f32, fallback: f32) -> f32 { if denom.abs() < 1e-6 { @@ -35,9 +24,6 @@ pub fn safe_div_f32(num: f32, denom: f32, fallback: f32) -> f32 { } } -// ─── Effect extraction ────────────────────────────────────────────────────── - -/// Resolved char animation config ready for the text renderer. #[derive(Debug, Clone)] pub struct ResolvedCharAnimation { pub preset: CharAnimPreset, @@ -47,95 +33,45 @@ pub struct ResolvedCharAnimation { pub easing: EasingType, pub delay: f32, pub overshoot: f32, - /// Starting blur sigma in px (`char_blur_in` only; 0 elsewhere). pub blur: f32, - /// Travel direction for the presets whose motion is a translate. pub direction: TextAnimDirection, - /// Multiplier on the preset's own travel distance (1.0 = as tuned). pub distance: f32, - /// Scale each unit starts at, or `None` for no scaling. pub scale_from: Option, - /// ±fraction of `stagger` each unit's start is nudged by (0 = even). pub jitter: f32, - /// Seed for the deterministic jitter offsets. pub seed: u32, - /// Colour each unit starts at before settling to the text's own. pub ink_from: Option, } impl ResolvedCharAnimation { - /// When unit `idx` starts, in seconds, including its jitter nudge. - /// - /// The nudge is a pure function of `(idx, seed)` — deliberately not an - /// RNG. Frames are rendered out of order, in parallel, and sometimes in - /// separate processes (`--frames a-b` segments), so anything stateful - /// here would make a unit jump between neighbouring frames. - /// - /// It is also clamped so a unit never starts before the effect's own - /// `delay`: a negative start would make the first units appear already - /// half-animated on frame 0. pub fn unit_start(&self, idx: usize) -> f64 { let even = self.delay as f64 + idx as f64 * self.stagger as f64; if self.jitter.abs() < 1e-6 || self.stagger.abs() < 1e-6 { return even; } - // Bit-mixing hash (splitmix64's finalizer) over the unit index and - // seed → a well-distributed value in -1.0..1.0. let mut h = (idx as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ (self.seed as u64); h ^= h >> 30; h = h.wrapping_mul(0xBF58_476D_1CE4_E5B9); h ^= h >> 27; h = h.wrapping_mul(0x94D0_49BB_1331_11EB); h ^= h >> 31; - let unit = (h >> 11) as f64 / (1u64 << 53) as f64; // 0.0..1.0 + let unit = (h >> 11) as f64 / (1u64 << 53) as f64; let nudge = (unit * 2.0 - 1.0) * self.jitter as f64 * self.stagger as f64; (even + nudge).max(self.delay as f64) } } -/// Extracted and categorized animation effects from an AnimationEffect slice. pub struct ExtractedEffects<'a> { pub presets: Vec<(AnimationPreset, PresetConfig)>, - /// Every `keyframes`/`tilt_in` effect's animations, in the order their - /// source effects appear in `style.animation` (constat #5: this used to - /// be split into two buckets — routed purely by whether the effect's - /// `delay` happened to be nonzero — resolved and merged separately, - /// which made "sum vs last-wins" on a shared property depend on that - /// unrelated field. Now there is one bucket, resolved in one - /// `resolve_animations` call, so the composition rule is always - /// "last effect in the array wins on a shared property" — a CSS-cascade - /// rule, independent of `delay`). pub keyframe_animations: Vec, - /// True when any contributing `keyframes`/`tilt_in` effect requested - /// `"loop": true` (constat #7). Applied uniformly to the whole - /// `keyframe_animations` bucket — see the doc comment on - /// `resolve_props_for_effects` for the same caveat presets already have - /// (multiple effects with different loop settings on the same property - /// is an unsupported edge case, not new to this fix). pub keyframes_loop: bool, pub wiggles: Vec<&'a WiggleConfig>, pub orbits: Vec<&'a OrbitConfig>, - /// Every `motion_path` effect, resolved by `apply_motion_paths` into - /// `translate_x`/`translate_y` (and, when `orient` is set, - /// `rotation`) — the same additive-into-`props` treatment `orbits` - /// already gets, and for the same reason: multiple path effects on one - /// node compose by simple vector addition, not last-wins. pub motion_paths: Vec<&'a MotionPathConfig>, pub glow: Option<&'a GlowConfig>, pub motion_blur: Option, pub char_animation: Option, } -/// M3: find the first `glow` effect in a list, if present. -/// -/// `glow` is a static (non-time-varying) coloured halo — unlike every other -/// effect `resolve_props_for_effects` resolves, it deliberately is *not* -/// folded into `AnimatedProperties`: `GlowConfig.color` has no corresponding -/// field there, and extending `AnimatedProperties`'s public shape is out of -/// scope for this workstream. Callers apply the returned config directly as -/// a CSS `filter: drop-shadow(...)` (see -/// `rustmotion_components::box_builder::apply_glow_effect`), which is the -/// only place that needs the raw colour string. pub fn find_glow_effect(effects: &[AnimationEffect]) -> Option<&GlowConfig> { effects.iter().find_map(|e| match e { AnimationEffect::Glow(cfg) => Some(cfg), @@ -143,7 +79,6 @@ pub fn find_glow_effect(effects: &[AnimationEffect]) -> Option<&GlowConfig> { }) } -/// Split a slice of AnimationEffect into categorized buckets for the renderer. pub fn extract_effects(effects: &[AnimationEffect]) -> ExtractedEffects<'_> { let mut result = ExtractedEffects { presets: Vec::new(), @@ -176,11 +111,6 @@ pub fn extract_effects(effects: &[AnimationEffect]) -> ExtractedEffects<'_> { AnimationEffect::CharBounce(_) => CharAnimPreset::Bounce, AnimationEffect::CharRotateIn(_) => CharAnimPreset::RotateIn, AnimationEffect::CharSlideUp(_) => CharAnimPreset::SlideUp, - // `char_blur_in` used to be resolved separately, off - // `style.animation` inside `text.rs`'s painter, which - // meant it silently missed container-level stagger - // shifting and `timeline`-embedded copies. It goes - // through the same door as its five siblings now. AnimationEffect::CharBlurIn(_) => CharAnimPreset::BlurIn, _ => unreachable!(), }; @@ -211,12 +141,6 @@ pub fn extract_effects(effects: &[AnimationEffect]) -> ExtractedEffects<'_> { result.orbits.push(config); } AnimationEffect::Keyframes(config) => { - // Keyframe times are absolute scene seconds; the - // config-level delay shifts them (applied unconditionally - // — a no-op when `delay == 0` — so every `keyframes` - // effect lands in the same bucket regardless of its - // delay; see the `ExtractedEffects::keyframe_animations` - // doc comment for why that used to matter). result .keyframe_animations .extend(config.keyframes.iter().map(|anim| { @@ -261,7 +185,7 @@ pub fn extract_effects(effects: &[AnimationEffect]) -> ExtractedEffects<'_> { AnimationEffect::MotionPath(config) => { result.motion_paths.push(config); } - _ => {} // preset variants already handled above + _ => {} } } } @@ -269,9 +193,6 @@ pub fn extract_effects(effects: &[AnimationEffect]) -> ExtractedEffects<'_> { result } -// ─── Easing functions ─────────────────────────────────────────────────────── - -/// Apply easing function to a normalized time t (0.0..1.0) pub fn ease(t: f64, easing: &EasingType) -> f64 { let t = t.clamp(0.0, 1.0); match easing { @@ -336,14 +257,8 @@ pub fn ease(t: f64, easing: &EasingType) -> f64 { } } EasingType::Bounce => bounce_ease_out(t), - EasingType::Spring => t, // Spring handled separately + EasingType::Spring => t, EasingType::CubicBezier { x1, y1, x2, y2 } => cubic_bezier_ease(t, *x1, *y1, *x2, *y2), - // CSS `steps(n, jump-end)`: hold at step `i`'s level (`i / n`) for - // the whole `[i/n, (i+1)/n)` span, then jump. `t == 1.0` always - // lands exactly on `1.0` — the final jump — rather than on the - // last held step, which the `floor` below would otherwise produce - // (`floor(1.0 * n) / n == 1.0` only by coincidence of exact - // arithmetic; guarding it explicitly avoids relying on that). EasingType::Steps(n) => { let n = (*n).max(1) as f64; if t >= 1.0 { @@ -355,17 +270,12 @@ pub fn ease(t: f64, easing: &EasingType) -> f64 { } } -/// Evaluate a cubic-bezier curve at parameter t using Newton's method -/// Control points: P0=(0,0), P1=(x1,y1), P2=(x2,y2), P3=(1,1) fn cubic_bezier_ease(t: f64, x1: f64, y1: f64, x2: f64, y2: f64) -> f64 { - // Find the parameter t_curve such that bezier_x(t_curve) = t - // Then return bezier_y(t_curve) let t_curve = find_bezier_t_for_x(t, x1, x2); bezier_component(t_curve, y1, y2) } fn bezier_component(t: f64, p1: f64, p2: f64) -> f64 { - // B(t) = 3(1-t)^2*t*p1 + 3(1-t)*t^2*p2 + t^3 let t2 = t * t; let t3 = t2 * t; let mt = 1.0 - t; @@ -379,8 +289,7 @@ fn bezier_component_derivative(t: f64, p1: f64, p2: f64) -> f64 { } fn find_bezier_t_for_x(x: f64, x1: f64, x2: f64) -> f64 { - // Newton-Raphson to solve bezier_x(t) = x - let mut t = x; // Initial guess + let mut t = x; for _ in 0..8 { let current_x = bezier_component(t, x1, x2); let dx = bezier_component_derivative(t, x1, x2); @@ -426,44 +335,15 @@ fn ease_in_out_cubic(t: f64) -> f64 { } } -// ─── Spring solver ────────────────────────────────────────────────────────── - -/// Default `rest_threshold` (fraction of the 0→1 travel) used by -/// `spring_rest_time`/the `duration` remap in `spring_value` when a -/// `SpringConfig` does not set one explicitly. 0.5% is tight enough that -/// "at rest" reads as visually still, without demanding the numeric search -/// chase an asymptote that (for a critically- or over-damped spring) is -/// never reached exactly. pub const DEFAULT_SPRING_REST_THRESHOLD: f64 = 0.005; -/// Hard cap, in seconds, on how far into the future `spring_settle_time` -/// searches for a rest point. A very lightly damped spring can take an -/// arbitrarily long time to decay under `rest_threshold` — in the limit -/// (`damping == 0`) it never does, oscillating forever at constant -/// amplitude — so the search needs a bound or it would not terminate. When -/// the cap is hit, the spring is reported as resting at the cap itself: a -/// defined, tested "has not settled by then" answer (see -/// `spring_duration_tests::undamped_spring_is_capped_not_infinite` and -/// `spring_duration_tests::very_lightly_damped_spring_is_also_capped_when_beyond_the_bound`) -/// rather than an unbounded loop. pub const MAX_SPRING_SEARCH_SECONDS: f64 = 30.0; thread_local! { - /// Cache for [`spring_settle_time_cached`], keyed on the exact bit - /// pattern of its four inputs. One `SpringConfig` is sampled once per - /// animated property per node per frame, always with the same - /// (floored) `damping`/`stiffness`/`mass`/`threshold` — the scan result - /// is frame-invariant, so a thread-local map turns the whole render - /// into one real scan per distinct spring plus O(1) lookups instead of - /// one scan per sample. static SPRING_SETTLE_TIME_CACHE: std::cell::RefCell> = std::cell::RefCell::new(std::collections::HashMap::new()); } -/// Memoized [`spring_settle_time`]: identical inputs always produce the -/// identical scan result, so a cache hit skips the coarse-then-bisect -/// search entirely. `max_t` is not part of the key because both call sites -/// below always pass [`MAX_SPRING_SEARCH_SECONDS`]. fn spring_settle_time_cached(damping: f64, stiffness: f64, mass: f64, threshold: f64) -> f64 { let key = ( damping.to_bits(), @@ -487,31 +367,6 @@ fn spring_settle_time_cached(damping: f64, stiffness: f64, mass: f64, threshold: }) } -/// Solve spring animation at time t (seconds). -/// Returns a value between 0.0 and 1.0 representing progress. -/// -/// Constat #6: `SpringConfig` accepts any `f64` (it's schema-level, not -/// range-checked at parse time), and `rustmotion validate` used to check -/// nothing about it either. `mass <= 0` or `stiffness <= 0` fed straight into -/// `sqrt`/division below produced NaN (sqrt of a negative/undefined ratio, -/// or division by zero), and negative `damping` flipped the decay -/// exponent's sign so the "settling" oscillation diverged to +-infinity -/// instead. Either poisons every transform/opacity value downstream once it -/// merges into `AnimatedProperties`. `validate_schema.rs` now rejects these -/// combinations as errors (belt), and this floor keeps the solver itself -/// finite and bounded even if an out-of-band caller skips validation -/// (suspenders) — see `spring_robustness_tests` below. -/// -/// `duration` (issue #167 lot E, `SpringConfig::duration`): when set, `t` is -/// linearly rescaled before it reaches the physics below — not the physical -/// parameters themselves — so that `spring_rest_time` on the *unscaled* -/// spring lands exactly on `duration`. The spring's shape (oscillation -/// count, overshoot amplitude) is entirely a function of -/// `damping`/`stiffness`/`mass`, so rescaling only the time axis preserves -/// it; see `spring_duration_tests::duration_remap_preserves_shape`. This -/// does *not* resize whatever keyframe segment `spring_value` is being -/// evaluated within — see the `duration` field's doc comment on -/// `SpringConfig` for why that is a separate, author-owned concern. pub fn spring_value(t: f64, config: &SpringConfig) -> f64 { let damping = config.damping.max(0.0); let stiffness = config.stiffness.max(1e-6); @@ -522,9 +377,6 @@ pub fn spring_value(t: f64, config: &SpringConfig) -> f64 { let threshold = spring_rest_threshold(config); let natural_rest = spring_settle_time_cached(damping, stiffness, mass, threshold); if natural_rest < 1e-9 { - // Degenerate: the spring starts at distance 1.0 from its - // target, so in practice `natural_rest` is never this - // small — fall back to unscaled rather than divide by ~0. spring_value_raw(t, damping, stiffness, mass) } else { let time_scale = natural_rest / duration; @@ -535,25 +387,18 @@ pub fn spring_value(t: f64, config: &SpringConfig) -> f64 { } } -/// The physics solver itself, unscaled by any `duration` remap. Takes -/// already-floored parameters (see `spring_value`'s constat #6 doc comment) -/// so `spring_settle_time`'s search can call it directly without redoing -/// the floor on every sample. fn spring_value_raw(t: f64, damping: f64, stiffness: f64, mass: f64) -> f64 { let omega = (stiffness / mass).sqrt(); let zeta = damping / (2.0 * (stiffness * mass).sqrt()); if zeta < 1.0 { - // Underdamped let omega_d = omega * (1.0 - zeta * zeta).sqrt(); let decay = (-zeta * omega * t).exp(); 1.0 - decay * ((omega_d * t).sin() * (zeta * omega / omega_d) + (omega_d * t).cos()) } else if (zeta - 1.0).abs() < 1e-6 { - // Critically damped let decay = (-omega * t).exp(); 1.0 - decay * (1.0 + omega * t) } else { - // Overdamped let s1 = -omega * (zeta - (zeta * zeta - 1.0).sqrt()); let s2 = -omega * (zeta + (zeta * zeta - 1.0).sqrt()); let c2 = -s1 / (s2 - s1); @@ -562,41 +407,10 @@ fn spring_value_raw(t: f64, damping: f64, stiffness: f64, mass: f64) -> f64 { } } -/// Lower bound on the number of samples `spring_settle_time` takes across -/// `[0, max_t]` — enough to resolve slow (critically-/over-damped) decays -/// even when the natural oscillation period doesn't drive the sample count -/// up on its own. const SPRING_SETTLE_MIN_SAMPLES: usize = 2_000; -/// Upper bound on samples, regardless of how short the oscillation period -/// is — keeps `spring_settle_time` (called on every `spring_value` sample -/// when `duration` is set) bounded-cost for very stiff/fast springs. const SPRING_SETTLE_MAX_SAMPLES: usize = 20_000; -/// Target sample density within one oscillation period, chosen empirically -/// (see the workstream report) to keep the coarse-then-bisect search within -/// ~0.1% of a brute-force reference across a broad random sweep of -/// damping/stiffness/mass. Shallow, near-tangential graze-and-return -/// excursions across the threshold band (a spring that dips back below the -/// line by a razor-thin margin on a secondary oscillation) can still be -/// missed — `spring_rest_time`/`spring_settle_time` are a documented -/// numeric approximation, not an exact guarantee. const SPRING_SETTLE_SAMPLES_PER_PERIOD: f64 = 48.0; -/// First `t >= 0` from which `spring_value_raw` stays within `threshold` of -/// its target (1.0) forever after. Implements the "mesure du repos" from -/// issue #167 lot E: `spring_value_raw` is closed-form, so a coarse scan to -/// bracket the last exceedance, refined by bisection, is enough — no need -/// to integrate anything. -/// -/// Two regimes get explicit handling (both required by the workstream -/// brief, both exercised in `spring_duration_tests`): -/// - an overdamped (or critically damped) spring never touches its target -/// exactly, only approaches it asymptotically — the scan terminates via -/// `threshold`, never via an exact equality check; -/// - a very lightly damped spring can take arbitrarily long to settle (an -/// undamped spring, `damping == 0`, never does — it oscillates forever at -/// constant amplitude). `max_t` bounds the search; if the last sample is -/// still outside `threshold`, `max_t` itself is returned — defined, -/// tested behaviour instead of an unbounded search. fn spring_settle_time(damping: f64, stiffness: f64, mass: f64, threshold: f64, max_t: f64) -> f64 { let threshold = threshold.max(1e-9); let omega = (stiffness / mass).sqrt(); @@ -618,13 +432,9 @@ fn spring_settle_time(damping: f64, stiffness: f64, mass: f64, threshold: f64, m } if last_exceed_idx >= steps { - // Still exceeding at (or past) max_t: capped, "not settled". return max_t; } - // Refine within (last_exceed, last_exceed + dt]: the coarse scan found - // this as the last sample outside the threshold band, so bisect for the - // point within this bracket where it steps inside for good. let mut lo = last_exceed_idx as f64 * dt; let mut hi = (lo + dt).min(max_t); for _ in 0..40 { @@ -638,13 +448,6 @@ fn spring_settle_time(damping: f64, stiffness: f64, mass: f64, threshold: f64, m hi } -/// The `rest_threshold` a `SpringConfig` resolves to: the author's value if -/// set, else `DEFAULT_SPRING_REST_THRESHOLD`, floored so `spring_settle_time` -/// always has a well-defined (nonzero) target — the same belt-and-suspenders -/// pattern `spring_value` already applies to `damping`/`stiffness`/`mass`. -/// the CLI's `check_spring_config` rejects non-positive or absurd -/// (`>= 1.0`) values at the author-facing layer; this floor is the -/// solver-side backstop. fn spring_rest_threshold(config: &SpringConfig) -> f64 { config .rest_threshold @@ -652,16 +455,6 @@ fn spring_rest_threshold(config: &SpringConfig) -> f64 { .max(1e-9) } -/// Public "measure du repos" (issue #167 lot E): the instant, in seconds, -/// at which this spring settles within `rest_threshold` of its target and -/// stays there — what `rustmotion info` surfaces so an author can size a -/// scene/preset duration around a spring instead of discovering it by -/// trial and error. -/// -/// When `config.duration` is set, this *is* that duration, exactly — that -/// is the point of the time remap `spring_value` performs (see its doc -/// comment). Otherwise it is the natural settle time computed from -/// `damping`/`stiffness`/`mass` alone via `spring_settle_time`. pub fn spring_rest_time(config: &SpringConfig) -> f64 { match config.duration { Some(d) if d > 0.0 => d, @@ -675,9 +468,6 @@ pub fn spring_rest_time(config: &SpringConfig) -> f64 { } } -// ─── Animation resolver ───────────────────────────────────────────────────── - -/// Resolved animated properties for a single layer at a specific frame #[derive(Debug, Clone)] pub struct AnimatedProperties { pub opacity: f32, @@ -687,13 +477,9 @@ pub struct AnimatedProperties { pub scale_y: f32, pub rotation: f32, pub blur: f32, - /// For typewriter effect: number of visible characters (-1 = all) pub visible_chars: i32, - /// For typewriter effect: progress 0.0→1.0 (-1.0 = unused, shows all) pub visible_chars_progress: f32, - /// Animated color override (hex string) pub color: Option, - // Extended animatable properties pub border_radius: f32, pub font_size: f32, pub width: f32, @@ -704,15 +490,11 @@ pub struct AnimatedProperties { pub shadow_blur: f32, pub glow_radius: f32, pub glow_intensity: f32, - // 3D perspective transforms pub rotate_x: f32, pub rotate_y: f32, pub perspective: f32, - // Path animation pub draw_progress: f32, - // Motion path progress (0.0 = start, 1.0 = end) pub motion_progress: f32, - // Char animation (from style.animation char_* variants) pub char_animation: Option, } @@ -750,11 +532,7 @@ impl Default for AnimatedProperties { } impl AnimatedProperties { - /// Merge another AnimatedProperties into self. Properties that have been - /// explicitly set in `other` (not sentinel -1.0) override values in self. pub fn merge(&mut self, other: &AnimatedProperties) { - // opacity: default is 1.0, so only override if other explicitly animated to non-1.0 - // For opacity we multiply (both presets contribute) if (other.opacity - 1.0).abs() > 0.001 { self.opacity *= other.opacity; } @@ -785,7 +563,6 @@ impl AnimatedProperties { if other.color.is_some() { self.color = other.color.clone(); } - // Sentinel-based fields (-1.0 = not set) if other.border_radius >= 0.0 { self.border_radius = other.border_radius; } @@ -816,7 +593,6 @@ impl AnimatedProperties { if other.glow_intensity >= 0.0 { self.glow_intensity = other.glow_intensity; } - // 3D perspective transforms (additive like rotation) if other.rotate_x.abs() > 0.01 { self.rotate_x += other.rotate_x; } @@ -838,12 +614,6 @@ impl AnimatedProperties { } } -/// High-level helper: extract `effects`, resolve presets/keyframes/wiggles/orbits, -/// and propagate the char animation. Returns the resolved [`AnimatedProperties`] -/// at `time` within a scene of `scene_duration` seconds. -/// -/// Used by both the legacy render pipeline and the new paint-tree dispatcher -/// so they share the exact same animation semantics. pub fn resolve_props_for_effects( effects: &[AnimationEffect], time: f64, @@ -859,15 +629,6 @@ pub fn resolve_props_for_effects( let p = resolve_animations(&[], Some(preset), Some(preset_config), time, scene_duration); props.merge(&p); } - // Every `keyframes`/`tilt_in` effect is resolved together in one call - // (constat #5): within a single `resolve_animations` call, multiple - // `Animation`s targeting the same property are applied in list order via - // `apply_property` (assignment, not addition), so the *last* effect in - // `style.animation` wins on a shared property — deterministic, and - // independent of any effect's `delay`. `keyframes_loop` (constat #7) - // carries `"loop": true` from any contributing effect into the solver, - // which `resolve_animations` used to never see (it was always called - // with `preset_config = None`, i.e. `repeat = false`). if !extracted.keyframe_animations.is_empty() { let loop_cfg = PresetConfig { repeat: extracted.keyframes_loop, @@ -900,7 +661,6 @@ pub fn resolve_props_for_effects( props } -/// Resolve animations for a layer at a specific time (seconds) within the scene pub fn resolve_animations( animations: &[Animation], preset: Option<&AnimationPreset>, @@ -913,10 +673,8 @@ pub fn resolve_animations( let config = preset_config.cloned().unwrap_or_default(); let should_loop = config.repeat; - // First, expand preset into animations let preset_animations = preset.map(|p| expand_preset(p, &config, scene_duration)); - // Merge preset animations with explicit animations (explicit wins on conflict) let all_animations: Vec<&Animation> = preset_animations .as_ref() .map(|pa| pa.iter().collect::>()) @@ -945,22 +703,6 @@ pub fn resolve_animations( props } -/// Maps `time` into the animation's own repeat cycle, honouring -/// `config.repeat_count` (finite vs. infinite), `config.yoyo` (ping-pong -/// direction) and `config.repeat_delay` (a pause held at each cycle's -/// resting value) — issue #330's generalisation of what used to be a -/// bare infinite modulo wrap. Only called when `config.repeat` is already -/// known true (see `resolve_animations`'s `should_loop` gate); a -/// non-looping animation never reaches this function. -/// -/// The default case — `repeat: true`, no `repeat_count`, `yoyo: false`, -/// `repeat_delay: 0.0` — reduces algebraically to `period == duration` and -/// `cycle_index` always even (never backward), so `within_cycle` is -/// exactly `(time - start) % duration` and the result is exactly -/// `start + (elapsed % duration)`: the old `loop_time` formula this -/// function replaces, byte-identical (see -/// `tests::repeat_true_with_no_new_fields_is_byte_identical_to_legacy_loop_time` -/// below). fn cycle_time(anim: &Animation, time: f64, config: &PresetConfig) -> f64 { let keyframes = &anim.keyframes; if keyframes.len() < 2 { @@ -973,9 +715,6 @@ fn cycle_time(anim: &Animation, time: f64, config: &PresetConfig) -> f64 { return time; } - // `repeat_delay` pads every cycle with a held pause before the next - // one starts — the period the clock wraps on is longer than the - // motion itself by exactly that pause. let period = duration + config.repeat_delay.max(0.0); if period < 1e-9 { return time; @@ -987,8 +726,6 @@ fn cycle_time(anim: &Animation, time: f64, config: &PresetConfig) -> f64 { cycle_index = 0; } - // A finite `repeat_count` freezes on the resting value of its last - // play once `time` runs past it, instead of continuing to cycle. if let Some(count) = config.repeat_count { let last_index = (count.max(1) - 1) as i64; if cycle_index > last_index { @@ -996,9 +733,6 @@ fn cycle_time(anim: &Animation, time: f64, config: &PresetConfig) -> f64 { } } - // Time spent inside this cycle's own motion window, clamped to - // `duration` — once past it, we're in the `repeat_delay` pause (or, - // for the clamped final cycle above, held there indefinitely). let within_cycle = (elapsed - cycle_index as f64 * period) .min(duration) .max(0.0); @@ -1011,27 +745,11 @@ fn cycle_time(anim: &Animation, time: f64, config: &PresetConfig) -> f64 { } } -/// Result of resolving an animation value — either a number or a color enum ResolvedValue { Number(f64), Color(String), } -/// Public wrapper around `resolve_animation_value_full` for callers outside -/// this module that want to reuse the exact segment/easing/spring -/// interpolation math (ordering, per-keyframe easing override, clamping at -/// the ends) on a synthetic `Animation` they built themselves, without -/// routing the result through `AnimatedProperties`/`apply_property`. -/// -/// This is how `box_builder.rs`'s `style.transition` smoothing for -/// `border-radius`/`background` is implemented: those two properties are -/// paint-time `CssStyle` fields that every painter already reads directly -/// (via `paint_pass.rs`, frozen) — there is no `AnimatedProperties` field -/// for them to land in that anything downstream would ever look at, so -/// resolving through the generic effects pipeline the way `opacity`/`color` -/// do would be a dead end. Calling this directly and writing the resolved -/// `CssStyle` field by hand instead reuses the proven interpolation math -/// while staying entirely inside `box_builder.rs`'s own file scope. pub fn resolve_keyframe_track(anim: &Animation, time: f64) -> KeyframeValue { match resolve_animation_value_full(anim, time) { ResolvedValue::Number(n) => KeyframeValue::Number(n), @@ -1079,7 +797,6 @@ fn resolve_animation_value_full(anim: &Animation, time: f64) -> ResolvedValue { let local_t = (time - kf0.time) / segment_duration; - // Use per-keyframe easing if specified, otherwise fall back to animation-level easing let segment_easing = kf0.easing.as_ref().unwrap_or(&anim.easing); let progress = match segment_easing { @@ -1090,7 +807,6 @@ fn resolve_animation_value_full(anim: &Animation, time: f64) -> ResolvedValue { other => ease(local_t, other), }; - // Check if both keyframes are colors if let (KeyframeValue::Color(c0), KeyframeValue::Color(c1)) = (&kf0.value, &kf1.value) { return ResolvedValue::Color(lerp_color(c0, c1, progress)); } @@ -1107,13 +823,11 @@ fn resolve_animation_value_full(anim: &Animation, time: f64) -> ResolvedValue { } } -/// Parse hex color to (r, g, b, a) as f64 components (0-255) fn parse_hex_components(hex: &str) -> (f64, f64, f64, f64) { let (r, g, b, a) = super::renderer::parse_hex_color(hex); (r as f64, g as f64, b as f64, a as f64) } -/// Interpolate between two hex colors pub fn lerp_color(c1: &str, c2: &str, t: f64) -> String { let (r1, g1, b1, a1) = parse_hex_components(c1); let (r2, g2, b2, a2) = parse_hex_components(c2); @@ -1158,34 +872,19 @@ fn apply_property(props: &mut AnimatedProperties, property: &str, value: f64) { "perspective" => props.perspective = value as f32, "draw_progress" => props.draw_progress = value as f32, "motion_progress" => props.motion_progress = value as f32, - _ => {} // Unknown property, ignore + _ => {} } } -// Note: an earlier workstream (constat #4, `schema/video.rs`) already closed -// the "unrecognized `Animation.property` is a silent no-op" gap this -// function's catch-all (`_ => {}` above) would otherwise hide — -// `KeyframesConfig.keyframes` deserializes through -// `deserialize_validated_keyframes`/`validate_motion_property`, which -// rejects any `property` outside `KNOWN_MOTION_PROPERTIES` (with a -// did-you-mean suggestion) at parse time, before a scenario ever reaches -// `validate`/render. This workstream verified that gap is closed rather -// than reopening it with a second, redundant "known properties" list here; -// see the workstream report's "generic interpolation" write-up. - -// ─── Wiggle resolution ────────────────────────────────────────────────────── - -/// Simple noise function based on sine waves with seed for pseudo-random behavior fn simplex_noise_1d(x: f64, seed: u64) -> f64 { use std::f64::consts::TAU; let s = seed as f64; (x * TAU + s * 0.1234).sin() * 0.6 + (x * TAU * 1.7 + s * 0.5678).sin() * 0.3 - + (x * TAU * 2.9 + s * 0.9012).sin() * 0.1 // roughly -1..1 + + (x * TAU * 2.9 + s * 0.9012).sin() * 0.1 } -/// Parameterized noise function with configurable octaves fn simplex_noise_1d_ext(x: f64, seed: u64, octaves: u32) -> f64 { use std::f64::consts::TAU; let s = seed as f64; @@ -1206,7 +905,6 @@ fn simplex_noise_1d_ext(x: f64, seed: u64, octaves: u32) -> f64 { } } -/// Apply wiggle offsets additively to animated properties pub fn apply_wiggles(props: &mut AnimatedProperties, wiggles: &[WiggleConfig], time: f64) { for wiggle in wiggles { let has_extras = wiggle.octaves.is_some() @@ -1228,7 +926,6 @@ pub fn apply_wiggles(props: &mut AnimatedProperties, wiggles: &[WiggleConfig], t simplex_noise_1d(input, wiggle.seed) }; - // Apply easing: normalize [-1,1] → [0,1], ease, remap to [-1,1] if let Some(ref easing) = wiggle.easing { let normalized = (noise_val + 1.0) * 0.5; let eased = ease(normalized, easing); @@ -1237,7 +934,6 @@ pub fn apply_wiggles(props: &mut AnimatedProperties, wiggles: &[WiggleConfig], t let mut amp = wiggle.amplitude; - // Apply exponential decay if let Some(decay) = wiggle.decay { amp *= (-decay * time).exp(); } @@ -1251,8 +947,6 @@ pub fn apply_wiggles(props: &mut AnimatedProperties, wiggles: &[WiggleConfig], t } } -/// Apply orbit effects additively to animated properties. -/// Creates circular/elliptical motion with pseudo-3D depth via scale and opacity modulation. pub fn apply_orbits(props: &mut AnimatedProperties, orbits: &[OrbitConfig], time: f64) { use std::f64::consts::{PI, TAU}; @@ -1263,33 +957,22 @@ pub fn apply_orbits(props: &mut AnimatedProperties, orbits: &[OrbitConfig], time let theta = TAU * orbit.speed * time + angle_offset + phase_offset; - // Elliptical orbit position let raw_x = orbit.radius_x * theta.cos(); let raw_y = orbit.radius_y * theta.sin(); - // Apply tilt: compress Y axis and add depth effect let x_offset = raw_x; let y_offset = raw_y * tilt_rad.cos(); props.translate_x += x_offset as f32; props.translate_y += y_offset as f32; - // Pseudo-depth: when "behind" (sin < 0), scale down and reduce opacity if orbit.depth > 0.0 { - // depth_factor goes from (1 - depth) to (1 + depth) based on orbit position - let depth_sin = if tilt_rad.abs() > 0.01 { - // With tilt, depth is based on the untilted Y (how far "back" the object is) - theta.sin() - } else { - // Without tilt, use Y component for depth - theta.sin() - }; + let depth_sin = theta.sin(); let scale_factor = 1.0 + orbit.depth * depth_sin; props.scale_x *= scale_factor as f32; props.scale_y *= scale_factor as f32; } - // Opacity modulation for depth if orbit.opacity_depth > 0.0 { let depth_sin = theta.sin(); let opacity_factor = 1.0 - orbit.opacity_depth * (1.0 - depth_sin) * 0.5; @@ -1298,32 +981,8 @@ pub fn apply_orbits(props: &mut AnimatedProperties, orbits: &[OrbitConfig], time } } -// ─── Motion path ──────────────────────────────────────────────────────────── - -/// Below this measured path length (in px), a `motion_path` is treated as -/// the "zero length" degenerate case: the component holds at its single -/// point instead of travelling, and `orient` contributes no rotation (a -/// tangent is undefined at zero length). Not `0.0` exactly — `PathMeasure` -/// is a numeric approximation, and a path whose segments collapse onto one -/// point within float precision (e.g. two near-coincident cubic control -/// points) should degrade the same defined way a literal single-point path -/// does, rather than pass through as a very short, jittery "real" travel. pub const MOTION_PATH_MIN_LENGTH: f32 = 1e-3; -/// Parse `path_data` and measure its length, in px — the shared primitive -/// `apply_motion_paths` (render time) and `validate_schema.rs`'s advisory -/// zero-length check (author time) both build on, so the two never -/// disagree about what "degenerate" means. -/// -/// Returns `None` when `path_data` is empty or not valid SVG path data. -/// Every `motion_path` effect reachable through `AnimationEffect` already -/// has this ruled out at JSON-parse time -/// (`schema/video.rs::deserialize_motion_path_data`), so in practice `None` -/// only fires if a caller builds a `MotionPathConfig` directly in Rust, -/// bypassing that gate. `Some(0.0)` (or a value below -/// `MOTION_PATH_MIN_LENGTH`) is returned for a syntactically valid path -/// with (near-)zero measured length — a well-defined, distinct case from -/// "invalid", per `MotionPathConfig`'s "Degenerate paths" doc section. pub fn motion_path_length(path_data: &str) -> Option { let path = skia_safe::Path::from_svg(path_data)?; if path.count_points() == 0 { @@ -1333,21 +992,6 @@ pub fn motion_path_length(path_data: &str) -> Option { Some(measure.length()) } -/// Progress along a `motion_path` effect's own timeline, already eased, in -/// `[0, 1]`. Mirrors the delay/duration semantics every other timed effect -/// in this file uses: before `delay`, progress is pinned to `0.0` (the path -/// hasn't started — the component sits at the path's start point, the same -/// "hold at the entrance state" every preset already does before its own -/// delay elapses); at/after `delay + duration` it is pinned to `1.0` (holds -/// at the path's end) unless `repeat` wraps it back into `[0, 1)` instead. -/// -/// `safe_div`'s fallback (`1.0`) makes a non-positive `duration` behave as -/// "already complete the instant `delay` elapses" — finite and defined, -/// never a NaN/∞ division — the same belt-and-suspenders posture -/// `spring_value` already takes on its own denominators. -/// `validate_schema.rs::check_motion_path_config` additionally rejects -/// `duration <= 0` as an author-facing error, so this fallback is a second -/// line of defence, not the only one. fn motion_path_progress(cfg: &MotionPathConfig, time: f64) -> f64 { let elapsed = time - cfg.delay; if elapsed <= 0.0 { @@ -1362,34 +1006,12 @@ fn motion_path_progress(cfg: &MotionPathConfig, time: f64) -> f64 { ease(progress, &cfg.easing) } -/// One `motion_path` effect's contribution at `time`: a translate delta (in -/// the component-local coordinate space `MotionPathConfig` documents) and a -/// tangent-derived rotation in degrees (`0.0` when `orient` is unset, or -/// when the path is the zero-length degenerate case). struct MotionPathSample { dx: f32, dy: f32, angle_deg: f32, } -/// Sample a `motion_path` effect at `time`. Never returns a NaN/infinite -/// component, for any input — the three degenerate cases the workstream -/// brief names are each handled explicitly rather than falling through to -/// whatever the underlying float operation happens to produce: -/// -/// - **empty/unparsable path**: `AnimationEffect::MotionPath` cannot carry -/// one past `schema/video.rs::deserialize_motion_path_data`'s parse-time -/// rejection, but this function stays defensive anyway (`(0.0, 0.0, -/// 0.0)`, i.e. no displacement) rather than assuming that gate always ran -/// — e.g. a future direct `MotionPathConfig` construction in Rust code -/// would bypass serde entirely. -/// - **single point** (`"M50,50"`) and **zero-length** (every segment -/// collapses onto one point, e.g. `"M10,10 L10,10"`): both measure to -/// (near-)zero length. Position holds at that single point (read via -/// `Path::get_point(0)`) for the entire timeline; orientation is `0.0` -/// regardless of `orient` — a tangent is undefined at zero length, so -/// `atan2(0.0, 0.0)`'s technically-zero-but-meaningless result is never -/// computed or relied on. fn motion_path_sample(cfg: &MotionPathConfig, time: f64) -> MotionPathSample { let zero = MotionPathSample { dx: 0.0, @@ -1406,13 +1028,6 @@ fn motion_path_sample(cfg: &MotionPathConfig, time: f64) -> MotionPathSample { let mut measure = skia_safe::PathMeasure::new(&path, false, None); let length = measure.length(); - // Verified empirically (not just assumed): `PathMeasure::pos_tan` on a - // zero-length contour returns `None` in this skia-safe build, which the - // `None` arm below would also catch — this early return is kept anyway - // as the one place the degenerate case is *named*, rather than an - // undocumented cross-version PathMeasure behaviour a reader would have - // to intuit, and it skips constructing/querying the measure entirely - // for the single most common degenerate input (a single-point path). if length <= MOTION_PATH_MIN_LENGTH { let (x, y) = path.points().first().map_or((0.0, 0.0), |p| (p.x, p.y)); return MotionPathSample { @@ -1438,10 +1053,6 @@ fn motion_path_sample(cfg: &MotionPathConfig, time: f64) -> MotionPathSample { angle_deg, } } - // `0 <= distance <= length` on a >0-length path should always - // report a position; if Skia ever declines anyway, hold at the - // path's start rather than let a missing sample surface as a jump - // to the component's untranslated origin or a NaN. None => { let (x, y) = path.points().first().map_or((0.0, 0.0), |p| (p.x, p.y)); MotionPathSample { @@ -1453,17 +1064,6 @@ fn motion_path_sample(cfg: &MotionPathConfig, time: f64) -> MotionPathSample { } } -/// Apply every `motion_path` effect additively to `props.translate_x`/ -/// `translate_y` (and, when `orient` is set, `props.rotation`) — the same -/// treatment `apply_orbits`/`apply_wiggles` already give their own -/// continuous effects, and critically, fields `css::animation:: -/// apply_animated_props` already bridges into `css.transform`'s -/// `translate`/`rotate` functions. That bridge — not a new one — is what -/// makes a `motion_path` excursion past the viewport visible to -/// `--strict-anim` (`rustmotion::cli::commands::geometry:: -/// apply_static_node_transform`, which folds `css.transform` to detect -/// overflow): this function must never write position/orientation anywhere -/// else, or that detection silently stops seeing it. pub fn apply_motion_paths(props: &mut AnimatedProperties, paths: &[MotionPathConfig], time: f64) { for cfg in paths { let sample = motion_path_sample(cfg, time); @@ -1502,11 +1102,6 @@ fn get_property_value(props: &AnimatedProperties, property: &str) -> f64 { } } -// ─── Preset expansion ─────────────────────────────────────────────────────── - -/// Properties eligible for the preset-level `spring` override: motion only. -/// Opacity keeps its ease (an alpha overshoot flashes), blur/draw_progress -/// would go out of range on overshoot. fn is_motion_property(property: &str) -> bool { matches!( property, @@ -1523,37 +1118,6 @@ fn is_motion_property(property: &str) -> bool { ) } -/// Apply a user-provided spring to a preset's motion animations (issue #88). -/// -/// Implementation note: a single generic post-processing pass was chosen over -/// editing each of the ~40 preset builders — the eligibility rules are uniform -/// and the builders stay oblivious to springs. Rules per animation: -/// - non-motion property (opacity, blur, …): untouched; -/// - 2 keyframes: easing → `Spring` with the given config. For `bounce_in` / -/// `elastic_in` this *overrides* their built-in spring, which thereby acts -/// as the default when no user config is provided; -/// - more than 2 keyframes with different endpoints (manual-overshoot -/// entrances like `scale_in`): collapsed to [first, last] + spring — the -/// spring supplies the overshoot itself, keeping the manual peak would -/// double it; -/// - more than 2 keyframes with identical endpoints (continuous oscillators: -/// pulse, shake, float): untouched — a spring toward the same value is a -/// no-op and would freeze the effect. -/// -/// `spring.duration` (issue #167 lot E) is *not* consulted here to resize -/// the keyframe pair's own span: the pair's `[delay, end]` still comes from -/// `AnimationTiming::delay`/`duration` (the same preset-level timing every -/// other easing uses), untouched by whatever `SpringConfig::duration` says. -/// `spring_value` — not this function — is where `duration` acts, by -/// rescaling the *physics* time axis it is fed. Consequently, if the -/// preset's own `duration` is shorter than `spring.duration`, the segment -/// still ends (and the property still snaps to its final keyframe value) at -/// the preset's `end`, before the spring has visually settled — exactly the -/// pre-existing behaviour for any other easing curve given too short a -/// segment. Pin `AnimationTiming::duration` (or the `keyframes` effect's own -/// keyframe span, for the other call site in `resolve_animation_value_full`) -/// to at least `spring_rest_time` to avoid that cutoff; `rustmotion info` -/// reports `spring_rest_time` for exactly this purpose. fn apply_spring_to_motion(animations: &mut [Animation], spring: &SpringConfig) { for anim in animations.iter_mut() { if !is_motion_property(&anim.property) || anim.keyframes.len() < 2 { @@ -1563,7 +1127,7 @@ fn apply_spring_to_motion(animations: &mut [Animation], spring: &SpringConfig) { let first = anim.keyframes.first().unwrap().clone(); let last = anim.keyframes.last().unwrap().clone(); if (first.value.as_f64() - last.value.as_f64()).abs() < 1e-9 { - continue; // oscillator — leave its shape alone + continue; } anim.keyframes = vec![first, last]; } @@ -1590,7 +1154,6 @@ fn expand_preset_inner(preset: &AnimationPreset, config: &PresetConfig) -> Vec vec![kf_anim( "opacity", delay, @@ -1762,10 +1325,6 @@ fn expand_preset_inner(preset: &AnimationPreset, config: &PresetConfig) -> Vec { - // Two beats, not one: the back-out scale places the element, then - // a short pulse draws the eye back to it. Collapsing them into a - // single overshooting curve reads as one wobble instead — the - // second beat has to land *after* the element has visibly settled. let pulse = 1.0 + config.overshoot.unwrap_or(0.18); let placed = delay + dur * 0.6; let peak = delay + dur * 0.8; @@ -1808,7 +1367,6 @@ fn expand_preset_inner(preset: &AnimationPreset, config: &PresetConfig) -> Vec { vec![kf_anim("opacity", delay, 1.0, end, 0.0, EasingType::EaseIn)] } @@ -1943,13 +1501,6 @@ fn expand_preset_inner(preset: &AnimationPreset, config: &PresetConfig) -> Vec vec![kf_anim_3kf_over( "scale", delay, @@ -1987,7 +1538,6 @@ fn expand_preset_inner(preset: &AnimationPreset, config: &PresetConfig) -> Vec vec![ kf_anim( "opacity", @@ -2058,17 +1608,7 @@ fn expand_preset_inner(preset: &AnimationPreset, config: &PresetConfig) -> Vec { - // The cycle spans delay..delay+duration, so `duration` sets the - // period and `delay` shifts the phase. - // - // Both were previously inert: the keyframes were pinned to 0.0 / - // 0.5 / 1.0 seconds, so every floating element in a scene shared - // one 1-second cycle and moved in lockstep no matter what the - // scenario asked for. A row of cards bobbing in unison reads as a - // dance; the same cards on different phases and travels read as - // depth, which is the point of the preset. let amp = config.amplitude.unwrap_or(12.0); let tilt = amp / 12.0; vec![ @@ -2110,7 +1650,6 @@ fn expand_preset_inner(preset: &AnimationPreset, config: &PresetConfig) -> Vec vec![kf_anim( "draw_progress", delay, @@ -2208,8 +1747,6 @@ fn kf_anim_spring_underdamped(property: &str, t0: f64, v0: f64, t1: f64, v1: f64 } } -/// Three-keyframe oscillation laid out over an explicit `start..end` window, -/// so the caller controls both when it begins and how long one cycle lasts. fn kf_anim_3kf_over( property: &str, start: f64, @@ -2227,9 +1764,6 @@ fn kf_anim_3kf_over( } } -/// Four-keyframe oscillation (quarter/half/end split) laid out over an -/// explicit `start..end` window — the `shake` counterpart to -/// `kf_anim_3kf_over`. #[allow(clippy::too_many_arguments)] fn kf_anim_4kf_over( property: &str, @@ -2257,8 +1791,6 @@ fn kf_anim_4kf_over( #[cfg(test)] mod spring_preset_tests { - //! TDD tests for issue #88: spring easing on any preset via - //! `AnimationTiming.spring`. use super::*; use crate::schema::AnimationEffect; @@ -2281,7 +1813,6 @@ mod spring_preset_tests { } } - /// Sample translate_y and opacity over the animation window. fn sample(effects: &[AnimationEffect], duration: f64) -> Vec<(f64, f64, f64)> { let steps = 80; (0..=steps) @@ -2295,7 +1826,6 @@ mod spring_preset_tests { #[test] fn fade_in_up_spring_overshoots_position() { - // Without spring: translate_y eases 60 → 0, never negative. let plain = sample(&[AnimationEffect::FadeInUp(timing(0.8, None))], 0.8); let min_plain = plain.iter().map(|(_, y, _)| *y).fold(f64::MAX, f64::min); assert!( @@ -2303,8 +1833,6 @@ mod spring_preset_tests { "without spring translate_y must never overshoot below 0, got min {min_plain}" ); - // With an underdamped spring: the position overshoots past the final - // value (goes measurably negative) somewhere inside the window. let sprung = sample( &[AnimationEffect::FadeInUp(timing(0.8, Some(underdamped())))], 0.8, @@ -2315,7 +1843,6 @@ mod spring_preset_tests { "with spring translate_y must overshoot below 0, got min {min_sprung}" ); - // At ~70% of the duration the two positions differ measurably. let y_plain_70 = plain[56].1; let y_sprung_70 = sprung[56].1; assert!( @@ -2338,7 +1865,6 @@ mod spring_preset_tests { "opacity must be identical with/without spring at sample {i}: {a_plain} vs {a_sprung}" ); } - // And alpha stays monotone non-decreasing (no overshoot flashes). for w in sprung.windows(2) { assert!( w[1].2 >= w[0].2 - 1e-6, @@ -2355,8 +1881,6 @@ mod spring_preset_tests { let fx = [AnimationEffect::BounceIn(timing(0.8, spring))]; resolve_props_for_effects(&fx, t, 5.0).scale_x as f64 }; - // Default (damping 12/stiffness 100) vs a heavily overdamped custom - // spring must produce different scales mid-flight. let overdamped = SpringConfig { damping: 40.0, stiffness: 100.0, @@ -2373,9 +1897,6 @@ mod spring_preset_tests { #[test] fn scale_in_spring_collapses_manual_overshoot() { - // ScaleIn's 3-keyframe manual overshoot (0 → 1.08 → 1) collapses to a - // 2-keyframe spring (0 → 1): the spring provides the overshoot itself, - // so scale must exceed 1.0 somewhere (underdamped) and converge to 1. let fx = [AnimationEffect::ScaleIn(timing(0.8, Some(underdamped())))]; let mut max_scale = f64::MIN; for i in 0..=80 { @@ -2383,9 +1904,6 @@ mod spring_preset_tests { let s = resolve_props_for_effects(&fx, t, 5.0).scale_x as f64; max_scale = max_scale.max(s); } - // The manual overshoot keyframe peaks at exactly 1.08; the collapsed - // underdamped spring (damping 8 / stiffness 120) peaks well above it — - // this discriminates the spring path from the manual keyframe path. assert!( max_scale > 1.12, "spring scale_in must overshoot past the manual 1.08 peak, got max {max_scale}" @@ -2399,9 +1917,6 @@ mod spring_preset_tests { #[test] fn pulse_oscillator_ignores_spring() { - // Pulse's scale loop (1 → 1.05 → 1) has identical endpoints — a - // spring toward the same value would freeze the effect, so the - // oscillator keeps its own shape. let at = |spring: Option, t: f64| -> f64 { let fx = [AnimationEffect::Pulse(timing(1.0, spring))]; resolve_props_for_effects(&fx, t, 5.0).scale_x as f64 @@ -2429,12 +1944,10 @@ mod spring_preset_tests { assert_eq!(s.stiffness, 120.0); assert_eq!(s.mass, 1.0, "mass defaults to 1"); - // Round-trip. let re = serde_json::to_string(&fx).unwrap(); let back: AnimationEffect = serde_json::from_str(&re).unwrap(); assert_eq!(fx, back); - // Absent spring stays absent. let plain: AnimationEffect = serde_json::from_str(r#"{ "name": "fade_in_up" }"#).unwrap(); let AnimationEffect::FadeInUp(t) = &plain else { panic!("wrong variant"); @@ -2445,11 +1958,6 @@ mod spring_preset_tests { #[cfg(test)] mod glow_tests { - //! M3: `find_glow_effect` extraction (issue #109). The colour/filter - //! side of the fix lives in - //! `rustmotion_components::box_builder::apply_glow_effect`, which is - //! covered in that crate's own tests since it needs `CssStyle`/`FilterFn` - //! (not available to this crate). use super::*; use crate::schema::{AnimationEffect, AnimationTiming, GlowConfig}; @@ -2481,13 +1989,6 @@ mod glow_tests { #[test] fn resolve_props_for_effects_does_not_touch_glow_radius_or_intensity() { - // Deliberate: the named `glow` effect is applied directly as a CSS - // filter by `box_builder::apply_glow_effect` (using the raw - // `GlowConfig.color`, which `AnimatedProperties` has no field for), - // not through this resolver. This guards against a future change - // accidentally routing it through `AnimatedProperties` too, which - // would double up into two stacked drop-shadows (see the doc comment - // on `apply_glow_effect`). let effects = vec![glow("#5C39EE", 12.0, 1.0)]; let props = resolve_props_for_effects(&effects, 0.0, 1.0); assert_eq!( @@ -2504,16 +2005,9 @@ mod glow_tests { #[cfg(test)] mod float3d_amplitude_tests { - //! Constat #1: `PresetConfig::amplitude` is read by `expand_preset_inner` - //! (`config.amplitude.unwrap_or(12.0)`) but `AnimationTiming::to_preset_config` - //! used to hardcode `amplitude: None`, so any author-supplied amplitude on - //! a `float_3d` effect never reached the solver — every element bobbed by - //! the same hardcoded 12px regardless of what was authored. use super::*; use crate::schema::AnimationEffect; - /// Peak absolute `translate_y` reached while sampling densely across one - /// cycle — proxy for the oscillation's amplitude actually resolved. fn peak_translate_y(effects: &[AnimationEffect], window: f64) -> f64 { let mut peak = 0.0f64; let steps = 200; @@ -2529,9 +2023,6 @@ mod float3d_amplitude_tests { #[test] fn author_supplied_amplitude_reaches_the_solver() { - // Parsed from raw JSON, not built in Rust — proves the value survives - // serde all the way to the resolver, not merely that the struct has a - // field for it. let default_fx: AnimationEffect = serde_json::from_str(r#"{ "name": "float_3d", "duration": 1.0 }"#).unwrap(); let big_fx: AnimationEffect = @@ -2555,11 +2046,6 @@ mod float3d_amplitude_tests { #[cfg(test)] mod continuous_preset_timing_tests { - //! Constat #2: `pulse` / `float` / `shake` / `spin` used to fabricate - //! keyframes at literal times 0.0/0.25/0.5/1.0, ignoring `config.delay` - //! and `config.duration` entirely — every element sharing one of these - //! presets moved in lockstep on a fixed 1-second cycle no matter what the - //! scenario authored. use super::*; use crate::schema::AnimationEffect; @@ -2567,8 +2053,6 @@ mod continuous_preset_timing_tests { AnimationTimingFixture { delay, duration } } - /// Minimal JSON round-trip helper — keeps every case going through serde, - /// like the author's JSON would. struct AnimationTimingFixture { delay: f64, duration: f64, @@ -2587,12 +2071,6 @@ mod continuous_preset_timing_tests { fn pulse_honours_delay_and_duration() { let t = timing(1.0, 2.0); let fx: AnimationEffect = serde_json::from_str(&t.json("pulse")).unwrap(); - // Before its delay, the cycle has not started: the resolver clamps to - // the first keyframe's value (the 0.95 trough) at every pre-delay - // instant — it must be identical at two different pre-delay times, - // not moving. Before the fix, delay/duration were ignored and the - // preset ran its own literal 0..1s cycle regardless, so t=0.1 and - // t=0.9 fell in different oscillation phases and disagreed. let early = resolve_props_for_effects(std::slice::from_ref(&fx), 0.1, 10.0).scale_x as f64; let late = resolve_props_for_effects(std::slice::from_ref(&fx), 0.9, 10.0).scale_x as f64; assert!( @@ -2604,7 +2082,6 @@ mod continuous_preset_timing_tests { (early - 0.95).abs() < 0.01, "pulse before its delay must clamp to the first keyframe (0.95), got {early}" ); - // At the midpoint of its cycle (delay + duration/2 = 2.0): near the peak (1.05). let mid = resolve_props_for_effects(&[fx], 2.0, 10.0).scale_x as f64; assert!( mid > 1.03, @@ -2639,7 +2116,6 @@ mod continuous_preset_timing_tests { before.abs() < 0.1, "shake at t=0.5 (before delay=1.0) must be at rest x=0, got {before}" ); - // Quarter point of the cycle (delay + duration/4 = 1.5): near +10 peak. let quarter = resolve_props_for_effects(&[fx], 1.5, 10.0).translate_x as f64; assert!( quarter > 8.0, @@ -2657,7 +2133,6 @@ mod continuous_preset_timing_tests { before.abs() < 0.1, "spin at t=0.5 (before delay=1.0) must be at rest rotation=0, got {before}" ); - // Halfway through its own cycle (delay + duration/2 = 2.0): ~180deg. let mid = resolve_props_for_effects(&[fx], 2.0, 10.0).rotation as f64; assert!( (mid - 180.0).abs() < 5.0, @@ -2668,27 +2143,9 @@ mod continuous_preset_timing_tests { #[cfg(test)] mod keyframes_composition_tests { - //! Constat #5: two `keyframes` effects targeting the same property used - //! to be routed into one of two buckets purely by whether `delay != 0` - //! (`owned_keyframes` vs `keyframes` in `extract_effects`), each bucket - //! resolved by its own `resolve_animations` call and combined via - //! `AnimatedProperties::merge` — which *sums* additive properties like - //! `translate_x` across buckets, while two effects landing in the *same* - //! bucket instead overwrite (last one in the list wins, since - //! `apply_property` assigns rather than adds). So the composition rule - //! depended entirely on an incidental field (`delay`) with no relation to - //! authoring intent. - //! - //! Chosen semantic: every `keyframes`/`tilt_in` effect is resolved - //! together in one `resolve_animations` call, in the order the effects - //! appear in `style.animation` — like a CSS cascade, the *last* effect - //! in the array wins on a shared property. This is deterministic and - //! independent of `delay`. use super::*; use crate::schema::{Animation, AnimationEffect, Keyframe, KeyframeValue, KeyframesConfig}; - /// A `keyframes` effect with one property ramping `0 -> value` over - /// `[0, 1]` (pre-shift), then shifted by `delay`. fn ramp(property: &str, value: f64, delay: f64) -> AnimationEffect { AnimationEffect::Keyframes(KeyframesConfig { keyframes: vec![Animation { @@ -2716,7 +2173,6 @@ mod keyframes_composition_tests { #[test] fn last_declared_effect_wins_regardless_of_which_one_carries_the_delay() { - // Case 1: A (delay=0) declared first, B (delay=0.5) declared second. let a1 = ramp("translate_x", 100.0, 0.0); let b1 = ramp("translate_x", 40.0, 0.5); let combined_1 = resolve_props_for_effects(&[a1, b1.clone()], 1.0, 5.0).translate_x as f64; @@ -2726,10 +2182,6 @@ mod keyframes_composition_tests { "B (declared last) must alone determine translate_x at t=1.0: combined={combined_1}, B-alone={b1_alone}" ); - // Case 2: swap which one carries the delay, keep declaration order - // (A first, B second) — the outcome must be identical in shape: B - // (still last) wins alone, this time using B's own (now delay=0) - // timing. let a2 = ramp("translate_x", 100.0, 0.5); let b2 = ramp("translate_x", 40.0, 0.0); let combined_2 = resolve_props_for_effects(&[a2, b2.clone()], 1.0, 5.0).translate_x as f64; @@ -2740,9 +2192,6 @@ mod keyframes_composition_tests { combined={combined_2}, B-alone={b2_alone}" ); - // The two cases must NOT collapse to the same number (sanity check - // that this test isn't vacuous — B's own resolved value genuinely - // differs between the two delay assignments). assert!( (combined_1 - combined_2).abs() > 1.0, "sanity: the two cases must differ (B's own timing changed): {combined_1} vs {combined_2}" @@ -2752,13 +2201,6 @@ mod keyframes_composition_tests { #[cfg(test)] mod keyframes_loop_tests { - //! Constat #7: `"loop": true` on a `keyframes` effect or on `tilt_in` - //! never reached the solver. `resolve_props_for_effects` always called - //! `resolve_animations(&kfs, None, None, ...)` for both keyframe buckets - //! — passing `preset_config = None` means `resolve_animations` falls back - //! to `PresetConfig::default()`, whose `repeat` is `false`, so - //! `loop_time` was never invoked no matter what `KeyframesConfig::repeat` - //! / `TiltInConfig::repeat` said. use super::*; use crate::schema::{Animation, AnimationEffect, Keyframe, KeyframeValue, KeyframesConfig}; @@ -2786,10 +2228,6 @@ mod keyframes_loop_tests { duration: 0.8, repeat: true, }); - // t=2.5 is past the keyframe's own last time (1.0). Without looping, - // the resolver clamps to the last keyframe's value (1.0) forever. - // With looping (start=0, end=1, duration=1), t=2.5 wraps to 0.5 -> - // opacity should be ~0.5, not 1.0. let opacity = resolve_props_for_effects(&[looping], 2.5, 5.0).opacity as f64; assert!( (opacity - 0.5).abs() < 0.05, @@ -2807,9 +2245,6 @@ mod keyframes_loop_tests { serde_json::from_str(r#"{ "name": "tilt_in", "delay": 0.0, "duration": 0.4 }"#) .unwrap(); - // Well past the settle time (0.4s): without loop, scale is pinned at - // the final resting value (1.0). With loop (cycle 0..0.4), t=1.0 - // wraps to local t=0.2 (t=1.0 % 0.4 = 0.2), mid-tilt, scale != 1.0. let settled_scale = resolve_props_for_effects(&[settled], 1.0, 5.0).scale_x as f64; let looping_scale = resolve_props_for_effects(&[looping_tilt], 1.0, 5.0).scale_x as f64; @@ -2826,13 +2261,6 @@ mod keyframes_loop_tests { #[cfg(test)] mod spring_robustness_tests { - //! Constat #6: `spring_value` fed `mass`/`stiffness`/`damping` straight - //! into `sqrt`/division with no floor, so `mass <= 0` or `stiffness <= 0` - //! produced NaN (division by zero or sqrt of a negative number), and - //! negative `damping` flipped the decay exponent's sign, diverging to - //! +-infinity instead of settling. A NaN/inf progress value then flows - //! into transform math (translate/scale) and contaminates the whole - //! subtree it touches. use super::*; #[test] @@ -2890,16 +2318,8 @@ mod spring_robustness_tests { #[cfg(test)] mod spring_duration_tests { - //! Issue #167 lot E: `SpringConfig::duration` forces a spring to settle - //! (see `rest_threshold`) at exactly that many seconds by rescaling the - //! time axis fed to the physics solver; `spring_rest_time` is the - //! public "measure du repos" `rustmotion info` surfaces. use super::*; - /// Reference settle time via a fine linear scan of the actual - /// implemented formula (`spring_value_raw`), independent of - /// `spring_settle_time`'s coarse-then-bisect implementation — these - /// tests check the algorithm against ground truth, not against itself. fn brute_force_settle_time( damping: f64, stiffness: f64, @@ -2921,21 +2341,6 @@ mod spring_duration_tests { #[test] fn red_phase_duration_is_ignored_by_the_raw_physical_solver() { - // Captured red-phase numbers (issue #167 lot E, before `duration` - // existed on `SpringConfig`): a spring's settle time was purely - // emergent from damping/stiffness/mass. `spring_value_raw` is - // exactly that pre-existing, unscaled solver — by construction it - // does not know about `duration`. - // - // damping=6, stiffness=120, mass=1 (the same "underdamped" preset - // this file already uses for elastic_in / kf_anim_spring_underdamped) - // at t=0.8s: spring_value_raw(0.8, 6, 120, 1) ~= 1.027616 — 2.76% - // past the target, well outside any reasonable rest_threshold - // (default 0.5%). An author asking this spring to "finish at 0.8s" - // got a value nowhere near rest. Reference recomputed for RM-09 - // (issue #220): the solver's underdamped branch fed the wrong - // argument to its sine term, so this captured value moved when that - // was corrected. let v = spring_value_raw(0.8, 6.0, 120.0, 1.0); assert!( (v - 1.027616).abs() < 1e-5, @@ -2960,9 +2365,6 @@ mod spring_duration_tests { }; let threshold = DEFAULT_SPRING_REST_THRESHOLD; - // Green phase: the same (damping, stiffness, mass) that the - // red-phase test above showed is 2.76% off at t=0.8s without a - // `duration` must now be within `threshold` of rest at t=0.8s. let v_at_duration = spring_value(0.8, &config); assert!( (v_at_duration - 1.0).abs() <= threshold, @@ -2971,9 +2373,6 @@ mod spring_duration_tests { (v_at_duration - 1.0).abs() ); - // And it must not already be at rest well before `duration` — - // this is a genuine rescale, not "duration happens to be late - // enough not to matter". let v_at_half = spring_value(0.4, &config); assert!( (v_at_half - 1.0).abs() > threshold, @@ -3037,9 +2436,6 @@ mod spring_duration_tests { #[test] fn overdamped_spring_never_reaches_target_exactly_but_settle_time_is_found() { - // Pitfall called out in the brief: an overdamped spring approaches - // its target asymptotically and never touches it. The search must - // terminate via `rest_threshold`, not by looking for an exact hit. let config = SpringConfig { damping: 200.0, stiffness: 100.0, @@ -3053,8 +2449,6 @@ mod spring_duration_tests { "expected a finite, non-degenerate settle time, got {t}" ); - // Confirm it genuinely never hits exactly 1.0 — the asymptotic - // property `rest_threshold` exists to work around. for i in 1..=200 { let sample_t = t + i as f64 * 0.1; let v = spring_value_raw(sample_t, 200.0, 100.0, 1.0); @@ -3067,9 +2461,6 @@ mod spring_duration_tests { #[test] fn undamped_spring_is_capped_not_infinite() { - // Pitfall: damping=0 means the spring oscillates forever at - // constant amplitude — it never settles. The search must return the - // defined cap (`MAX_SPRING_SEARCH_SECONDS`), not loop forever. let config = SpringConfig { damping: 0.0, stiffness: 100.0, @@ -3086,11 +2477,6 @@ mod spring_duration_tests { #[test] fn very_lightly_damped_spring_is_also_capped_when_beyond_the_bound() { - // Not literally undamped, but damped so lightly it does not reach a - // 0.5% rest threshold within the search bound — same defined-cap - // behaviour as the fully undamped case, exercised with nonzero - // damping so the `zeta == 0` special case isn't the only path - // that's actually bounded. let config = SpringConfig { damping: 0.05, stiffness: 100.0, @@ -3107,13 +2493,6 @@ mod spring_duration_tests { #[test] fn duration_remap_preserves_shape() { - // The whole point of a spring's `duration` is to keep its shape — - // oscillation count, overshoot amplitude — and only rescale how - // fast it plays back. Compare the natural (no-duration) curve to a - // duration-remapped curve of the *same* underlying spring, sampled - // at matching fractions of each one's own settle time: if the remap - // were instead clipping the tail (shortening, not rescaling), these - // would diverge. let damping = 6.0; let stiffness = 120.0; let mass = 1.0; @@ -3126,7 +2505,7 @@ mod spring_duration_tests { }; let natural_rest = spring_rest_time(&natural); - let pinned_duration = 2.5; // deliberately different from natural_rest + let pinned_duration = 2.5; let pinned = SpringConfig { damping, stiffness, @@ -3147,9 +2526,6 @@ mod spring_duration_tests { let v_natural = spring_value(frac * natural_rest, &natural); let v_pinned = spring_value(frac * pinned_duration, &pinned); - // Same fraction of each spring's own settle time must produce - // the same progress value — that is the shape being preserved, - // only the clock speed differs. assert!( (v_natural - v_pinned).abs() < 1e-9, "shape mismatch at fraction {frac}: natural={v_natural} pinned={v_pinned}" @@ -3186,11 +2562,6 @@ mod spring_duration_tests { #[test] fn duration_does_not_change_delay_semantics() { - // `spring_value`'s `t` argument is already local to the enclosing - // segment (time since the segment/keyframe start — `delay` is - // baked into where that segment begins, upstream of this call). - // `duration` must not reinterpret that: t=0 must still be the - // spring's own start regardless of `duration`. let config = SpringConfig { damping: 6.0, stiffness: 120.0, @@ -3221,18 +2592,6 @@ mod motion_path_tests { } } - // ─── The decisive property: on the curve, not the chord ────────────── - - /// A bent two-segment polyline ("M0,0 L100,0 L100,100", total length - /// 200) makes this trivial to prove without any bezier arithmetic: - /// halfway along the *path* (distance 100) lands exactly on the corner - /// (100, 0). The *chord* between the two endpoints (0,0)→(100,100) has - /// its own midpoint at (50, 50) — a linear interpolation between - /// endpoints (what a buggy "lerp the bounding box" implementation would - /// produce) would land there instead. Asserting the real result is far - /// from (50, 50) and exactly at (100, 0) is what distinguishes "walks - /// the path" from "interpolates the endpoints" — checking only t=0/t=1 - /// bounds would pass either implementation. #[test] fn mid_path_progress_lands_on_the_curve_not_on_the_endpoint_chord() { let c = cfg("M0,0 L100,0 L100,100"); @@ -3262,8 +2621,6 @@ mod motion_path_tests { ); } - // ─── Endpoints, as a sanity boundary (not the decisive test on its own) ── - #[test] fn progress_zero_and_one_land_on_the_paths_own_endpoints() { let c = cfg("M10,20 L310,20 L310,220"); @@ -3274,12 +2631,6 @@ mod motion_path_tests { assert!((end.dx - 310.0).abs() < 0.5 && (end.dy - 220.0).abs() < 0.5); } - // ─── Coordinate space: deltas relative to the laid-out position ────── - - /// The path's own coordinates are used literally as the translate delta - /// — not normalized so the path's first point becomes (0,0). A path - /// that starts away from the origin therefore starts the component - /// already displaced by that much, on top of wherever layout placed it. #[test] fn path_coordinates_are_used_literally_as_the_translate_delta() { let c = cfg("M100,50 L300,50"); @@ -3292,8 +2643,6 @@ mod motion_path_tests { ); } - // ─── The channel: translate_x/translate_y/rotation, additive ───────── - #[test] fn apply_motion_paths_writes_translate_and_rotation_additively() { let mut props = AnimatedProperties { @@ -3305,19 +2654,13 @@ mod motion_path_tests { c.orient = true; apply_motion_paths(&mut props, &[c], 0.0); - // Path start is (0,0), so translate ends up unchanged from the - // pre-existing (5, -5) contribution — proves this is additive, not - // an overwrite. assert!((props.translate_x - 5.0).abs() < 0.5); assert!((props.translate_y - (-5.0)).abs() < 0.5); - // Horizontal rightward tangent ⇒ 0 degrees. assert!(props.rotation.abs() < 0.5, "got {}", props.rotation); } #[test] fn orient_false_never_touches_rotation() { - // A vertical segment has a 90°-ish tangent; if `orient` leaked - // through despite being false, rotation would move off 0. let c = cfg("M0,0 L0,100"); let mut props = AnimatedProperties::default(); apply_motion_paths(&mut props, &[c], 0.5); @@ -3329,7 +2672,6 @@ mod motion_path_tests { let mut vertical = cfg("M0,0 L0,100"); vertical.orient = true; let sample = motion_path_sample(&vertical, 0.5); - // Downward tangent (Skia is Y-down): atan2(1, 0) = 90°. assert!( (sample.angle_deg - 90.0).abs() < 1.0, "got {}", @@ -3346,8 +2688,6 @@ mod motion_path_tests { ); } - // ─── Degenerate cases: defined, finite, never NaN ───────────────────── - #[test] fn single_point_path_holds_position_and_never_produces_nan() { let mut c = cfg("M50,50"); @@ -3375,9 +2715,6 @@ mod motion_path_tests { #[test] fn empty_path_data_never_panics_or_produces_nan() { - // Bypasses `deserialize_motion_path_data`'s parse-time rejection on - // purpose (constructed directly in Rust) — the runtime sampler must - // still be safe on its own, defence in depth. let c = cfg(""); let sample = motion_path_sample(&c, 0.5); assert_eq!((sample.dx, sample.dy, sample.angle_deg), (0.0, 0.0, 0.0)); @@ -3425,15 +2762,11 @@ mod motion_path_tests { assert!((len - 100.0).abs() < 0.5, "got {len}"); } - // ─── Loop semantics ──────────────────────────────────────────────────── - #[test] fn looping_wraps_progress_back_toward_the_start() { let mut c = cfg("M0,0 L100,0 L100,100"); c.repeat = true; c.duration = 1.0; - // 1.5s in, with a 1s loop period, is equivalent to t=0.5 within the - // loop — same corner-of-the-L assertion as the non-looping test. let sample = motion_path_sample(&c, 1.5); assert!((sample.dx - 100.0).abs() < 0.5 && (sample.dy - 0.0).abs() < 0.5); } @@ -3447,8 +2780,6 @@ mod motion_path_tests { assert_eq!(at_end.dy, past_end.dy); } - // ─── Determinism: same time in, same result out ─────────────────────── - #[test] fn sampling_is_deterministic_across_repeated_calls() { let c = cfg("M0,0 C50,-100 150,-100 200,0"); @@ -3505,7 +2836,6 @@ mod char_animation_tuning_tests { let a = anim(0.2, 0.0, 0); for i in 0..6 { let expected = 0.5 + i as f64 * 0.2; - // f32 fields widened to f64 — compare at f32 precision. assert!( (a.unit_start(i) - expected).abs() < 1e-6, "unit {i} should start at {expected}, got {}", @@ -3516,10 +2846,6 @@ mod char_animation_tuning_tests { #[test] fn jitter_is_a_pure_function_of_index_and_seed() { - // Frames are rendered out of order, in parallel, and across separate - // processes (`--frames a-b` segments). If the nudge came from an RNG, - // a word would land at a different time in each of those, i.e. jump - // between neighbouring frames of the same video. let a = anim(0.2, 0.6, 42); let b = anim(0.2, 0.6, 42); for i in 0..32 { @@ -3559,8 +2885,6 @@ mod char_animation_tuning_tests { #[test] fn no_unit_starts_before_the_effects_own_delay() { - // A negative nudge on the first unit would have it appear already - // half-animated on frame 0 — the one artefact the clamp exists for. let a = anim(0.2, 2.0, 99); for i in 0..64 { assert!( @@ -3573,8 +2897,6 @@ mod char_animation_tuning_tests { #[test] fn a_zero_stagger_is_unaffected_by_jitter() { - // Nothing to spread out: every unit shares one start time, and - // `jitter` scales off `stagger`, so it has nothing to scale. let a = anim(0.0, 1.0, 3); for i in 0..8 { assert!((a.unit_start(i) - 0.5).abs() < 1e-9); @@ -3584,10 +2906,6 @@ mod char_animation_tuning_tests { #[cfg(test)] mod easing_steps_tests { - //! Issue #330: `EasingType::Steps(n)` — a caret that jumps rather than - //! fades. `steps(1)` is the acceptance criterion's own example: hold - //! the start value for the whole segment, then jump to the end - //! exactly at `t = 1.0`. use super::*; #[test] @@ -3628,12 +2946,8 @@ mod easing_steps_tests { #[cfg(test)] mod repeat_cycle_tests { - //! Issue #330: `PresetConfig::{repeat_count, yoyo, repeat_delay}` widen - //! what used to be a bare infinite-or-nothing `bool repeat`, resolved - //! by `cycle_time` (this module's private `loop_time` replacement). use super::*; - /// A single property ramping 0.0 -> 1.0 linearly over `[0, duration]`. fn ramp(duration: f64) -> Animation { kf_anim("x", 0.0, 0.0, duration, 1.0, EasingType::Linear) } @@ -3650,9 +2964,6 @@ mod repeat_cycle_tests { #[test] fn repeat_true_with_no_new_fields_is_byte_identical_to_legacy_loop_time() { - // The exact formula the old `loop_time` used, kept here verbatim - // (not by calling `cycle_time`) as the independent reference this - // test checks `cycle_time` against. fn legacy_loop_time(start: f64, duration: f64, time: f64) -> f64 { if duration < 1e-9 || time < start { return time; @@ -3680,9 +2991,6 @@ mod repeat_cycle_tests { fn a_finite_repeat_count_freezes_on_the_last_plays_resting_value() { let duration = 1.0; let anim = ramp(duration); - // 3 plays total: cycles [0,1), [1,2), [2,3). Past t=3 it must hold - // exactly the value cycle index 2 ends on (the ramp's own end, 1.0 - // in `x`-progress terms — checked here as resolved cycle_time). let cfg = config(Some(3), false, 0.0); let frozen_at = cycle_time(&anim, 3.0, &cfg); for t in [3.0, 3.5, 10.0, 1_000.0] { @@ -3692,8 +3000,6 @@ mod repeat_cycle_tests { "t={t} must stay frozen at the last play's end ({frozen_at}), got {got}" ); } - // And the first two plays must still have actually cycled (not - // frozen from the start). assert!((cycle_time(&anim, 0.5, &cfg) - 0.5).abs() < 1e-9); assert!((cycle_time(&anim, 1.5, &cfg) - 0.5).abs() < 1e-9); } @@ -3715,21 +3021,14 @@ mod repeat_cycle_tests { let duration = 1.0; let anim = ramp(duration); let cfg = config(None, true, 0.0); - // Cycle 0 (forward): local time == elapsed. assert!((cycle_time(&anim, 0.25, &cfg) - 0.25).abs() < 1e-9); - // Cycle 1 (backward, elapsed in [1,2)): mapped time counts back - // down from the end (1.0) instead of up from the start. assert!((cycle_time(&anim, 1.25, &cfg) - 0.75).abs() < 1e-9); assert!((cycle_time(&anim, 1.75, &cfg) - 0.25).abs() < 1e-9); - // Cycle 2 (forward again): back to counting up from the start. assert!((cycle_time(&anim, 2.25, &cfg) - 0.25).abs() < 1e-9); } #[test] fn yoyo_produces_a_continuous_value_at_every_cycle_boundary() { - // A ping-pong must never visibly jump at the seam between two - // cycles — the resolved value approaching a boundary from either - // side must converge to the same number. let duration = 1.0; let anim = ramp(duration); let cfg = config(None, true, 0.0); @@ -3747,25 +3046,16 @@ mod repeat_cycle_tests { fn repeat_delay_holds_the_resting_value_between_plays() { let duration = 1.0; let anim = ramp(duration); - let cfg = config(None, false, 0.5); // period = 1.5 - // Motion window [0,1): still animating. + let cfg = config(None, false, 0.5); assert!((cycle_time(&anim, 0.5, &cfg) - 0.5).abs() < 1e-9); - // Pause window [1,1.5): held at the end of the motion window (1.0). assert!((cycle_time(&anim, 1.0, &cfg) - 1.0).abs() < 1e-9); assert!((cycle_time(&anim, 1.3, &cfg) - 1.0).abs() < 1e-9); - // Next cycle starts fresh at 1.5. assert!((cycle_time(&anim, 1.5, &cfg) - 0.0).abs() < 1e-9); assert!((cycle_time(&anim, 2.0, &cfg) - 0.5).abs() < 1e-9); } #[test] fn resolve_animations_actually_applies_yoyo_and_repeat_count_end_to_end() { - // Same scenario at the public `resolve_animations` entry point - // (not just the private `cycle_time` helper), proving the fields - // reach the solver through `PresetConfig` for a real `Animation` - // list, not only for presets. `translate_x` is used instead of - // `ramp`'s own `"x"` property, which `apply_property` doesn't - // recognise. let animations = vec![kf_anim( "translate_x", 0.0, @@ -3784,7 +3074,6 @@ mod repeat_cycle_tests { "yoyo'd second play: {}", at(1.25) ); - // repeat_count: 2 -> only 2 plays; past t=2 it holds frozen. let frozen = at(2.0); assert!( (at(5.0) - frozen).abs() < 1e-4, diff --git a/crates/rustmotion-core/src/engine/box_tree.rs b/crates/rustmotion-core/src/engine/box_tree.rs index b0ba1aec..9cdabce6 100644 --- a/crates/rustmotion-core/src/engine/box_tree.rs +++ b/crates/rustmotion-core/src/engine/box_tree.rs @@ -1,37 +1,19 @@ -//! Box tree — the intermediate representation between a JSON scenario and -//! the layout/paint passes. Each node carries a resolved [`CssStyle`], a -//! discriminator pointing to the source component, and an optional intrinsic -//! measurement callback (text, image, table, chart, ...). - use std::sync::Arc; use crate::css::CssStyle; -/// Stable identifier for a node within a single layout pass. pub type NodeId = u32; -/// A renderable box. `kind` is opaque to the engine; the painter dispatch -/// downcasts the inner `Arc` to the concrete component type when -/// invoked. pub struct BoxNode { pub id: NodeId, pub kind: BoxKind, pub css: CssStyle, pub children: Vec, - /// Optional intrinsic measurement (used by taffy's `measure_fn` for - /// leaves like text / table / image). `None` = pure container. pub intrinsic: Option>, - /// JSON path of this node relative to its scene's `children` array, e.g. - /// "/children/2/children/0". `None` for synthetic nodes (the scene root). pub source_path: Option, - /// Visibility window from the component's `start_at`/`end_at` (seconds, - /// scene-relative). Outside the window the node and its subtree are not - /// painted but still occupy layout space (CSS `visibility` semantics — - /// siblings must not jump when the component appears). pub window: Option, } -/// Half-open visibility window `[start, end)`; `None` bounds are unbounded. #[derive(Debug, Clone, Copy, PartialEq)] pub struct PaintWindow { pub start: Option, @@ -69,8 +51,6 @@ impl BoxNode { } } - /// Walk the tree and assign sequential `id` values to each node. - /// Returns the next free id. pub fn assign_ids(&mut self, mut next: NodeId) -> NodeId { self.id = next; next += 1; @@ -80,7 +60,6 @@ impl BoxNode { next } - /// Find a node by id. pub fn find(&self, id: NodeId) -> Option<&BoxNode> { if self.id == id { return Some(self); @@ -94,26 +73,14 @@ impl BoxNode { } } -/// Discriminates the source component without coupling the engine to its -/// concrete types. #[derive(Clone)] pub enum BoxKind { - /// Generic container — no custom paint, only box decorations. Container, - /// Component-backed leaf or container. Holds an opaque payload that the - /// dispatcher knows how to handle. Component(Arc), - /// Temporal ghost for motion-blur / trail effects. Painted exactly like - /// `Component` (same payload, same dispatcher dispatch) but excluded from - /// the hit-map so the studio never selects a ghost node. Ghost(Arc), } -/// Trait implemented by leaves whose intrinsic size depends on their content. -/// Called by taffy during layout. pub trait IntrinsicMeasure: Send + Sync { - /// Measure intrinsic size given the available width/height. - /// Either side may be `None` if unconstrained (e.g. min-content pass). fn measure( &self, known: (Option, Option), @@ -121,7 +88,6 @@ pub trait IntrinsicMeasure: Send + Sync { ) -> (f32, f32); } -/// CSS available-space hint, mirrored from taffy. #[derive(Debug, Clone, Copy, PartialEq)] pub enum AvailableSpace { Definite(f32), diff --git a/crates/rustmotion-core/src/engine/deps.rs b/crates/rustmotion-core/src/engine/deps.rs index 3f9ece61..57db6f47 100644 --- a/crates/rustmotion-core/src/engine/deps.rs +++ b/crates/rustmotion-core/src/engine/deps.rs @@ -1,48 +1,3 @@ -//! Cross-node references — `node("id", "prop")` — for the expression engine -//! (issue #328). -//! -//! [`crate::expr`] parses `node("id", "prop")` and resolves it through -//! [`crate::expr::Scope::node_prop`] at eval time; this module is what a -//! [`Scope`](crate::expr::Scope) implementor calls to actually answer it. -//! Two problems, kept deliberately separate: -//! -//! - **Ordering.** A `line` whose `x2` reads `node("badge_3", "tx")` must -//! see `badge_3`'s transform *at the same frame*, never the previous -//! one. That only holds if `badge_3` is resolved before `line` on every -//! frame — declaration order in the JSON gives no such guarantee. [`scan_node_refs`] -//! finds every reference a node's own expressions make; [`DepGraph::build`] -//! turns those into a dependency graph, topologically sorted once at load -//! (Kahn's algorithm) so a per-frame resolution loop that visits ids in -//! [`DepGraph::order`] never reads a value that hasn't been computed yet -//! for the current frame. -//! - **Reading.** Once a node's per-frame state (its resolved -//! [`BoxLayout`], its animated transform, its text/glyph metrics if it has -//! any) exists, [`ResolvedNode::prop`] is the one place that maps a -//! `prop` string (`"x"`, `"tx"`, `"textWidth"`, `"glyph_x:3"`, ...) onto a -//! number. [`ResolvedFrame`] is the progressively-filled table a caller -//! builds while walking [`DepGraph::order`] — filled strictly in that -//! order, one node at a time, and never carried over from a previous -//! frame (a stale carry-over is exactly the one-frame-lag bug this module -//! exists to make impossible). -//! -//! # Why text metrics are a trait, not a direct call -//! -//! `rustmotion-core` doesn't know about `Text`/`GradientText` — those are -//! defined in `rustmotion-components`, which depends on this crate, not the -//! other way around. [`TextMetricsProvider`] is this module's half of that -//! bridge, the same role [`crate::engine::paint_pass::PaintDispatcher`] -//! already plays for painting: `rustmotion-components` provides the -//! concrete implementation (downcasting a node's `Arc` payload to -//! its real component type), this module only calls it. -//! -//! # What this module does not do -//! -//! It does not decide *which* JSON fields carry expressions, does not parse -//! or evaluate `Expr` itself (that's [`crate::expr`]), and does not run the -//! per-frame render loop (that lives in the higher-level crate). It is the -//! dependency graph plus the read side a caller's `Scope` impl and -//! evaluation loop are built on top of. - use std::collections::{HashMap, HashSet, VecDeque}; use crate::engine::box_tree::{BoxKind, BoxNode}; @@ -50,29 +5,12 @@ use crate::engine::layout_pass::{BoxLayout, LayoutResult}; use crate::engine::paint_pass::animated_transform; use crate::engine::renderer::GlyphMetric; -// ─── Reference scanning ───────────────────────────────────────────────────── - -/// One `node("id", "prop")` occurrence found in an expression string. #[derive(Debug, Clone, PartialEq, Eq)] pub struct NodeRef { pub id: String, pub prop: String, } -/// Find every `node("id", "prop")` call in `src` (a raw scenario JSON -/// string value — typically an `"= ..."` expression, but this scans the -/// literal text regardless of the `=` prefix). -/// -/// Deliberately re-implements just the string-literal grammar -/// `crates/rustmotion-core/src/expr/lexer.rs` already defines for `node(...)` -/// arguments (double-quoted, `\"`/`\\` escapes) instead of depending on -/// [`crate::expr::Expr`]'s internals: `Expr` exposes [`crate::expr::Expr::free_vars`] -/// for `$name` references but deliberately not which node ids a `node(...)` -/// call touches (see that module's doc comment) — adding an accessor for -/// that would be reshaping a type this workstream must leave frozen. Building -/// the dependency graph only needs to *find* candidate references, not -/// evaluate anything, so a syntax-aware scan over the same two-string-literal -/// shape is sufficient and keeps this module self-contained. pub fn scan_node_refs(src: &str) -> Vec { let bytes = src.as_bytes(); let mut out = Vec::new(); @@ -106,9 +44,6 @@ fn skip_ws(bytes: &[u8], mut i: usize) -> usize { i } -/// Having just consumed the identifier `node`, try to parse `(` string `,` -/// string `)` starting at `after_ident`. Returns the reference and the byte -/// offset right after the closing `)` on success. fn try_parse_node_call(src: &str, after_ident: usize) -> Option<(NodeRef, usize)> { let bytes = src.as_bytes(); let mut i = skip_ws(bytes, after_ident); @@ -130,10 +65,6 @@ fn try_parse_node_call(src: &str, after_ident: usize) -> Option<(NodeRef, usize) Some((NodeRef { id, prop }, i + 1)) } -/// Scan a double-quoted string literal starting at `start` (which must be -/// the opening `"`). Mirrors `expr::lexer::tokenize`'s string handling: -/// `\"` and `\\` are the only recognised escapes, everything else copies -/// through verbatim (full UTF-8, not just ASCII). fn scan_string_literal(src: &str, start: usize) -> Option<(String, usize)> { let bytes = src.as_bytes(); if bytes.get(start) != Some(&b'"') { @@ -162,71 +93,33 @@ fn scan_string_literal(src: &str, start: usize) -> Option<(String, usize)> { } } -// ─── Dependency graph ─────────────────────────────────────────────────────── - #[derive(Debug, Clone, PartialEq, thiserror::Error)] pub enum DepsError { - /// A dependency cycle, e.g. `A -> B -> A`: `A` references `B` and `B` - /// (transitively) references `A` back. `chain` names every node on the - /// cycle in reference order, starting and ending on the same id. #[error("node(\"...\") dependency cycle: {}", .chain.join(" -> "))] Cycle { chain: Vec }, - /// `referencing` calls `node("{target}", ...)` but no node in this - /// scene declares that `id` — and it isn't declared in any other scene - /// either (see [`DepsError::CrossScene`] for that case). #[error( "node \"{referencing}\" references unknown id \"{target}\" via node(\"{target}\", ...)" )] UnknownId { referencing: String, target: String }, - /// `referencing` calls `node("{target}", ...)` where `target` is a real - /// id, but declared in a *different* scene. Rejected (`reference_cross_scene`): - /// scenes are independent render units, so a reference across that - /// boundary can never resolve to a real per-frame value. #[error( "node \"{referencing}\" references \"{target}\" from another scene \ (reference_cross_scene: scenes are independent render units)" )] CrossScene { referencing: String, target: String }, - /// Two nodes in the same scene declare the same `id`. A node's `id` - /// must be unique within its scene (issue #328's own first deliverable) - /// — without this check, `node("dup", "x")` would silently resolve to - /// whichever of the two happened to be inserted into a - /// [`ResolvedFrame`] last, which is exactly the kind of load-time- - /// silent ambiguity this workstream exists to reject instead of guess - /// at. #[error("duplicate node id \"{0}\" declared more than once in the same scene")] DuplicateId(String), } -/// A scene's `node("id", ...)` dependency graph, topologically sorted once -/// at load. See the module doc for why the order matters. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct DepGraph { order: Vec, } impl DepGraph { - /// Ids in dependency-first order: every id a node references appears - /// before that node itself. A node that makes no references of its - /// own — the common case, e.g. an orbiting badge nothing else depends - /// on for *its own* fields — can appear anywhere consistent with that - /// constraint; ties are broken by declaration order in the `nodes` - /// slice [`DepGraph::build`] was given, so the order is deterministic - /// across runs of the same scenario. pub fn order(&self) -> &[String] { &self.order } - /// Build and topologically sort the dependency graph for one scene. - /// - /// `nodes`: every id declared in this scene, paired with the - /// `node(...)` references its own expressions make. An id with no - /// references of its own still needs an entry (with an empty `Vec`) so - /// it appears in [`DepGraph::order`]. - /// - /// `other_scene_ids`: every id declared in *any other* scene of the - /// same scenario — consulted only to tell a genuinely unknown id apart - /// from one that exists but crosses a scene boundary. pub fn build( nodes: &[(String, Vec)], other_scene_ids: &HashSet, @@ -238,7 +131,6 @@ impl DepGraph { } } - // `dependents[x]` = nodes that reference `x` (must resolve after x). let mut dependents: HashMap<&str, Vec<&str>> = HashMap::new(); let mut indegree: HashMap<&str, usize> = nodes.iter().map(|(id, _)| (id.as_str(), 0)).collect(); @@ -265,8 +157,6 @@ impl DepGraph { } } - // Kahn's algorithm, seeded in declaration order for determinism - // when several ids have no dependencies of their own. let mut queue: VecDeque<&str> = nodes .iter() .map(|(id, _)| id.as_str()) @@ -302,12 +192,6 @@ impl DepGraph { } } -/// Walk reference edges (`id -> the ids it references`) from the first -/// residual (unresolved) node until one repeats, and return that repeated -/// id's cycle as a chain (`A -> B -> A`). `residual` is guaranteed non-empty -/// by the only caller, and every node still in it has at least one -/// reference that is also in `residual` (that's *why* Kahn's algorithm -/// could never resolve it), so this always terminates by finding a repeat. fn find_cycle_chain(residual: &[&str], nodes: &[(String, Vec)]) -> Vec { let deps: HashMap<&str, Vec<&str>> = nodes .iter() @@ -337,46 +221,22 @@ fn find_cycle_chain(residual: &[&str], nodes: &[(String, Vec)]) -> Vec< current = n; } None => { - // Defensive: shouldn't happen for a genuine residual set, - // but never loop forever if it does. return path.iter().map(|s| s.to_string()).collect(); } } } } -// ─── Reading a resolved node ──────────────────────────────────────────────── - -/// Text/glyph measurements for one node, computed after `layout_pass` (its -/// wrap decision needs the node's resolved content-box width). See -/// [`TextMetricsProvider`]. #[derive(Debug, Clone, Default, PartialEq)] pub struct TextMetrics { - /// Widest wrapped line's measured width, in px. pub text_width: f32, pub cap_height: f32, - /// Distance from the top of the em box to the top of the tallest - /// glyphs, in px (font metric, wrap-independent). pub ascender: f32, - /// Y offset of the first line's baseline, relative to the node's own - /// content-box top, in px. pub baseline: f32, - /// Every glyph across every wrapped line, in reading order, `x` - /// relative to the node's own content-box left edge (line-local - /// position plus that line's `text-align` offset — see - /// [`crate::engine::renderer::GlyphMetric`]). pub glyphs: Vec, } -/// Bridges `rustmotion-core` to the concrete component types (`Text`, -/// `GradientText`, ...) that only `rustmotion-components` knows about — see -/// the module doc's "Why text metrics are a trait" section. pub trait TextMetricsProvider { - /// `payload` is a node's [`BoxKind::Component`]/[`BoxKind::Ghost`] - /// inner value; `content_box_width` is that node's own resolved - /// content-box width (`BoxLayout::content_box().2`, post-`layout_pass`). - /// Returns `None` for a non-text-bearing component, or one this - /// provider doesn't (yet) implement. fn text_metrics( &self, payload: &(dyn std::any::Any + Send + Sync), @@ -384,9 +244,6 @@ pub trait TextMetricsProvider { ) -> Option; } -/// A [`TextMetricsProvider`] that never has an answer — for callers with no -/// text-bearing nodes to resolve, or in tests that only exercise the -/// geometry/transform families. pub struct NoTextMetrics; impl TextMetricsProvider for NoTextMetrics { @@ -399,9 +256,6 @@ impl TextMetricsProvider for NoTextMetrics { } } -/// One node's per-frame resolved state — everything [`ResolvedNode::prop`] -/// needs to answer any of the four families in issue #328's deliverable -/// table. #[derive(Debug, Clone, Copy, Default)] struct Transform { tx: f32, @@ -419,10 +273,6 @@ pub struct ResolvedNode { } impl ResolvedNode { - /// Build directly from already-resolved parts — for a caller that - /// doesn't have a [`BoxNode`] on hand (e.g. a test, or a synthetic - /// node). [`snapshot_node`] is the usual way to get one from a real box - /// tree. pub fn new( layout: BoxLayout, transform: (f32, f32, f32, f32, f32), @@ -442,12 +292,6 @@ impl ResolvedNode { } } - /// Resolve one `prop` string against this node's state. `None` means - /// "this node has no such property" (an unknown geometry/transform name, - /// or a text/glyph property on a node with no [`TextMetrics`], or an - /// out-of-range glyph index) — the caller's [`crate::expr::Scope::node_prop`] - /// turns that into [`crate::expr::ExprError::UnknownIdent`], same as any - /// other unresolved reference. pub fn prop(&self, prop: &str) -> Option { match prop { "x" => Some(self.layout.x as f64), @@ -473,14 +317,6 @@ impl ResolvedNode { } } -/// `glyph_x:` / `glyph_cx:` — the glyph family's index is encoded in -/// the `prop` string itself (see the module doc: `node(...)`'s two -/// arguments are string literals, never sub-expressions, so there is no -/// third-argument call syntax to add without reshaping the frozen `expr` -/// grammar). `n` is a fixed literal the author writes, e.g. -/// `node("sentence", "glyph_x:7")` — consistent with issue #328's own -/// worked example (a `?` detaching from a sentence the author wrote -/// *without* its last character, so they already know the trailing index). fn glyph_prop(text: Option<&TextMetrics>, prop: &str) -> Option { let text = text?; if let Some(n) = prop.strip_prefix("glyph_x:") { @@ -494,16 +330,6 @@ fn glyph_prop(text: Option<&TextMetrics>, prop: &str) -> Option { None } -/// Snapshot one [`BoxNode`]'s current-frame state — its resolved -/// [`BoxLayout`] (must already exist in `layout`, i.e. this runs after -/// `layout_pass`), its animated transform (decomposed from `node.css`, -/// already resolved for the current frame — see -/// [`crate::engine::paint_pass::animated_transform`]'s doc), and, if -/// `text_provider` recognises the node's component payload, its text/glyph -/// metrics. -/// -/// Returns `None` only when `node.id` has no entry in `layout` at all (the -/// node was never laid out — e.g. it doesn't exist in this frame's tree). pub fn snapshot_node( node: &BoxNode, layout: &LayoutResult, @@ -522,11 +348,6 @@ pub fn snapshot_node( Some(ResolvedNode::new(box_layout, transform, text)) } -/// A single frame's progressively-filled table of resolved nodes, keyed by -/// author `id`. Built by inserting one node at a time in [`DepGraph::order`] -/// — never in JSON/tree order, and never reused across frames (a fresh -/// `ResolvedFrame` every frame is what makes "no one-frame lag" structural -/// rather than a discipline the caller has to remember). #[derive(Debug, Clone, Default)] pub struct ResolvedFrame { nodes: HashMap, @@ -537,30 +358,15 @@ impl ResolvedFrame { Self::default() } - /// Record `id`'s resolved state. Called once per id, in - /// [`DepGraph::order`] — by the time a later id's own expressions are - /// evaluated (and therefore call [`Self::node_prop`]), every id it may - /// legally reference (per [`DepGraph::build`]'s cycle/cross-scene - /// checks) is already present. pub fn insert(&mut self, id: impl Into, node: ResolvedNode) { self.nodes.insert(id.into(), node); } - /// Resolve `node("id", "prop")`. `None` if `id` hasn't been inserted - /// yet (out-of-order use — a caller respecting [`DepGraph::order`] - /// never triggers this for a legal reference) or `prop` doesn't apply - /// to that node. pub fn node_prop(&self, id: &str, prop: &str) -> Option { self.nodes.get(id)?.prop(prop) } } -/// Minimal [`crate::expr::Scope`] adapter over a [`ResolvedFrame`] — answers -/// `node_prop` only, `var` always `None`. A real per-frame `Scope` -/// (combining `$t`/`$W`/config variables with node references) implements -/// the trait itself and delegates its own `node_prop` to -/// [`ResolvedFrame::node_prop`]; this adapter exists for tests and any -/// caller with node references and nothing else to resolve. pub struct FrameScope<'a>(pub &'a ResolvedFrame); impl crate::expr::Scope for FrameScope<'_> { @@ -672,8 +478,6 @@ mod graph_tests { #[test] fn a_dependent_resolves_after_its_dependency_even_when_declared_first() { - // `line` is declared before `badge` in the JSON, but depends on it — - // the graph must still put `badge` first. let n = nodes(&[("line", &[("badge", "tx")]), ("badge", &[])]); let g = DepGraph::build(&n, &HashSet::new()).unwrap(); let pos = |id: &str| g.order().iter().position(|x| x == id).unwrap(); @@ -825,10 +629,6 @@ mod resolution_tests { assert_eq!(frame.node_prop("chip", "ty"), Some(-3.0)); assert_eq!(frame.node_prop("chip", "scale"), Some(1.5)); assert_eq!(frame.node_prop("chip", "rotation"), Some(45.0)); - // `0.8` isn't exactly representable in binary floating point, so the - // f32 input promoted to f64 and an f64 literal `0.8` round - // differently in their last bit — compare against the *same* - // f32->f64 promotion rather than a fresh f64 literal. assert_eq!(frame.node_prop("chip", "opacity"), Some(0.8_f32 as f64)); } @@ -906,26 +706,11 @@ mod resolution_tests { ), ); let scope = FrameScope(&frame); - // The acceptance example from issue #328: `x2 = node("chip3", "cx") + - // node("chip3", "tx")`. let expr = Expr::parse(r#"= node("chip3", "cx") + node("chip3", "tx")"#).unwrap(); let got = expr.eval(&scope).unwrap(); assert_eq!(got, 520.0 + 77.0); } - // ─── The trap this workstream exists to avoid: topological, not tree, - // order (issue #328's own acceptance bar) ────────────────────────────── - - /// Builds a two-node scene ("badge" orbits, "line" reads - /// `node("badge", "tx"|"ty")`) across several frames, resolving strictly - /// in [`DepGraph::order`] every frame (a *fresh* [`ResolvedFrame`] each - /// time — no frame-to-frame carry-over). At every sampled frame, the - /// value `line`'s expression reads back must equal `badge`'s own - /// transform *at that same frame*, not the previous one. A resolver - /// that read `badge` in tree/declaration order instead of dependency - /// order (or reused a stale `ResolvedFrame` across frames) would report - /// last frame's `badge` position here — this test fails against that - /// implementation and passes against this module's. #[test] fn orbiting_node_reference_tracks_every_frame_with_no_one_frame_lag() { let n = vec![ @@ -953,8 +738,6 @@ mod resolution_tests { }; for angle in [0.0f32, 37.0, 90.0, 181.0, 269.5, 350.0] { - // A brand-new frame every iteration: nothing here can leak a - // value from the previous angle. let mut frame = ResolvedFrame::new(); for id in graph.order() { match id.as_str() { @@ -969,9 +752,6 @@ mod resolution_tests { ); } "line" => { - // `line`'s own endpoint is itself an expression — - // evaluated here, after `badge` (earlier in - // topological order) is already in `frame`. let scope = FrameScope(&frame); let x2 = Expr::parse(r#"= node("badge", "tx")"#) .unwrap() @@ -1007,18 +787,9 @@ mod resolution_tests { } } - /// Same scenario, but resolved in *declaration* (tree) order instead of - /// [`DepGraph::order`] — `line` is declared first in this scene, so a - /// caller that (incorrectly) walked the JSON in document order would - /// try to resolve `line`'s `node("badge", ...)` reference before - /// `badge` has ever been inserted into `frame`. That must fail loudly - /// (`ExprError::UnknownIdent`), not silently reuse whatever `badge` - /// last resolved to — proving there is no hidden fallback that would - /// mask the one-frame-lag bug the topological order exists to prevent. #[test] fn resolving_in_declaration_order_instead_of_topological_order_fails_loudly() { let mut frame = ResolvedFrame::new(); - // Declaration order: "line" first, "badge" second — the wrong order. let scope = FrameScope(&frame); let err = Expr::parse(r#"= node("badge", "tx")"#) .unwrap() @@ -1026,8 +797,6 @@ mod resolution_tests { .unwrap_err(); assert!(matches!(err, crate::expr::ExprError::UnknownIdent(_))); - // Only after inserting "badge" (i.e. respecting the dependency - // order) does the same expression resolve. frame.insert( "badge", ResolvedNode::new( diff --git a/crates/rustmotion-core/src/engine/heropatterns.rs b/crates/rustmotion-core/src/engine/heropatterns.rs index 6a40e21a..3b078fda 100644 --- a/crates/rustmotion-core/src/engine/heropatterns.rs +++ b/crates/rustmotion-core/src/engine/heropatterns.rs @@ -1,6 +1,3 @@ -// Auto-generated from https://github.com/lowmess/hero-patterns -// 87 patterns total - pub struct HeropatternDef { pub name: &'static str, pub svg_paths: &'static str, diff --git a/crates/rustmotion-core/src/engine/layout_pass.rs b/crates/rustmotion-core/src/engine/layout_pass.rs index 3f8188a0..bc133544 100644 --- a/crates/rustmotion-core/src/engine/layout_pass.rs +++ b/crates/rustmotion-core/src/engine/layout_pass.rs @@ -1,5 +1,3 @@ -//! Layout pass — runs taffy on the BoxTree to produce per-node geometry. - use std::collections::HashMap; use taffy::prelude as tf; @@ -9,7 +7,6 @@ use crate::css::taffy_bridge::{content_box_inset, to_taffy_style, ConversionCont use crate::css::units::LengthContext; use crate::engine::box_tree::{BoxNode, IntrinsicMeasure, NodeId}; -/// Resolved geometry for a single node, in absolute viewport coordinates. #[derive(Debug, Clone, Copy, Default)] pub struct BoxLayout { pub x: f32, @@ -29,24 +26,18 @@ pub struct Insets { } impl BoxLayout { - /// Horizontal centre of the border box, in absolute viewport - /// coordinates. One of the geometry properties a `node("id", "cx")` - /// expression reads post-`layout_pass` (issue #328). pub fn cx(&self) -> f32 { self.x + self.width / 2.0 } - /// Vertical centre of the border box. See [`Self::cx`]. pub fn cy(&self) -> f32 { self.y + self.height / 2.0 } - /// Right edge of the border box (`x + width`). See [`Self::cx`]. pub fn right(&self) -> f32 { self.x + self.width } - /// Bottom edge of the border box (`y + height`). See [`Self::cx`]. pub fn bottom(&self) -> f32 { self.y + self.height } @@ -78,7 +69,6 @@ impl BoxLayout { } } -/// Result of a layout pass: layout per node, indexed by `NodeId`. #[derive(Debug, Clone, Default)] pub struct LayoutResult { pub layouts: HashMap, @@ -90,16 +80,6 @@ impl LayoutResult { } } -/// Per-node user data stored in the taffy tree to keep the link between -/// taffy nodes and our `BoxNode` ids + intrinsic measurers. -/// -/// `inset_width`/`inset_height` are this node's own resolved padding+border -/// (RM-27): taffy's `compute_leaf_layout` already subtracts them from -/// `available_space` before calling the measure function, but forwards -/// `known_dimensions` — the outer border-box size — untouched, handing an -/// `IntrinsicMeasure` implementor two arguments in different coordinate -/// spaces. The measure closure below subtracts the same inset from `known` -/// so both arguments describe the content box. struct NodeData { #[allow(dead_code)] box_id: NodeId, @@ -108,16 +88,11 @@ struct NodeData { inset_height: f32, } -/// Run taffy on a [`BoxNode`] tree and return the resolved layouts. pub fn run_layout(root: &BoxNode, viewport: (f32, f32), ctx: &ConversionContext) -> LayoutResult { let mut tree: TaffyTree = TaffyTree::new(); - // Disable taffy's pixel rounding: it floors widths/heights to integers, - // but our painters use sub-pixel Skia metrics. A width of 710.376 rounded - // to 710 makes the painter re-wrap onto an extra line. tree.disable_rounding(); let mut node_map: HashMap = HashMap::new(); - // Build the taffy tree top-down. let root_tf = build(&mut tree, &mut node_map, root, ctx, ctx.length.font_size); let viewport_size = tf::Size { @@ -149,25 +124,12 @@ pub fn run_layout(root: &BoxNode, viewport: (f32, f32), ctx: &ConversionContext) }, ); - // Walk the tree to collect absolute layouts. let mut layouts: HashMap = HashMap::new(); collect(&tree, root, &node_map, 0.0, 0.0, &mut layouts); LayoutResult { layouts } } -/// Build one taffy node and, recursively, its subtree. -/// -/// `inherited_font_size` is the already-resolved (px) font-size of `node`'s -/// parent (RM-26): CSS resolves `em` on every layout property against the -/// element's *own* computed font-size, and font-size itself inherits down -/// the tree unless overridden. `to_taffy_style` and [`content_box_inset`] -/// only ever see the single `ConversionContext` handed to them, so their -/// `em` resolution is only as correct as the per-node context built here — -/// a call site building one shared `ConversionContext` for the whole tree -/// (as every production caller of `to_taffy_style` still does directly) -/// resolves every node's `em` against that one context's `font_size` -/// instead. fn build( tree: &mut TaffyTree, map: &mut HashMap, @@ -323,7 +285,6 @@ mod tests { assert_eq!(c1.x, 0.0); assert_eq!(c1.y, 0.0); assert_eq!(c2.x, 0.0); - // 50px first child + 10px gap = 60 assert_eq!(c2.y, 60.0); } @@ -371,7 +332,6 @@ mod tests { root.assign_ids(1); let res = run_layout(&root, (200.0, 200.0), &ctx()); let child = res.get(2).expect("child"); - // (200 - 50) / 2 = 75 assert_eq!(child.x, 75.0); } diff --git a/crates/rustmotion-core/src/engine/paint_pass.rs b/crates/rustmotion-core/src/engine/paint_pass.rs index 6b7eeaf8..c6abfb1d 100644 --- a/crates/rustmotion-core/src/engine/paint_pass.rs +++ b/crates/rustmotion-core/src/engine/paint_pass.rs @@ -1,20 +1,3 @@ -//! Paint pass — walks the BoxTree post-layout and paints each node. -//! -//! Order per node (top-down): -//! 1. canvas.save() -//! 2. apply transform (CSS `transform` → Skia matrix) -//! 3. open opacity layer if `opacity < 1.0` -//! 4. clip to padding-box if `overflow != visible` -//! 5. paint outset box-shadow -//! 6. paint background -//! 7. paint border (border-radius aware) -//! 8. delegate component-specific paint via `PaintDispatcher` -//! 9. recurse children sorted by z-index -//! 10. canvas.restore() -//! -//! The dispatcher hook lets the higher-level crate plug component-specific -//! paint without coupling `rustmotion-core` to all 51 component types. - use std::cell::RefCell; use skia_safe::gradient::{self, Colors as GradientColors, Gradient}; @@ -31,52 +14,28 @@ use crate::css::units::{parse_origin_component, LengthContext, LengthPercentage, use crate::engine::box_tree::{BoxKind, BoxNode, NodeId}; use crate::engine::layout_pass::{BoxLayout, LayoutResult}; -/// Frame-level paint context (timing + viewport). #[derive(Debug, Clone, Copy)] pub struct PaintFrame { pub time: f64, - /// Seconds since the start of the *scenario* (of the view, for a `world` - /// view), as opposed to `time`, which restarts at every scene. - /// - /// Only the audio-reactive painters want this: an audio analysis is - /// indexed on the scenario's own timeline, so reading it with `time` gave - /// a scene starting at t=73 s the analysis at 73 s *into that scene*. - /// Everything else — animation progress, reveals, transitions — is - /// correctly scene-local and must stay on `time`. pub scenario_time: f64, pub frame_index: u32, pub fps: u32, pub video_width: u32, pub video_height: u32, - /// Total duration of the scene in seconds — used by the dispatcher to - /// compute animation progress (`time / scene_duration`). pub scene_duration: f64, - /// Resolved scene camera for per-plane parallax (issue #90). `Some` only - /// when the scene declares a camera AND at least one top-level child has - /// an explicit `style.depth` — the paint pass then applies the camera per - /// plane (each direct child of the root, scaled by its depth) and the - /// caller must NOT apply the global camera transform. pub camera: Option, } -/// Scene camera resolved at a fixed time, ready for per-plane application. #[derive(Debug, Clone, Copy, PartialEq)] pub struct PlaneCamera { pub pan_x: f32, pub pan_y: f32, pub zoom: f32, pub rotation: f32, - /// Focal point in frame pixels (already resolved; default = frame centre). pub origin_x: f32, pub origin_y: f32, } -/// Apply the scene camera scaled by a plane `depth` (issue #90): -/// pan' = pan·d, zoom' = 1 + (zoom−1)·d, rotation' = rotation·d, around the -/// camera origin. `depth == 1` reproduces the global camera matrix exactly; -/// `depth == 0` is the identity (locked plane). The content-space viewport -/// clip mirrors the global path's clip-after-camera so plane content is cut -/// at the scene rectangle exactly like the single-transform path. fn apply_plane_camera(canvas: &Canvas, cam: &PlaneCamera, depth: f32, viewport: (f32, f32)) { let zoom = 1.0 + (cam.zoom - 1.0) * depth; let rotation = cam.rotation * depth; @@ -98,7 +57,6 @@ fn apply_plane_camera(canvas: &Canvas, cam: &PlaneCamera, depth: f32, viewport: ); } -/// Axis-aligned bounding box of a painted node, in device (video-pixel) coords. #[derive(Debug, Clone, Copy, PartialEq)] pub struct HitRect { pub x: f32, @@ -107,35 +65,23 @@ pub struct HitRect { pub h: f32, } -/// One clickable node: its layout id and on-screen bounding box. #[derive(Debug, Clone, Copy, PartialEq)] pub struct HitNode { pub node_id: NodeId, pub rect: HitRect, } -/// Hit-test map for a single painted frame, in paint order (so later entries -/// are visually on top). pub type HitMap = Vec; -/// A hit enriched with a component kind label, ready for the studio overlay. -/// `node_id` is stable within a single rendered frame. #[derive(Debug, Clone, PartialEq)] pub struct EnrichedHit { pub node_id: NodeId, pub kind: String, pub rect: HitRect, - /// JSON path relative to the scene's `children`, e.g. "/children/2". pub pointer: Option, } -/// Hook to delegate component-specific painting. Implemented by the -/// higher-level crate that owns the actual `Component` enum. pub trait PaintDispatcher { - /// Called for `BoxKind::Component(payload)`. Implementations downcast - /// `payload` to the concrete component type and paint into `canvas`. - /// The canvas is already translated to the content-box origin and - /// clipped if `overflow: hidden`. fn dispatch( &self, canvas: &Canvas, @@ -146,7 +92,6 @@ pub trait PaintDispatcher { ); } -/// No-op dispatcher (useful for tests where only generic box decoration is exercised). pub struct NoopDispatcher; impl PaintDispatcher for NoopDispatcher { @@ -161,7 +106,6 @@ impl PaintDispatcher for NoopDispatcher { } } -/// Paint a fully-laid-out box tree onto a Skia canvas. pub fn paint_tree( canvas: &Canvas, root: &BoxNode, @@ -179,9 +123,6 @@ pub fn paint_tree( paint_node(canvas, root, &ctx, 0); } -/// Like [`paint_tree`] but also returns the per-frame hit-map: the on-screen -/// bounding box of every component-backed node, in paint order. Used by the -/// studio for click-to-select; the video render path uses [`paint_tree`]. pub fn paint_tree_with_hits( canvas: &Canvas, root: &BoxNode, @@ -209,11 +150,7 @@ struct PaintContext<'a> { hits: Option<&'a RefCell>, } -/// `tree_depth` counts levels below the synthetic scene root (root = 0, -/// direct children = 1). Per-plane parallax cameras apply at level 1 only. fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: usize) { - // Visibility window (start_at/end_at): the node keeps its layout space - // but paints nothing — subtree included — outside the window. if let Some(window) = &node.window { if !window.contains(ctx.frame.time) { return; @@ -233,13 +170,6 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u font_size: node.css.font_size_px_or(16.0), root_font_size: 16.0, }; - // Per-axis contexts for `transform`'s translate percentages: CSS - // resolves a `translate`/`translate3d` x-component percentage against - // the box's own WIDTH and the y-component against its own HEIGHT — never - // `max(width, height)` on both axes (that's only correct for square - // boxes). Mirrors what `resolve_origin` already does for - // `transform-origin` below. `z`/`perspective()` keep the general - // (shared) context — CSS has no per-axis convention for them. let length_ctx_x = LengthContext { parent_size: box_layout.width, ..length_ctx @@ -251,9 +181,6 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u canvas.save(); - // 1.5 per-plane scene camera (issue #90): every direct child of the scene - // root is a plane; its explicit `depth` (default 1.0) scales the camera. - // Deeper nodes inherit their plane's transform via the canvas matrix. if tree_depth == 1 { if let Some(cam) = &ctx.frame.camera { let depth = node.css.depth.unwrap_or(1.0); @@ -261,7 +188,6 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u } } - // 2. transform if node.css.transform.is_some() || node.css.perspective.is_some() { let (tx, ty, _tz) = resolve_origin(node.css.transform_origin.as_ref(), box_layout, &length_ctx); @@ -299,11 +225,6 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u ); } - // Hit-map: record the on-screen bbox of component-backed nodes. The canvas - // matrix here already includes this node's and all ancestors' transforms, - // so mapping the (absolute) layout rect yields the device-space AABB. - // Ghost nodes are intentionally excluded — they are temporal echoes for - // motion-blur/trail effects and must never be selectable in the studio. if let (Some(hits), BoxKind::Component(_)) = (ctx.hits, &node.kind) { let local = Rect::from_xywh( box_layout.x, @@ -323,22 +244,6 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u }); } - // 3. backdrop-filter: filter what is *already painted behind this node* - // (earlier siblings, ancestor backgrounds), clipped to its own (rounded) - // border-box — the glassmorphism pattern. This must run BEFORE this - // node's own opacity/filter layer (step 4) opens: if it ran after (as it - // used to), the backdrop's `SaveLayerRec::backdrop()` would sample the - // freshly-opened, still-empty opacity layer instead of the real scene - // beneath it, making the blur a total no-op the instant `opacity < 1.0` - // or a `filter` is also present on the same node — exactly the - // glassmorphism + fade_in combination rules/glassmorphism.md recommends. - // Self-contained bracket (save/clip/layer/restore/restore): the panel is - // baked directly onto the canvas below, so this node's own opacity later - // fades its own background/border/content on top of it without - // re-fading the panel itself (avoiding a second, unrelated ordering - // hazard: a shared clip+layer would also have to stay open across - // background/border painting, reintroducing the overflow/shadow bug - // fixed below for those steps too). if let Some(filters) = node.css.backdrop_filter.as_deref() { if let Some(backdrop) = filters_to_image_filter(filters, &length_ctx) { let radius = node @@ -356,28 +261,8 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u } } - // Hoisted from step 8 below: the layer-bounds computation right after - // this needs it too, to decide whether descendant ink painted outside - // the border-box (legitimate under `overflow: visible`) must stay - // reachable by the opacity/filter layer opened next. let overflow = node.css.overflow.unwrap_or(Overflow::Visible); - // 4. opacity / filter layer — one shared layer carries both the group - // alpha and the CSS `filter` chain (applies to the node and its - // subtree). Bounded to the node's own box, padded by: the filter - // chain's blur/drop-shadow bleed, this node's own outset box-shadow - // extent (painted inside this same layer at step 5, outside the - // border-box), and — when `overflow` leaves descendant ink free to - // paint past the border-box — the union of the whole subtree's layout - // boxes. An unbounded `SaveLayerRec` sizes the layer against the - // current clip — usually the whole viewport — so every faded/filtered - // node allocates and composites a full-frame layer regardless of how - // small it is (measured on this repo's release binary, 1080x1920/60 - // frames, 30 small `opacity: 0.5` shapes, `--threads 1`: ~42-60s wall - // time unbounded vs. ~0.5s bounded — roughly two orders of magnitude, - // not a rounding error; cost scales with viewport area, not node - // size), so the bound stays tight to the content that can actually - // paint rather than falling back to the viewport. let opacity = node.css.opacity.unwrap_or(1.0).clamp(0.0, 1.0); let content_filter = node .css @@ -423,14 +308,6 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u false }; - // 5. outset box-shadow, 6. background, 7. border — the box's own - // decorations. Painted BEFORE any overflow clip (step 8): CSS `overflow` - // clips a box's *descendants*, never the box's own border-box - // decorations (an outset box-shadow exists precisely outside the - // border-box; background/border are already shaped by border-radius on - // their own and gain nothing from an extra clip). They still sit inside - // the opacity/filter layer above so a faded node fades its whole - // appearance uniformly, background included. if let Some(shadows) = node.css.box_shadow.as_ref() { for shadow in shadows { if shadow.inset.unwrap_or(false) { @@ -442,18 +319,12 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u if let Some(bg) = node.css.background.as_ref() { paint_background(canvas, box_layout, &node.css, bg, &length_ctx); } - // `gradient-border` replaces the standard border when present (a box - // has one border, not two stacked ones). if let Some(gb) = node.css.gradient_border.as_ref() { paint_gradient_border(canvas, box_layout, &node.css, gb, &length_ctx); } else if let Some(border) = node.css.border.as_ref() { paint_border(canvas, box_layout, &node.css, border, &length_ctx); } - // 7.5. shimmer layer. The band composites against the pixels this node - // paints, which do not exist yet — so an isolated layer is opened here, - // filled by steps 9 and 10 below, and the band is stamped onto it with - // `SrcATop` just before it closes. let shimmer = active_shimmer(&node.css, ctx.frame.time); let opened_shimmer_layer = if shimmer.is_some() { let bounds = Rect::from_xywh( @@ -469,10 +340,6 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u false }; - // 8. clip overflow:hidden / clip — scoped to this node's own content and - // its children only (see step 5-7's comment for why the box's own - // decorations must stay outside this clip). `overflow` was hoisted - // above step 4. let opened_overflow_clip = if matches!( overflow, Overflow::Hidden | Overflow::Clip | Overflow::Scroll | Overflow::Auto @@ -491,8 +358,6 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u false }; - // 9. component-specific content (Ghost is painted identically to Component; - // the only difference is that Ghost is excluded from the hit-map above). let payload_opt = match &node.kind { BoxKind::Component(p) | BoxKind::Ghost(p) => Some(p), BoxKind::Container => None, @@ -502,7 +367,6 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u .dispatch(canvas, payload.as_ref(), &node.css, box_layout, ctx.frame); } - // 10. children (z-index ordered, then source order) let mut indices: Vec = (0..node.children.len()).collect(); indices.sort_by_key(|&i| node.children[i].css.z_index.unwrap_or(0)); for &i in &indices { @@ -513,7 +377,6 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u canvas.restore(); } - // inset shadows (after children so they overlay content) if let Some(shadows) = node.css.box_shadow.as_ref() { for shadow in shadows { if shadow.inset.unwrap_or(false) { @@ -535,11 +398,6 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u canvas.restore(); } -/// The shimmer effect on this node and how far through its sweep it is at -/// `time`, or `None` when there is none or the sweep is not running. -/// -/// Returning `None` outside the sweep window is what keeps the cost off every -/// other node and every other frame: no window, no isolated layer. fn active_shimmer(css: &CssStyle, time: f64) -> Option<(&crate::schema::ShimmerConfig, f32)> { let cfg = css.animation.iter().find_map(|e| match e { crate::schema::AnimationEffect::Shimmer(c) => Some(c), @@ -562,8 +420,6 @@ fn active_shimmer(css: &CssStyle, time: f64) -> Option<(&crate::schema::ShimmerC Some((cfg, progress as f32)) } -/// Stamp the sweeping band onto the layer built by steps 9-10, restricted to -/// the pixels that layer actually painted. fn paint_shimmer_band( canvas: &Canvas, layout: &BoxLayout, @@ -578,9 +434,6 @@ fn paint_shimmer_band( let transparent = SColor::from_argb(0, r, g, b); let highlight = SColor::from_argb(peak_alpha, r, g, b); - // The band's axis, and the extent of the box measured along it. Using the - // projected extent rather than the width keeps an angled band sweeping - // fully off both ends instead of stopping short on the diagonal. let theta = cfg.angle.to_radians(); let (dx, dy) = (theta.cos(), theta.sin()); let cx = layout.x + layout.width / 2.0; @@ -588,8 +441,6 @@ fn paint_shimmer_band( let half_extent = (layout.width * dx).abs() / 2.0 + (layout.height * dy).abs() / 2.0; let band = (cfg.width.max(0.01) * half_extent * 2.0).max(1.0); - // Travel from fully off one end to fully off the other, so the element is - // clean at both ends of the sweep rather than starting mid-glint. let start = -half_extent - band; let centre = start + progress * (2.0 * half_extent + 2.0 * band); @@ -611,9 +462,6 @@ fn paint_shimmer_band( paint.set_style(PaintStyle::Fill); paint.set_anti_alias(true); paint.set_shader(shader); - // The whole point: light only the pixels the element itself painted, so - // the sheen reads as catching the glyphs rather than as a rectangle - // sliding past them. paint.set_blend_mode(skia_safe::BlendMode::SrcATop); canvas.draw_rect( Rect::from_xywh(layout.x, layout.y, layout.width, layout.height), @@ -621,15 +469,6 @@ fn paint_shimmer_band( ); } -/// Conservative outward bleed (px) a `filter` chain can paint beyond the -/// node's own box — used to size the opacity/filter layer's `SaveLayerRec` -/// bounds generously enough that `blur`/`drop-shadow` never get clipped at -/// the box edge (see the perf fix in step 4 above: an unbounded layer costs -/// ~5.9x render time, but a *too-tight* one would silently clip filter -/// bleed, trading a perf bug for a correctness one). `1.5x` the nominal -/// radius covers the visible falloff of `image_filters::blur`'s Gaussian -/// (sigma = radius/2, and ~3*sigma is the point the kernel is visually -/// negligible). fn filter_bleed(list: &[crate::css::style::FilterFn], ctx: &LengthContext) -> f32 { use crate::css::style::FilterFn; let mut bleed = 0.0f32; @@ -655,12 +494,6 @@ fn filter_bleed(list: &[crate::css::style::FilterFn], ctx: &LengthContext) -> f3 bleed } -/// Conservative outward bleed (px) a node's own outset `box_shadow` list -/// paints beyond its border-box — the same role `filter_bleed` plays for -/// `filter`, and sized the same way (offset + spread pushes the shadow rect -/// out, `1.5x` blur radius covers the Gaussian falloff). Inset shadows are -/// clipped to the padding-box by `paint_box_shadow` and never bleed outward, -/// so they are skipped here. fn box_shadow_bleed(shadows: &[BoxShadow], ctx: &LengthContext) -> f32 { let mut bleed = 0.0f32; for shadow in shadows { @@ -687,14 +520,6 @@ fn box_shadow_bleed(shadows: &[BoxShadow], ctx: &LengthContext) -> f32 { bleed } -/// Bounding box (viewport coordinates) of every descendant's own layout box, -/// recursively — the same "leave the layer big enough to hold what can -/// legitimately paint outside the border-box" contract as `filter_bleed`, -/// applied to `overflow: visible` subtrees instead of a filter chain. Each -/// descendant contributes only its plain layout rect (not its own -/// filter/shadow bleed or transform): a tight bound for the common cases — -/// absolutely-positioned children, `marquee`, a taller-than-parent flow — -/// without walking the whole subtree's CSS. fn subtree_layout_bounds(node: &BoxNode, layout: &LayoutResult) -> Option { let mut bounds: Option = None; for child in &node.children { @@ -714,12 +539,6 @@ fn subtree_layout_bounds(node: &BoxNode, layout: &LayoutResult) -> Option bounds } -// ---- CSS filters ---- - -/// Build a Skia `ImageFilter` chain from a CSS `filter`/`backdrop-filter` -/// list. Color functions use the CSS Filter Effects spec matrices; Skia's -/// `color_filters::matrix_row_major` expects the translation column in -/// normalized 0..1 space (verified by `css_filter_invert_flips_colors`). fn filters_to_image_filter( list: &[crate::css::style::FilterFn], ctx: &LengthContext, @@ -755,19 +574,12 @@ fn filters_to_image_filter( None, ) } - FilterFn::Noise { intensity, seed } => { - match noise_image_filter(*intensity, *seed) { - // Sequential CSS composition: the chain so far is the - // background, the grain layer blends on top of it. - Some(noise) => image_filters::blend( - skia_safe::BlendMode::Overlay, - chain, - Some(noise), - None, - ), - None => chain, + FilterFn::Noise { intensity, seed } => match noise_image_filter(*intensity, *seed) { + Some(noise) => { + image_filters::blend(skia_safe::BlendMode::Overlay, chain, Some(noise), None) } - } + None => chain, + }, other => color_matrix_for(other) .map(|m| skia_safe::color_filters::matrix_row_major(&m, None)) .and_then(|cf| image_filters::color_filter(cf, chain, None)), @@ -776,18 +588,6 @@ fn filters_to_image_filter( chain } -/// Build the film-grain layer for `FilterFn::Noise` as an `ImageFilter`. -/// -/// Composition formula: -/// 1. `fractal_noise(base_frequency = (0.9, 0.9), octaves = 2, seed)` — -/// high frequency ⇒ fine per-pixel grain; the Skia Perlin shader is a -/// pure function of (x, y, seed): no implicit time, so the grain is -/// byte-identical across frames/renders for a given seed. -/// 2. A 4×5 color matrix collapses RGB to luminance (0.213/0.715/0.072) for -/// monochrome grain and scales alpha by `intensity` (0..1). -/// 3. The caller blends the result over the chain input with -/// `BlendMode::Overlay` — grain brightens/darkens the underlying pixels -/// proportionally to the noise-layer alpha (`intensity`). fn noise_image_filter(intensity: f32, seed: u64) -> Option { use skia_safe::image_filters; @@ -796,7 +596,6 @@ fn noise_image_filter(intensity: f32, seed: u64) -> Option Option Option<[f32; 20]> { use crate::css::style::FilterFn; #[rustfmt::skip] fn saturation(s: f32) -> [f32; 20] { - // Luminance weights per the CSS Filter Effects spec. let (r, g, b) = (0.213, 0.715, 0.072); [ r + (1.0 - r) * s, g * (1.0 - s), b * (1.0 - s), 0.0, 0.0, @@ -903,30 +699,12 @@ fn color_matrix_for(f: &crate::css::style::FilterFn) -> Option<[f32; 20]> { } } -// ---- Transform ---- - -/// Resolve a `transform-origin` / `perspective-origin` value to absolute -/// viewport coordinates `(x, y)`. -/// -/// # Resolution rules -/// -/// - Absent `origin` → centre of the box (the pre-existing hard-coded behaviour, -/// preserved byte-identically). -/// - `x` / `y` values follow the CSS spec: percentages are relative to the box -/// **width** (for x) and **height** (for y); px values are relative to the -/// box top-left corner. The result is in absolute viewport coordinates. -/// - Keywords (`left`, `center`, `right`, `top`, `bottom`) are handled by -/// `parse_origin_component` which maps them to 0%/50%/100%. -/// - An absent component (`None`) defaults to 50% on that axis. -/// - The `z` component is returned as `f32` for use in the 3-D path (defaults -/// to 0.0 when absent). fn resolve_origin( origin: Option<&TransformOrigin>, layout: &BoxLayout, ctx: &LengthContext, ) -> (f32, f32, f32) { let Some(o) = origin else { - // Default: 50% 50% 0 — dead-centre of the box. return ( layout.x + layout.width / 2.0, layout.y + layout.height / 2.0, @@ -934,7 +712,6 @@ fn resolve_origin( ); }; - // Resolve x against box width. let ox = if let Some(lp) = &o.x { let parsed = match lp { LengthPercentage::String(s) => { @@ -951,7 +728,6 @@ fn resolve_origin( layout.x + layout.width / 2.0 }; - // Resolve y against box height. let oy = if let Some(lp) = &o.y { let parsed = match lp { LengthPercentage::String(s) => { @@ -968,7 +744,6 @@ fn resolve_origin( layout.y + layout.height / 2.0 }; - // Resolve z (optional; only used in 3D path). let oz = o.z.as_ref().map(|l| l.resolve(ctx)).unwrap_or(0.0); (ox, oy, oz) @@ -990,12 +765,6 @@ fn has_3d_transform(list: &[TransformFn]) -> bool { }) } -/// Per-axis length-resolution contexts for `transform`. CSS resolves a -/// `translate`/`translate3d` percentage's x-component against the box's own -/// width and its y-component against its own height — never `max(width, -/// height)` on both axes (see `apply_transform`/`transform_to_m44`). -/// `z`/`perspective()` have no established per-axis CSS convention, so they -/// keep the general (pre-existing) context. #[derive(Clone, Copy)] struct TransformAxes { x: LengthContext, @@ -1003,16 +772,6 @@ struct TransformAxes { general: LengthContext, } -/// Apply CSS transform + perspective to the canvas. -/// -/// # Parameters -/// -/// - `transform_pivot`: the origin for the `transform` property (resolved from -/// `transform-origin`, defaults to box centre). -/// - `perspective_pivot`: the origin for the perspective projection (resolved -/// from `perspective-origin`). When `perspective-origin` is absent this equals -/// `transform_pivot` and we use the cheaper single-translate path. When they -/// differ we bracket the perspective matrix with its own translate pair. fn apply_transform( canvas: &Canvas, list: &[TransformFn], @@ -1021,12 +780,10 @@ fn apply_transform( perspective_pivot: (f32, f32), axes: &TransformAxes, ) { - // Detect whether perspective and transform pivots differ. let pivots_equal = (transform_pivot.0 - perspective_pivot.0).abs() < 0.001 && (transform_pivot.1 - perspective_pivot.1).abs() < 0.001; if perspective_d.is_none() && !has_3d_transform(list) { - // Fast path: 2D-only, use the native Skia 2D canvas API. let pivot = transform_pivot; canvas.translate(Point::new(pivot.0, pivot.1)); for tr in list { @@ -1075,8 +832,6 @@ fn apply_transform( } canvas.translate(Point::new(-pivot.0, -pivot.1)); } else if pivots_equal { - // 3D path, single-pivot (fast): perspective + transforms share the same pivot. - // Structure: T(pivot) · Persp · Transforms · T(-pivot) let pivot = transform_pivot; let mut m = M44::new_identity(); m.pre_concat(&M44::translate(pivot.0, pivot.1, 0.0)); @@ -1089,23 +844,16 @@ fn apply_transform( m.pre_concat(&M44::translate(-pivot.0, -pivot.1, 0.0)); canvas.concat_44(&m); } else { - // 3D path, dual-pivot: perspective-origin ≠ transform-origin. - // Structure: T(pp) · Persp · T(-pp) · T(tp) · Transforms · T(-tp) - // - // This matches the CSS spec where `perspective-origin` shifts the - // vanishing point while `transform-origin` sets the local pivot. let tp = transform_pivot; let pp = perspective_pivot; let mut m = M44::new_identity(); - // Outer perspective bracket (perspective-origin). m.pre_concat(&M44::translate(pp.0, pp.1, 0.0)); if let Some(d) = perspective_d { m.pre_concat(&css_perspective_m44(d)); } m.pre_concat(&M44::translate(-pp.0, -pp.1, 0.0)); - // Inner transform bracket (transform-origin). m.pre_concat(&M44::translate(tp.0, tp.1, 0.0)); for tr in list { m.pre_concat(&transform_to_m44(tr, axes)); @@ -1116,33 +864,6 @@ fn apply_transform( } } -/// Decomposes a node's already-resolved `CssStyle.transform`/`opacity` into -/// the five scalars a `node("id", "tx"|"ty"|"scale"|"rotation"|"opacity")` -/// expression reads (issue #328) — the "animated transform" family, the one -/// the eight orbiting-badge lines this workstream exists for actually need. -/// -/// Reads straight off `css`/`layout`, both already resolved for the current -/// frame by the time this is called (animation resolution happens once per -/// frame, before the box tree is built — see `rustmotion-components`' -/// `box_builder::build_scene_at_time`), so this never reaches back into the -/// animator itself and never risks reading a stale frame. -/// -/// Compound transforms (more than one `translate`/`scale`/`rotate` function -/// on a single node, or a raw `matrix`/`matrix3d`) are approximated by -/// folding the individual functions in list order — summing translations, -/// multiplying scale factors, summing rotation degrees — rather than -/// composing an actual matrix and decomposing it. That is exact for the -/// single-function-per-frame case every `AnimationEffect` preset in this -/// engine produces (an orbiting badge's `orbit`/keyframe animation resolves -/// to one `translate`, not several), and is the documented limit for a -/// hand-authored `transform` list beyond that. `scale` collapses `scale_x`/ -/// `scale_y` to their average — exact for the overwhelmingly common uniform -/// case (`scale_x == scale_y`) and a reasonable single-scalar stand-in -/// otherwise, since the expression grammar has no vector return type to -/// hand back `(scale_x, scale_y)` separately. `rotation` sums only -/// `rotate`/`rotate_z`/`rotate3d`'s own `deg` — `rotate_x`/`rotate_y` tilt -/// out of the 2D plane a `line` endpoint lives in, so folding them into the -/// same scalar would misrepresent what's actually visible on screen. pub fn animated_transform( css: &CssStyle, layout: &BoxLayout, @@ -1201,8 +922,6 @@ pub fn animated_transform( (tx, ty, scale, rotation, opacity) } -/// CSS `perspective(d)` projection matrix in row-major form. -/// Maps (x, y, z, 1) → w' = 1 - z/d; perspective divide yields depth scaling. fn css_perspective_m44(d: f32) -> M44 { M44::row_major(&[ 1.0, @@ -1314,8 +1033,6 @@ fn transform_to_m44(tr: &TransformFn, axes: &TransformAxes) -> M44 { } } -// ---- Background ---- - fn paint_background( canvas: &Canvas, layout: &BoxLayout, @@ -1339,7 +1056,6 @@ fn paint_background( } Background::Single(layer) => paint_bg_layer(canvas, &rrect, layer), Background::Layers(layers) => { - // Painted bottom-up per CSS spec (last layer = bottom). for layer in layers.iter().rev() { paint_bg_layer(canvas, &rrect, layer); } @@ -1385,7 +1101,6 @@ fn paint_bg_layer(canvas: &Canvas, rrect: &RRect, layer: &BackgroundLayer) { } } BackgroundLayer::ConicGradient { stops, .. } => { - // Skia has SweepGradient = conic. let bounds = rrect.bounds(); let center = Point::new( bounds.left + bounds.width() / 2.0, @@ -1395,8 +1110,6 @@ fn paint_bg_layer(canvas: &Canvas, rrect: &RRect, layer: &BackgroundLayer) { let gradient_colors = GradientColors::new(&colors, Some(&positions), skia_safe::TileMode::Clamp, None); let grad = Gradient::new(gradient_colors, gradient::Interpolation::default()); - // `None` angles on the deprecated API defaulted to a full 0..360 - // sweep; the new signature makes that range mandatory. if let Some(shader) = gradient::shaders::sweep_gradient(center, (0.0, 360.0), &grad, None) { @@ -1404,9 +1117,7 @@ fn paint_bg_layer(canvas: &Canvas, rrect: &RRect, layer: &BackgroundLayer) { canvas.draw_rrect(rrect, &paint); } } - BackgroundLayer::Image { .. } => { - // TODO: image background — needs a resource resolver. - } + BackgroundLayer::Image { .. } => {} } } @@ -1422,12 +1133,6 @@ fn gradient_stops(stops: &[crate::css::style::GradientStop]) -> (Vec, V (colors, positions) } -/// Gradient-line endpoints for a CSS ``: `0deg` points the line "to -/// top" (first stop at the bottom, travelling up to the last stop), and the -/// angle increases clockwise, so `90deg` is "to right" and `180deg` (the -/// default) is "to bottom" (first stop at the top). Skia's -/// `linear_gradient` places `colors[0]` at `p0`, so `p0` is always the end -/// the angle points *away from*. fn gradient_endpoints(bounds: Rect, angle_deg: f32) -> (Point, Point) { let cx = bounds.left + bounds.width() / 2.0; let cy = bounds.top + bounds.height() / 2.0; @@ -1439,8 +1144,6 @@ fn gradient_endpoints(bounds: Rect, angle_deg: f32) -> (Point, Point) { (p0, p1) } -// ---- Border ---- - fn paint_border( canvas: &Canvas, layout: &BoxLayout, @@ -1448,7 +1151,6 @@ fn paint_border( border: &BorderEdges, ctx: &LengthContext, ) { - // Uniform fast-path: same width on all sides + same color + solid style. let style = border.style.unwrap_or(BorderStyle::Solid); if matches!(style, BorderStyle::None) { return; @@ -1459,7 +1161,6 @@ fn paint_border( .map(parse_color) .unwrap_or(SColor::BLACK); - // Compute per-side widths (already resolved into BoxLayout.border by taffy). let widths = layout.border; let max_w = widths .top @@ -1476,7 +1177,6 @@ fn paint_border( .map(|r| resolve_border_radius(r, layout, ctx)) .unwrap_or([0.0; 4]); - // Outer rrect (border box) and inner rrect (padding box). let outer = border_rrect(layout, radius); let inner = inner_rrect(layout, radius); @@ -1485,20 +1185,9 @@ fn paint_border( paint.set_style(PaintStyle::Fill); paint.set_color(color); - // Use DRRect = outer minus inner for an accurate stroked border with radius. canvas.draw_drrect(outer, inner, &paint); } -/// Paint a gradient-colored border ring (issue #87). -/// -/// Painted **instead of** the standard `border` when both are set. Unlike -/// `border`, `gradient-border` is a pure paint decoration: it does not consume -/// layout space (taffy never sees it), the ring is inset from the border-box -/// edge by `gb.width`. -/// -/// The gradient is linear along `gb.angle` with the **same angle convention as -/// `background` linear gradients** (see [`gradient_endpoints`]) so the two -/// stay visually consistent within one style block. Colors are evenly spaced. fn paint_gradient_border( canvas: &Canvas, layout: &BoxLayout, @@ -1517,7 +1206,6 @@ fn paint_gradient_border( .map(|r| resolve_border_radius(r, layout, ctx)) .unwrap_or([0.0; 4]); - // Outer ring edge = border box; inner edge = inset by the border width. let outer = border_rrect(layout, radius); let inner_rect = Rect::from_xywh( layout.x + width, @@ -1567,7 +1255,6 @@ fn border_rrect(layout: &BoxLayout, radius: [f32; 4]) -> RRect { fn padding_rrect(layout: &BoxLayout, radius: [f32; 4]) -> RRect { let (x, y, w, h) = layout.padding_box(); let rect = Rect::from_xywh(x, y, w, h); - // Inner radius: max(0, outer_radius - border_width). let r = [ (radius[0] - layout.border.left.max(layout.border.top)).max(0.0), (radius[1] - layout.border.right.max(layout.border.top)).max(0.0), @@ -1582,7 +1269,6 @@ fn inner_rrect(layout: &BoxLayout, radius: [f32; 4]) -> RRect { } fn rrect_from_corners(rect: Rect, radius: [f32; 4]) -> RRect { - // Order: top-left, top-right, bottom-right, bottom-left. let radii = [ Point::new(radius[0], radius[0]), Point::new(radius[1], radius[1]), @@ -1614,8 +1300,6 @@ fn resolve_border_radius(r: &BorderRadius, layout: &BoxLayout, ctx: &LengthConte } } -// ---- Box-shadow ---- - fn paint_box_shadow( canvas: &Canvas, layout: &BoxLayout, @@ -1665,8 +1349,6 @@ fn paint_box_shadow( let rrect = rrect_from_corners(rect, radius); canvas.draw_rrect(rrect, &paint); } else { - // Inset: invert — paint the area outside the inner rect within the box. - // Approximation: draw a stroked rrect inside the padding box. let (px, py, pw, ph) = layout.padding_box(); let outer = rrect_from_corners(Rect::from_xywh(px, py, pw, ph), radius); canvas.save(); @@ -1688,7 +1370,6 @@ fn paint_box_shadow( clear.set_mask_filter(filter); } } - // Cheap approximation — TODO: proper inset shadow with subtraction path. let mut path = PathBuilder::new(); path.add_rrect(outer, None, None); path.add_rrect(inner, None, None); @@ -1698,23 +1379,6 @@ fn paint_box_shadow( } } -// ---- Color parsing ---- -// -// Both functions below route through `renderer::parse_css_color` — the -// single frozen entry point (see `renderer/colors.rs`) — rather than -// duplicating hex/rgb/hsl/named-colour parsing here. That parser accepts -// every common CSS colour form (3/4/6/8-digit hex, rgb()/rgba(), hsl()/ -// hsla(), the full CSS named-colour set) and returns `None` for anything -// else. -// -// A `Color::String` that fails to parse is a real authoring bug — the -// previous behaviour (`unwrap_or(SColor::BLACK)`) rendered it as invisible -// text on this tool's dark-background target style. It now falls back to -// `renderer::UNRESOLVED_COLOR` (opaque magenta) and logs a warning instead, -// so the failure is visible on screen and in the render logs. Wiring a -// hard validation-time error for this is out of scope here (sibling -// workstream); this only stops the render path from lying. - pub fn parse_color(c: &Color) -> SColor { match c { Color::Rgba { r, g, b, a } => { @@ -1729,8 +1393,6 @@ fn parse_color_string(s: &str) -> Option { crate::engine::renderer::parse_css_color(s).map(|(r, g, b, a)| SColor::from_argb(a, r, g, b)) } -/// Loud, non-black fallback for a colour string that couldn't be resolved. -/// See the module note above `parse_color`. fn unresolved_color(original: &str) -> SColor { eprintln!( "Warning: unrecognized color '{original}' — rendering as opaque magenta instead of \ @@ -1740,7 +1402,6 @@ fn unresolved_color(original: &str) -> SColor { SColor::from_argb(a, r, g, b) } -// Suppress unused import warnings for items only used in trait-bound paths. #[allow(dead_code)] fn _unused_marker(_e: &Edges, _l: &LengthPercentage, _p: &ParsedLength, _f: Color4f) {} @@ -1770,8 +1431,6 @@ mod hit_tests { #[test] fn hitmap_reports_component_rect_for_untransformed_node() { - // A component leaf, absolutely positioned at (40, 30), sized 100x80, - // inside a flex-column root that fills a 400x400 viewport. let leaf = BoxNode { id: 0, kind: BoxKind::Component(Arc::new(1u32)), @@ -1815,7 +1474,6 @@ mod hit_tests { &NoopDispatcher, ); - // Only the component leaf is reported, not the synthetic container root. assert_eq!(hits.len(), 1, "expected exactly one component hit"); let h = &hits[0]; assert_eq!(h.node_id, root.children[0].id); @@ -1830,9 +1488,6 @@ mod hit_tests { use crate::css::style::{Background, Color as CssColor, FilterFn}; use crate::css::units::Length; - // Top half black on a white root; a backdrop-blur panel straddles - // the boundary. Inside the panel the boundary must smear into greys; - // outside it stays a hard black/white edge. let black_top = BoxNode { id: 0, kind: BoxKind::Container, @@ -1906,10 +1561,8 @@ mod hit_tests { assert!(surface.read_pixels(&info, &mut buf, 200 * 4, (0, 0))); let red = |x: usize, y: usize| buf[(y * 200 + x) * 4] as i32; - // Outside the panel: hard edge preserved. assert!(red(10, 97) < 10, "outside/above must stay black"); assert!(red(10, 103) > 245, "outside/below must stay white"); - // Inside the panel: boundary smeared to intermediate greys. let above = red(100, 97); let below = red(100, 103); assert!( @@ -1924,17 +1577,6 @@ mod hit_tests { #[test] fn backdrop_filter_survives_sibling_opacity_below_one() { - // Same scene as `backdrop_filter_blurs_content_behind`, but the panel - // also carries `opacity: 0.99` — a visually-imperceptible change that - // must NOT disable the blur. Bug: the opacity/filter SaveLayerRec - // (paint_pass step 3) used to open BEFORE the backdrop-filter's own - // save_layer(backdrop) (old step 5.5), so the backdrop sampled the - // freshly-opened, still-empty opacity layer instead of the real - // scene beneath it — a total no-op. `opacity` alone (no - // `backdrop_filter`) is not the trigger; only nodes that combine - // both are affected, which is exactly the documented glassmorphism - // template (glassmorphism.md pairs `backdrop-filter` with a - // `fade_in`/`fade_in_up` entrance animation that drives `opacity`). use crate::css::style::{Background, Color as CssColor, FilterFn}; use crate::css::units::Length; @@ -2012,11 +1654,8 @@ mod hit_tests { assert!(surface.read_pixels(&info, &mut buf, 200 * 4, (0, 0))); let red = |x: usize, y: usize| buf[(y * 200 + x) * 4] as i32; - // Outside the panel: hard edge preserved. assert!(red(10, 97) < 10, "outside/above must stay black"); assert!(red(10, 103) > 245, "outside/below must stay white"); - // Inside the panel: boundary must still smear into greys — the bug - // makes this a hard 0/255 edge identical to the outside columns. let above = red(100, 97); let below = red(100, 103); assert!( @@ -2033,9 +1672,6 @@ mod hit_tests { fn hitmap_reflects_node_transform() { use crate::css::style::TransformFn; - // Same leaf as the untransformed test, but with transform: scale(2). - // The engine applies transforms around the node's center, so a 100x80 - // box scaled 2x grows to 200x160 (centered on the same point). let leaf = BoxNode { id: 0, kind: BoxKind::Component(Arc::new(1u32)), @@ -2082,8 +1718,6 @@ mod hit_tests { assert_eq!(hits.len(), 1); let h = &hits[0]; - // Scaled 2x: width 100->200, height 80->160. Assert the dimensions - // (these prove the canvas transform is reflected in the hit rect). assert!((h.rect.w - 200.0).abs() < 1.0, "w = {}", h.rect.w); assert!((h.rect.h - 160.0).abs() < 1.0, "h = {}", h.rect.h); } @@ -2091,10 +1725,6 @@ mod hit_tests { #[cfg(test)] mod transform_origin_tests { - //! TDD tests for `resolve_origin` and the pivot integration in `paint_node`. - //! - //! Strategy: paint a coloured rect, read back pixel centroids / columns, and - //! verify that the transformed position matches the expected pivot behaviour. use super::*; @@ -2120,7 +1750,6 @@ mod transform_origin_tests { } } - /// Render a tree and return the pixel buffer (RGBA8888). fn render_pixels(root: &mut BoxNode, w: u32, h: u32) -> Vec { root.assign_ids(0); let layout = run_layout(root, (w as f32, h as f32), &ConversionContext::default()); @@ -2143,19 +1772,16 @@ mod transform_origin_tests { buf } - /// Red channel at pixel (x, y) in a w-wide buffer. fn r(buf: &[u8], w: u32, x: u32, y: u32) -> u8 { buf[((y * w + x) * 4) as usize] } - /// Count pixels in `buf` where red > 200 (i.e., predominantly red). fn count_red(buf: &[u8]) -> usize { buf.chunks(4) .filter(|px| px[0] > 200 && px[1] < 50 && px[2] < 50) .count() } - /// Compute column centroid (weighted x) of pixels where red > 200. fn red_centroid_x(buf: &[u8], w: u32, h: u32) -> f32 { let mut sum_x = 0.0f64; let mut count = 0.0f64; @@ -2177,7 +1803,6 @@ mod transform_origin_tests { } } - /// Compute row centroid (weighted y) of pixels where red > 200. fn red_centroid_y(buf: &[u8], w: u32, h: u32) -> f32 { let mut sum_y = 0.0f64; let mut count = 0.0f64; @@ -2237,33 +1862,25 @@ mod transform_origin_tests { } } - // ---- Test 1: no transform — pixel-identical to reference baseline ---- - #[test] fn no_transform_origin_unchanged_non_regression() { - // A red 100x100 box at (50, 50) with no transform: pixels must appear - // exactly at (50..150, 50..150) — no origin logic involved. let mut root = root_node( 300.0, 300.0, vec![{ let mut n = red_box(Position::Absolute, 50.0, 50.0, 100.0, 100.0); n.css.transform = Some(vec![TransformFn::Scale { x: 1.0, y: 1.0 }]); - // transform-origin absent → should default to center (no change) n }], ); let buf = render_pixels(&mut root, 300, 300); - // Centroid must be ~100, ~100 (center of the 50..150 range). let cx = red_centroid_x(&buf, 300, 300); let cy = red_centroid_y(&buf, 300, 300); assert!((cx - 99.5).abs() < 2.0, "cx={cx}"); assert!((cy - 99.5).abs() < 2.0, "cy={cy}"); } - // ---- Test 2: 50% 50% == absent (byte-identical behavior) ---- - #[test] fn transform_origin_50pct_is_center_identity() { let make_root = |with_origin: bool| -> Vec { @@ -2288,13 +1905,8 @@ mod transform_origin_tests { ); } - // ---- Test 3: rotate 90° around left-top vs center — different quadrants ---- - #[test] fn rotate_90_left_top_vs_center_occupy_different_quadrants() { - // A red 100x100 box at (100, 100) rotated 90° around: - // - center (150, 150): box stays centered on itself, centroid ~(150, 150) - // - left-top (100, 100): the box pivots around top-left; centroid moves let make_root_with_origin = |ox: Option, oy: Option| -> Vec { let mut n = red_box(Position::Absolute, 100.0, 100.0, 100.0, 100.0); n.css.transform = Some(vec![TransformFn::Rotate { deg: 90.0 }]); @@ -2309,9 +1921,7 @@ mod transform_origin_tests { render_pixels(&mut root, 400, 400) }; - // Center pivot (default). let buf_center = make_root_with_origin(None, None); - // Left-top pivot: x=0px (relative to box left), y=0px. let buf_left_top = make_root_with_origin( Some(CLP::String("0%".into())), Some(CLP::String("0%".into())), @@ -2322,7 +1932,6 @@ mod transform_origin_tests { let cx_lt = red_centroid_x(&buf_left_top, 400, 400); let cy_lt = red_centroid_y(&buf_left_top, 400, 400); - // With center pivot (150, 150), rotate 90° CW → box centroid stays at ~(150, 150). assert!( (cx_center - 150.0).abs() < 5.0, "center-pivot cx should be ~150, got {cx_center}" @@ -2332,14 +1941,10 @@ mod transform_origin_tests { "center-pivot cy should be ~150, got {cy_center}" ); - // With left-top pivot (100, 100), rotating 90° CW around that point: - // box center (150,150) maps to (50, 150) — centroid moves left. - // cx_lt ≈ 50, while cx_center ≈ 150. The x-shift is the distinguishing axis. assert!( cx_lt < cx_center - 50.0, "left-top pivot cx ({cx_lt}) should be well left of center-pivot cx ({cx_center})" ); - // The overall pixel-centroid distance should be large. let dist = ((cx_lt - cx_center).powi(2) + (cy_lt - cy_center).powi(2)).sqrt(); assert!( dist > 50.0, @@ -2347,8 +1952,6 @@ mod transform_origin_tests { ); } - // ---- Test 4: keyword "left top" == "0% 0%" ---- - #[test] fn keyword_left_top_equals_zero_percent() { let make_root = |origin_x: CLP, origin_y: CLP| -> Vec { @@ -2371,8 +1974,6 @@ mod transform_origin_tests { ); } - // ---- Test 5: keyword "right bottom" == "100% 100%" ---- - #[test] fn keyword_right_bottom_equals_100_percent() { let make_root = |origin_x: CLP, origin_y: CLP| -> Vec { @@ -2395,14 +1996,8 @@ mod transform_origin_tests { ); } - // ---- Test 6: 3D rotate_y with left-center origin — left edge stays fixed ---- - #[test] fn rotate_y_left_origin_left_edge_is_stable() { - // A 200x200 red box at (100, 100). RotateY with origin "left center" - // means the left edge (x=100) is the pivot — so the leftmost painted - // column should always be near x=100 regardless of angle. - // With center origin, the center (x=200) is the pivot and the left edge moves. let make_root = |origin_x: Option| -> Vec { let mut n = red_box(Position::Absolute, 100.0, 100.0, 200.0, 200.0); n.css.transform = Some(vec![TransformFn::RotateY { deg: 45.0 }]); @@ -2420,7 +2015,6 @@ mod transform_origin_tests { let buf_left = make_root(Some(CLP::String("0%".into()))); let buf_center = make_root(None); - // Find the leftmost red pixel x for each render. fn leftmost_red(buf: &[u8], w: u32, h: u32) -> Option { for x in 0..w { for y in 0..h { @@ -2440,28 +2034,23 @@ mod transform_origin_tests { let left_edge_center = leftmost_red(&buf_center, 500, 500) .expect("no red pixels in center-origin render") as f32; - // Left-origin: left edge stays near x=100 (pivot is exactly there). assert!( left_edge_left > 90.0 && left_edge_left < 115.0, "left-origin left edge should be near 100, got {left_edge_left}" ); - // Center-origin: left edge moves away from 100. assert!( left_edge_center > left_edge_left + 20.0, "center-origin left edge ({left_edge_center}) should be further right than left-origin ({left_edge_left})" ); } - // ---- Test 7: perspective_origin ≠ transform_origin — must not panic ---- - #[test] fn different_perspective_and_transform_origins_no_panic() { use crate::css::units::Length; let mut n = red_box(Position::Absolute, 100.0, 100.0, 200.0, 200.0); n.css.transform = Some(vec![TransformFn::RotateY { deg: 30.0 }]); n.css.perspective = Some(Length::Px(800.0)); - // perspective-origin at top-left, transform-origin at bottom-right n.css.transform_origin = Some(TransformOrigin { x: Some(CLP::String("100%".into())), y: Some(CLP::String("100%".into())), @@ -2473,7 +2062,6 @@ mod transform_origin_tests { z: None, }); let mut root = root_node(500.0, 500.0, vec![n]); - // Must not panic and must produce at least some red pixels. let buf = render_pixels(&mut root, 500, 500); let red_count = count_red(&buf); assert!( @@ -2482,17 +2070,8 @@ mod transform_origin_tests { ); } - // ---- Test 8: translate percentages resolve per-axis, not max(w,h) ---- - #[test] fn translate_percent_resolves_against_own_axis_not_max_dimension() { - // A 200x100 red box at (0,0), `transform: translate(50%, 50%)`. - // CSS resolves a translate-x percentage against the box's own WIDTH - // and translate-y against its own HEIGHT — never `max(width, - // height)` on both axes (only correct for square boxes). Expected: - // x shifts by 100 (50% of 200) -> [100,299]; y shifts by 50 (50% of - // 100) -> [50,149]. The bug instead resolved y against max(200,100) - // = 200, doubling the vertical shift to +100 -> [100,199]. let mut n = red_box(Position::Absolute, 0.0, 0.0, 200.0, 100.0); n.css.transform = Some(vec![TransformFn::Translate { x: CLP::String("50%".into()), @@ -2533,8 +2112,6 @@ mod transform_origin_tests { #[cfg(test)] mod glassmorphism_tests { - //! TDD tests for issue #87: gradient-border painting and the `noise` - //! filter function (film grain, deterministic per seed). use super::*; @@ -2588,7 +2165,6 @@ mod glassmorphism_tests { (buf[i], buf[i + 1], buf[i + 2], buf[i + 3]) } - /// Unique (r,g,b,a) values within a rectangular region. fn unique_colors_in( buf: &[u8], w: u32, @@ -2647,13 +2223,8 @@ mod glassmorphism_tests { leaf(css) } - // ---- gradient-border ---- - #[test] fn gradient_border_paints_both_colors_on_perimeter_center_intact() { - // 200x200 box at (100, 100) on a white root; gradient-border 12px - // red→blue along the horizontal axis (angle 90). Expect: one vertical - // border edge red-dominant, the other blue-dominant, centre untouched. let node = abs_box( 100.0, 100.0, @@ -2671,11 +2242,9 @@ mod glassmorphism_tests { let mut root = root_node(400.0, 400.0, Some("#ffffff"), vec![node]); let buf = render_pixels(&mut root, 400, 400); - // Sample border midpoints: left edge (x=106, y=200), right edge (x=294, y=200). let left = px(&buf, 400, 106, 200); let right = px(&buf, 400, 294, 200); - // One side red-dominant, the other blue-dominant (convention-agnostic). let red_side = if left.0 > left.2 { left } else { right }; let blue_side = if left.0 > left.2 { right } else { left }; assert!( @@ -2686,13 +2255,11 @@ mod glassmorphism_tests { blue_side.2 > 150 && blue_side.0 < 100, "expected a blue-dominant border edge, got {blue_side:?}" ); - // Both extremes must actually differ (it's a gradient, not a flat color). assert_ne!( left, right, "border edges must show different gradient stops" ); - // Centre of the box stays the root background (white). let center = px(&buf, 400, 200, 200); assert_eq!( center, @@ -2700,7 +2267,6 @@ mod glassmorphism_tests { "box centre must not be painted by the gradient border" ); - // Top border midpoint is painted (not background). let top_mid = px(&buf, 400, 200, 106); assert_ne!( top_mid, @@ -2712,8 +2278,6 @@ mod glassmorphism_tests { #[test] fn gradient_border_respects_border_radius() { use crate::css::style::BorderRadius; - // Rounded 200x200 box: the square corner pixel must stay background, - // while edge midpoints are painted. let node = abs_box( 100.0, 100.0, @@ -2732,14 +2296,12 @@ mod glassmorphism_tests { let mut root = root_node(400.0, 400.0, Some("#ffffff"), vec![node]); let buf = render_pixels(&mut root, 400, 400); - // Square corner (inside the box bounds but outside the rounded path). let corner = px(&buf, 400, 104, 104); assert_eq!( corner, (255, 255, 255, 255), "square corner must stay background with border-radius" ); - // Edge midpoints painted. let left_mid = px(&buf, 400, 104, 200); let top_mid = px(&buf, 400, 200, 104); assert_ne!(left_mid, (255, 255, 255, 255), "left edge must be painted"); @@ -2749,8 +2311,6 @@ mod glassmorphism_tests { #[test] fn gradient_border_replaces_standard_border() { use crate::css::style::{BorderEdges, BorderStyle, Edges}; - // A node with BOTH border (solid green) and gradient-border (red/blue): - // the gradient border wins; no green pixels appear. let node = abs_box( 100.0, 100.0, @@ -2784,8 +2344,6 @@ mod glassmorphism_tests { ); } - // ---- noise filter ---- - fn gray_box_with_filter(filter: Option>) -> BoxNode { abs_box( 50.0, @@ -2821,7 +2379,6 @@ mod glassmorphism_tests { ); let buf_noise = render_pixels(&mut root_noise, 300, 300); - // Sample well inside the box to avoid AA edges. let plain = unique_colors_in(&buf_plain, 300, 70, 70, 230, 230); let noisy = unique_colors_in(&buf_noise, 300, 70, 70, 230, 230); assert!( @@ -2876,9 +2433,6 @@ mod glassmorphism_tests { #[test] fn backdrop_noise_grains_only_the_panel_region() { - // Uniform gray root; a panel at (50, 50, 100, 100) with - // backdrop-filter noise. Inside the panel: grain (many colors). - // Outside: untouched uniform gray. let panel = abs_box( 50.0, 50.0, @@ -2910,8 +2464,6 @@ mod glassmorphism_tests { ); } - // ---- serde ---- - #[test] fn css_gradient_border_and_noise_deserialize() { let json = r##"{ @@ -2931,7 +2483,6 @@ mod glassmorphism_tests { seed: 9 }]) if (intensity - 0.3).abs() < 1e-6 )); - // Defaults: intensity 0.15, seed 42. assert!(matches!( s.backdrop_filter.as_deref(), Some([FilterFn::Noise { @@ -2943,8 +2494,6 @@ mod glassmorphism_tests { #[test] fn css_legacy_zombies_accepted() { - // backdrop-blur / inner-shadow parse into CssStyle (accepted for - // compat) — rendering is intentionally not wired; validate warns. let json = r##"{ "backdrop-blur": 20, "inner-shadow": { "color": "#000000", "offset_x": 0, "offset_y": 2, "blur": 8 } @@ -2957,12 +2506,6 @@ mod glassmorphism_tests { #[cfg(test)] mod paint_order_tests { - //! TDD tests for two paint-pass audit findings: - //! - overflow:hidden must never clip a node's OWN outset box-shadow - //! (CSS clips descendants, never the box's own decorations). - //! - the opacity/filter SaveLayerRec must be bounded to the node's box - //! (+ filter bleed), not left to size against the ambient clip - //! (usually the whole viewport) — without clipping visible blur. use super::*; @@ -3029,9 +2572,6 @@ mod paint_order_tests { } } - /// Count of "probe" red pixels (spread-only, blur:0, so a hard-edged - /// halo) in a rectangular region — used to compare before/after pixel - /// counts for the paint-order fix. fn count_red_in(buf: &[u8], w: u32, x0: u32, y0: u32, x1: u32, y1: u32) -> usize { let mut n = 0; for y in y0..y1 { @@ -3079,10 +2619,6 @@ mod paint_order_tests { #[test] fn overflow_hidden_does_not_clip_own_outset_box_shadow() { - // 100x100 white card at (50,50) on a 200x200 black canvas, outset - // box-shadow (red, spread 20, blur 0 -> hard-edged halo rect from - // (30,30) to (170,170)). Probe points (100,45) and (100,155) sit in - // the halo band above/below the card, outside its own border-box. let without = { let mut root = root_node(200.0, 200.0, "#000000", vec![card_with_shadow(false)]); render_pixels(&mut root, 200, 200) @@ -3119,7 +2655,6 @@ mod paint_order_tests { "overflow:hidden must not erase the node's own outset shadow, got {below_hidden:?}" ); - // Probe-pixel count over the full halo band, before/after overflow. let halo_count_plain = count_red_in(&without, 200, 25, 25, 175, 175); let halo_count_hidden = count_red_in(&with_hidden, 200, 25, 25, 175, 175); assert_eq!( @@ -3131,13 +2666,6 @@ mod paint_order_tests { #[test] fn filter_layer_bounds_do_not_clip_blur_bleed() { - // A 60x60 opaque red square with `opacity: 0.999` (forces the - // SaveLayerRec open) AND `filter: blur(24px)` on a 300x300 black - // canvas. Bounding the layer to the node's box (issue #4 fix) must - // still leave room for the blur to bleed outward — if the bounds - // were the bare box rect, Skia would hard-clip the blurred fringe - // at the box edge, and the region just outside the box would stay - // pure black instead of picking up a soft red glow. let n = BoxNode { id: 0, kind: BoxKind::Container, @@ -3166,14 +2694,11 @@ mod paint_order_tests { let i = (y * 300 + x) * 4; buf[i] }; - // 8px outside the left edge of the box (box left edge = x=120), - // vertically centered (y=150): must show blur bleed (red > black). let bled = probe(112, 150); assert!( bled > 15, "blur must bleed past the box edge under bounded SaveLayerRec, got r={bled}" ); - // Far outside any plausible bleed radius: must stay black. let far = probe(20, 20); assert_eq!(far, 0, "far corner must stay untouched, got r={far}"); } @@ -3221,9 +2746,6 @@ mod tests { #[test] fn parse_white_forms_all_agree() { - // Regression test for the C2 audit finding: `#fff`, `#FFF`, `white` - // and `rgb(255,255,255)` must all resolve to the same opaque white, - // never to black. let white = SColor::from_argb(255, 255, 255, 255); assert_eq!(parse_color_string("#fff").unwrap(), white); assert_eq!(parse_color_string("#FFF").unwrap(), white); @@ -3235,9 +2757,6 @@ mod tests { #[test] fn parse_color_string_supports_extended_named_set() { - // Only 11 names were hardcoded before; spot-check a few outside - // that set to prove it now routes through the full CSS keyword - // table in `renderer::colors`. assert!(parse_color_string("rebeccapurple").is_some()); assert!(parse_color_string("cornflowerblue").is_some()); assert!(parse_color_string("dodgerblue").is_some()); @@ -3319,13 +2838,6 @@ mod animated_transform_tests { #[test] fn an_orbiting_node_at_two_different_frames_reports_two_different_positions() { - // The exact shape of the "eight lines to orbiting badges" scenario: - // a node whose `transform` is a single `translate` recomputed every - // frame by the (unowned) animator. Simulating two frames' worth of - // already-resolved CSS here proves `animated_transform` reads - // whatever it is handed, frame-fresh, with no memory of the last - // call — the property the per-frame, topological resolution in - // `engine::deps` depends on. let orbit = |angle_deg: f32| { let mut css = CssStyle::default(); let (s, c) = angle_deg.to_radians().sin_cos(); diff --git a/crates/rustmotion-core/src/engine/renderer/assets.rs b/crates/rustmotion-core/src/engine/renderer/assets.rs index 00cf203a..0cf2ee0a 100644 --- a/crates/rustmotion-core/src/engine/renderer/assets.rs +++ b/crates/rustmotion-core/src/engine/renderer/assets.rs @@ -14,50 +14,24 @@ type VideoFrame = (f64, Vec, u32, u32); type VideoFrameList = Arc>; type VideoFrameCacheMap = Arc>; -/// Global asset cache for decoded images (keyed by file path) static ASSET_CACHE: OnceLock>> = OnceLock::new(); pub fn asset_cache() -> &'static Arc> { ASSET_CACHE.get_or_init(|| Arc::new(DashMap::new())) } -/// Clear the asset cache (call between renders if needed) pub fn clear_asset_cache() { if let Some(cache) = ASSET_CACHE.get() { cache.clear(); } } -/// GIF frame data cache: stores decoded frames with pre-computed cumulative timestamps -/// (frames_rgba, cumulative_times, total_duration) keyed by file path static GIF_CACHE: OnceLock = OnceLock::new(); pub fn gif_cache() -> &'static GifCacheMap { GIF_CACHE.get_or_init(|| Arc::new(DashMap::new())) } -// ─── Shared HTTP agent ────────────────────────────────────────────────────── - -/// The [`ureq::Agent`] every outbound HTTP call in this crate must go -/// through — the icon fetch below, and the remote `include` fetch in the -/// `rustmotion` crate (`crates/rustmotion/src/include.rs`), which imports -/// [`http_agent`] rather than building its own. -/// -/// `ureq::get(...)`, the free function used before this fix, always resolves -/// to an *unconfigured* default agent. In ureq 3.x every field of -/// `Timeouts` defaults to `None` except `await_100` (`config.rs`'s `impl -/// Default for Timeouts`), so a host that accepts the TCP connection and -/// then never answers — or trickles one byte a minute — hangs the calling -/// thread forever; ureq's 10 MB body cap bounds bytes, not time. On the icon -/// path that thread can be a render worker with nobody at the keyboard to -/// notice (RM-41). -/// -/// `Config::builder()` starts from `Config::default()`, which already -/// resolves a proxy from `HTTPS_PROXY`/`https_proxy`/`HTTP_PROXY`/ -/// `http_proxy`/`ALL_PROXY` via `Proxy::try_from_env()` — the same audit -/// separately found every network call here ignoring a configured egress -/// proxy, and routing through the builder rather than hand-building a -/// `Config` fixes that as a side effect, not a separate change. static HTTP_AGENT: OnceLock = OnceLock::new(); pub fn http_agent() -> &'static ureq::Agent { @@ -70,36 +44,8 @@ pub fn http_agent() -> &'static ureq::Agent { }) } -// ─── Icon fetching ────────────────────────────────────────────────────────── - -/// How much larger than the *target* (layout) size icons are rasterized, so -/// Skia's downscale keeps edges crisp under sub-pixel positioning and minor -/// scale animations. -/// -/// This lives here — not as a local `const` inside the painter — because it -/// must feed the exact same computation [`icon_cache_key`] uses. See that -/// function's doc for why (issue #166). pub const ICON_OVERSAMPLE: u32 = 2; -/// Single source of truth for both the oversampled rasterization size and -/// the [`asset_cache`] key used for a given icon at a given *target* -/// (layout) size. Returns `(render_width, render_height, cache_key)`. -/// -/// # Issue #166 -/// -/// Before this function existed, `icon.rs`'s painter and `preload.rs`'s -/// prefetcher each built the cache key from their own inlined `format!`. -/// The painter multiplied the target size by [`ICON_OVERSAMPLE`] before -/// building the key; the preloader did not. For a 40×40 icon the painter -/// looked up `"icon:...:80x80"` while the preloader could only ever have -/// written `"icon:...:40x40"` — the two keys could never collide, so -/// `prefetch_icons` never once avoided a duplicate network fetch, and (had -/// its rasterization size matched the key by coincidence) would have cached -/// a bitmap at half the resolution the painter actually samples. -/// -/// Both call sites now go through this one function, so they cannot drift -/// apart again — fixing the key without also fixing the raster size (or -/// vice versa) is no longer expressible. pub fn icon_cache_key(icon: &str, color: &str, target_w: u32, target_h: u32) -> (u32, u32, String) { let render_w = target_w.max(1) * ICON_OVERSAMPLE; let render_h = target_h.max(1) * ICON_OVERSAMPLE; @@ -107,11 +53,6 @@ pub fn icon_cache_key(icon: &str, color: &str, target_w: u32, target_h: u32) -> (render_w, render_h, cache_key) } -/// Returns the icon disk-cache directory: `~/.cache/rustmotion/icons`. -/// -/// Mirrors [`google_fonts::font_cache_dir`](super::google_fonts::font_cache_dir), -/// which does the same thing for downloaded font files — see that module -/// for the disk-cache-before-network shape this was lifted from. pub fn icon_cache_dir() -> PathBuf { #[cfg(target_os = "windows")] let base = std::env::var_os("LOCALAPPDATA") @@ -126,26 +67,16 @@ pub fn icon_cache_dir() -> PathBuf { base.join("rustmotion").join("icons") } -/// Deterministic on-disk file name for a given (icon, color, size). Icon ids -/// contain `:` (`"lucide:home"`); replaced so the id survives as a legible -/// file name instead of being hashed away. fn icon_cache_file(cache_dir: &Path, icon: &str, color: &str, width: u32, height: u32) -> PathBuf { let slug = icon.replace(':', "_"); let hex_color = color.trim_start_matches('#').to_lowercase(); cache_dir.join(format!("{slug}-{hex_color}-{width}x{height}.svg")) } -/// Fetch an icon's SVG bytes, checking the on-disk cache first and falling -/// back to the Iconify API on a miss. Same public signature as before this -/// fix — every existing caller (icon.rs, preload.rs, badge.rs, list.rs, -/// stat.rs) gets the disk cache for free. pub fn fetch_icon_svg(icon: &str, color: &str, width: u32, height: u32) -> Result> { fetch_icon_svg_in(icon, color, width, height, &icon_cache_dir()) } -/// Core of [`fetch_icon_svg`], with the cache directory injectable so tests -/// can exercise the cache-hit path without touching `$HOME` or the network — -/// mirrors `google_fonts::resolve_google_font`'s `cache_dir` parameter. pub fn fetch_icon_svg_in( icon: &str, color: &str, @@ -188,9 +119,6 @@ pub fn fetch_icon_svg_in( reason: e.to_string(), })?; - // Best-effort disk-cache write: failing to persist must not fail a - // fetch that already succeeded (matches the in-memory `asset_cache`'s - // existing tolerance for a cache that just doesn't get populated). if std::fs::create_dir_all(cache_dir).is_ok() { let _ = std::fs::write(&cache_file, &body); } @@ -198,9 +126,6 @@ pub fn fetch_icon_svg_in( Ok(body) } -// ─── Video frame extraction ───────────────────────────────────────────────── - -/// Cache for pre-extracted video frames: key = "src:width:height", value = sorted list of (time, RGBA data, width, height) static VIDEO_FRAME_CACHE: OnceLock = OnceLock::new(); pub fn video_frame_cache() -> &'static VideoFrameCacheMap { @@ -230,17 +155,6 @@ pub fn find_closest_frame( Some((rgba, w, h)) } -/// Returns `true` if `ffmpeg` is on `PATH`. -/// -/// Single source of truth for "should we even attempt to shell out to -/// ffmpeg" — `extract_video_frame` below already surfaces a missing binary -/// as `RustmotionError::FfmpegSpawn` on first use, but callers that decode -/// many frames up front (`preload::preextract_video_frames`) want to check -/// once and print one clear warning instead of failing identically once per -/// frame. Mirrors the `ffmpeg_available` helper the `rustmotion` crate's -/// `encode::video_audio` module already uses for the embedded-audio -/// extraction path (PR #151) — same check, same reasoning, different asset -/// kind. pub fn ffmpeg_available() -> bool { std::process::Command::new("ffmpeg") .args(["-version"]) @@ -251,18 +165,6 @@ pub fn ffmpeg_available() -> bool { .unwrap_or(false) } -/// Rejects a `src` that names a network URL rather than a local file path. -/// -/// `extract_video_frame` below hands `src` to `ffmpeg -i` verbatim; ffmpeg's -/// own demuxer understands its full built-in protocol set (`http://`, -/// `rtmp://`, `concat:`, …), which turns an unfiltered `src` into an SSRF -/// primitive — a scenario author can point it at -/// `http://169.254.169.254/...` (the cloud metadata endpoint) or an internal -/// service, and read the exit status as a port-scan oracle (RM-42). Remote -/// video was never a designed feature here — this module's own `is_remote` -/// doc, a few functions below, and `rustmotion info`'s identical assumption -/// both already treat every `src` as a local path — so this closes an -/// accidental reach rather than opening an allowlist for one. fn reject_remote_video_src(src: &str) -> Result<()> { let Some(scheme_end) = src.find("://") else { return Ok(()); @@ -319,39 +221,6 @@ pub fn extract_video_frame(src: &str, time: f64, width: u32, height: u32) -> Res Ok(output.stdout) } -// ─── Media metadata probing ──────────────────────────────────────────────── -// -// "How long is this audio file? What are the dimensions of this image?" — -// `rustmotion info` (`crates/rustmotion/src/cli/commands/info.rs`) answers -// these by calling the functions below, one per asset kind. Two rules tie -// them together: -// -// 1. Never touch the network. A `src` starting with `http://`/`https://` is -// identified and reported by the *caller* as "remote, not probed" before -// any of these functions ever run — probing a remote asset could mean -// downloading an unbounded amount of data just to read a header (e.g. a -// large file whose metadata atom sits at the end). These functions are -// written and tested only against local paths on the assumption the -// caller has already filtered URLs out; they do not special-case `http(s) -// ://` themselves. -// 2. Cheap when a cheap path exists, honest when it does not. Image -// dimensions come from the `image` crate's `into_dimensions()`, which -// parses only the header bytes the decoder needs — not a full raster -// decode. Video has no such shortcut available in this codebase (nothing -// here links an ffmpeg *library*, only the `ffmpeg`/`ffprobe` -// *binaries*), so `probe_video_metadata` shells out to `ffprobe` — the -// same "assume PATH, fail with an actionable message otherwise" contract -// `ffmpeg_available`/`extract_video_frame` above already establish for -// ffmpeg itself. - -/// Cheap (header-only) dimensions of a local raster image file. The `image` -/// crate's `ImageReader::into_dimensions` builds just enough of the decoder -/// to read its declared dimensions, without decoding any pixel data — -/// unlike every existing paint-time image load in this codebase (`image.rs`, -/// `avatar.rs`, `mockup.rs`, ...), which all go through -/// `skia_safe::Image::from_encoded` and pay for a full raster decode because -/// they need the pixels themselves. A metadata-only query has no such need, -/// so it takes the cheaper of the two paths instead of reusing theirs. pub fn probe_image_dimensions(path: &str) -> Result<(u32, u32)> { let reader = image::ImageReader::open(path) .map_err(|e| RustmotionError::ImageLoad { @@ -371,9 +240,6 @@ pub fn probe_image_dimensions(path: &str) -> Result<(u32, u32)> { }) } -/// Returns `true` if `ffprobe` is on `PATH`. Mirrors [`ffmpeg_available`] -/// above — ffprobe ships alongside ffmpeg in every common distribution but -/// is its own binary, so its own check. pub fn ffprobe_available() -> bool { std::process::Command::new("ffprobe") .args(["-version"]) @@ -384,17 +250,11 @@ pub fn ffprobe_available() -> bool { .unwrap_or(false) } -/// Duration, dimensions and frame rate of a local video file, read from its -/// container/stream metadata via `ffprobe` — never by decoding a frame (that -/// is [`extract_video_frame`]'s job, and it decodes exactly one, not the -/// metadata). #[derive(Debug, Clone, PartialEq)] pub struct VideoProbe { pub width: u32, pub height: u32, pub duration_secs: f64, - /// `None` when ffprobe reports no parseable frame rate for the stream — - /// absence is reported as such, never guessed at. pub fps: Option, } @@ -424,10 +284,6 @@ struct FfprobeFormat { duration: Option, } -/// Parses ffprobe's `r_frame_rate` field ("30/1", "30000/1001", ...) into a -/// float. `None` on anything that is not a clean `num/den` pair — including -/// a zero denominator, which ffprobe can itself report for a stream with no -/// meaningful frame rate. fn parse_frame_rate(s: &str) -> Option { let (num, den) = s.split_once('/')?; let num: f64 = num.trim().parse().ok()?; @@ -438,12 +294,6 @@ fn parse_frame_rate(s: &str) -> Option { Some(num / den) } -/// Probes a local video file's metadata via `ffprobe -show_streams -/// -show_format -of json`, a single subprocess call (no frame decode, no -/// download): the first video stream's width/height/frame rate, and a -/// duration that prefers the stream's own `duration` field but falls back to -/// the container's `format.duration` (some containers — notably ones -/// produced by streaming muxers — only populate the latter). pub fn probe_video_metadata(src: &str) -> Result { if !ffprobe_available() { return Err(RustmotionError::Generic(format!( @@ -537,14 +387,6 @@ mod tests { dir } - // ── icon_cache_key: the fix for issue #166 ────────────────────────────── - - /// Regression for issue #166: `icon.rs`'s painter and `preload.rs`'s - /// prefetcher used to build the cache key independently and disagreed - /// (painter oversampled, preloader did not — see the RED-phase output - /// this test replaced: `"icon:lucide:home:#FFFFFF:80x80"` vs - /// `"icon:lucide:home:#FFFFFF:40x40"`). Both call sites now go through - /// this one function, so there is only one formula left to test. #[test] fn oversamples_the_target_size_and_keys_on_the_oversampled_size() { let (render_w, render_h, key) = icon_cache_key("lucide:home", "#FFFFFF", 40, 40); @@ -569,8 +411,6 @@ mod tests { assert_ne!(key_a, key_c); } - // ── fetch_icon_svg_in: disk cache (issue #166 item 2) ──────────────────── - #[test] fn disk_cache_hit_returns_bytes_without_touching_the_network() { let cache_dir = unique_temp_dir("cache-hit"); @@ -582,10 +422,6 @@ mod tests { let cache_file = icon_cache_file(&cache_dir, icon, color, w, h); std::fs::write(&cache_file, &svg_bytes).unwrap(); - // If this ever fell through to the network, either the test host is - // offline (fast, deterministic `IconFetch` error — `unwrap` panics - // clearly) or "test-suite:offline-icon" 404s upstream (same - // outcome). A silent pass here means the cache was genuinely hit. let result = fetch_icon_svg_in(icon, color, w, h, &cache_dir).expect("cache hit"); assert_eq!(result, svg_bytes); } @@ -610,8 +446,6 @@ mod tests { )); } - // Live network test — mirrors `google_fonts`'s `live_fetch_inter_400`: - // excluded from normal runs, exercised manually when touching this path. #[test] #[ignore = "requires network access"] fn live_fetch_writes_through_to_the_disk_cache() { @@ -629,25 +463,15 @@ mod tests { "a successful live fetch must be persisted to disk" ); - // A second call must be served from disk. `disk_cache_hit_returns_ - // bytes_without_touching_the_network` already proves the mechanism - // in isolation; this just confirms the live-written file round-trips. let second = fetch_icon_svg_in(icon, color, w, h, &cache_dir).expect("cache hit"); assert_eq!(first, second); } - // ── ffmpeg_available ────────────────────────────────────────────────── - #[test] fn ffmpeg_available_does_not_panic_either_way() { - // Not asserting the actual bool: whether ffmpeg is installed depends - // on the host. This just proves the probe itself cannot panic or - // hang the preload path that depends on it (item 3). let _ = ffmpeg_available(); } - // ── media-io: probe_image_dimensions ──────────────────────────────────── - fn scratch_path(name: &str) -> PathBuf { std::env::temp_dir().join(format!( "rm_assets_probe_test_{}_{}_{}", @@ -686,9 +510,6 @@ mod tests { let _ = std::fs::remove_file(&path); } - /// Issue: nothing in `rustmotion-core` decoded GIF before this fix - /// (`Cargo.toml`'s `image` dependency only enabled png/jpeg/webp) — the - /// `gif` feature this test depends on is itself part of the fix. #[test] fn probe_image_dimensions_reads_a_gif_header() { let path = scratch_path("dims.gif"); @@ -720,8 +541,6 @@ mod tests { let _ = std::fs::remove_file(&path); } - // ── media-io: parse_frame_rate ────────────────────────────────────────── - #[test] fn parse_frame_rate_reads_integer_and_ntsc_fractions() { assert_eq!(parse_frame_rate("30/1"), Some(30.0)); @@ -734,8 +553,6 @@ mod tests { assert_eq!(parse_frame_rate("not-a-rate"), None); } - // ── media-io: probe_video_metadata ────────────────────────────────────── - fn make_test_video(path: &Path, width: u32, height: u32, fps: u32, duration_s: u32) -> bool { std::process::Command::new("ffmpeg") .args([ diff --git a/crates/rustmotion-core/src/engine/renderer/audio_analysis.rs b/crates/rustmotion-core/src/engine/renderer/audio_analysis.rs index 94657d28..8b0abfd8 100644 --- a/crates/rustmotion-core/src/engine/renderer/audio_analysis.rs +++ b/crates/rustmotion-core/src/engine/renderer/audio_analysis.rs @@ -1,33 +1,16 @@ use dashmap::DashMap; use std::sync::{Arc, OnceLock}; -/// Per-track audio analysis: amplitude (RMS) and frequency bands per video frame. #[derive(Debug, Clone)] pub struct AudioAnalysis { - /// The video frame rate this analysis was computed at. pub frame_rate: u32, - /// RMS amplitude per frame, normalized 0..1 over the whole track. pub amplitude: Vec, - /// 16 log-spaced frequency bands (20 Hz–16 kHz) per frame, normalized 0..1. pub bands: Vec<[f32; 16]>, - /// Where the track sits on the scenario timeline (`AudioTrack::start`). - /// - /// The analysis indexes the *file* from its own sample 0, while every - /// consumer asks in *scenario* time. Without this the two only line up when - /// `start == 0`: a track placed at 73 s played from its beginning while the - /// waveform drew the file's content at 73 s. Held here rather than at the - /// call sites because the cache is keyed by path and the painters never see - /// the `AudioTrack`. pub start: f64, - /// `AudioTrack::end`, past which the track is cut and the visualisation - /// must go flat rather than keep drawing an envelope nobody hears. pub end: Option, } impl AudioAnalysis { - /// Scenario time → offset into the file, or `None` when the track is not - /// playing at that moment. Every lookup below goes through this, so the - /// placement is applied in exactly one place. fn track_time(&self, time: f64) -> Option { if time < self.start { return None; @@ -40,7 +23,6 @@ impl AudioAnalysis { Some(time - self.start) } - /// Get the amplitude at a given time (seconds), clamped to valid range. pub fn amplitude_at(&self, time: f64) -> f32 { let Some(time) = self.track_time(time) else { return 0.0; @@ -52,7 +34,6 @@ impl AudioAnalysis { .unwrap_or(0.0) } - /// Get a specific band [0..16) value at a given time. pub fn band_at(&self, time: f64, band: u8) -> f32 { let Some(time) = self.track_time(time) else { return 0.0; @@ -65,7 +46,6 @@ impl AudioAnalysis { .unwrap_or(0.0) } - /// Smoothed amplitude at time: average over the past `smoothing_frames` frames (inclusive of current). pub fn amplitude_smoothed(&self, time: f64, smoothing_frames: u32) -> f32 { if smoothing_frames == 0 || self.amplitude.is_empty() { return self.amplitude_at(time); @@ -83,7 +63,6 @@ impl AudioAnalysis { window.iter().sum::() / window.len() as f32 } - /// Smoothed band value at time over `smoothing_frames` frames. pub fn band_smoothed(&self, time: f64, band: u8, smoothing_frames: u32) -> f32 { if smoothing_frames == 0 || self.bands.is_empty() { return self.band_at(time, band); @@ -106,7 +85,6 @@ type AudioCacheMap = Arc>>; static AUDIO_ANALYSIS_CACHE: OnceLock = OnceLock::new(); -/// Global audio analysis cache: keyed by the audio track's `src` path. pub fn audio_analysis_cache() -> &'static AudioCacheMap { AUDIO_ANALYSIS_CACHE.get_or_init(|| Arc::new(DashMap::new())) } diff --git a/crates/rustmotion-core/src/engine/renderer/colors.rs b/crates/rustmotion-core/src/engine/renderer/colors.rs index 5d4ffe4b..4bac9ec2 100644 --- a/crates/rustmotion-core/src/engine/renderer/colors.rs +++ b/crates/rustmotion-core/src/engine/renderer/colors.rs @@ -1,48 +1,7 @@ -//! CSS colour parsing. -//! -//! [`parse_css_color`] is the single entry point every colour-consuming path -//! in the engine should route through. It accepts every colour form a CSS -//! author would reasonably write: -//! -//! - hex: `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa` (case-insensitive) -//! - `rgb()` / `rgba()`, comma- or space-separated, integers or percentages, -//! alpha as a `0..1` float, a percentage, or after a `/` -//! - `hsl()` / `hsla()`, same separator/alpha flexibility -//! - the full CSS named-colour keyword set (147 names + `transparent`) -//! -//! It returns `None` for anything else so callers can detect the failure -//! instead of silently painting something wrong. -//! -//! [`parse_hex_color`] predates this module and keeps its historical, -//! infallible signature so every existing call site keeps compiling -//! untouched — it is now a thin wrapper around [`parse_css_color`]. Its name -//! is a bit of a misnomer at this point (it accepts any CSS colour form, not -//! just hex) but changing it would ripple across dozens of call sites in -//! sibling crates, which is out of scope here. -//! -//! Because `parse_hex_color` can't propagate a `None` (its signature is -//! frozen), unresolvable input logs a warning to stderr and returns -//! [`UNRESOLVED_COLOR`] — an unmistakable opaque magenta — instead of -//! quietly falling back to black. Black is a real, common colour choice; on -//! the dark backgrounds this tool targets, a black fallback for a failed -//! parse is *invisible*, which is exactly the silent-failure bug this module -//! exists to close. Magenta never blends in. - use skia_safe::{Color4f, Paint}; -/// Sentinel colour returned by [`parse_hex_color`] (and used by callers of -/// [`parse_css_color`] that choose to mirror this convention) when the input -/// string cannot be resolved as any known CSS colour form. Deliberately -/// jarring so a bad colour string is obvious on screen rather than -/// disappearing into a dark background. pub const UNRESOLVED_COLOR: (u8, u8, u8, u8) = (255, 0, 255, 255); -/// Parse any CSS colour string into `(r, g, b, a)` bytes. -/// -/// Returns `None` when `input` doesn't match any recognised form. Callers -/// that need an infallible result should decide their own fallback and -/// **should not** default to black without a very good reason — see the -/// module docs. pub fn parse_css_color(input: &str) -> Option<(u8, u8, u8, u8)> { let s = input.trim(); if s.is_empty() { @@ -63,9 +22,6 @@ pub fn parse_css_color(input: &str) -> Option<(u8, u8, u8, u8)> { named_color(&lower) } -/// Parse a hex colour string (any CSS colour form, historically hex-only — -/// see module docs) into RGBA components. Infallible: unresolvable input is -/// reported to stderr and mapped to [`UNRESOLVED_COLOR`] rather than black. pub fn parse_hex_color(hex: &str) -> (u8, u8, u8, u8) { let s = hex.trim(); @@ -73,9 +29,6 @@ pub fn parse_hex_color(hex: &str) -> (u8, u8, u8, u8) { return rgba; } - // Historical leniency: the original implementation stripped a leading - // '#' unconditionally, so callers could (and do) pass hex digits with no - // '#' at all. Retry with one prepended before giving up. if !s.starts_with('#') { if let Some(rgba) = parse_css_color(&format!("#{s}")) { return rgba; @@ -105,8 +58,6 @@ pub fn paint_from_hex(hex: &str) -> Paint { paint } -// ---- hex ---- - fn parse_hex_digits(hex: &str) -> Option<(u8, u8, u8, u8)> { if hex.is_empty() || !hex.chars().all(|c| c.is_ascii_hexdigit()) { return None; @@ -145,10 +96,6 @@ fn parse_hex_digits(hex: &str) -> Option<(u8, u8, u8, u8)> { } } -// ---- functional notation: rgb()/rgba(), hsl()/hsla() ---- - -/// Split `name(inner)` into its lowercase function name and inner argument -/// string. `s` is expected to already be trimmed and lowercased. fn split_function(s: &str) -> Option<(&str, &str)> { let s = s.trim(); let open = s.find('(')?; @@ -160,9 +107,6 @@ fn split_function(s: &str) -> Option<(&str, &str)> { Some((name, inner)) } -/// Tokenize a functional-notation argument list. Accepts the classic -/// comma-separated form (`10, 20, 30, 0.5`) and the modern space-separated -/// form with an optional `/` before alpha (`10 20 30 / 50%`). fn split_components(inner: &str) -> Vec { let inner = inner.trim(); if inner.contains(',') { @@ -225,8 +169,6 @@ fn parse_hsl_fn(s: &str) -> Option<(u8, u8, u8, u8)> { Some((r, g, b, a)) } -/// A single `rgb()`/`rgba()` colour channel: an integer/float 0-255, or a -/// percentage 0%-100%. fn parse_channel_255(s: &str) -> Option { let s = s.trim(); if let Some(pct) = s.strip_suffix('%') { @@ -237,13 +179,6 @@ fn parse_channel_255(s: &str) -> Option { Some(v.clamp(0.0, 255.0).round() as u8) } -/// Alpha channel: a float 0-1, or a percentage 0%-100%. -/// -/// Both branches round, so `0.5` and `50%` resolve to the same byte (128). -/// The old hand-rolled `rgba()` parser truncated (`as u8`), mapping `0.5` to -/// `127`; that was an artefact of the formula rather than a deliberate -/// choice, and it made the two notations for one value disagree. Rounding -/// matches the CSS spec and browsers. fn parse_alpha(s: &str) -> Option { let s = s.trim(); if let Some(pct) = s.strip_suffix('%') { @@ -254,8 +189,6 @@ fn parse_alpha(s: &str) -> Option { Some((v.clamp(0.0, 1.0) * 255.0).round() as u8) } -/// Hue: a bare number or a number with a `deg` suffix, normalised into -/// `0..360`. fn parse_hue(s: &str) -> Option { let s = s.trim(); let s = s.strip_suffix("deg").unwrap_or(s).trim(); @@ -267,7 +200,6 @@ fn parse_hue(s: &str) -> Option { Some(h) } -/// A required percentage (saturation/lightness), normalised to `0..1`. fn parse_percent_unit(s: &str) -> Option { let s = s.trim().strip_suffix('%')?; let v: f32 = s.trim().parse().ok()?; @@ -295,11 +227,6 @@ fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) { (to_u8(r1), to_u8(g1), to_u8(b1)) } -// ---- named colours ---- - -/// The full CSS named-colour keyword set (CSS Color Module Level 4 extended -/// keywords, 147 names) plus `transparent`. `name` must already be -/// lowercased. fn named_color(name: &str) -> Option<(u8, u8, u8, u8)> { if name == "transparent" { return Some((0, 0, 0, 0)); @@ -467,8 +394,6 @@ fn named_color(name: &str) -> Option<(u8, u8, u8, u8)> { mod tests { use super::*; - // ---- hex ---- - #[test] fn hex_3_digit_shorthand() { assert_eq!(parse_css_color("#fff"), Some((255, 255, 255, 255))); @@ -499,8 +424,6 @@ mod tests { assert_eq!(parse_css_color("#AbCdEf"), parse_css_color("#abcdef")); } - // ---- rgb / rgba ---- - #[test] fn rgb_function_white() { assert_eq!( @@ -523,8 +446,6 @@ mod tests { #[test] fn rgba_function_with_float_alpha() { - // 0.5 * 255 = 127.5, rounded like the CSS spec and like browsers. - // Percentage and bare-float alpha resolve identically. let (r, g, b, a) = parse_css_color("rgba(255,255,255,0.5)").unwrap(); assert_eq!((r, g, b), (255, 255, 255)); assert_eq!(a, 128); @@ -546,8 +467,6 @@ mod tests { ); } - // ---- hsl / hsla ---- - #[test] fn hsl_function_primary_colors() { assert_eq!(parse_css_color("hsl(0, 100%, 50%)"), Some((255, 0, 0, 255))); @@ -582,8 +501,6 @@ mod tests { assert_eq!(parse_css_color("hsl(0 100% 50%)"), Some((255, 0, 0, 255))); } - // ---- named colors ---- - #[test] fn named_color_white_black() { assert_eq!(parse_css_color("white"), Some((255, 255, 255, 255))); @@ -598,7 +515,6 @@ mod tests { #[test] fn named_color_extended_set_sample() { - // Spot-check a handful outside the old 11-name table. assert_eq!( parse_css_color("rebeccapurple"), Some((0x66, 0x33, 0x99, 255)) @@ -617,8 +533,6 @@ mod tests { assert_eq!(parse_css_color("darkgray"), parse_css_color("darkgrey")); } - // ---- whitespace tolerance ---- - #[test] fn tolerates_surrounding_and_internal_whitespace() { assert_eq!(parse_css_color(" #fff "), Some((255, 255, 255, 255))); @@ -629,8 +543,6 @@ mod tests { ); } - // ---- rejected forms ---- - #[test] fn rejects_garbage_word() { assert_eq!(parse_css_color("notacolor"), None); @@ -663,8 +575,6 @@ mod tests { assert_eq!(parse_css_color(" "), None); } - // ---- parse_hex_color (infallible wrapper) ---- - #[test] fn parse_hex_color_still_infallible_for_6_digit() { assert_eq!(parse_hex_color("#ffffff"), (255, 255, 255, 255)); @@ -672,16 +582,12 @@ mod tests { #[test] fn parse_hex_color_now_handles_3_digit_shorthand() { - // Regression test for the exact silent-black bug: previously any hex - // string shorter than 6 chars (post `#`-strip) returned black. assert_eq!(parse_hex_color("#fff"), (255, 255, 255, 255)); assert_eq!(parse_hex_color("#FFF"), (255, 255, 255, 255)); } #[test] fn parse_hex_color_now_handles_named_and_functional_forms() { - // Callers across the codebase pass arbitrary CSS colour strings - // (not just hex) through this function via `paint_from_hex`. assert_eq!(parse_hex_color("white"), (255, 255, 255, 255)); assert_eq!(parse_hex_color("rgb(255,255,255)"), (255, 255, 255, 255)); } diff --git a/crates/rustmotion-core/src/engine/renderer/fonts.rs b/crates/rustmotion-core/src/engine/renderer/fonts.rs index 8de53b60..ff4d4e6a 100644 --- a/crates/rustmotion-core/src/engine/renderer/fonts.rs +++ b/crates/rustmotion-core/src/engine/renderer/fonts.rs @@ -9,12 +9,8 @@ use crate::schema::FontEntry; use super::google_fonts::{font_cache_dir, resolve_google_font}; -// Thread-local FontMgr instance, created once per thread and reused thread_local! { static THREAD_FONT_MGR: FontMgr = FontMgr::default(); - // Per-thread cache of Typefaces built from the global custom-font bytes, - // keyed by (family, weight, italic) — the exact variant `custom_typeface` - // picked — so each render thread builds each custom face at most once. static CUSTOM_TYPEFACES: RefCell> = RefCell::new(HashMap::new()); } @@ -23,11 +19,6 @@ pub fn font_mgr() -> FontMgr { THREAD_FONT_MGR.with(|mgr| mgr.clone()) } -/// One registered custom-font file: its raw bytes plus the `(weight, -/// italic)` style Skia parsed out of the file itself when it was -/// registered — the ground truth for what that file actually renders as, -/// independent of which nominal weight the caller happened to request it -/// under. #[derive(Clone)] struct CustomFontVariant { data: Vec, @@ -35,24 +26,11 @@ struct CustomFontVariant { italic: bool, } -/// Global registry of custom/Google-font bytes, keyed by family name. Filled -/// once by [`load_custom_fonts`] on the main thread; read by every render -/// thread through [`custom_typeface`]. Each family holds every distinct -/// `(weight, italic)` variant registered for it — e.g. a Google Fonts -/// declaration with `weights: [400, 700]` registers two variants — so -/// [`custom_typeface`] can pick whichever is the closest match to what a -/// paint call asks for, instead of always returning the first file that -/// happened to register (the previous behaviour: every variant after the -/// first was invisible, and every weight/style request resolved to -/// whichever one file won the race). fn custom_font_registry() -> &'static Mutex>> { static REG: OnceLock>>> = OnceLock::new(); REG.get_or_init(|| Mutex::new(HashMap::new())) } -/// Register a custom font's bytes under `family`, tagged with the `(weight, -/// italic)` style Skia reports for the parsed file. A no-op if that exact -/// `(family, weight, italic)` combination is already registered. pub fn register_custom_font_variant(family: &str, data: Vec, weight: i32, italic: bool) { let mut reg = custom_font_registry() .lock() @@ -70,9 +48,6 @@ pub fn register_custom_font_variant(family: &str, data: Vec, weight: i32, it } } -/// The raw bytes registered for `family`'s closest `(weight, italic)` match, -/// if any variant is registered under that family (test/introspection -/// helper). #[cfg(test)] fn custom_font_bytes(family: &str, weight: i32, italic: bool) -> Option> { let reg = custom_font_registry() @@ -82,11 +57,6 @@ fn custom_font_bytes(family: &str, weight: i32, italic: bool) -> Option> closest_variant(variants, weight, italic).map(|v| v.data.clone()) } -/// Pick the registered variant closest to `(weight, italic)`: exact -/// italic-ness match preferred, then the smallest weight distance — the -/// same nearest-match spirit as CSS font matching (`font-weight`/ -/// `font-style` never fail to resolve to *something*, they resolve to the -/// closest available face). fn closest_variant( variants: &[CustomFontVariant], weight: i32, @@ -98,9 +68,6 @@ fn closest_variant( }) } -/// Resolve a registered custom font to a Typeface for the requested `style`, -/// building it from the global bytes on first use per thread and caching it -/// thereafter. `None` when no custom font is registered under `family`. fn custom_typeface(family: &str, style: FontStyle) -> Option { let weight = *style.weight(); let italic = style.slant() != skia_safe::font_style::Slant::Upright; @@ -123,34 +90,15 @@ fn custom_typeface(family: &str, style: FontStyle) -> Option { }) } -/// Look up only the custom/Google-font registry for `family` at the -/// requested `style`, without falling through to any system font. Exposed -/// for callers with their own family-specific system fallback chain that -/// need to check "did the scenario declare a custom font for this family" -/// *before* trying that chain — unlike [`typeface_with_fallback`], which -/// interleaves a single system-family lookup between the custom check and -/// its own generic Helvetica/Arial catch-all, an order that doesn't suit -/// every caller (a caller with a hardcoded monospace fallback list, for -/// instance, would never reach it if `typeface_with_fallback`'s own system -/// lookup already matched a decoy system family, e.g. "JetBrains Mono"). pub fn resolve_custom_typeface(family: &str, style: FontStyle) -> Option { custom_typeface(family, style) } -/// Validate a `FontEntry` and resolve it to a list of TTF file paths. -/// -/// - Local entry (`path` set, `source` absent): returns `[path]` as-is. -/// - Google Fonts entry (`source = "google"`, `path` absent): downloads -/// (or reads from cache) and returns one path per requested weight. -/// - Conflict (`source` and `path` both set): returns an error. -/// - Neither (`path` absent and `source` absent): returns an error. pub fn resolve_font_entry(entry: &FontEntry) -> Result> { match (&entry.source, &entry.path) { - // Conflict: both path and source set. (Some(_), Some(_)) => Err(RustmotionError::FontSourceAndPathConflict { family: entry.family.clone(), }), - // Google Fonts. (Some(source), None) if source == "google" => { let weights = entry .weights @@ -160,22 +108,17 @@ pub fn resolve_font_entry(entry: &FontEntry) -> Result> let cache_dir = font_cache_dir(); resolve_google_font(&entry.family, weights, &cache_dir) } - // Unknown source value — treat as a user error. (Some(other), None) => Err(RustmotionError::Generic(format!( "FontEntry for '{}': unknown source value '{}' (only \"google\" is supported)", entry.family, other ))), - // Local file. (None, Some(path)) => Ok(vec![std::path::PathBuf::from(path)]), - // Neither path nor source. (None, None) => Err(RustmotionError::FontMissingPath { family: entry.family.clone(), }), } } -/// Load custom fonts from FontEntry definitions. Emits a single warning per -/// missing or unreadable file so the user notices broken paths up-front. pub fn load_custom_fonts(fonts: &[FontEntry]) { let font_mgr = font_mgr(); for entry in fonts { @@ -192,7 +135,6 @@ pub fn load_custom_fonts(fonts: &[FontEntry]) { } } -/// Register a single TTF/OTF file into the given FontMgr. fn register_font_file(font_mgr: &FontMgr, family: &str, path: &std::path::Path) { if !path.exists() { eprintln!( @@ -213,16 +155,6 @@ fn register_font_file(font_mgr: &FontMgr, family: &str, path: &std::path::Path) ); return; }; - // Skia's default FontMgr can build a Typeface from `new_from_data` - // but never exposes it to `match_family_style` (name lookup only - // sees installed system fonts). So keep the raw bytes in a global - // registry; `typeface_with_fallback` builds and caches a Typeface - // from them per thread, ahead of the system match. Tag the - // variant with the (weight, italic) Skia parsed out of the file - // itself — the ground truth for what it actually renders as — - // so a family with several registered weights (e.g. Google - // Fonts `weights: [400, 700]`) exposes every one of them instead - // of only whichever file happened to register first. let parsed_style = tf.font_style(); let weight = *parsed_style.weight(); let italic = parsed_style.slant() != skia_safe::font_style::Slant::Upright; @@ -239,17 +171,7 @@ fn register_font_file(font_mgr: &FontMgr, family: &str, path: &std::path::Path) } } -/// Resolve a typeface for `family` falling back through Helvetica → Arial → -/// the OS default. Returns `RustmotionError::FontNotFound` only if the host -/// system has no usable font at all (essentially unreachable on every -/// supported platform). Use this instead of `.expect("FontNotFound")` so we -/// never panic from a `paint` callback. pub fn typeface_with_fallback(family: &str, style: FontStyle) -> Result { - // Custom/Google fonts declared in the scenario win over system fonts: - // they are not visible to `match_family_style`, so resolve them from the - // registry first — matched against the requested `style` so a family - // registered with several weights picks the right one instead of - // whichever file happened to register first (#6). if let Some(t) = custom_typeface(family, style) { return Ok(t); } @@ -269,7 +191,6 @@ pub fn typeface_with_fallback(family: &str, style: FontStyle) -> Result Option { thread_local! { static EMOJI_TF: Option = { @@ -283,19 +204,6 @@ pub fn emoji_typeface() -> Option { EMOJI_TF.with(|tf| tf.clone()) } -/// Resolve a system fallback typeface that actually contains a glyph for -/// `c`, for when `primary_family`'s own face doesn't cover it (audit #3: -/// CJK/Arabic/Devanagari/other scripts rendered as `.notdef` tofu when only -/// a Latin `font-family` was requested, because neither measurement nor -/// painting ever looked past the single requested typeface). This is -/// Skia's font-fallback-by-character API — the same mechanism a browser -/// uses to substitute, say, a CJK font for Chinese text embedded in an -/// otherwise-Latin paragraph, instead of leaving `.notdef` tofu. Memoized -/// per thread (keyed on the inputs that actually affect the OS's fallback -/// decision) since callers may probe this once per uncovered code point -/// during run segmentation. Returns `None` if no installed font covers `c` -/// either — the caller falls back to the originally requested (tofu- -/// producing) font, exactly the pre-fix behaviour, not worse. pub fn fallback_typeface_for_char( primary_family: &str, style: FontStyle, @@ -319,8 +227,6 @@ pub fn fallback_typeface_for_char( }) } -// ─── Unit tests ────────────────────────────────────────────────────────────── - #[cfg(test)] mod tests { use super::*; @@ -372,9 +278,6 @@ mod tests { #[test] fn custom_font_registry_stores_distinct_weights_and_serves_bytes() { register_custom_font_variant("RmProbeRegistryFamily", vec![1, 2, 3], 400, false); - // A *different* (weight, italic) is a genuinely new variant — not a - // clobber of the first (the old `family`-only-keyed `or_insert` - // registry made every registration after the first invisible; #6). register_custom_font_variant("RmProbeRegistryFamily", vec![9, 9], 700, false); assert_eq!( custom_font_bytes("RmProbeRegistryFamily", 400, false), @@ -400,12 +303,6 @@ mod tests { #[test] fn custom_typeface_lookup_picks_the_closest_registered_weight() { - // Pure selection-logic reproduction of #6's fix mechanism, - // independent of any font actually installed on the host: three - // variants registered under one family; a lookup for an - // intermediate weight must pick the *closest* one, not always the - // first registered — the defect the audit measured (bold and - // normal always resolving to the same file). register_custom_font_variant("RmProbeClosestFamily", vec![1], 400, false); register_custom_font_variant("RmProbeClosestFamily", vec![2], 700, false); register_custom_font_variant("RmProbeClosestFamily", vec![3], 900, false); @@ -426,10 +323,6 @@ mod tests { ); } - /// The bug this fix targets: a registered custom family must resolve to the - /// custom typeface, not the Helvetica/Arial fallback. Uses the cached Anton - /// TTF when present (Google-font path); skips on a cold cache so CI without - /// network still passes — the render QA is the visual counterpart. #[test] fn registered_custom_font_resolves_over_system_fallback() { let path = format!( @@ -437,7 +330,7 @@ mod tests { std::env::var("HOME").unwrap_or_default() ); let Ok(bytes) = std::fs::read(&path) else { - return; // cold font cache → skip (render QA covers it) + return; }; let fm = font_mgr(); let parsed = fm @@ -458,15 +351,6 @@ mod tests { ); } - /// End-to-end reproduction of #6: a family registered with two distinct - /// weights (mirrors `fonts: [{"family":"Inter","source":"google", - /// "weights":[400,700]}]`) must resolve *different* typefaces for - /// `font-weight: normal` vs `font-weight: bold`. Before the fix, - /// `custom_typeface` ignored `style` entirely and `register_custom_ - /// font_bytes` kept only the first-registered file, so the audit's two - /// rendered PNGs (bold vs normal) came out byte-for-byte identical. - /// Skips on a cold font cache (no network access in CI) — the render QA - /// in `examples/` is the visual counterpart. #[test] fn family_with_two_registered_weights_resolves_distinct_typefaces() { let cache_dir = format!( @@ -477,7 +361,7 @@ mod tests { std::fs::read(format!("{cache_dir}/inter-400.ttf")), std::fs::read(format!("{cache_dir}/inter-700.ttf")), ) else { - return; // cold font cache → skip (render QA covers it) + return; }; let fm = font_mgr(); @@ -530,7 +414,6 @@ mod tests { #[test] fn google_entry_with_cached_file_resolves() { - // Build a pre-warmed cache dir and inject it via resolve_google_font directly. let cache_dir = std::env::temp_dir() .join("rustmotion-test-fonts") .join("fonts-rs-google-cache"); @@ -538,8 +421,6 @@ mod tests { std::fs::write(cache_dir.join("inter-400.ttf"), b"fake ttf").unwrap(); let entry = google_entry("Inter", None); - // We call resolve_google_font directly with the injected dir to avoid - // any real network in unit tests. let paths = crate::engine::renderer::google_fonts::resolve_google_font( &entry.family, &[400], diff --git a/crates/rustmotion-core/src/engine/renderer/google_fonts.rs b/crates/rustmotion-core/src/engine/renderer/google_fonts.rs index 6f34a01b..6925d4ae 100644 --- a/crates/rustmotion-core/src/engine/renderer/google_fonts.rs +++ b/crates/rustmotion-core/src/engine/renderer/google_fonts.rs @@ -1,39 +1,7 @@ -//! Google Fonts download and disk-cache helpers. -//! -//! ## Cache layout -//! -//! ```text -//! ~/.cache/rustmotion/fonts/ -//! inter-400.ttf -//! inter-700.ttf -//! jetbrains-mono-400.ttf -//! ``` -//! -//! File names are derived from the family name by lower-casing and replacing -//! spaces with hyphens: `"JetBrains Mono"` → `"jetbrains-mono"`. -//! -//! ## Network protocol -//! -//! 1. `GET https://fonts.googleapis.com/css2?family=:wght@;` -//! with `ureq`'s default (non-browser) User-Agent. Google returns a CSS -//! stylesheet containing `url(…)` entries pointing at `.ttf` files. -//! 2. We extract every `url(…)` from the CSS and download each TTF. -//! -//! No new crate dependencies are introduced; `ureq` (v3) is already in -//! `rustmotion-core`. - use std::path::{Path, PathBuf}; use crate::error::{Result, RustmotionError}; -// ─── Cache directory ──────────────────────────────────────────────────────── - -/// Returns the platform-appropriate font cache directory. -/// -/// - macOS/Linux: `$HOME/.cache/rustmotion/fonts` -/// - Windows: `%LOCALAPPDATA%\rustmotion\fonts` -/// -/// The directory is created if it does not exist. pub fn font_cache_dir() -> PathBuf { #[cfg(target_os = "windows")] let base = std::env::var_os("LOCALAPPDATA") @@ -48,13 +16,6 @@ pub fn font_cache_dir() -> PathBuf { base.join("rustmotion").join("fonts") } -// ─── Public entry point ───────────────────────────────────────────────────── - -/// Ensure TTFs for `family` + `weights` are present on disk (download if needed) -/// and return their paths. -/// -/// `cache_dir` is accepted as a parameter so tests can inject a temporary -/// directory without touching `$HOME`. pub fn resolve_google_font( family: &str, weights: &[u16], @@ -66,7 +27,6 @@ pub fn resolve_google_font( let mut paths = Vec::with_capacity(weights.len()); let mut missing_weights: Vec = Vec::new(); - // Check which weights are already cached. for &weight in weights { let dest = cache_dir.join(format!("{slug}-{weight}.ttf")); if dest.exists() { @@ -80,11 +40,9 @@ pub fn resolve_google_font( return Ok(paths); } - // Fetch the CSS2 stylesheet from Google Fonts. let url = build_css2_url(family, &missing_weights); let css = fetch_css2(&url, family)?; - // Parse the TTF URLs out of the CSS. let ttf_urls = parse_ttf_urls(&css); if ttf_urls.is_empty() { return Err(RustmotionError::GoogleFontsNoUrls { @@ -92,8 +50,6 @@ pub fn resolve_google_font( }); } - // Download each TTF. We map each URL to a weight in order because - // Google Fonts returns one face per weight in the order requested. for (i, &weight) in missing_weights.iter().enumerate() { let ttf_url = ttf_urls .get(i) @@ -106,20 +62,6 @@ pub fn resolve_google_font( Ok(paths) } -// ─── URL construction ──────────────────────────────────────────────────────── - -/// Build the Google Fonts CSS2 API URL. -/// -/// Spaces in the family name are replaced with `+`; weights are joined with -/// `;` as per the CSS2 API spec. -/// -/// # Examples -/// -/// ``` -/// # use rustmotion_core::engine::renderer::google_fonts::build_css2_url; -/// let url = build_css2_url("JetBrains Mono", &[400, 700]); -/// assert_eq!(url, "https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700"); -/// ``` pub fn build_css2_url(family: &str, weights: &[u16]) -> String { let family_encoded = family.replace(' ', "+"); let weights_str = weights @@ -130,33 +72,17 @@ pub fn build_css2_url(family: &str, weights: &[u16]) -> String { format!("https://fonts.googleapis.com/css2?family={family_encoded}:wght@{weights_str}") } -// ─── CSS parsing ───────────────────────────────────────────────────────────── - -/// Extract all `url(…)` values that end with `.ttf` from a Google Fonts CSS -/// response. No regex crate is used — we find `url(` markers and extract the -/// inner content. -/// -/// # Examples -/// -/// ``` -/// # use rustmotion_core::engine::renderer::google_fonts::parse_ttf_urls; -/// let css = r#"src: url(https://fonts.gstatic.com/s/inter/v13/foo-400.ttf) format('truetype');"#; -/// let urls = parse_ttf_urls(css); -/// assert_eq!(urls, vec!["https://fonts.gstatic.com/s/inter/v13/foo-400.ttf"]); -/// ``` pub fn parse_ttf_urls(css: &str) -> Vec { let mut urls = Vec::new(); let mut search = css; while let Some(start) = search.find("url(") { search = &search[start + 4..]; - // Strip optional surrounding quotes. let inner = search.trim_start_matches(['\'', '"']); let end = inner.find([')', '\'', '"']).unwrap_or(inner.len()); let url = &inner[..end]; if url.ends_with(".ttf") || url.ends_with(".ttf)") { urls.push(url.trim_end_matches(')').to_string()); } - // Advance past the closing paren. if let Some(close) = search.find(')') { search = &search[close + 1..]; } else { @@ -166,25 +92,10 @@ pub fn parse_ttf_urls(css: &str) -> Vec { urls } -// ─── File-name slug ────────────────────────────────────────────────────────── - -/// Convert a font family name to a filesystem-safe slug. -/// -/// `"JetBrains Mono"` → `"jetbrains-mono"` -/// -/// # Examples -/// -/// ``` -/// # use rustmotion_core::engine::renderer::google_fonts::family_slug; -/// assert_eq!(family_slug("JetBrains Mono"), "jetbrains-mono"); -/// assert_eq!(family_slug("Inter"), "inter"); -/// ``` pub fn family_slug(family: &str) -> String { family.to_lowercase().replace(' ', "-") } -// ─── Network helpers ───────────────────────────────────────────────────────── - fn fetch_css2(url: &str, family: &str) -> Result { let response = ureq::get(url).call().map_err(|e| { let cache_hint = font_cache_dir() @@ -236,14 +147,10 @@ fn fetch_ttf(url: &str, dest: &Path, family: &str, weight: u16) -> Result<()> { Ok(()) } -// ─── Unit tests ────────────────────────────────────────────────────────────── - #[cfg(test)] mod tests { use super::*; - // --- URL construction --- - #[test] fn url_single_weight() { let url = build_css2_url("Inter", &[400]); @@ -271,8 +178,6 @@ mod tests { ); } - // --- CSS parsing --- - #[test] fn parse_single_ttf_url() { let css = @@ -286,7 +191,6 @@ mod tests { #[test] fn parse_multiple_ttf_urls_from_embedded_css() { - // Simulate a real (stripped) Google Fonts CSS2 response for Inter 400+700. let css = r#" @font-face { font-family: 'Inter'; @@ -315,8 +219,6 @@ mod tests { assert!(urls.is_empty()); } - // --- File slug --- - #[test] fn slug_lowercase_spaces_to_hyphens() { assert_eq!(family_slug("JetBrains Mono"), "jetbrains-mono"); @@ -324,9 +226,6 @@ mod tests { assert_eq!(family_slug("Noto Sans SC"), "noto-sans-sc"); } - // --- Cache hit (zero network) --- - - /// Create a unique temp directory for the test (no tempfile crate needed). fn make_test_cache(test_name: &str) -> PathBuf { let base = std::env::temp_dir() .join("rustmotion-test-fonts") @@ -339,13 +238,9 @@ mod tests { fn cache_hit_returns_path_without_network() { let cache_dir = make_test_cache("cache-hit-single"); - // Pre-position the TTF so no network call is needed. let pre_placed = cache_dir.join("inter-400.ttf"); std::fs::write(&pre_placed, b"fake ttf data").unwrap(); - // resolve_google_font must return the pre-placed path without hitting - // the network (if it did hit the network and failed, the test would - // error — no mocking needed). let paths = resolve_google_font("Inter", &[400], &cache_dir).unwrap(); assert_eq!(paths.len(), 1); assert_eq!(paths[0], pre_placed); @@ -362,7 +257,6 @@ mod tests { assert_eq!(paths.len(), 2); } - // Live network test — excluded from normal CI runs. #[test] #[ignore = "requires network access"] fn live_fetch_inter_400() { @@ -372,4 +266,28 @@ mod tests { let size = std::fs::metadata(&paths[0]).unwrap().len(); assert!(size > 1000, "expected a real TTF, got {size} bytes"); } + + #[test] + fn build_css2_url_joins_weights_with_semicolons() { + assert_eq!( + build_css2_url("JetBrains Mono", &[400, 700]), + "https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700" + ); + } + + #[test] + fn parse_ttf_urls_extracts_the_truetype_source() { + let css = + r#"src: url(https://fonts.gstatic.com/s/inter/v13/foo-400.ttf) format('truetype');"#; + assert_eq!( + parse_ttf_urls(css), + vec!["https://fonts.gstatic.com/s/inter/v13/foo-400.ttf"] + ); + } + + #[test] + fn family_slug_lowercases_and_hyphenates() { + assert_eq!(family_slug("JetBrains Mono"), "jetbrains-mono"); + assert_eq!(family_slug("Inter"), "inter"); + } } diff --git a/crates/rustmotion-core/src/engine/renderer/shapes.rs b/crates/rustmotion-core/src/engine/renderer/shapes.rs index 48156aa2..6410b28a 100644 --- a/crates/rustmotion-core/src/engine/renderer/shapes.rs +++ b/crates/rustmotion-core/src/engine/renderer/shapes.rs @@ -2,7 +2,6 @@ use skia_safe::{Canvas, Paint, Rect}; use crate::schema::ShapeType; -/// Build a `Path` for the given shape type without drawing it. pub fn build_shape_path( shape_type: &ShapeType, x: f32, diff --git a/crates/rustmotion-core/src/engine/renderer/text.rs b/crates/rustmotion-core/src/engine/renderer/text.rs index 59cb559d..01195acd 100644 --- a/crates/rustmotion-core/src/engine/renderer/text.rs +++ b/crates/rustmotion-core/src/engine/renderer/text.rs @@ -1,7 +1,5 @@ use skia_safe::{Canvas, Font, Paint, Point, TextBlob, Typeface}; -// ─── Counter formatting ───────────────────────────────────────────────────── - pub fn format_counter_value( value: f64, decimals: u8, @@ -9,15 +7,12 @@ pub fn format_counter_value( prefix: &Option, suffix: &Option, ) -> String { - // Format with decimals let formatted_number = format!("{:.prec$}", value, prec = decimals as usize); - // Apply thousands separator if specified let formatted_number = if let Some(sep) = separator { let parts: Vec<&str> = formatted_number.split('.').collect(); let integer_part = parts[0]; - // Handle negative sign let (sign, digits) = if let Some(stripped) = integer_part.strip_prefix('-') { ("-", stripped) } else { @@ -46,7 +41,6 @@ pub fn format_counter_value( formatted_number }; - // Build final string with prefix/suffix let mut result = String::new(); if let Some(p) = prefix { result.push_str(p); @@ -58,8 +52,6 @@ pub fn format_counter_value( result } -// ─── Text utilities ───────────────────────────────────────────────────────── - pub fn wrap_text(text: &str, font: &Font, max_width: Option) -> Vec { let explicit_lines: Vec<&str> = text.split('\n').collect(); @@ -118,31 +110,12 @@ pub fn make_text_blob_with_spacing(text: &str, font: &Font, spacing: f32) -> Opt TextBlob::from_pos_text(text, &positions, font) } -// ─── Glyph metrics (issue #328) ───────────────────────────────────────────── -// -// Glyph positions were already computed here — `str_to_glyphs_vec`, then -// `get_widths`, then a `positions` vector, exactly what -// `make_text_blob_with_spacing` above builds — but only ever consumed -// straight into a `TextBlob` and thrown away once painted. Nothing let an -// expression ask where, say, the last glyph of a typed sentence actually -// landed. The functions below retain that same per-glyph data instead of -// discarding it. - -/// One glyph's left edge and advance width, in a **line's own** coordinate -/// space: `x == 0.0` at the start of the line, before any `text-align` -/// offset a caller applies on top (mirrors [`draw_text_with_fallback`]'s own -/// `cursor_x`, which starts at the line's left edge for the same reason). #[derive(Debug, Clone, Copy, PartialEq)] pub struct GlyphMetric { pub x: f32, pub width: f32, } -/// Per-glyph `(x, width)` for `text` measured against a single `font`, no -/// run segmentation — the same primitive [`make_text_blob_with_spacing`] -/// already computes (`str_to_glyphs_vec` + `get_widths`) to place each -/// glyph in a `TextBlob`, retained here as positions instead of being -/// consumed straight into one. fn glyph_metrics_single_font(text: &str, font: &Font, letter_spacing: f32) -> Vec { let glyphs = font.str_to_glyphs_vec(text); if glyphs.is_empty() { @@ -159,14 +132,6 @@ fn glyph_metrics_single_font(text: &str, font: &Font, letter_spacing: f32) -> Ve out } -/// Per-glyph `(x, width)` for one already-wrapped line of `text`, run-aware -/// (emoji-font and glyph-coverage fallback — see [`segment_text_runs`]) -/// exactly like [`draw_text_with_fallback`]: same runs, same per-run font -/// choice, so a glyph index into this list lines up with the glyph -/// `draw_text_with_fallback` actually paints at that position. `x` is -/// line-relative (see [`GlyphMetric`]'s doc) — a caller placing the line -/// inside a box via `text-align` adds its own line offset on top, the same -/// way `draw_text_with_fallback`'s callers already compute `line_x`. pub fn compute_glyph_metrics( text: &str, font: &Font, @@ -198,99 +163,49 @@ pub fn compute_glyph_metrics( out } -// ─── Emoji support ────────────────────────────────────────────────────────── - -/// Code points that render as emoji **by default**, in every context, -/// regardless of any following variation selector — genuine pictograph -/// blocks (Miscellaneous Symbols and Pictographs, Emoticons, Transport, -/// Supplemental Symbols/Pictographs, flags, keycaps, ZWJ sequences...). -/// Nothing in these ranges has a meaningful plain-text rendering, so there -/// is no narrowing to do here — contrast with -/// [`is_text_presentation_by_default`], which needs one (audit #8). fn is_emoji_presentation_default(c: char) -> bool { let cp = c as u32; matches!(cp, - // Miscellaneous Symbols and Pictographs (includes skin tone modifiers 1F3FB-1F3FF) 0x1F300..=0x1F5FF | - // Emoticons 0x1F600..=0x1F64F | - // Transport and Map Symbols 0x1F680..=0x1F6FF | - // Supplemental Symbols and Pictographs 0x1F900..=0x1F9FF | - // Symbols and Pictographs Extended-A 0x1FA00..=0x1FA6F | - // Symbols and Pictographs Extended-B 0x1FA70..=0x1FAFF | - // Zero-Width Joiner 0x200D | - // Combining Enclosing Keycap 0x20E3 | - // Regional Indicator Symbols (flags) 0x1F1E0..=0x1F1FF | - // Tags block (flag subdivisions) 0xE0020..=0xE007F | - // Playing cards, mahjong 0x1F004 | 0x1F0CF | - // Misc technical, unconditionally emoji (⏩..⏳ ⏸..⏺) 0x23E9..=0x23F3 | 0x23F8..=0x23FA | - // Arrows and geometric symbols, unconditionally emoji 0x2B1B..=0x2B1C | 0x2B50 | 0x2B55 ) } -/// Code points that are TEXT-presentation **by default** — a normal glyph -/// in the primary font, honouring `style.color` — but that Unicode still -/// marks `Emoji=Yes`: they render as color emoji only when the author -/// explicitly opts in with a following U+FE0F variation selector (audit -/// #8). Routing these unconditionally to the emoji font (the previous -/// behaviour, lumped in with [`is_emoji_presentation_default`]) either -/// painted a color bitmap that ignores `style.color` (✔, ©, ®, ™ — all -/// covered by Apple Color Emoji) or a `.notdef` tofu square for code points -/// the emoji font itself doesn't cover even though the primary font does -/// (✓ U+2713 — absent from Apple Color Emoji even though U+2714 sits one -/// code point over and is present). fn is_text_presentation_by_default(c: char) -> bool { let cp = c as u32; matches!(cp, - // Copyright, registered, trademark 0x00A9 | 0x00AE | 0x2122 | - // Misc technical (⌚ ⌛) 0x231A..=0x231B | - // Miscellaneous Symbols (☀️..⛿) 0x2600..=0x26FF | - // Dingbats (✂️..➰ and arrows/symbols), includes ✓/✔ U+2713/2714 0x2702..=0x27B0 | - // Arrows and geometric symbols used as emoji only with VS16 0x2934..=0x2935 | 0x25AA..=0x25AB | 0x25B6 | 0x25C0 | 0x25FB..=0x25FE | 0x2B05..=0x2B07 | - // CJK symbols 0x3030 | 0x303D | 0x3297 | 0x3299 ) } -/// Variation Selector-16 — forces the *preceding* text-presentation-default -/// code point (see [`is_text_presentation_by_default`]) into emoji -/// presentation. Narrower than the old catch-all `0xFE00..=0xFE0F` range: -/// VS-15 (U+FE0E, forces *text* presentation) and the rest of that block -/// are not emoji-forcing. const VARIATION_SELECTOR_EMOJI: char = '\u{FE0F}'; -/// True if `c` should be painted/measured with the emoji font, given the -/// character immediately following it (`next`). Needed because -/// [`is_text_presentation_by_default`] code points only opt into emoji -/// presentation when explicitly followed by U+FE0F — a bare lookup of `c` -/// alone can't tell "©" (text) from "©️" (explicit emoji presentation) -/// apart. fn char_wants_emoji_font(c: char, next: Option) -> bool { if c == VARIATION_SELECTOR_EMOJI { - return true; // always grouped with whatever code point selected it + return true; } if is_emoji_presentation_default(c) { return true; @@ -301,22 +216,12 @@ fn char_wants_emoji_font(c: char, next: Option) -> bool { false } -/// Which font a run of text should be painted/measured with. enum RunKind { Primary, Emoji, - /// #3: a system fallback typeface resolved for a code point the - /// primary font doesn't cover (e.g. CJK/Arabic/Devanagari when only a - /// Latin `font-family` was requested). Fallback(Typeface), } -/// True if `a` and `b` are the "same" run kind for the purpose of merging -/// adjacent characters into one run. Two `Fallback` runs merge only if they -/// resolved to the *same* typeface (compared by Skia's unique id) — two -/// characters that both need fallback but belong to different scripts -/// (e.g. mixed CJK + Arabic) must not merge into a single run painted with -/// only one of the two fonts. fn same_run_kind(a: &RunKind, b: &RunKind) -> bool { match (a, b) { (RunKind::Primary, RunKind::Primary) => true, @@ -326,24 +231,11 @@ fn same_run_kind(a: &RunKind, b: &RunKind) -> bool { } } -/// True if `font` has an actual glyph (not `.notdef`, glyph id 0) for every -/// character in `text`. Used both to gate whether a code point needs a -/// fallback lookup at all (#3), and as a coverage guard before actually -/// using the emoji font for a run classified as emoji (#8): some code -/// points Unicode marks emoji-capable are, on a given platform, absent -/// from the color emoji font even though the primary font has a perfectly -/// good text glyph for them (U+2713 on Apple Color Emoji). fn font_covers(font: &Font, text: &str) -> bool { let glyphs = font.str_to_glyphs_vec(text); !glyphs.is_empty() && glyphs.iter().all(|&g| g != 0) } -/// Classify a single character's font choice: emoji presentation first -/// (#8), then primary-font glyph coverage, then a system fallback typeface -/// resolved via [`super::fallback_typeface_for_char`] for the primary -/// font's own `(family, style)` (#3). Whitespace/control code points are -/// always `Primary` (assumed universally present, or invisible) so the -/// fallback lookup isn't triggered by the spaces between same-script words. fn classify_char(c: char, primary: &Font, next: Option) -> RunKind { if char_wants_emoji_font(c, next) { return RunKind::Emoji; @@ -359,14 +251,10 @@ fn classify_char(c: char, primary: &Font, next: Option) -> RunKind { let family = primary_typeface.family_name(); match super::fallback_typeface_for_char(&family, style, c) { Some(tf) => RunKind::Fallback(tf), - // No installed font covers `c` either — same `.notdef` tofu as - // before this fix, not worse. None => RunKind::Primary, } } -/// Segment text into runs, each tagged with which font should paint/measure -/// it (see [`classify_char`]). fn segment_text_runs(text: &str, primary: &Font) -> Vec { let chars: Vec<(usize, char)> = text.char_indices().collect(); let mut runs = Vec::new(); @@ -397,17 +285,12 @@ fn segment_text_runs(text: &str, primary: &Font) -> Vec { runs } -/// A segment of text tagged with which font it should be painted/measured -/// with (see [`RunKind`]). struct TextRun { - start: usize, // byte offset - end: usize, // byte offset + start: usize, + end: usize, kind: RunKind, } -/// True if `text` contains any character that wants emoji presentation -/// (#8-aware: honours the VS16 opt-in for text-presentation-default code -/// points, so plain "©"/"✓" no longer count as emoji here). pub fn has_emoji(text: &str) -> bool { let chars: Vec = text.chars().collect(); chars @@ -416,35 +299,16 @@ pub fn has_emoji(text: &str) -> bool { .any(|(i, &c)| char_wants_emoji_font(c, chars.get(i + 1).copied())) } -/// True if `text` needs the full run-segmentation machinery in -/// [`draw_text_with_fallback`]/[`measure_text_with_fallback`]: it contains -/// emoji-presentation content (and an emoji font is actually available), or -/// at least one non-whitespace/control code point the primary `font` -/// doesn't cover (#3). When neither is true, every caller's existing -/// single-font fast path is exactly correct and segmentation would be -/// wasted work. fn needs_segmentation(text: &str, primary: &Font, emoji_font: &Option) -> bool { if emoji_font.is_some() && has_emoji(text) { return true; } text.chars().any(|c| { - // ASCII short-circuits before the `unichar_to_glyph` FFI call: every - // font this engine resolves covers printable ASCII, and callers - // that draw a lot of short spans per frame (`rich_text`'s per-span - // styling, in particular) call this once per span — skipping the - // Skia round-trip for the overwhelmingly common all-ASCII case - // keeps #3's fix from adding per-glyph FFI overhead to code that - // was never affected by the tofu/coverage bug it fixes. !(c.is_ascii() || c.is_whitespace() || (c as u32) < 0x20) && primary.unichar_to_glyph(c as i32) == 0 }) } -/// Resolve which `Font` a run should actually be painted/measured with, -/// applying the emoji coverage guard (#8) and building a same-size `Font` -/// from a resolved fallback [`Typeface`] (#3). Returns a borrow of one of -/// the two inputs, or an owned font stored in `owned` to keep the borrow -/// alive at the call site. fn resolve_run_font<'a>( kind: &RunKind, segment: &str, @@ -456,9 +320,6 @@ fn resolve_run_font<'a>( RunKind::Primary => primary, RunKind::Emoji => match emoji_font { Some(ef) if font_covers(ef, segment.trim_end_matches(VARIATION_SELECTOR_EMOJI)) => ef, - // Coverage guard (#8): the emoji font doesn't actually have - // this glyph (or isn't available at all) — fall back to the - // primary font rather than paint `.notdef`. _ => primary, }, RunKind::Fallback(tf) => { @@ -468,10 +329,6 @@ fn resolve_run_font<'a>( } } -/// Draw a text line with emoji-font and glyph-coverage fallback (#3, #8). -/// If `emoji_font` is None, falls back to drawing everything with the -/// primary font (plus any `#3` system fallback the primary font's coverage -/// gap needs). pub fn draw_text_with_fallback( canvas: &Canvas, text: &str, @@ -482,8 +339,6 @@ pub fn draw_text_with_fallback( y: f32, paint: &Paint, ) { - // Fast path: nothing here needs emoji presentation or a glyph-coverage - // fallback — every earlier caller's exact previous behaviour. if !needs_segmentation(text, font, emoji_font) { if letter_spacing.abs() > 0.01 { if let Some(blob) = make_text_blob_with_spacing(text, font, letter_spacing) { @@ -511,7 +366,6 @@ pub fn draw_text_with_fallback( canvas.draw_text_blob(&blob, (cursor_x, y), paint); } - // Advance cursor by the measured width of this run let (w, _) = f.measure_str(segment, None); let extra = if letter_spacing.abs() > 0.01 { letter_spacing * (segment.chars().count() as f32 - 1.0).max(0.0) @@ -522,16 +376,12 @@ pub fn draw_text_with_fallback( } } -/// Measure the width of a text line with emoji-font and glyph-coverage -/// fallback (#3, #8) — mirrors [`draw_text_with_fallback`] run-for-run so -/// the width this returns always matches what actually gets painted. pub fn measure_text_with_fallback( text: &str, font: &Font, emoji_font: &Option, letter_spacing: f32, ) -> f32 { - // Fast path if !needs_segmentation(text, font, emoji_font) { let (w, _) = font.measure_str(text, None); let extra = if letter_spacing.abs() > 0.01 { @@ -560,31 +410,6 @@ pub fn measure_text_with_fallback( total_w } -/// Wrap text respecting emoji font fallback for accurate measurement, -/// honouring `letter_spacing` in the fit test itself (issue #125). -/// -/// This is the correct entry point for any caller whose paint step applies -/// non-zero `letter-spacing` (i.e. it also calls -/// [`measure_text_with_fallback`] / [`draw_text_with_fallback`] with a -/// non-zero `letter_spacing`): the word-fits-on-this-line test below now -/// measures with the *same* tracking that will actually be painted, so the -/// line count this function returns is the line count the paint step will -/// agree with. -/// -/// Before this existed, every caller went through -/// [`wrap_text_with_fallback`], whose fit test always measured at zero -/// tracking regardless of the real value. That is harmless when -/// `letter_spacing == 0.0`, but wrong otherwise in a specific and dangerous -/// way for *negative* tracking (the register this engine targets: tight -/// negative tracking on 200–400px display type): negative tracking makes -/// the real painted line narrower than the zero-tracking fit test believes, -/// so the fit test can decide a line needs to wrap when the real, tighter -/// text would still have fit on one line — and because the box that holds -/// the text is sized from one width sample while the paint step re-derives -/// the same (still zero-tracking) wrap decision against a *different* -/// available width later in layout, the two passes can disagree on the line -/// count, leaving painted lines centered inside a box sized for a different -/// number of lines. pub fn wrap_text_with_tracking( text: &str, font: &Font, @@ -630,19 +455,6 @@ pub fn wrap_text_with_tracking( result } -/// Wrap text respecting emoji font fallback for accurate measurement. -/// -/// **Known gap (issue #125):** the fit test below always measures at zero -/// letter-spacing, regardless of what the caller will actually paint with. -/// Kept at its original signature/behaviour for source compatibility with -/// existing call sites (`rustmotion-components/src/intrinsic.rs`, -/// `text.rs`, `shape.rs`, `gradient_text.rs` — outside this workstream's -/// file scope) that measure and paint with real `letter-spacing` but still -/// wrap through this function. **New call sites, and any existing call site -/// whose text can carry non-zero `letter-spacing`, should call -/// [`wrap_text_with_tracking`] instead** — it is a straight drop-in (same -/// signature plus one trailing `letter_spacing: f32` argument) that fixes -/// the measure/paint disagreement described there. pub fn wrap_text_with_fallback( text: &str, font: &Font, @@ -652,32 +464,18 @@ pub fn wrap_text_with_fallback( wrap_text_with_tracking(text, font, emoji_font, max_width, 0.0) } -// ─── Tests: issue #125 — letter-spacing-aware line breaking ─────────────── - #[cfg(test)] mod tracking_tests { use super::super::typeface_with_fallback; use super::*; use skia_safe::{surfaces, AlphaType, Color, ColorType, FontStyle as SkFontStyle, ImageInfo}; - /// A real bold typeface at `size`, via the same fallback chain - /// (`typeface_with_fallback`) the renderer uses. Doesn't depend on any - /// particular family being installed (falls through to Helvetica/Arial/ - /// the OS default), so this is safe in any CI environment. fn bold_font(size: f32) -> Font { let typeface = typeface_with_fallback("Helvetica", SkFontStyle::bold()) .expect("host must have a fallback typeface"); Font::from_typeface(typeface, size) } - // ---- 1. The wrap decision must use real tracking, not 0.0 ---- - - /// Direct reproduction of issue #125 §1's headline defect ("gains it a - /// line break it did not have at zero"): negative tracking narrows the - /// real painted width below the zero-tracking fit-test's estimate, so - /// there is a width window — real width <= max_w < zero-tracking width — - /// where the buggy zero-tracking test wraps to a second line that the - /// real, tighter text did not need. #[test] fn negative_tracking_gains_an_unneeded_break_in_old_wrap_but_not_new() { let text = "THAT MOVES"; @@ -693,8 +491,6 @@ mod tracking_tests { zero-tracking estimate at {font_size}px (real={real_width}, zero={zero_width})" ); - // A width inside the window the bug lives in: the real (tracked) - // string fits, but the zero-tracking fit test disagrees. let max_w = (real_width + zero_width) / 2.0; let old_lines = wrap_text_with_fallback(text, &font, &None, Some(max_w)); @@ -715,13 +511,6 @@ mod tracking_tests { } } - /// The fixed wrap decision must be *stable*: re-evaluated at any width - /// sample that is consistent with the real (tracked) content width, it - /// always agrees on line count. This is the structural property whose - /// absence causes issue #125 §1's "the box is sized for one line, paint - /// decides two are needed" — two different width samples taken during - /// layout (the intrinsic-measure pass vs. the final paint pass) must not - /// be able to make the same content wrap differently. #[test] fn tracking_aware_wrap_agrees_across_width_samples_old_wrap_does_not() { let text = "THAT MOVES"; @@ -730,9 +519,6 @@ mod tracking_tests { let real_width = measure_text_with_fallback(text, &font, &None, letter_spacing); let zero_width = measure_text_with_fallback(text, &font, &None, 0.0); - // Two plausible width samples that both satisfy "the real content - // fits": comfortably above the zero-tracking width, and just above - // the real tracked width (inside the bug's window). let sample_a = zero_width + 24.0; let sample_b = real_width + 1.0; assert!( @@ -763,10 +549,6 @@ mod tracking_tests { ); } - /// `wrap_text_with_fallback` (the pre-existing, still-used-by-default - /// name) must keep its exact old behaviour — 0.0 tracking — so every - /// existing call site's output is unchanged until it opts in to - /// `wrap_text_with_tracking`. #[test] fn wrap_text_with_fallback_is_unchanged_zero_tracking_behaviour() { let text = "THAT MOVES"; @@ -778,25 +560,6 @@ mod tracking_tests { ); } - // ---- 2. Real Skia render + measured ink extent ---- - // - // Reproduces the audit's methodology from issue #125 §1 (render, then - // measure the ink bounding box) directly against this module's public - // functions — the primitive this workstream owns — rather than through - // the full JSON-scenario pipeline. `rustmotion-components/src/ - // intrinsic.rs` and `text.rs` (the actual `text` component's box-sizing - // and paint code) are outside this workstream's file scope and still - // call `wrap_text_with_fallback` (0.0 tracking) as of this fix, so this - // test proves the corrected primitive is centred and self-consistent - // when used correctly end-to-end — i.e. what the render will look like - // once those call sites switch to `wrap_text_with_tracking` — not what - // today's shipped renderer currently outputs for a `text` component. - - /// Draw `lines`, each centred within a `box_width`-wide box at - /// `box_left`, onto a `w`×`h` black raster, and return the horizontal - /// ink centre (mean of the leftmost and rightmost non-black pixel - /// columns across every line) — the same "ink extent" measurement - /// issue #125's audit table reports. fn render_and_measure_ink_centre_x( lines: &[String], font: &Font, @@ -854,13 +617,6 @@ mod tracking_tests { } } - /// The sweep from issue #125 §1's audit table — "THAT MOVES", 1920×1080, - /// centred, `line-height: 0.85` — rendered and measured end-to-end with - /// the fixed primitive. The wrap decision and the paint centring both - /// derive from the *same* tracking-aware measurement, so the box the - /// text is centred in and the lines painted into it never disagree: ink - /// centre must land on the frame centre (960) at every size, whether or - /// not tracking causes a wrap. #[test] fn sweep_that_moves_fixed_primitive_stays_centred() { const W: u32 = 1920; @@ -868,11 +624,6 @@ mod tracking_tests { const FRAME_CENTRE: f32 = 960.0; let text = "THAT MOVES"; - // A max-width tight enough that -9 tracking genuinely changes the - // wrap outcome at some of these sizes (mirrors the audit's - // font-size sweep; the exact width is a stand-in for "however wide - // this headline's card/frame is" — not taken from the audit, which - // doesn't publish its scenario JSON). let box_max_width = 1400.0f32; let mut results = Vec::new(); @@ -909,14 +660,6 @@ mod tracking_tests { results.push((font_size, letter_spacing, lines.len(), ink_centre)); - // Tolerance: advance-width centring (what text-align:center - // actually does, here and in the real painter) is not the same - // thing as ink-bbox centring — individual glyphs have left/right - // side-bearings that don't visually balance out, especially - // comparing e.g. "THAT" vs "MOVES" as separate lines. A few px - // of optical vs. advance discrepancy is expected and is not the - // #125 defect (that defect was a 408px miscentre from a wrap/ - // paint disagreement, not glyph-metric noise). assert!( (ink_centre - FRAME_CENTRE).abs() < 15.0, "fixed pipeline ink centre {ink_centre:.1} should be within 15px of \ @@ -934,22 +677,13 @@ mod tracking_tests { } } -// ─── Tests: #8 — emoji presentation must default to text, not tofu/color ── - #[cfg(test)] mod emoji_presentation_tests { use super::super::{emoji_typeface, typeface_with_fallback}; use super::*; - // These assertions are pure code-point classification — no font, no - // machine dependency — so they hold on every host. - #[test] fn text_presentation_default_symbols_are_not_emoji_without_vs16() { - // #8's headline repro: ✓ (U+2713) and ✔ (U+2714) — both inside the - // old unconditional 0x2702..=0x27B0 dingbats range — must NOT be - // classified as emoji when they appear bare, since Unicode marks - // them text-presentation by default. assert!( !char_wants_emoji_font('\u{2713}', None), "✓ bare must be text" @@ -958,7 +692,6 @@ mod emoji_presentation_tests { !char_wants_emoji_font('\u{2714}', None), "✔ bare must be text" ); - // Copyright/registered/trademark: also text-presentation by default. assert!( !char_wants_emoji_font('\u{00A9}', None), "© bare must be text" @@ -975,17 +708,12 @@ mod emoji_presentation_tests { #[test] fn text_presentation_default_symbols_opt_into_emoji_with_vs16() { - // Explicit author intent (U+FE0F immediately after) must still be - // honoured — this is what "presentation *by default*" means. assert!(char_wants_emoji_font('\u{2713}', Some('\u{FE0F}'))); assert!(char_wants_emoji_font('\u{00A9}', Some('\u{FE0F}'))); } #[test] fn genuine_pictographs_are_always_emoji_regardless_of_vs16() { - // Regression guard: the narrowing must not touch the actual emoji - // blocks (grinning face, etc.) — these have no meaningful text - // rendering at all. assert!( char_wants_emoji_font('\u{1F600}', None), "😀 must stay emoji" @@ -995,15 +723,11 @@ mod emoji_presentation_tests { #[test] fn variation_selector_16_itself_is_always_emoji() { - // So it merges into whatever run selected it, rather than becoming - // its own (invisible, harmless either way) primary-font run. assert!(char_wants_emoji_font('\u{FE0F}', None)); } #[test] fn variation_selector_15_does_not_force_emoji() { - // VS-15 (U+FE0E) forces *text* presentation — must not be conflated - // with VS-16 the way the old catch-all 0xFE00..=0xFE0F range did. assert!(!char_wants_emoji_font('\u{2713}', Some('\u{FE0E}'))); } @@ -1014,16 +738,12 @@ mod emoji_presentation_tests { assert!(has_emoji("grinning \u{1F600}")); } - // ---- render-level reproduction: color and coverage, no emoji font needed ---- - fn helvetica_font(size: f32) -> Font { let typeface = typeface_with_fallback("Helvetica", skia_safe::FontStyle::normal()) .expect("host must have a fallback typeface"); Font::from_typeface(typeface, size) } - /// Renders `text` at `size` in white and returns `(ink_pixel_count, - /// mean_r, mean_g, mean_b)` over every non-transparent pixel. fn render_and_sample(text: &str, size: f32) -> (usize, f64, f64, f64) { use skia_safe::{surfaces, AlphaType, Color, ColorType, ImageInfo}; const W: i32 = 200; @@ -1071,14 +791,6 @@ mod emoji_presentation_tests { #[test] fn bare_check_mark_paints_requested_white_not_tofu_or_color_bitmap() { - // #8 render-level reproduction, environment-independent: whether or - // not an emoji font is installed on this host is irrelevant here — - // with the fix, U+2713 alone is classified as *text*, so - // `draw_text_with_fallback` never even looks at `emoji_font` for - // it. Guard on the primary font actually covering the glyph so - // this doesn't fail on some exotic host where even Helvetica lacks - // it (the audit's own finding: Helvetica/Menlo DO have it, only - // Apple Color Emoji doesn't). let font = helvetica_font(64.0); if !font_covers(&font, "\u{2713}") { eprintln!("skip: primary font doesn't cover U+2713 on this host"); @@ -1086,8 +798,6 @@ mod emoji_presentation_tests { } let (ink, r, g, b) = render_and_sample("\u{2713}", 64.0); assert!(ink > 20, "expected visible ink for ✓, got {ink} pixels"); - // White request -> painted channels should be bright and roughly - // neutral (not a dark/colored emoji-bitmap tint). assert!( r > 150.0 && g > 150.0 && b > 150.0, "✓ should paint near-white, got mean rgb=({r:.0},{g:.0},{b:.0})" @@ -1095,8 +805,6 @@ mod emoji_presentation_tests { } } -// ─── Tests: #3 — glyph fallback for scripts the primary font doesn't cover ─ - #[cfg(test)] mod glyph_fallback_tests { use super::super::{fallback_typeface_for_char, typeface_with_fallback}; @@ -1110,28 +818,16 @@ mod glyph_fallback_tests { #[test] fn font_covers_detects_missing_glyphs_deterministically() { - // The detection mechanism itself, independent of whatever fallback - // font may or may not be installed: Helvetica (guaranteed present - // by `typeface_with_fallback`'s own contract) covers plain ASCII - // and does not cover CJK. let font = helvetica_font(32.0); assert!(font_covers(&font, "ABC"), "Helvetica must cover ASCII"); assert!( - !font_covers(&font, "\u{4F60}\u{597D}"), // 你好 + !font_covers(&font, "\u{4F60}\u{597D}"), "Helvetica must not cover CJK" ); } #[test] fn classify_char_stays_primary_for_a_codepoint_no_font_covers() { - // A Private Use Area code point is uncovered by the primary font - // (nothing standard assigns glyphs there); it may or may not be - // "covered" by *some* installed font's own PUA convention (icon - // fonts, vendor glyphs) depending on the host, so skip gracefully - // rather than assume every machine agrees — the point of this test - // is that `classify_char` doesn't crash/loop when nothing covers a - // code point, falling back to `Primary` (the pre-fix, tofu- - // producing behaviour) rather than panicking. let font = helvetica_font(32.0); let pua = '\u{E000}'; assert!( @@ -1156,14 +852,6 @@ mod glyph_fallback_tests { #[test] fn classify_char_resolves_a_fallback_when_the_host_has_one() { - // Environment-dependent by nature (issue: CJK coverage depends on - // installed fonts) — skip gracefully, per this workstream's brief, - // rather than asserting a specific glyph renders. When a fallback - // *is* available (true on stock macOS/Windows/most Linux desktops - // via Noto/PingFang/MS-Gothic-class fonts), `classify_char` itself - // — not just the underlying `fallback_typeface_for_char` primitive - // — must actually route to it, and the resolved typeface must - // cover the character that triggered the lookup. let font = helvetica_font(32.0); let style = font.typeface().font_style(); let family = font.typeface().family_name(); @@ -1187,14 +875,8 @@ mod glyph_fallback_tests { #[test] fn measure_and_paint_agree_on_cjk_width_when_fallback_is_available() { - // Measure-vs-paint parity (this workstream's core mandate): - // `measure_text_with_fallback` must report the width that actually - // gets painted. Skips gracefully (see above) when the host has no - // CJK-capable font at all — on such a host both measure and paint - // agree on the pre-fix degraded behaviour (0-width primary-font - // tofu), which is a separate, already-covered case. let font = helvetica_font(48.0); - let text = "\u{4F60}\u{597D}"; // 你好 + let text = "\u{4F60}\u{597D}"; if !text.chars().any(|c| { fallback_typeface_for_char("Helvetica", font.typeface().font_style(), c).is_some() }) { @@ -1202,11 +884,6 @@ mod glyph_fallback_tests { return; } - // Directly prove segmentation actually routes through the fallback - // mechanism (not just that `fallback_typeface_for_char` the - // primitive would resolve *something* if called) — this is what - // distinguishes "the fix is wired up" from "the fix exists but - // nothing calls it". let runs = segment_text_runs(text, &font); assert!( runs.iter().any(|r| matches!(r.kind, RunKind::Fallback(_))), @@ -1222,9 +899,6 @@ mod glyph_fallback_tests { ); let measured_w = measure_text_with_fallback(text, &font, &None, 0.0); - // Not tofu-narrow: with a real CJK fallback, two full-width - // ideographs at 48px measure well beyond a couple of `.notdef` box - // glyphs' worth of width. assert!( measured_w > 30.0, "expected a real CJK measurement, got suspiciously narrow {measured_w}" @@ -1243,12 +917,6 @@ mod glyph_fallback_tests { let ascent = -metrics.ascent; draw_text_with_fallback(canvas, text, &font, &None, 0.0, 10.0, ascent + 10.0, &paint); - // Ink detection: the canvas is cleared to opaque black (alpha=255 - // everywhere), so — unlike a transparent-cleared surface — alpha - // can't distinguish text from background here. White text against - // black shows up as a bright *red* (or green/blue) channel instead - // (same technique `tracking_tests::render_and_measure_ink_centre_x` - // above uses). let info = ImageInfo::new((W, H), ColorType::RGBA8888, AlphaType::Unpremul, None); let mut buf = vec![0u8; (W * H * 4) as usize]; surface.read_pixels(&info, &mut buf, (W * 4) as usize, (0, 0)); @@ -1269,10 +937,6 @@ mod glyph_fallback_tests { ); let painted_width = (max_x - min_x) as f32; - // Loose tolerance: ink-bbox width vs advance width can differ from - // glyph side-bearings/overhang even for a well-behaved font — this - // is not the #125-style "wrap/paint disagreement" defect (a wild, - // multiple-hundred-px miscentre), just normal glyph-metric slack. assert!( (painted_width - measured_w).abs() < measured_w * 0.5 + 20.0, "measured width {measured_w} should roughly match the painted ink width \ @@ -1281,8 +945,6 @@ mod glyph_fallback_tests { } } -// ─── Tests: glyph metrics (issue #328) ────────────────────────────────────── - #[cfg(test)] mod glyph_metrics_tests { use super::super::typeface_with_fallback; @@ -1340,12 +1002,6 @@ mod glyph_metrics_tests { #[test] fn last_glyph_is_the_detachable_question_mark() { - // The motivating scenario (issue #328): the author writes the - // sentence *without* its trailing `?` and places a separate node at - // `x = node("sentence", "glyph_x:") + node("sentence", - // "glyph_x:").width` — proven here at the primitive level: the - // last glyph of "Sentence" really is the `e`, immediately before - // where a detached `?` would sit. let font = test_font(32.0); let metrics = compute_glyph_metrics("Sentence", &font, &None, 0.0); let e_glyph = *metrics.last().unwrap(); @@ -1365,10 +1021,6 @@ mod glyph_metrics_tests { #[test] fn single_font_path_matches_make_text_blob_with_spacing_positions() { - // `compute_glyph_metrics`'s fast path and `make_text_blob_with_spacing` - // must compute the exact same per-glyph x positions — they exist to - // describe the same drawn glyphs, just one keeps the positions and - // the other consumes them into a blob. let font = test_font(40.0); let text = "Hello"; let spacing = 2.0; diff --git a/crates/rustmotion-core/src/engine/renderer/yuv.rs b/crates/rustmotion-core/src/engine/renderer/yuv.rs index 9aabbb44..defae597 100644 --- a/crates/rustmotion-core/src/engine/renderer/yuv.rs +++ b/crates/rustmotion-core/src/engine/renderer/yuv.rs @@ -1,5 +1,3 @@ -/// Convert RGBA pixels to YUV420 (I420) for H.264 encoding. -/// Uses integer BT.601 arithmetic and rayon parallelization for performance. pub fn rgba_to_yuv420(rgba: &[u8], width: u32, height: u32) -> Vec { use rayon::prelude::*; @@ -14,7 +12,6 @@ pub fn rgba_to_yuv420(rgba: &[u8], width: u32, height: u32) -> Vec { let (y_plane, uv_planes) = yuv.split_at_mut(y_size); let (u_plane, v_plane) = uv_planes.split_at_mut(uv_size); - // Compute Y plane in parallel (one row per task) y_plane .par_chunks_mut(w) .enumerate() @@ -29,7 +26,6 @@ pub fn rgba_to_yuv420(rgba: &[u8], width: u32, height: u32) -> Vec { } }); - // Compute U and V planes in parallel (one row-pair per task) let uv_combined: Vec<(u8, u8)> = (0..uv_h) .into_par_iter() .flat_map(|uv_row| { diff --git a/crates/rustmotion-core/src/engine/shake.rs b/crates/rustmotion-core/src/engine/shake.rs index a17da33e..93d7b764 100644 --- a/crates/rustmotion-core/src/engine/shake.rs +++ b/crates/rustmotion-core/src/engine/shake.rs @@ -1,22 +1,6 @@ -//! Evaluates [`crate::schema::shake::SceneShake`] at a given scene-local -//! time (issue #330). See that type's own doc for the damped-oscillation -//! formula this module implements verbatim; nothing here should ever -//! diverge from it — if the two disagree, the schema doc is the spec and -//! this file has a bug. -//! -//! [`shake_offset`] is a pure function: it knows nothing about -//! [`crate::schema::scenario::Camera`] or how a resolved camera transform -//! gets built. The offset it returns is meant to be added to whatever the -//! camera already resolved to independently — see [`SceneShake`]'s own doc -//! for why "additive" rather than "instead of". - use crate::schema::shake::SceneShake; use crate::schema::time::{TimeCtx, TimeError}; -/// The camera's shake contribution at a scene-local `time`, in the same -/// units [`crate::schema::scenario::Camera`]'s own `x`/`y` (pixels) and -/// `rotation` (degrees) use. Additive: a caller adds this to the camera's -/// independently-resolved values rather than replacing them. #[derive(Debug, Clone, Copy, PartialEq, Default)] pub struct ShakeOffset { pub x: f64, @@ -34,19 +18,6 @@ impl ShakeOffset { } } -/// Evaluates every impact in `shake.impacts` at `time` (scene-local -/// seconds — the same clock `PaintCtx::time` uses, not -/// `PaintCtx::scenario_time`) and sums their contributions. Each impact's -/// own `at` is a [`crate::schema::time::TimePoint`], resolved against `ctx` -/// via [`crate::schema::time::TimePoint::resolve_relative`] — the same -/// "relative to the scene's own start unless `@`-prefixed" rule every -/// other in-scene time follows. -/// -/// See [`SceneShake`]'s doc for the formula. Returns [`TimeError`] only -/// when an impact's `at` fails to resolve (e.g. a `b`-unit term with no -/// `bpm` declared) — a grammatically malformed `at` is caught earlier, at -/// scenario deserialization, by the same mechanism every other -/// [`crate::schema::time::TimePoint`] uses. pub fn shake_offset( shake: &SceneShake, ctx: &TimeCtx, @@ -124,7 +95,7 @@ mod tests { #[test] fn the_envelope_decays_to_roughly_one_over_e_after_one_over_decay_seconds() { let decay = 10.0; - let shake = single_impact(0.0, 20.0, decay, 0.0, 0.0); // frequency=0 -> x is the raw envelope + let shake = single_impact(0.0, 20.0, decay, 0.0, 0.0); let offset = shake_offset(&shake, &ctx(), 1.0 / decay).unwrap(); let expected = 20.0 / std::f64::consts::E; assert!( @@ -170,7 +141,6 @@ mod tests { #[test] fn beat_grid_impacts_resolve_through_bpm_and_beat_offset() { - // bpm=120 -> 0.5s/beat. Impact at beat 2 lands at t=1.0s. let shake = SceneShake { impacts: vec![ShakeImpact { at: TimePoint::Spec("2b".to_string()), @@ -205,18 +175,6 @@ mod tests { assert!(shake_offset(&shake, &no_bpm_ctx, 1.0).is_err()); } - /// Six declarative impacts (as the acceptance criterion describes) - /// reproduce the same damped-sine shape the old workflow got from - /// sampling this exact formula 155 times into a hand-pasted camera - /// keyframe track. Since the original reel's Python loop isn't - /// available to diff against, this test is the strongest available - /// proof: it independently re-derives the formula from `SceneShake`'s - /// own doc comment (not by calling into this module's - /// implementation) and checks `shake_offset` agrees at 155 sampled - /// points across the shake's span — i.e. that a hand-rolled, - /// per-frame keyframe track built the way the old workflow built one - /// would be indistinguishable from what six `{at, amplitude}` pairs - /// now produce directly. #[test] fn six_impacts_reproduce_a_hand_sampled_155_keyframe_track() { let decay = 12.0; @@ -243,8 +201,6 @@ mod tests { rotation, }; - // Independent re-derivation of the doc's formula — a hand-rolled - // sampler, exactly as a Python loop pasting keyframes would build. let hand_sampled = |t: f64| -> (f64, f64, f64) { let mut x = 0.0; let mut y = 0.0; @@ -261,7 +217,7 @@ mod tests { (x, y, rotation * x) }; - let span_end = 3.0; // last impact at 2.5s, well decayed by 3.0s. + let span_end = 3.0; let samples = 155; for i in 0..samples { let t = span_end * i as f64 / (samples - 1) as f64; diff --git a/crates/rustmotion-core/src/engine/text/cosmic.rs b/crates/rustmotion-core/src/engine/text/cosmic.rs index 5af06565..e947dbac 100644 --- a/crates/rustmotion-core/src/engine/text/cosmic.rs +++ b/crates/rustmotion-core/src/engine/text/cosmic.rs @@ -1,32 +1,3 @@ -//! cosmic-text → Skia bridge. -//! -//! Provides: -//! - a global [`FontSystem`] (lazy, system + bundled fonts) -//! - `measure_text(...)` for taffy's `measure_fn` -//! - `paint_text(...)` to draw a laid-out buffer onto a Skia canvas via -//! per-glyph rasterization through `SwashCache` -//! -//! Shaping & line-breaking are delegated to cosmic-text. Paint is done by -//! rasterizing each glyph to an alpha mask, tinting it with the requested -//! color, and blitting it as a small `Image` into Skia. -//! -//! **Not currently wired into the real render path (audit #10).** Every -//! component's actual measure/paint goes through `skia_safe::Font:: -//! measure_str` / `TextBlob::new` in `engine::renderer::text` + -//! `rustmotion-components::intrinsic::TextIntrinsic`, not through this -//! module — `measure_text`/`paint_text` below have no callers outside their -//! own tests (`grep -rn "engine::text\|text::cosmic" crates/` confirms -//! this). If you're chasing a text overflow/measure-vs-paint bug, look in -//! `engine::renderer::text.rs` and `rustmotion-components::intrinsic` -//! instead — the shaping/bidi/glyph-fallback behaviour cosmic-text would -//! provide here is not what actually renders today. Kept building (and the -//! `cosmic-text` dependency kept) as a candidate landing spot for a future -//! real shaping engine; not deleted unilaterally by this fix since that -//! call — wire it in for real vs. remove the module and its dependency — -//! is bigger than any single finding in this pass. See -//! `rustmotion-components::intrinsic` module doc for the other side of this -//! (it also used to claim a cosmic-text backing it doesn't have). - use std::sync::{Mutex, OnceLock}; use cosmic_text::{ @@ -38,24 +9,20 @@ use skia_safe::{images, Canvas, Color, ColorType, Data, ImageInfo, Paint, Point} static FONT_SYSTEM: OnceLock> = OnceLock::new(); static SWASH_CACHE: OnceLock> = OnceLock::new(); -/// Borrow the global FontSystem. Created on first use with system fonts. pub fn font_system() -> &'static Mutex { FONT_SYSTEM.get_or_init(|| Mutex::new(FontSystem::new())) } -/// Borrow the global SwashCache (for glyph rasterization). pub fn swash_cache() -> &'static Mutex { SWASH_CACHE.get_or_init(|| Mutex::new(SwashCache::new())) } -/// Result of measuring a text run. #[derive(Debug, Clone, Copy, Default)] pub struct TextMetrics { pub width: f32, pub height: f32, } -/// Configuration for laying out a single text run. #[derive(Debug, Clone)] pub struct TextStyle<'a> { pub font_family: Option<&'a str>, @@ -73,7 +40,7 @@ impl<'a> Default for TextStyle<'a> { Self { font_family: None, font_size: 16.0, - line_height: 0.0, // 0 → derived from font_size * 1.2 + line_height: 0.0, weight: 400, italic: false, max_width: None, @@ -105,7 +72,6 @@ fn attrs_for<'a>(style: &'a TextStyle) -> Attrs<'a> { a } -/// Measure a text string given font + line constraints. pub fn measure_text(text: &str, style: &TextStyle) -> TextMetrics { if text.is_empty() { return TextMetrics { @@ -113,10 +79,6 @@ pub fn measure_text(text: &str, style: &TextStyle) -> TextMetrics { height: metrics_for(style).line_height, }; } - // Poison-tolerant: a panic on another render thread (e.g. a Skia panic - // caught by a preview worker's panic fence) must not poison text shaping - // for every subsequent frame — the FontSystem stays usable, each shape - // call builds its own Buffer. let mut fs = font_system().lock().unwrap_or_else(|e| e.into_inner()); let metrics = metrics_for(style); let mut buf = Buffer::new(&mut fs, metrics); @@ -138,7 +100,6 @@ pub fn measure_text(text: &str, style: &TextStyle) -> TextMetrics { } } -/// Paint a text string onto a Skia canvas at `origin` (top-left). pub fn paint_text( canvas: &Canvas, text: &str, @@ -149,7 +110,6 @@ pub fn paint_text( if text.is_empty() { return; } - // Poison-tolerant for the same reason as in `measure_text`. let mut fs = font_system().lock().unwrap_or_else(|e| e.into_inner()); let metrics = metrics_for(style); let mut buf = Buffer::new(&mut fs, metrics); @@ -207,11 +167,8 @@ fn blit_glyph( } let mut rgba: Vec = Vec::with_capacity((w * h * 4) as usize); if is_color { - // SwashContent::Color : data is already RGBA premultiplied per glyph. - // Layout matches what Skia expects with ColorType::RGBA8888 & Premul. rgba.extend_from_slice(data); } else { - // Mask: alpha-only. Tint with `tint` and premultiply. let tr = tint.r() as u32; let tg = tint.g() as u32; let tb = tint.b() as u32; @@ -247,7 +204,7 @@ mod tests { fn measure_empty_returns_zero_width() { let m = measure_text("", &TextStyle::default()); assert_eq!(m.width, 0.0); - assert!(m.height > 0.0); // one empty line of line_height + assert!(m.height > 0.0); } #[test] @@ -287,10 +244,6 @@ mod tests { assert!(m.height < 16.0 * 1.2 * 2.0, "should be one line"); } - /// A panic on another thread while it holds the font mutex (e.g. a Skia - /// panic caught by a preview worker's panic fence) poisons the lock. - /// Shaping must survive that — otherwise one panic turns every subsequent - /// text render on every thread into a panic cascade. #[test] fn measure_survives_poisoned_font_mutex() { let _ = std::thread::spawn(|| { diff --git a/crates/rustmotion-core/src/engine/text/mod.rs b/crates/rustmotion-core/src/engine/text/mod.rs index c443625c..b4f759f9 100644 --- a/crates/rustmotion-core/src/engine/text/mod.rs +++ b/crates/rustmotion-core/src/engine/text/mod.rs @@ -1,3 +1 @@ -//! Text shaping & rendering bridge between cosmic-text and Skia. - pub mod cosmic; diff --git a/crates/rustmotion-core/src/engine/transition.rs b/crates/rustmotion-core/src/engine/transition.rs index 22e5923f..587be4d8 100644 --- a/crates/rustmotion-core/src/engine/transition.rs +++ b/crates/rustmotion-core/src/engine/transition.rs @@ -5,25 +5,13 @@ use crate::schema::{ }; use skia_safe::{surfaces, Color4f, ColorType, ImageInfo, Paint, PathBuilder, Rect}; -/// The per-type knobs a transition may read, bundled. -/// -/// Each of these is inert for every transition but the one or two that read -/// it, so they travel together rather than as a growing tail of positional -/// arguments threaded through the render task queue — a shape that made -/// adding a transition a five-file edit. #[derive(Debug, Clone, Copy, PartialEq)] pub struct TransitionOptions { - /// `corner_reveal`: which corner the reveal grows from. pub corner: TransitionCorner, - /// `pixel_dissolve`: cell edge in px. pub cell: f32, - /// `pixel_dissolve`: scatter seed. pub seed: u32, - /// `pixel_dissolve`: which cells turn first. pub order: PixelDissolveOrder, - /// `chromatic_wipe`: which way it travels. pub direction: TransitionDirection, - /// `chromatic_wipe`: channel-split multiplier. pub aberration: f32, } @@ -53,8 +41,6 @@ impl From<&Transition> for TransitionOptions { } } -/// Composite two RGBA frames during a transition. -/// `progress` goes from 0.0 (fully frame_a) to 1.0 (fully frame_b). pub fn apply_transition( frame_a: &[u8], frame_b: &[u8], @@ -115,14 +101,6 @@ pub fn apply_transition( } } -/// Reveal the incoming frame through a rectangle anchored at one corner. -/// -/// Measured on a reference piece, over 15 frames (0.5 s): the right and top -/// edges stay pinned to the frame while the left edge travels 2160 -> 0 and the -/// bottom edge 1480 -> 2152. So it is not a wipe — `wipe_*` moves one -/// full-width band — and not an `iris`, which is a circle. Both edges move at -/// once, and the incoming scene sits still behind the growing window rather -/// than sliding in: what arrives is *uncovered*, not pushed. fn corner_reveal( frame_a: &[u8], frame_b: &[u8], @@ -157,8 +135,6 @@ fn corner_reveal( surface_to_pixels(surface, width, height) } -/// The revealed rectangle at `progress`, anchored so that two edges stay on the -/// frame and two travel. fn corner_rect(corner: TransitionCorner, w: f32, h: f32, progress: f32) -> skia_safe::Rect { let p = progress.clamp(0.0, 1.0); let (rw, rh) = (w * p, h * p); @@ -170,16 +146,8 @@ fn corner_rect(corner: TransitionCorner, w: f32, h: f32, progress: f32) -> skia_ } } -/// How much a cell's own position pulls its threshold, against the hash. Enough -/// to read as a front travelling inward, little enough that the front stays -/// ragged instead of collapsing to a clean rectangle closing in. const SPATIAL_WEIGHT: f32 = 0.72; -/// Deterministic 0..1 threshold for a cell — the moment it starts to turn. -/// -/// A hash of the cell's coordinates, not a random draw: the transition must -/// dissolve the same way on every render, and re-rolling per frame would make -/// the mosaic boil instead of resolve. fn cell_hash01(col: i32, row: i32, seed: u32) -> f32 { let mut h = seed .wrapping_mul(0x9E37_79B9) @@ -191,12 +159,6 @@ fn cell_hash01(col: i32, row: i32, seed: u32) -> f32 { (h & 0x00FF_FFFF) as f32 / 0x0100_0000 as f32 } -/// The threshold once the spatial order is folded in. -/// -/// `EdgesIn` gives border cells an early threshold and the centre a late one, -/// so the subject in the middle is the last thing to go. The hash still -/// contributes: without it the front is a rectangle closing in, which reads as -/// a wipe rather than a dissolve. fn cell_threshold( col: i32, row: i32, @@ -209,9 +171,6 @@ fn cell_threshold( if order == PixelDissolveOrder::Random { return noise; } - // Chebyshev distance from the centre, 0 at the middle and 1 at the border: - // it follows the frame's own rectangle, where a Euclidean radius would - // leave the corners lagging behind the edges. let (cx, cy) = ((cols - 1) as f32 / 2.0, (rows - 1) as f32 / 2.0); let dx = if cx > 0.0 { (col as f32 - cx).abs() / cx @@ -232,13 +191,6 @@ fn cell_threshold( (spatial * SPATIAL_WEIGHT + noise * (1.0 - SPATIAL_WEIGHT)).clamp(0.0, 1.0) } -/// Cross-fade the two frames cell by cell on a square lattice. -/// -/// Each cell has its own start time, so at any instant the frame is a mosaic of -/// both scenes with a band of half-faded cells between them — which is what -/// separates this from `dissolve` (one global opacity, no structure) and from -/// the wipes (a single hard boundary). `feather` is what makes a cell *fade* -/// rather than flip: with it at 0 the effect degrades to a hard checkerboard. fn pixel_dissolve( frame_a: &[u8], frame_b: &[u8], @@ -269,8 +221,6 @@ fn pixel_dissolve( let canvas = surface.canvas(); canvas.draw_image(&img_a, (0.0, 0.0), None); - // The whole run has to finish by progress 1, so the schedule is compressed - // to leave room for the last cell's own fade. const FEATHER: f32 = 0.35; let p = progress.clamp(0.0, 1.0) * (1.0 + FEATHER); @@ -339,10 +289,8 @@ fn wipe( let w = width as f32; let h = height as f32; - // Draw frame A as background canvas.draw_image(&img_a, (0.0, 0.0), None); - // Clip frame B to the wipe region let clip_rect = match direction { Direction::Left => Rect::from_xywh(0.0, 0.0, w * progress, h), Direction::Right => Rect::from_xywh(w * (1.0 - progress), 0.0, w * progress, h), @@ -418,7 +366,6 @@ fn zoom_transition( let h = height as f32; if zoom_in { - // Frame A zooms in and fades out, revealing frame B let scale = 1.0 + progress * 0.3; canvas.draw_image(&img_b, (0.0, 0.0), None); canvas.save(); @@ -430,7 +377,6 @@ fn zoom_transition( canvas.draw_image(&img_a, (0.0, 0.0), Some(&paint)); canvas.restore(); } else { - // Frame B zooms out from larger to normal canvas.draw_image(&img_a, (0.0, 0.0), None); let scale = 1.3 - progress * 0.3; canvas.save(); @@ -469,10 +415,8 @@ fn flip_transition( let canvas = surface.canvas(); let w = width as f32; - // Simulate 3D flip by scaling X axis - // First half: frame_a shrinks on X. Second half: frame_b grows on X. if progress < 0.5 { - let scale_x = 1.0 - progress * 2.0; // 1.0 -> 0.0 + let scale_x = 1.0 - progress * 2.0; canvas.clear(Color4f::new(0.0, 0.0, 0.0, 1.0)); canvas.save(); canvas.translate((w / 2.0, 0.0)); @@ -481,7 +425,7 @@ fn flip_transition( canvas.draw_image(&img_a, (0.0, 0.0), None); canvas.restore(); } else { - let scale_x = (progress - 0.5) * 2.0; // 0.0 -> 1.0 + let scale_x = (progress - 0.5) * 2.0; canvas.clear(Color4f::new(0.0, 0.0, 0.0, 1.0)); canvas.save(); canvas.translate((w / 2.0, 0.0)); @@ -515,12 +459,10 @@ fn clock_wipe(frame_a: &[u8], frame_b: &[u8], width: u32, height: u32, progress: let cy = h / 2.0; let radius = (w * w + h * h).sqrt(); - // Draw frame A as background canvas.draw_image(&img_a, (0.0, 0.0), None); - // Draw frame B clipped to a clock-wipe arc let sweep_angle = progress * 360.0; - let start_angle = -90.0; // Start from top + let start_angle = -90.0; let mut path = PathBuilder::new(); path.move_to((cx, cy)); @@ -568,10 +510,8 @@ fn iris_transition( let max_radius = (w * w + h * h).sqrt() / 2.0; let radius = max_radius * progress; - // Draw frame A as background canvas.draw_image(&img_a, (0.0, 0.0), None); - // Clip frame B to an expanding circle let mut path = PathBuilder::new(); path.add_circle((cx, cy), radius, None); @@ -606,7 +546,6 @@ fn slide_transition( let canvas = surface.canvas(); let w = width as f32; - // Frame A slides left, frame B slides in from right let offset = -progress * w; canvas.draw_image(&img_a, (offset, 0.0), None); canvas.draw_image(&img_b, (offset + w, 0.0), None); @@ -614,13 +553,6 @@ fn slide_transition( surface_to_pixels(surface, width, height) } -/// A fast slide whose reveal edge splits into red and cyan at the peak. -/// -/// Both frames travel the same way — the incoming one is simply one screen -/// behind — so the edge between them is a hard seam rather than a dissolve. -/// The channel split is applied to that composite, peaking mid-transition and -/// gone by the time it lands, so the flash reads as an artefact of the *speed* -/// of the cut rather than as a colour treatment on either scene. fn chromatic_wipe( frame_a: &[u8], frame_b: &[u8], @@ -631,8 +563,6 @@ fn chromatic_wipe( aberration: f32, ) -> Vec { let (w, h) = (width as f32, height as f32); - // Slide axis, as a unit vector. Both frames move along it; B starts one - // full screen back. let (ux, uy) = match direction { TransitionDirection::Left => (-1.0, 0.0), TransitionDirection::Right => (1.0, 0.0), @@ -658,16 +588,12 @@ fn chromatic_wipe( surface_to_pixels(surface, width, height) }; - // Peak at the midpoint, nothing at either end: a split still present on - // the last frame would bleed into the scene that follows. let peak = 1.0 - (progress * 2.0 - 1.0).abs(); let shift = (aberration.max(0.0) * peak * w * 0.012).round() as i32; if shift == 0 { return slid; } - // Red leads the travel, blue trails it — the two channels sampled from - // either side of where green is, which is what a lens does under speed. let mut out = slid.clone(); let (sx, sy) = ( (ux * shift as f32).round() as i32, @@ -695,32 +621,9 @@ fn dissolve_transition( _height: u32, progress: f32, ) -> Vec { - // Dissolve is a smooth cross-dissolve (same as fade in standard video editing) blend_fade(frame_a, frame_b, progress) } -/// Camera pan transition: composited background + sliding foreground children. -/// `bg_a`/`bg_b` are the outgoing/incoming backgrounds, `fg_a`/`fg_b` are -/// children-only (transparent). fg_a slides out by (-dx*t, -dy*t), fg_b -/// slides in from (dx*(1-t), dy*(1-t)). -/// -/// `pan_background` controls how the two backgrounds combine: -/// - `Static`: neither travels nor scales — the backdrop holds its position, -/// which is what keeps a shared ambience continuous across a beat when both -/// scenes actually share the same background (the crossfade below is then -/// a no-op, since blending a frame with itself returns that frame). -/// - `Travel`: each background moves locked to its own foreground, so the -/// two beats read as different places rather than one space. -/// -/// Both modes crossfade the two background layers in f32 rather than through -/// Skia's `Paint` alpha, which quantizes to an 8-bit byte: while the byte -/// climbs, the premultiplied blend truncates ~1 LSB per channel across the -/// whole frame, and the instant it reaches 255 Skia takes the opaque fast -/// path and every pixel regains that level in a single frame — a visible -/// step at 40-80x the local per-frame rate. `blend_fade` already does this -/// crossfade correctly (see its doc); we render each background into its own -/// full-frame layer first (needed for `Travel`'s scale + translate), then -/// hand both raw buffers to it. #[allow(clippy::too_many_arguments)] pub fn camera_pan_transition( bg_a: &[u8], @@ -750,20 +653,10 @@ pub fn camera_pan_transition( None => return bg_a.to_vec(), }; - // Offsets: the outgoing plane exits, the incoming one arrives. They tile - // exactly, so together they always cover the frame. let (out_x, out_y) = (-dx * t, -dy * t); let (in_x, in_y) = (dx * (1.0 - t), dy * (1.0 - t)); let blended_bg = match pan_background { - // Travelling: each background moves with its own scene, but at a - // fraction of the foreground's distance and fading across the pan. - // - // Two reasons for the fraction. It is how parallax actually works — - // what is far away moves less — and it makes the two backgrounds - // overlap across most of the frame instead of meeting edge to edge. - // Opaque images laid side by side join on a hard line no crossfade can - // hide; overlapping ones dissolve into each other. PanBackground::Travel => { let img_bg_a = match frame_to_image(bg_a, width, height) { Some(i) => i, @@ -774,27 +667,10 @@ pub fn camera_pan_transition( None => return bg_a.to_vec(), }; - // Backgrounds drift at a fraction of the foreground's distance — - // that is how parallax works, and it keeps them overlapping - // instead of meeting edge to edge, where two opaque images join on - // a line no fade can hide. const BG_PARALLAX: f32 = 0.12; let (bax, bay) = (out_x * BG_PARALLAX, out_y * BG_PARALLAX); let (bbx, bby) = (in_x * BG_PARALLAX, in_y * BG_PARALLAX); - // Translating an opaque image uncovers a strip on the opposite - // side, and that strip reads as a hard edge just as much as a - // join would. Each layer is therefore overscaled by exactly its - // own current displacement — just enough to cover, never more. - // - // Sizing it on the *maximum* drift instead makes the margin - // constant across the transition, including at both ends where the - // displacement is zero. The background then jumps between a normal - // frame and an enlarged one at every junction — measured at up to - // 128px of halo movement in a single frame, an order of magnitude - // beyond the drift itself. Tying the margin to the current offset - // makes it vanish exactly where a transition meets a normal frame, - // so the two are continuous. let w = width as f32; let h = height as f32; let spread = |ox: f32, oy: f32| { @@ -812,14 +688,6 @@ pub fn camera_pan_transition( }; blend_fade(&layer_a, &layer_b, t) } - // Static: no spatial movement, but still crossfaded in place. When - // both scenes share the same background this is a no-op — blending a - // frame with itself is that frame, so it stays visually frozen, which - // is what makes the junction invisible. When they don't, holding A - // for the whole pan and jump-cutting to B on the first normal frame - // afterward measured +8.25 mean luminance in a single frame (385x the - // local rate) — a hard cut. Crossfading spreads that change across - // the whole pan instead of concentrating it at the boundary. PanBackground::Static => blend_fade(bg_a, bg_b, t), }; let img_bg = match frame_to_image(&blended_bg, width, height) { @@ -830,17 +698,6 @@ pub fn camera_pan_transition( let canvas = surface.canvas(); canvas.draw_image(&img_bg, (0.0, 0.0), None); - // The scene being left behind dissolves rather than sliding off as a solid - // slab, and the arriving one materialises. Drift alone gives the two planes - // different speeds; letting them also come and go is what reads as depth - // instead of a sheet of paper being pulled sideways. - // - // Both curves are pinned at their own end — `fg_a` is fully opaque at t=0, - // `fg_b` fully opaque at t=1 — because a transition frame sits directly - // against a normal frame at each junction and any alpha short of 1 there is - // a visible step. Mirrored exponents (rather than a plain crossfade) keep - // both planes at 67% through the middle instead of 50%, so the frame never - // washes out to near-empty half way through. const FG_DISSOLVE: f32 = 1.6; let mut fg_paint = Paint::default(); @@ -853,9 +710,6 @@ pub fn camera_pan_transition( surface_to_pixels(surface, width, height) } -/// Draw `img` into `dest` on a fresh full-frame surface and read back the raw -/// pixels. Used to pre-render a background plane (with its `Travel` scale + -/// translate applied) before crossfading it against its counterpart in f32. fn render_layer(img: &skia_safe::Image, dest: Rect, width: u32, height: u32) -> Option> { let mut surface = create_skia_surface(width, height)?; surface @@ -868,11 +722,6 @@ fn render_layer(img: &skia_safe::Image, dest: Rect, width: u32, height: u32) -> mod camera_pan_tests { use super::*; - // The junction invariant. A transition frame sits directly against a - // normal frame at each end, so the dissolve must be a no-op exactly there: - // at progress 0 the outgoing scene is untouched, at progress 1 the - // incoming one is. Any alpha short of 1 at an endpoint is a visible step, - // which is the class of bug that produced the halo jumps. #[test] fn the_foreground_dissolve_is_a_noop_at_both_junctions() { let (w, h) = (8u32, 4u32); @@ -902,8 +751,6 @@ mod camera_pan_tests { } } - // Mid-pan both planes are partly transparent — that is the effect — but - // neither may collapse to near-nothing or the frame reads as empty. #[test] fn mid_pan_both_planes_stay_substantially_visible() { let (w, h) = (8u32, 4u32); @@ -924,7 +771,6 @@ mod camera_pan_tests { &EasingType::Linear, PanBackground::Static, ); - // Left half carries the outgoing plane, right half the incoming one. let left_red = out[0]; let right_blue = out[((w - 1) * 4 + 2) as usize]; assert!(left_red > 128, "outgoing plane faded too far: {left_red}"); @@ -938,16 +784,10 @@ mod camera_pan_tests { (0..width * height).flat_map(|_| [r, g, b, a]).collect() } - // Fully transparent so the foreground planes never contribute — isolates - // the background compositing under test. fn transparent(width: u32, height: u32) -> Vec { solid(width, height, 0, 0, 0, 0) } - // Issue #124 item 2: `Static` used to hold bg_a for the entire pan and - // hard-cut to bg_b afterward. It must now crossfade in place instead — - // a mid-pan frame should show a genuine blend of both, not either one - // alone. #[test] fn static_background_crossfades_instead_of_freezing() { let (w, h) = (4, 4); @@ -969,7 +809,6 @@ mod camera_pan_tests { PanBackground::Static, ); - // blend_fade(10, 200, 0.5) = (10*0.5 + 200*0.5 + 0.5) as u8 = 105. for px in out.as_chunks::<4>().0.iter() { assert_eq!( *px, @@ -984,9 +823,6 @@ mod camera_pan_tests { ); } - // Issue #124 item 1 + 3: the crossfade must be exact float math with no - // residual once progress reaches 1.0 — no Skia alpha-byte quantization - // left over from an `Option`-based alpha blend. #[test] fn static_background_reaches_bg_b_exactly_at_full_progress() { let (w, h) = (4, 4); @@ -1010,9 +846,6 @@ mod camera_pan_tests { assert_eq!(out, bg_b, "progress=1.0 must land exactly on bg_b"); } - // Travel mode's incoming layer has zero offset at t=1 (in_x = dx*(1-t) = - // 0), so it is drawn 1:1 with no resampling — the crossfade should still - // land exactly on bg_b there too. #[test] fn travel_background_reaches_bg_b_exactly_at_full_progress() { let (w, h) = (4, 4); @@ -1044,9 +877,6 @@ mod camera_pan_tests { mod corner_reveal_tests { use super::*; - /// Two edges stay on the frame, two travel. Measured on the reference - /// piece: the right and top edges never move while the left runs - /// 2160 -> 0 and the bottom 1480 -> 2152, over 15 frames. #[test] fn the_anchored_edges_never_move() { for p in [0.05, 0.3, 0.5, 0.8, 1.0] { @@ -1056,8 +886,6 @@ mod corner_reveal_tests { } } - /// …and the travelling edges do move, monotonically, in the direction the - /// corner names. #[test] fn the_travelling_edges_open_from_the_corner() { let at = |p| corner_rect(TransitionCorner::TopRight, 1920.0, 1080.0, p); @@ -1072,7 +900,6 @@ mod corner_reveal_tests { ); } - /// The ends are the whole point: nothing revealed at 0, everything at 1. #[test] fn it_starts_empty_and_ends_full() { let empty = corner_rect(TransitionCorner::TopRight, 1920.0, 1080.0, 0.0); @@ -1084,7 +911,6 @@ mod corner_reveal_tests { ); } - /// Each corner anchors its own two edges — otherwise `corner` is decoration. #[test] fn every_corner_anchors_its_own_edges() { let (w, h, p) = (1920.0f32, 1080.0f32, 0.4); @@ -1096,8 +922,6 @@ mod corner_reveal_tests { assert!(bl.left.abs() < 1e-3 && (bl.bottom - h).abs() < 1e-3); } - /// Progress outside 0..1 must clamp, not invert the rectangle: a negative - /// width would make the clip empty and the transition would look like a cut. #[test] fn out_of_range_progress_clamps() { for p in [-0.5, 1.5] { @@ -1114,8 +938,6 @@ mod corner_reveal_tests { mod pixel_dissolve_tests { use super::*; - /// `edges_in` must turn the border before the middle — that is the whole - /// point: whatever sits in the centre is the last thing to go. #[test] fn edges_in_turns_the_border_first() { let (cols, rows) = (40, 24); @@ -1132,7 +954,6 @@ mod pixel_dissolve_tests { avg(&border), avg(&middle) ); - // The very centre goes last. let centre = cell_threshold( cols / 2, rows / 2, @@ -1144,14 +965,12 @@ mod pixel_dissolve_tests { assert!(centre > 0.6, "the centre cell must be late, got {centre}"); } - /// …and `center_out` is its mirror, or the option is decoration. #[test] fn center_out_is_the_mirror_of_edges_in() { let (cols, rows) = (40, 24); for (c, r) in [(0, 0), (20, 12), (39, 5)] { let a = cell_threshold(c, r, cols, rows, 11, PixelDissolveOrder::EdgesIn); let b = cell_threshold(c, r, cols, rows, 11, PixelDissolveOrder::CenterOut); - // Same hash contribution, opposite spatial term. assert!( (a + b - (SPATIAL_WEIGHT + 2.0 * (1.0 - SPATIAL_WEIGHT) * cell_hash01(c, r, 11))) .abs() @@ -1160,9 +979,6 @@ mod pixel_dissolve_tests { } } - /// The front has to stay ragged. A purely spatial threshold would close a - /// clean rectangle inward, which reads as a wipe, not a dissolve — so - /// neighbours at the same distance from the centre must still differ. #[test] fn the_front_is_ragged_not_a_closing_rectangle() { let (cols, rows) = (40, 24); @@ -1177,15 +993,12 @@ mod pixel_dissolve_tests { ); } - /// `random` keeps its old behaviour — the spatial term must not leak in. #[test] fn random_ignores_position() { let t = cell_threshold(7, 3, 40, 24, 11, PixelDissolveOrder::Random); assert_eq!(t, cell_hash01(7, 3, 11)); } - /// The same cell must turn at the same moment on every render: a per-frame - /// draw would make the mosaic boil instead of resolve. #[test] fn a_cell_keeps_its_threshold() { assert_eq!(cell_hash01(4, 9, 11), cell_hash01(4, 9, 11)); @@ -1196,7 +1009,6 @@ mod pixel_dissolve_tests { ); } - /// …and two seeds must dissolve in a different order, or `seed` is a lie. #[test] fn the_seed_changes_the_order() { let a: Vec = (0..40).map(|i| cell_hash01(i, 0, 11)).collect(); @@ -1204,23 +1016,18 @@ mod pixel_dissolve_tests { assert_ne!(a, b); } - /// Neighbours must not turn in step — a threshold that tracks the - /// coordinate sweeps a diagonal line, which is a wipe, not a dissolve. #[test] fn neighbouring_cells_turn_at_unrelated_times() { let close = (0..30) .flat_map(|c| (0..30).map(move |r| (c, r))) .filter(|&(c, r)| (cell_hash01(c, r, 11) - cell_hash01(c + 1, r, 11)).abs() < 0.05) .count(); - // 900 pairs; a swept threshold would put nearly all of them under 0.05. assert!( close < 200, "{close}/900 neighbours turn together — that is a wipe" ); } - /// The spread is the whole point: at half-way the frame must hold cells in - /// *both* states plus some mid-fade, not one global opacity. #[test] fn midway_the_frame_holds_both_scenes_and_a_fading_band() { const FEATHER: f32 = 0.35; @@ -1239,8 +1046,6 @@ mod pixel_dissolve_tests { ); } - /// Every cell must be settled by the end, or the last of the outgoing scene - /// survives into the next one. #[test] fn every_cell_completes_by_the_end() { const FEATHER: f32 = 0.35; diff --git a/crates/rustmotion-core/src/error.rs b/crates/rustmotion-core/src/error.rs index 79d62f8d..abcb7402 100644 --- a/crates/rustmotion-core/src/error.rs +++ b/crates/rustmotion-core/src/error.rs @@ -1,6 +1,5 @@ use thiserror::Error; -/// Crate-wide result type using `RustmotionError`. pub type Result = std::result::Result; #[derive(Debug, Error)] @@ -8,25 +7,21 @@ pub enum RustmotionError { #[error("{0}")] Generic(String), - // --- IO / File errors --- #[error("Failed to read '{path}': {source}")] FileRead { path: String, source: std::io::Error, }, - // --- JSON parsing --- #[error("Failed to parse JSON: {source}")] JsonParse { #[from] source: serde_json::Error, }, - // --- HTML transpile --- #[error("HTML transpile error: {0}")] HtmlParse(String), - // --- Asset loading --- #[error("Failed to load image '{path}': {reason}")] ImageLoad { path: String, reason: String }, @@ -69,7 +64,6 @@ pub enum RustmotionError { #[error("No fonts available on this system")] FontNotFound, - // --- Google Fonts --- #[error( "FontEntry for '{family}' has source=\"google\" but also sets 'path' — use one or the other" )] @@ -104,7 +98,6 @@ pub enum RustmotionError { reason: String, }, - // --- Include system --- #[error("Include depth limit ({limit}) exceeded while resolving '{path}'")] IncludeDepthExceeded { limit: u8, path: String }, @@ -135,7 +128,6 @@ pub enum RustmotionError { #[error("Unknown heropattern '{name}' — see heropatterns.com for available patterns")] UnknownHeropattern { name: String }, - // --- Variables --- #[error("Variable '${name}' is not defined in '{path}'")] UndefinedVariable { name: String, path: String }, @@ -148,7 +140,6 @@ pub enum RustmotionError { #[error("Cannot interpolate non-string variable '${name}' into string in '{path}'")] VariableInterpolationTypeError { name: String, path: String }, - // --- Templates: `components` / `use` / `for-each` (see rustmotion_core::expand) --- #[error("'components' at '{path}' must be an object mapping names to definitions")] ComponentsBlockNotObject { path: String }, @@ -203,13 +194,9 @@ pub enum RustmotionError { )] ExpansionDepthExceeded { limit: u32, path: String }, - // --- Encoding --- #[error("No frames to render (total duration is 0)")] NoFrames, - // ffmpeg is otherwise the one that finds out, and only after every frame - // has been rendered: it exits with "Nothing was written into output file" - // and a raw -22 dump. Naming the working combination costs one line. #[error("codec '{codec}' cannot be written into a .{container} file — {fix}")] CodecContainerMismatch { codec: String, @@ -226,9 +213,6 @@ pub enum RustmotionError { #[error("Failed to open FFmpeg stdin pipe")] FfmpegPipe, - // A broken pipe here nearly always means ffmpeg already died on its own - // arguments, so the useful diagnostic is ffmpeg's stderr rather than our - // write error. Carry it in the error so it survives `--quiet`. #[error("Failed to write to FFmpeg pipe: {reason}{}", .stderr.as_ref().map(|s| format!("\nffmpeg reported:\n{}", s)).unwrap_or_default())] FfmpegWrite { reason: String, @@ -250,7 +234,6 @@ pub enum RustmotionError { #[error("Failed to write GIF frame: {reason}")] GifFrame { reason: String }, - // --- Audio --- #[error("Failed to open audio file '{path}': {reason}")] AudioOpen { path: String, reason: String }, @@ -263,7 +246,6 @@ pub enum RustmotionError { #[error("Failed to create decoder for '{path}': {reason}")] AudioDecoder { path: String, reason: String }, - // --- Audio synthesis (issue #331) --- #[error("audio.score: {source}")] AudioSynthScore { #[from] @@ -273,7 +255,6 @@ pub enum RustmotionError { #[error("failed to write synthesised audio to a temporary file '{path}': {reason}")] AudioSynthWrite { path: String, reason: String }, - // --- Rendering --- #[error("Failed to create Skia surface")] SurfaceCreation, @@ -286,7 +267,6 @@ pub enum RustmotionError { #[error("Failed to create motion blur surface")] MotionBlurSurface, - // --- CLI --- #[error("Cannot use both input file and --json")] ConflictingInput, @@ -327,11 +307,9 @@ pub enum RustmotionError { #[error("Path is not valid UTF-8: '{path}'")] NonUtf8Path { path: String }, - // --- Preview --- #[error("Failed to create preview window: {reason}")] PreviewWindow { reason: String }, - // --- Lottie --- #[error("Failed to read Lottie file '{path}': {reason}")] LottieRead { path: String, reason: String }, @@ -347,17 +325,14 @@ pub enum RustmotionError { #[error("Lottie render failed: {reason}")] LottieRender { reason: String }, - // --- Skills --- #[error( "Unknown skill or rule: '{name}'. Run `rustmotion skills list` to see available rules." )] UnknownSkill { name: String }, - // --- IO --- #[error("{0}")] Io(#[from] std::io::Error), - // --- External library errors --- #[error("Image processing error: {0}")] Image(#[from] image::ImageError), diff --git a/crates/rustmotion-core/src/expand.rs b/crates/rustmotion-core/src/expand.rs index 40bdbe39..bf370ee5 100644 --- a/crates/rustmotion-core/src/expand.rs +++ b/crates/rustmotion-core/src/expand.rs @@ -1,147 +1,3 @@ -//! Data-driven repetition and reusable component templates. -//! -//! This is the answer to the dominant failure mode the original audit named: -//! an LLM asked for "ten identical cards, different data" hand-writes ten -//! JSON subtrees, and every copy is a chance to diverge (a forgotten color, -//! a stray `font-size`, a `position` that doesn't match its siblings). Two -//! directives close that gap, both usable inside any `children` array — -//! exactly where a component would go: -//! -//! - **`for-each`** repeats a `template` subtree once per element of an -//! array, binding the current element's fields (plus `$index`) into it. -//! - **`use`** instantiates a named, reusable subtree declared once in a -//! top-level `components` block, with `props` overrides — a factored-out -//! component definition, the same relationship `include` has to a whole -//! scenario file, but *within* one file and *without* the I/O. -//! -//! ## Why this lives in `rustmotion-core`, not `rustmotion` -//! -//! `include.rs` needs file/network I/O (`std::fs`, `ureq`), so it lives in -//! the `rustmotion` crate. This module is pure `serde_json::Value` rewriting -//! — no I/O, same as `variables.rs` — so it lives next to it here. -//! -//! ## Syntax, and why it looks like `include`/`config` rather than a third -//! dialect -//! -//! ```json -//! { -//! "components": { -//! "stat_card": { -//! "params": { -//! "label": { "type": "string" }, -//! "value": { "type": "number", "default": 0 }, -//! "color": { "type": "string", "default": "#6366F1" } -//! }, -//! "template": { -//! "type": "card", -//! "style": { "width": "300px", "background": "$color" }, -//! "children": [ -//! { "type": "text", "content": "$label" }, -//! { "type": "counter", "value": "$value" } -//! ] -//! } -//! } -//! }, -//! "scenes": [{ -//! "duration": 3.0, -//! "children": [ -//! { -//! "for-each": "$rows", -//! "template": { "use": "stat_card", "props": { "label": "$label", "value": "$value" } } -//! } -//! ] -//! }] -//! } -//! ``` -//! -//! `components[name].params` is deliberately the exact same shape as the -//! scenario-level `config` block (`{"type": ..., "default": ..., "description": ...}`, -//! see [`crate::schema::VariableDefinition`]) — a param is a variable scoped -//! to one component instead of the whole file. `use` + its overrides field -//! mirrors `IncludeDirective { include, config }` (a name plus overrides) — -//! *except* the overrides field is called **`props`**, not `config`. That is -//! a deliberate, load-bearing difference, not inconsistency: [`substitute`] -//! (shared with `variables.rs`) skips recursing into any object key literally -//! named `"config"`, so that the scenario-level `config` *declarations* block -//! (whose `default` values must stay literal, see -//! `variables::test_config_key_not_substituted`) is never accidentally -//! rewritten by whole-document substitution. Reusing that same key name for -//! `use`'s overrides would make a `for-each` binding (`$label`) placed inside -//! a *nested* `use`'s overrides silently never substitute — exactly the kind -//! of silent failure this workstream exists to remove. `props` sidesteps the -//! collision entirely while keeping the rest of the shape familiar. -//! -//! For `for-each`, each array element's own fields are bound directly (flat, -//! not `$item.label`): the codebase's existing `$name` substitution has no -//! dotted-path support (see `variables::parse_single_var_ref`), so an element -//! `{"label": "Revenue", "value": 120}` exposes `$label` and `$value` -//! straight into the template, exactly like a `config` default would. The -//! whole element is *also* bound to `$item` (for forwarding it wholesale, -//! e.g. into a nested `use`'s `props` via `{"$var": "item"}`), and the -//! 0-based position is bound to `$index` — plus, for the arithmetic- -//! expression grammar in [`crate::expr`], the short aliases `$i` (same -//! value as `$index`) and `$count` (the array's length). Explicit data -//! always wins: if an element's own field is named `index`, `item`, `i` or -//! `count`, that value is kept and the built-in is not inserted over it. -//! -//! ## Pass ordering (load-bearing, tested in -//! `rustmotion/tests/templates_iteration.rs`) -//! -//! Every call site runs `expand_directives` immediately *after* -//! `variables::apply_variables` and *before* `Scenario` is deserialized — -//! same document, same pass boundary `include` sits on the other side of. -//! Concretely, per document (root scenario file, and independently for each -//! file pulled in by `include`, since `components` is file-local — see -//! below): -//! -//! 1. Parse JSON. -//! 2. `variables::apply_variables` — resolves the file's own `config`/`$var`. -//! 3. **`expand::expand_directives`** (this module) — resolves `for-each`/ -//! `use` using the now-literal document, then removes `components`. -//! 4. Deserialize into `Scenario`. -//! 5. `include::resolve_includes` — splices in child files (each of which -//! already went through steps 1-4 independently inside -//! `include::fetch_and_resolve`). -//! -//! Two consequences fall out of running expansion strictly after variable -//! substitution and strictly per-document: -//! -//! - **You *can* iterate over an array that came from a variable.** -//! `"for-each": "$rows"` is, by the time this module sees it, no longer a -//! `$`-string — step 2 already replaced it with the literal array (if -//! `rows` is a declared `config` variable of array type). `for-each` itself -//! never has to know variables exist. -//! - **You *cannot* instantiate a component defined in an included file** — -//! not from the *parent's* `use` sites, anyway. `components` is scoped to -//! the document it is declared in, the same way `config` is: each document -//! gets its own `apply_variables` + `expand_directives` pass over its own -//! text before it is ever spliced into anything else. A `use` inside a -//! file that *includes* another file cannot see the includee's -//! `components`, and a `use` inside the includee cannot see the includer's. -//! This is a deliberate simplicity choice (no cross-file component -//! registry, no import syntax to design and version) — see the module test -//! `use_cannot_reach_a_component_defined_in_a_sibling_included_file` in -//! `rustmotion/tests/templates_iteration.rs` for the resulting diagnostic. -//! -//! ## The index-shift trap (already drew blood once — see PR #145 / #160) -//! -//! `include` has the exact same shape of bug this module could reintroduce: -//! a directive that expands to a scene count other than 1 shifts every -//! later `views[V].scenes[S]` index, and `--fix` patches the raw JSON by -//! that same indexed path. `for-each` is strictly worse on this axis — ten -//! elements shift nine siblings, not (at most) a handful. This module does -//! **not** try to solve that by tracking pre/post-expansion index maps: it -//! solves it the way `include` already does, by removing the temptation. -//! `expand_directives` runs *before* `Scenario` is deserialized, so -//! `LoadedScenario::raw` (what `--fix` would serialize) is *already* the -//! expanded tree by the time `commands/validate.rs` sees it — same as -//! `include`'s resolved scenes are already spliced into `raw` by the time -//! `--fix` runs. `commands/validate.rs::refuse_fix` is extended with a -//! `UsesTemplateDirectives` case, detected the same (raw-substring, -//! conservative-by-design) way `UsesInclude` already is, so `--fix` refuses -//! outright rather than writing the expansion back over the author's -//! `for-each`/`use`/`components` source. - use std::collections::HashMap; use serde::Deserialize; @@ -151,45 +7,17 @@ use crate::error::{Result, RustmotionError}; use crate::schema::VariableType; use crate::variables::substitute; -/// Defense-in-depth ceiling on nested `use`/`for-each` expansion. True -/// self-reference cycles are caught immediately by the name stack in -/// [`resolve_entry`] and never reach this; this only guards against -/// legitimately deep (non-cyclic) nesting run away, mirroring -/// `include::MAX_INCLUDE_DEPTH`'s role for the sibling mechanism. const MAX_EXPANSION_DEPTH: u32 = 64; -/// Ceiling on the total number of nodes a single document's `for-each` -/// expansion may produce, across every level of nesting combined (RM-43). -/// [`MAX_EXPANSION_DEPTH`] bounds how deep directives may nest, not how many -/// nodes they produce — and nesting one `for-each` inside another's -/// `template` is explicitly supported (`use_template_can_contain_a_nested_ -/// for_each` below), so the node count a legal, non-cyclic document can -/// declare is the *product* of every level's array length, not their sum. -/// Four nested levels of 50 elements is 6.25M nodes from a file under 1 KB. -/// This is checked incrementally as each `for-each` directive is about to -/// produce its items (see [`consume_node_budget`]), so a runaway product is -/// rejected partway through, well before the full tree is ever materialized. const MAX_EXPANSION_NODES: u64 = 2_000_000; -/// Cheap first line of defence ahead of [`MAX_EXPANSION_NODES`]: a single -/// `for-each` directive's own array, before any nesting is even considered. const MAX_FOR_EACH_ITEMS: usize = 100_000; -/// One entry of the top-level `components` map: a named, parameterised -/// subtree. `params` reuses the exact shape of the scenario-level `config` -/// block, except a param's `default` is optional — omitting it makes the -/// parameter *required*, which `config` variables cannot express (every -/// `config` variable must have a default, since it is meant to render -/// standalone with no overrides at all; a component parameter has no such -/// obligation — an icon component's `icon` name, for instance, has no -/// sensible default). #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] struct ComponentDefinition { #[serde(default)] params: HashMap, - /// The subtree to instantiate: a single component object, or an array of - /// sibling component objects (a fragment spliced in place). template: Value, } @@ -198,7 +26,6 @@ struct ComponentDefinition { struct ComponentParam { #[serde(rename = "type")] #[allow(dead_code)] - // documentation/schema parity with `config`; not cross-checked against `default`'s actual JSON type (same as `VariableDefinition::var_type` today) param_type: VariableType, #[serde(default)] default: Option, @@ -207,7 +34,6 @@ struct ComponentParam { description: Option, } -/// `{"use": "name", "props": {...}}` — instantiate a `components` entry. #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct UseDirective { @@ -217,8 +43,6 @@ struct UseDirective { props: HashMap, } -/// `{"for-each": [...], "template": {...}}` — repeat `template` once per -/// element of the (already variable-substituted) array. #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct ForEachDirective { @@ -235,18 +59,6 @@ fn is_use(v: &Value) -> bool { matches!(v, Value::Object(m) if m.contains_key("use")) } -/// Expand every `for-each`/`use` directive found in any `children` array -/// anywhere in `value`, and consume the top-level `components` block (like -/// `variables::apply_variables` consumes `config`, it is removed so it never -/// reaches `Scenario`'s `deny_unknown_fields`). Call this once per document, -/// immediately after `variables::apply_variables` and before deserializing -/// into `Scenario` — see the module doc for why that ordering is load-bearing. -/// -/// `file_label` is the same kind of label `apply_variables` takes (a file -/// path, ``, or ``) — used only for error messages, alongside a -/// structural location built while walking (e.g. `scenes[2].children[1]`), -/// so a diagnostic names *where in the source* the offending directive is, -/// not just which file. pub fn expand_directives(value: &mut Value, file_label: &str) -> Result<()> { let defs = extract_component_definitions(value, file_label)?; let mut budget = MAX_EXPANSION_NODES; @@ -307,12 +119,6 @@ pub fn expand_directives(value: &mut Value, file_label: &str) -> Result<()> { Ok(()) } -/// Subtracts `n` from the shared expansion-node budget, or fails naming the -/// limit and where it was hit (RM-43). See [`MAX_EXPANSION_NODES`] for why -/// this is checked once per `for-each` directive's item count rather than -/// once per final node: it is the only point in the recursion where the -/// multiplicative blow-up can be caught before the work that would produce -/// it actually runs. fn consume_node_budget(budget: &mut u64, n: u64, file_label: &str, location: &str) -> Result<()> { match budget.checked_sub(n) { Some(remaining) => { @@ -326,20 +132,6 @@ fn consume_node_budget(budget: &mut u64, n: u64, file_label: &str, location: &st } } -/// Report `$name`s that survived both variable substitution and directive -/// expansion. -/// -/// `variables::apply_variables` runs its own scan, but *before* this pass and -/// skipping `template`/`props`/`components` — every `$name` in there is a -/// binding this function is about to resolve, and reporting them would emit a -/// warning per binding on every correct scenario. Those keys are consumed by -/// the time we get here, so scanning the expanded document sees only genuine -/// leftovers: a `$typo` in a template that matched no data field, or a `$` in -/// ordinary content. -/// -/// A warning rather than an error, matching what `apply_variables` decided for -/// the same diagnostic: a literal `$` in a price or a shell path is legitimate -/// content and must not fail a render. fn warn_unresolved_after_expansion(value: &Value, file_label: &str) { for name in crate::variables::find_unresolved(value) { eprintln!( @@ -382,11 +174,6 @@ fn extract_component_definitions( } } -/// Find the `children` array on `value` (if any), expand every entry in it -/// (concrete entries pass through unchanged but are still recursed into, so -/// nested containers get their own `children` expanded too), then recurse -/// into every other field generically — a `for-each`/`use` can appear -/// anywhere a `children` array can, at any nesting depth. fn walk_children( value: &mut Value, defs: &HashMap, @@ -427,20 +214,6 @@ fn walk_children( Ok(()) } -/// Resolve one `children` array entry into zero or more concrete entries. -/// A plain component entry resolves to exactly itself (after recursing into -/// its own `children`, if it has one). A `for-each`/`use` directive resolves -/// to the nodes it produces — which are, in turn, run back through this same -/// function, so a `for-each` template that is itself a `use`, or a `use` -/// whose template is itself a `for-each`, composes without special-casing. -/// -/// A bare JSON array (a `for-each`/`use` template written as a *fragment* — -/// several sibling nodes instead of one) is flattened here too, generically, -/// rather than only where `use` happens to produce one: both directives' -/// `template` accept either shape, and this is the single place that -/// splices a fragment's elements into the parent `children` array instead of -/// nesting a raw `[...]` inside it (which downstream `Component` -/// deserialization has no concept of). fn resolve_entry( entry: Value, defs: &HashMap, @@ -553,23 +326,12 @@ fn expand_for_each_directive( bindings.insert(k.clone(), v.clone()); } } - // Explicit data wins: only fill these in if the element didn't - // already define a field with that name. bindings .entry("index".to_string()) .or_insert_with(|| Value::from(idx)); bindings .entry("item".to_string()) .or_insert_with(|| element.clone()); - // `i`/`count` are the short aliases the expression grammar - // (`crates/rustmotion-core/src/expr/`) recognises for the same two - // facts `index` and the item count already give a template — see - // that module's doc for why `$i`/`$count` need to already be plain - // numeric text by the time an expression string reaches - // `crates/rustmotion/src/loader.rs`'s static-folding pass: that pass - // never sees `for-each` iteration state itself, only the document - // this substitution already rewrote. Bound the same way as - // `index`/`item` — explicit data wins, built-ins only fill a gap. bindings .entry("i".to_string()) .or_insert_with(|| Value::from(idx)); @@ -671,13 +433,6 @@ mod tests { Ok(value) } - // ---- unresolved-reference scanning across the two passes ---- - - /// `apply_variables` scans for leftover `$name`s before this pass runs. - /// Left unguarded it reported every template binding as a typo — six - /// warnings on the canonical example, each accusing the author of a - /// mistake they had not made. Warnings that are reliably wrong teach the - /// reader to ignore warnings, which costs more than the scan is worth. #[test] fn template_bindings_are_not_reported_as_unresolved_before_expansion() { let doc = json!({ @@ -700,10 +455,6 @@ mod tests { ); } - /// The other half: skipping those keys must not turn a false positive - /// into a false negative. Once expansion has consumed them, a `$name` - /// that matched no data field is a genuine leftover and is visible to the - /// very same scan. #[test] fn a_typo_inside_a_template_is_still_found_after_expansion() { let expanded = expand(json!({ @@ -721,8 +472,6 @@ mod tests { ); } - /// And the correct spelling leaves nothing behind, so the scan above is - /// discriminating rather than merely quiet. #[test] fn a_correct_binding_leaves_nothing_unresolved_after_expansion() { let expanded = expand(json!({ @@ -736,8 +485,6 @@ mod tests { assert!(crate::variables::find_unresolved(&expanded).is_empty()); } - // ---- for-each ---- - #[test] fn for_each_repeats_template_once_per_element_binding_its_fields() { let doc = json!({ @@ -834,10 +581,6 @@ mod tests { #[test] fn for_each_source_that_is_not_an_array_is_a_named_error_not_a_silent_empty_result() { - // The exact silent failure mode the brief calls out: a for-each - // source key typo'd or referencing an undeclared variable leaves a - // literal, non-array `$...` string here — this must be a hard error - // naming where, not a quietly empty `children`. let doc = json!({ "video": { "width": 100, "height": 100 }, "scenes": [{ @@ -876,10 +619,6 @@ mod tests { #[test] fn for_each_with_a_fragment_template_splices_every_sibling_in_place_not_a_nested_array() { - // Each iteration's `template` is an *array* of two sibling nodes - // (an icon + a label), not a single object — both must end up as - // direct, flat siblings in the surrounding `children` array; a - // nested `[...]` there would not deserialize as a component. let doc = json!({ "video": { "width": 100, "height": 100 }, "scenes": [{ @@ -907,8 +646,6 @@ mod tests { assert_eq!(children[3]["content"], json!("B")); } - // ---- use / components ---- - fn doc_with_stat_card(props: Value) -> Value { json!({ "video": { "width": 100, "height": 100 }, @@ -985,8 +722,6 @@ mod tests { #[test] fn use_missing_a_required_parameter_is_a_named_error() { - // `label` has no default in `doc_with_stat_card` — omitting it must - // fail, not silently render an empty/placeholder value. let out = expand(doc_with_stat_card(json!({}))); let err = out.expect_err("missing required param must fail"); match &err { @@ -1083,8 +818,6 @@ mod tests { assert_eq!(children[1]["content"], json!("hi")); } - // ---- composition of the two directives ---- - #[test] fn for_each_template_can_be_a_use_directive() { let doc = json!({ @@ -1163,8 +896,6 @@ mod tests { assert_eq!(inner[1]["content"], json!(2)); } - // ---- the tree-identity proof, at the JSON-value level ---- - #[test] fn for_each_authored_tree_is_identical_to_the_hand_written_equivalent() { let generated = json!({ diff --git a/crates/rustmotion-core/src/expr/ast.rs b/crates/rustmotion-core/src/expr/ast.rs index 74566297..f01bd68f 100644 --- a/crates/rustmotion-core/src/expr/ast.rs +++ b/crates/rustmotion-core/src/expr/ast.rs @@ -1,12 +1,3 @@ -//! The parse tree [`super::parser`] builds and [`super::eval::compile`] -//! lowers into the flat program actually executed. -//! -//! Kept as a plain, owned tree (not a flat token slice with indices) because -//! it only ever exists transiently, inside [`crate::expr::Expr::parse`]: -//! built once, walked once by the compiler, then dropped. Nothing here is -//! stored on [`crate::expr::Expr`] — see that struct's doc for why it keeps -//! the compiled program instead. - use super::builtins::Builtin; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -32,21 +23,10 @@ pub(crate) enum BinOp { #[derive(Debug, Clone, PartialEq)] pub(crate) enum Ast { Num(f64), - /// A `$name` reference, resolved against a [`super::Scope`] at eval time - /// — the only kind of identifier this grammar defers past parse time. Var(String), Unary(UnOp, Box), Bin(BinOp, Box, Box), - /// `cond ? then : else`. Kept as its own node (not desugared into - /// something else) because it is the one place evaluation must *not* - /// walk both children — see `eval::Op::Ternary`'s doc for why that - /// matters beyond performance. Ternary(Box, Box, Box), - /// A builtin call. The callee is already resolved to a fixed-arity - /// [`Builtin`] by the parser — there is no other kind of call. Call(Builtin, Vec), - /// `node("id", "prop")` — resolved against [`super::Scope::node_prop`] - /// at eval time. The two arguments are string literals, not - /// sub-expressions: a node id/prop name is never itself computed. NodeRef(String, String), } diff --git a/crates/rustmotion-core/src/expr/builtins.rs b/crates/rustmotion-core/src/expr/builtins.rs index 85966280..4fd4e160 100644 --- a/crates/rustmotion-core/src/expr/builtins.rs +++ b/crates/rustmotion-core/src/expr/builtins.rs @@ -1,24 +1,3 @@ -//! The closed set of functions an expression can call. -//! -//! There is no user-defined function and no recursion (see the module doc on -//! [`crate::expr`] for why that is a deliberate ceiling, not a missing -//! feature): every callable name an expression can use is one of the -//! variants below, resolved by name at *parse* time in -//! [`super::parser`] — not looked up in a [`super::Scope`] at eval time the -//! way a `$name` variable is. That is what lets a typo like `sni(x)` fail -//! immediately, at the same place `TooDeep`/`Arity` already fail, instead of -//! surfacing only once a frame happens to hit it. -//! -//! [`Builtin::call`] is a pure function of its arguments — no thread-local, -//! no global counter, no clock read — for [`Builtin::Rand`] and -//! [`Builtin::Noise`] included. That purity is load-bearing: two renders of -//! the same file must produce identical frames, and a render can evaluate -//! the same node's expression many times (once per sampled frame plus once -//! more during static folding for the parts that turn out foldable), so -//! anything but a pure function of the arguments would make the output -//! depend on evaluation order. - -/// A builtin function name, resolved once at parse time. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum Builtin { Sin, @@ -44,9 +23,6 @@ pub(crate) enum Builtin { } impl Builtin { - /// Case-sensitive lookup by the identifier the lexer read. `None` means - /// "not a builtin" — the parser still has [`constant`] and the special - /// `node(...)` form to try before giving up with `UnknownIdent`. pub(crate) fn from_name(name: &str) -> Option { Some(match name { "sin" => Self::Sin, @@ -73,8 +49,6 @@ impl Builtin { }) } - /// The name this variant was parsed from — used to build `ExprError` - /// messages (`Arity`) that name the function the way the author wrote it. pub(crate) fn name(self) -> &'static str { match self { Self::Sin => "sin", @@ -100,8 +74,6 @@ impl Builtin { } } - /// Fixed arity, checked at parse time against the argument list the - /// parser collected — no builtin is variadic. pub(crate) fn arity(self) -> usize { match self { Self::Sin @@ -121,9 +93,6 @@ impl Builtin { } } - /// Evaluate. `args.len()` is guaranteed to equal [`Builtin::arity`] by - /// the parser (which checks it once, at parse time) — the stack machine - /// in [`super::eval`] never calls this with a mismatched slice. pub(crate) fn call(self, args: &[f64]) -> f64 { match self { Self::Sin => args[0].sin(), @@ -159,10 +128,6 @@ impl Builtin { } fn clamp(x: f64, lo: f64, hi: f64) -> f64 { - // `f64::clamp` panics when `lo > hi`; an author-supplied bound pair is - // not a Rust invariant violation, it is bad data, so fall back to a - // saturating order-independent clamp instead of propagating a panic out - // of an evaluator that is meant to be hang/crash-proof by construction. let (lo, hi) = if lo <= hi { (lo, hi) } else { (hi, lo) }; x.max(lo).min(hi) } @@ -184,8 +149,6 @@ fn smoothstep(edge0: f64, edge1: f64, x: f64) -> f64 { t * t * (3.0 - 2.0 * t) } -/// Named constant lookup for bare (non-`$`) identifiers that are not a -/// function call — the other half of what a bare `Ident` token can mean. pub(crate) fn constant(name: &str) -> Option { match name { "PI" => Some(std::f64::consts::PI), @@ -195,10 +158,6 @@ pub(crate) fn constant(name: &str) -> Option { } } -/// `splitmix64`: a small, well-known bit-mixer, chosen only because it is -/// cheap and has no dependency — not because the noise needs to be -/// cryptographically strong. It is deterministic in `x` alone, which is the -/// whole point: no seeding from the clock, no `static` counter. fn splitmix64(x: u64) -> u64 { let x = x.wrapping_add(0x9E37_79B9_7F4A_7C15); let mut z = x; @@ -207,14 +166,10 @@ fn splitmix64(x: u64) -> u64 { z ^ (z >> 31) } -/// Map a mixed 64-bit word to `[0, 1)` using its top 53 bits — the standard -/// trick for producing a `f64` with uniform mantissa coverage. fn unit_interval(bits: u64) -> f64 { (bits >> 11) as f64 * (1.0 / (1u64 << 53) as f64) } -/// `rand(seed)` — deterministic, pure hash of `seed`'s bit pattern into -/// `[0, 1)`. Same `seed` in, same value out, on this render or the next one. fn rand(seed: f64) -> f64 { unit_interval(splitmix64(seed.to_bits())) } @@ -228,10 +183,6 @@ fn smootherstep(t: f64) -> f64 { t * t * t * (t * (t * 6.0 - 15.0) + 10.0) } -/// `noise(x, seed)` — 1D value noise: smoothly interpolates between -/// deterministic per-integer lattice values around `x`, both lattice points -/// seeded by `seed`. Same inputs, same curve, every time — no incremental -/// state to carry between calls, unlike a typical streaming noise generator. fn noise(x: f64, seed: f64) -> f64 { let i0 = x.floor() as i64; let i1 = i0 + 1; @@ -270,8 +221,6 @@ mod tests { #[test] fn noise_is_continuous_at_lattice_points() { - // At an integer x, noise(x, seed) must equal the lattice value there - // (smootherstep(0) == 0), so neighbouring samples don't jump. let seed = 11.0; assert_eq!(noise(4.0, seed), lattice(4, seed)); } diff --git a/crates/rustmotion-core/src/expr/eval.rs b/crates/rustmotion-core/src/expr/eval.rs index 7be0a959..45eaa641 100644 --- a/crates/rustmotion-core/src/expr/eval.rs +++ b/crates/rustmotion-core/src/expr/eval.rs @@ -1,30 +1,3 @@ -//! Lowers an [`Ast`] into a flat program of [`Op`]s once, and runs that -//! program against a [`Scope`] as many times as needed afterwards. -//! -//! This is the piece that makes the two-tier evaluation model in the -//! [`crate::expr`] module doc actually cheap: [`compile`] walks the tree a -//! single time, at [`crate::expr::Expr::parse`] time, and everything it -//! produces — the op list, the interned variable-name table, the interned -//! node-reference table — is owned by the resulting [`Expr`](super::Expr) -//! and never rebuilt. [`run`] then executes that fixed program using a -//! stack-allocated `[f64; MAX_STACK]` array: no `Vec`, no `String`, no heap -//! traffic of any kind on the per-frame path, however many times a frame -//! loop calls it. -//! -//! Arithmetic (`Num`, `Var`, unary/binary operators, builtin calls) compiles -//! to genuinely flat, linear bytecode executed by a simple push/pop -//! interpreter over that array — a real stack machine for the part of the -//! grammar that has no branching. [`Op::Ternary`] is the one construct that -//! must *not* evaluate eagerly (a condition guards a division by zero, or a -//! branch none reads a node that doesn't exist this frame — evaluating both -//! sides unconditionally would surface an error the author's own branching -//! was written to avoid), so it holds its three arms as their own -//! independently-compiled flat programs and `run` recurses into exactly one -//! of them. That recursion is bounded by the same nesting cap `parser.rs` -//! already enforces at parse time, so it can never run deeper than -//! `MAX_DEPTH` stack frames — nowhere near enough to threaten the native -//! stack. - use super::ast::{Ast, BinOp, UnOp}; use super::builtins::Builtin; use super::{ExprError, Scope}; @@ -32,7 +5,6 @@ use super::{ExprError, Scope}; #[derive(Debug, Clone, PartialEq)] pub(crate) enum Op { Num(f64), - /// Index into the compiled [`Expr`](super::Expr)'s variable-name table. Var(u16), Neg, Add, @@ -46,12 +18,8 @@ pub(crate) enum Op { Le, Gt, Ge, - /// Pops `argc` values (in argument order), pushes one result. Call(Builtin, u8), - /// Index into the compiled [`Expr`](super::Expr)'s node-reference table. NodeProp(u16), - /// `(cond, then, else)`, each its own flat program — see the module doc - /// on why this can't be three ordinary operands on the value stack. Ternary(Box<[Op]>, Box<[Op]>, Box<[Op]>), } @@ -63,13 +31,6 @@ pub(crate) struct Compiled { pub is_static: bool, } -/// Names that can never be folded at load time because their value is only -/// known once the frame loop actually starts (or, for `duration`, because -/// computing it soundly at this stage would mean re-deriving the transition -/// arithmetic `crates/rustmotion/src/encode` owns — see -/// `crates/rustmotion/src/loader.rs`'s fold pass for where that arithmetic -/// actually lives). An expression naming any of these is never static, -/// regardless of what else it references. fn is_dynamic_var_name(name: &str) -> bool { matches!(name, "t" | "T" | "beat" | "duration") } @@ -170,13 +131,6 @@ pub(crate) fn compile(ast: &Ast) -> Compiled { } } -/// Bound on the value stack `run` uses. Every op is compiled from an AST -/// whose nesting is itself capped at parse time (`parser::MAX_DEPTH`, 64), -/// and no single grammar construct pushes more than a small constant number -/// of pending values per nesting level, so this is never approached by any -/// expression that made it past `parse` — it exists purely so a bug in that -/// invariant fails loudly (an `ExprError`) instead of indexing out of -/// bounds. const MAX_STACK: usize = 256; pub(crate) fn run( @@ -275,7 +229,7 @@ pub(crate) fn run( } Op::Call(builtin, argc) => { let n = *argc as usize; - let mut args = [0.0_f64; 3]; // max builtin arity is 3 (clamp/lerp/smoothstep) + let mut args = [0.0_f64; 3]; for slot in args.iter_mut().take(n).rev() { *slot = pop!(); } diff --git a/crates/rustmotion-core/src/expr/lexer.rs b/crates/rustmotion-core/src/expr/lexer.rs index bb6e35b1..6ef1ea59 100644 --- a/crates/rustmotion-core/src/expr/lexer.rs +++ b/crates/rustmotion-core/src/expr/lexer.rs @@ -1,23 +1,10 @@ -//! Character-level scanning: `&str` in, `Vec` out, or an -//! [`ExprError::Parse`] naming exactly what byte offset choked. -//! -//! Deliberately hand-rolled rather than pulled in as a dependency — the -//! token set is tiny (numbers, `$name` references, bare identifiers, string -//! literals for `node(...)`, and a fixed punctuation set) and a generated -//! lexer would cost more to read than it saves to write. - use super::ExprError; #[derive(Debug, Clone, PartialEq)] pub(crate) enum Token { Num(f64), - /// `$name` — the `$` is consumed here, `name` is the bare identifier. Var(String), - /// A bare identifier: a builtin name, a constant (`PI`/`TAU`/`E`), or - /// the `node` keyword. Which one it is gets resolved by the parser. Ident(String), - /// A double-quoted string literal — only meaningful as a `node(...)` - /// argument; the parser rejects it anywhere else. Str(String), Plus, Minus, @@ -37,11 +24,6 @@ pub(crate) enum Token { Ge, } -/// A generous but finite cap on source length, independent of the nesting -/// cap `parser.rs` enforces on structure: a pathological flat input (a -/// million `+` signs, no nesting at all) would sail through the depth guard -/// but still shouldn't be handed to the tokenizer — reject it up front with -/// a plain, cheap length check instead of discovering the cost mid-scan. const MAX_SOURCE_LEN: usize = 8192; pub(crate) fn tokenize(src: &str) -> Result, ExprError> { @@ -177,12 +159,6 @@ pub(crate) fn tokenize(src: &str) -> Result, ExprError> { j += 2; } Some(_) => { - // Safe: we only ever step by one *byte* when it's - // part of a UTF-8 continuation too, but to keep - // this simple and correct for the identifier/ - // number cases above we only claim ASCII there. - // String bodies may be full UTF-8, so decode via - // the original &str instead of raw bytes here. let ch = src[j..].chars().next().unwrap(); s.push(ch); j += ch.len_utf8(); diff --git a/crates/rustmotion-core/src/expr/mod.rs b/crates/rustmotion-core/src/expr/mod.rs index 92e49264..62b0e047 100644 --- a/crates/rustmotion-core/src/expr/mod.rs +++ b/crates/rustmotion-core/src/expr/mod.rs @@ -1,110 +1,3 @@ -//! Arithmetic expressions for scenario values. -//! -//! Without this module, every number in a scenario is a frozen literal: a -//! generator that wants eight badges evenly spaced on a circle, or a facet -//! shaded by its angle to a light, has no way to say so in JSON and instead -//! has to compute the numbers itself, offline, and paste the results in — -//! unreadable in the studio, and no longer connected to the intent that -//! produced them. This module gives a scenario value an alternative to -//! being a literal: a small, deliberately not Turing-complete expression -//! language, written as a string. -//! -//! # The `=` prefix -//! -//! A JSON string is an expression only when it starts with `=`; anything -//! else is a plain literal string, exactly as before: -//! -//! ```json -//! { "x": "= $W/2 + cos($i / $count * TAU - PI/2) * 700 - 110" } -//! ``` -//! -//! [`Expr::parse`] itself is tolerant of either form — a leading `=` (with -//! optional surrounding whitespace) is stripped if present, and the rest is -//! parsed as-is otherwise — so callers can hand it either the raw JSON -//! string value or just the expression body without having to agree in two -//! places on who strips the sigil. The decision of *whether* a given string -//! is even attempted as an expression (i.e. whether it starts with `=` at -//! all) is made by the caller — see `crates/rustmotion/src/loader.rs`'s -//! static-folding pass, and eventually [`Computed`] below for the typed -//! schema fields that wrap this. -//! -//! # Grammar -//! -//! Numeric literals, `+ - * / %`, unary minus, parentheses, the six -//! comparisons (`== != < <= > >=`, each producing `0.0`/`1.0`), a ternary -//! `cond ? a : b`, and calls to a fixed, closed set of builtins (see -//! [`builtins::Builtin`]) plus the constants `PI`, `TAU`, `E`. No loops, no -//! user-defined functions, no recursion — see [`ExprError::TooDeep`] for -//! the nesting cap this buys: a hostile expression fails at *parse*, never -//! hangs a render. -//! -//! A `$name` token is a reference resolved against a [`Scope`] at -//! evaluation time — never at parse time — the one place this grammar looks -//! outside itself. `node("id", "prop")` is the other: a node-reference call -//! resolved via [`Scope::node_prop`], syntactically distinguished from an -//! ordinary builtin call because its two arguments are string literals -//! (an id and a property name), never sub-expressions. -//! -//! # Two evaluation tiers -//! -//! An [`Expr`] is parsed exactly once, in [`Expr::parse`], into a compiled -//! program (see [`eval`] for the compiler and the allocation-free stack -//! machine that runs it) — evaluating it later, however many times, never -//! re-parses the source string. What happens with that compiled `Expr` then -//! splits in two: -//! -//! - **Static fold, at load.** [`Expr::is_static`] is true when -//! [`Expr::free_vars`] names nothing time-varying (`t`, `T`, `beat`, -//! `duration` — see [`eval::is_dynamic_var_name`]) and the expression -//! contains no `node(...)` call. Such an expression can only ever -//! evaluate to one value for a given document, so it is evaluated once -//! during loading and the JSON literal it produces replaces it — zero -//! per-frame cost, and the reason a `for-each` placing eight badges on a -//! circle costs nothing more at render time than eight literal -//! coordinates would have. See `crates/rustmotion/src/loader.rs`. -//! - **Per frame, otherwise.** An expression reading `$t`/`$T`/`$beat`, a -//! [`Scope::var`]-backed animated variable, or a `node(...)` reference is -//! evaluated once per sampled frame against that frame's [`Scope`] — still -//! without re-parsing, and without allocating, since [`Expr::eval`] only -//! ever walks the already-compiled program. -//! -//! # Scope -//! -//! [`Scope::var`] is the single hook a caller implements to expose names to -//! an expression. This module does not itself decide what `$t`, `$W`, or an -//! arbitrary `$myVar` mean — it only defines the two categories -//! [`Expr::is_static`] treats specially (dynamic vs. potentially -//! load-time-known) so that callers building a [`Scope`] for either tier -//! know which names they are expected to answer. The scenario-level names -//! this workstream's issue names are `$t` (scene time), `$T` (absolute -//! time), `$beat`, `$i`/`$index`/`$item`/`$count` (bound by a surrounding -//! `for-each`), `$W`/`$H`/`$fps`/`$duration`, plus arbitrary `$name` for a -//! declared `config` variable or an animated variable (`Scope::var`) — the -//! same `$name` sigil `crates/rustmotion/src/variables.rs` already resolves -//! for whole-value and interpolated substitution, deliberately reused -//! rather than inventing a second one. -//! -//! ``` -//! use rustmotion_core::expr::{Expr, Scope}; -//! -//! struct Fixed; -//! impl Scope for Fixed { -//! fn var(&self, name: &str) -> Option { -//! match name { -//! "W" => Some(1080.0), -//! "i" => Some(3.0), -//! "count" => Some(8.0), -//! _ => None, -//! } -//! } -//! } -//! -//! let expr = Expr::parse("= $W/2 + cos($i / $count * TAU) * 700").unwrap(); -//! assert!(expr.is_static()); -//! let x = expr.eval(&Fixed).unwrap(); -//! assert!((x - (1080.0 / 2.0 + (3.0_f64 / 8.0 * std::f64::consts::TAU).cos() * 700.0)).abs() < 1e-9); -//! ``` - mod ast; mod builtins; mod eval; @@ -114,29 +7,9 @@ mod parser; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -/// What an expression can read from its evaluation context: named -/// variables, and optionally another node's already-resolved property. -/// -/// Implemented by whoever is evaluating an [`Expr`] — the load-time static -/// folder (`crates/rustmotion/src/loader.rs`, a handful of reserved names -/// only), and eventually the per-frame engine context (animated variables -/// via [`Scope::var`], node references via [`Scope::node_prop`]). Object -/// safety (`&dyn Scope`) is deliberate: [`Expr::eval`] takes a trait object -/// so callers never have to make the compiled program generic over their -/// own context type. pub trait Scope { - /// Resolve a `$name` reference. `None` means "not defined in this - /// scope" — [`Expr::eval`] turns that into - /// [`ExprError::UnknownIdent`], the same error a genuinely unknown name - /// produces, since from the expression's point of view the two are - /// indistinguishable. fn var(&self, name: &str) -> Option; - /// Resolve `node("id", "prop")`. Defaulted to always return `None` (not - /// made a required method) because most [`Scope`] implementations never - /// need to answer it: an expression containing a node reference is - /// never [`Expr::is_static`], so the load-time static-fold scope in - /// particular is never asked. fn node_prop(&self, id: &str, prop: &str) -> Option { let _ = (id, prop); None @@ -159,12 +32,6 @@ pub enum ExprError { TooDeep(usize), } -/// A parsed, compiled arithmetic expression. -/// -/// Construct with [`Expr::parse`]; the source string is consumed at that -/// point and never touched again — [`Expr::eval`] only ever walks the -/// compiled program built once during parsing. See the module doc for the -/// grammar and the two evaluation tiers. #[derive(Debug, Clone, PartialEq)] pub struct Expr { ops: Box<[eval::Op]>, @@ -175,9 +42,6 @@ pub struct Expr { } impl Expr { - /// Parse (and fully compile) an expression. Accepts either the raw - /// `"= ..."` JSON string value or just the body after the sigil — see - /// the module doc's "The `=` prefix" section. pub fn parse(src: &str) -> Result { let body = strip_expr_prefix(src); let tree = parser::parse(body)?; @@ -191,26 +55,14 @@ impl Expr { }) } - /// Evaluate the compiled program against `scope`. Allocation-free: see - /// the [`eval`] module doc. pub fn eval(&self, scope: &dyn Scope) -> Result { eval::run(&self.ops, &self.var_names, &self.node_refs, scope) } - /// Every distinct `$name` this expression references, in first-seen - /// order. Does not include `node(...)` reference ids/props — those are - /// string literals resolved through [`Scope::node_prop`], not - /// `$name` variables. pub fn free_vars(&self) -> Vec { self.free_vars.clone() } - /// True when [`Expr::free_vars`] names nothing time-varying and the - /// expression contains no `node(...)` call — see the module doc's - /// "Two evaluation tiers" section. A caller that gets `true` back may - /// evaluate once, at load time, and keep the result forever; `false` - /// means the value can change from frame to frame and must be - /// re-evaluated each time. pub fn is_static(&self) -> bool { self.is_static } @@ -281,21 +133,17 @@ mod tests { #[test] fn plain_arithmetic() { let expr = Expr::parse("= (2 + 3) * 4 - 10 / 2 % 3").unwrap(); - // (2+3)*4 - (10/2 % 3) = 20 - (5.0 % 3.0) = 20 - 2.0 = 18.0 assert_eq!(expr.eval(&EmptyScope).unwrap(), 18.0); } #[test] fn ternary_short_circuits() { - // The untaken branch divides by zero; if both were evaluated - // eagerly this would produce +inf instead of the taken branch's 5. let expr = Expr::parse("= 1 > 0 ? 5 : (1 / 0)").unwrap(); assert_eq!(expr.eval(&EmptyScope).unwrap(), 5.0); } #[test] fn ternary_untaken_branch_errors_are_not_raised() { - // The untaken branch references an unknown var; must not error. let expr = Expr::parse("= 1 < 0 ? $missing : 42").unwrap(); assert_eq!(expr.eval(&EmptyScope).unwrap(), 42.0); } @@ -378,7 +226,6 @@ mod tests { let a = expr.eval(&EmptyScope).unwrap(); let b = expr.eval(&EmptyScope).unwrap(); assert_eq!(a, b); - // And a fresh parse of the same source is identical too. let c = Expr::parse("= rand(42)") .unwrap() .eval(&EmptyScope) @@ -423,4 +270,25 @@ mod tests { let json = serde_json::to_string(&expr).unwrap(); assert_eq!(json, "\"= 1 + 1\""); } + + #[test] + fn a_static_expression_over_for_each_bindings_folds_at_load() { + struct Fixed; + impl Scope for Fixed { + fn var(&self, name: &str) -> Option { + match name { + "W" => Some(1080.0), + "i" => Some(3.0), + "count" => Some(8.0), + _ => None, + } + } + } + + let expr = Expr::parse("= $W/2 + cos($i / $count * TAU) * 700").unwrap(); + assert!(expr.is_static()); + let x = expr.eval(&Fixed).unwrap(); + let want = 1080.0 / 2.0 + (3.0_f64 / 8.0 * std::f64::consts::TAU).cos() * 700.0; + assert!((x - want).abs() < 1e-9); + } } diff --git a/crates/rustmotion-core/src/expr/parser.rs b/crates/rustmotion-core/src/expr/parser.rs index 4aa17f60..69fd58f0 100644 --- a/crates/rustmotion-core/src/expr/parser.rs +++ b/crates/rustmotion-core/src/expr/parser.rs @@ -1,36 +1,8 @@ -//! Recursive-descent parser: `Vec` in, [`Ast`] out. -//! -//! Precedence, low to high: ternary (`?:`, right-associative) → comparison -//! (`== != < <= > >=`, left-associative chaining) → additive (`+ -`) → -//! multiplicative (`* / %`) → unary minus → primary (literal, `$var`, -//! `(expr)`, builtin call, `node("id","prop")`, constant). -//! -//! ## The nesting cap -//! -//! [`MAX_DEPTH`] bounds how deep the *recursive descent itself* is allowed -//! to go, checked at every point the grammar re-enters "parse one full -//! sub-expression": parenthesised groups, each function/`node()` argument, -//! both ternary branches, and each link of a chained unary minus. Because -//! every other production in this grammar (comparison, additive, -//! multiplicative) is an iterative precedence-climb — a `while` loop, not a -//! function calling itself — those four sites are the *only* ways an -//! adversarial input can make the parser recurse, so guarding them is -//! sufficient to guarantee the whole parse (and the `Box` tree it -//! produces) is bounded, without having to thread the check through every -//! grammar rule individually. A pathological input like 5000 nested parens -//! or a `pow(pow(pow(...)))` chain hits [`ExprError::TooDeep`] here, at -//! parse time, rather than blowing the native call stack or building an -//! unbounded tree that a later pass would have to walk. - use super::ast::{Ast, BinOp, UnOp}; use super::builtins::{constant, Builtin}; use super::lexer::{tokenize, Token}; use super::ExprError; -/// Recursion budget for the four self-recursive grammar entry points (see -/// module doc). 64 is far beyond any nesting a hand- or LLM-written -/// expression plausibly needs, and comfortably inside the native stack — -/// the point is to fail long before that becomes a concern either way. const MAX_DEPTH: usize = 64; pub(crate) fn parse(src: &str) -> Result { @@ -99,8 +71,6 @@ impl<'a> Parser<'a> { self.depth -= 1; } - /// Top-level rule and the guarded re-entry point for parenthesised - /// groups, each ternary branch, and every call argument. fn parse_expr(&mut self) -> Result { self.enter()?; let result = self.parse_ternary(); @@ -174,9 +144,6 @@ impl<'a> Parser<'a> { Ok(left) } - /// Guarded separately from [`Parser::parse_expr`] because a chain of - /// unary minuses (`----1`) recurses into itself directly, never passing - /// back through `parse_expr` — see the module doc. fn parse_unary(&mut self) -> Result { if matches!(self.peek(), Some(Token::Minus)) { self.advance(); @@ -185,10 +152,6 @@ impl<'a> Parser<'a> { self.exit(); return Ok(Ast::Unary(UnOp::Neg, Box::new(inner?))); } - // No unary `+`: the frozen grammar names only unary minus. Adding - // a second unguarded-by-default recursive entry point here for a - // no-op sign isn't worth the extra surface, so `+1` is a parse - // error rather than a silent alias for `1`. self.parse_primary() } @@ -285,7 +248,6 @@ mod tests { #[test] fn parses_precedence() { - // 1 + 2 * 3 == 1 + (2 * 3) let ast = parse("1 + 2 * 3").unwrap(); assert_eq!( ast, @@ -379,8 +341,6 @@ mod tests { #[test] fn huge_nested_pow_chain_hits_the_depth_cap() { - // A hostile "huge exponent" shape: pow(pow(pow(...2...))), nested - // deep enough to be the kind of input that should never reach eval. let mut src = "2".to_string(); for _ in 0..200 { src = format!("pow({src}, 2)"); diff --git a/crates/rustmotion-core/src/macros.rs b/crates/rustmotion-core/src/macros.rs index d8a86cc8..6675fe19 100644 --- a/crates/rustmotion-core/src/macros.rs +++ b/crates/rustmotion-core/src/macros.rs @@ -1,16 +1,3 @@ -/// Macro to implement common traits by delegating to embedded config fields. -/// -/// Usage: -/// ```ignore -/// impl_traits!(Text { -/// Animatable => animation, // Vec directly -/// Timed => timing, -/// Styled => style, // CssStyle -/// }); -/// ``` -/// -/// This generates both the object-safe accessor trait AND the mutable -/// builder trait (e.g. `Styled` + `StyledMut`). #[macro_export] macro_rules! impl_traits { ($type:ty { $($trait_name:ident => $field:ident),* $(,)? }) => { diff --git a/crates/rustmotion-core/src/schema/animation.rs b/crates/rustmotion-core/src/schema/animation.rs index 8cd80fed..8443de90 100644 --- a/crates/rustmotion-core/src/schema/animation.rs +++ b/crates/rustmotion-core/src/schema/animation.rs @@ -1,12 +1,6 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -// `deny_unknown_fields` (reliquat of wave-A's constat, PR #158): closes the -// last gap in `style.animation[*]` typo detection. Wave A covered the nine -// effect-config structs in `schema/video.rs`; the types *inside* a -// `keyframes[*]` entry (this struct and `Keyframe` below) were left -// uncovered — a typo'd key here (e.g. `duratoin`) used to be silently -// dropped instead of reported, same as every other struct this wave closed. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct Animation { @@ -153,7 +147,6 @@ fn default_mass() -> f64 { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum AnimationPreset { - // Entrées FadeIn, FadeInUp, FadeInDown, @@ -169,7 +162,6 @@ pub enum AnimationPreset { RotateIn, ElasticIn, PopIn, - // Sorties FadeOut, FadeOutUp, FadeOutDown, @@ -181,24 +173,19 @@ pub enum AnimationPreset { BounceOut, BlurOut, RotateOut, - // Effets continus Pulse, Float, Shake, Spin, - // 3D FlipInX, FlipInY, FlipOutX, FlipOutY, TiltIn, - // Stroke DrawIn, StrokeReveal, - // Floating/orbit #[serde(alias = "float_3d")] Float3d, - // Spéciaux Typewriter, WipeLeft, WipeRight, @@ -280,14 +267,6 @@ mod deny_unknown_fields_tests { use super::*; use serde_json::json; - // ---- reliquat of the wave-A fix (PR #158): `deny_unknown_fields` was - // added to the nine effect-config structs in `schema/video.rs`, but the - // types *inside* a `keyframes[*]` entry — `Animation` and `Keyframe`, - // both in this file — were left uncovered. A typo'd key inside one of - // these (e.g. `duratoin` on an `Animation`, or a per-keyframe field - // typo) used to be silently ignored instead of reported. This is the - // one change this workstream is authorized to make in this file. ---- - #[test] fn animation_rejects_unknown_fields() { let json = json!({ diff --git a/crates/rustmotion-core/src/schema/background.rs b/crates/rustmotion-core/src/schema/background.rs index c24b363c..36d06ff5 100644 --- a/crates/rustmotion-core/src/schema/background.rs +++ b/crates/rustmotion-core/src/schema/background.rs @@ -234,7 +234,6 @@ fn default_hero_scale() -> f32 { 1.0 } -/// Typed background preset with its config. #[derive(Debug, Clone)] pub enum BackgroundPreset { GradientShift(GradientShiftConfig), @@ -260,17 +259,12 @@ impl BackgroundPreset { } } -/// Animated background configuration for scenes. #[derive(Debug, Clone)] pub struct AnimatedBackground { pub preset: BackgroundPreset, - /// Horizontal offset (pixels). pub x: f32, - /// Vertical offset (pixels). pub y: f32, - /// Animation speed (px/sec for tiled presets, deg/sec for gradient_shift). pub speed: f32, - /// Scroll direction. pub direction: Option, } @@ -279,7 +273,6 @@ impl Serialize for AnimatedBackground { use serde::ser::SerializeMap; let mut map = serializer.serialize_map(None)?; map.serialize_entry("preset", self.preset.name())?; - // Serialize preset-specific config under its name key match &self.preset { BackgroundPreset::GradientShift(cfg) => map.serialize_entry("gradient_shift", cfg)?, BackgroundPreset::GridDots(cfg) => map.serialize_entry("grid_dots", cfg)?, @@ -305,10 +298,6 @@ impl Serialize for AnimatedBackground { } } -/// Every preset name the engine actually recognises. A `preset` value -/// outside this list — including the empty string produced when the key is -/// missing entirely — is rejected below instead of silently becoming -/// `gradient_shift` (constat #3, sink 1). const KNOWN_BACKGROUND_PRESETS: &[&str] = &[ "gradient_shift", "grid_dots", @@ -324,12 +313,8 @@ impl<'de> Deserialize<'de> for AnimatedBackground { let map: serde_json::Map = serde_json::Map::deserialize(deserializer)?; - // Common fields let x = map.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32; let y = map.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32; - // Constat #3 (related sink, fixed alongside): a mistyped `direction` - // used to be swallowed by `.ok()` into a silent `None` — same class - // as the preset/zones/colors sinks below, just on a smaller field. let direction: Option = match map.get("direction") { Some(v) => Some(serde_json::from_value(v.clone()).map_err(|e| { serde::de::Error::custom(format!("animated-background.direction: {e}")) @@ -345,17 +330,14 @@ impl<'de> Deserialize<'de> for AnimatedBackground { ))); } - // Detect new vs legacy format: new format has a sub-object keyed by preset name let is_new_format = map.get(preset_str).is_some_and(|v| v.is_object()); let (preset, speed) = if is_new_format { - // New format: config in sub-object let sub = map.get(preset_str).unwrap().clone(); let speed = map.get("speed").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32; let preset = deserialize_preset_config::(preset_str, sub)?; (preset, speed) } else { - // Legacy flat format let legacy_speed = map.get("speed").and_then(|v| v.as_f64()).unwrap_or(30.0) as f32; let preset = match preset_str { "grid_dots" => { @@ -401,14 +383,6 @@ impl<'de> Deserialize<'de> for AnimatedBackground { }) } "halo" => { - // Constat #3, sink 2: was `.ok().unwrap_or_default()` — - // a malformed (or entirely missing) `zones` silently - // became an empty halo instead of erroring. Route - // through the same validated-struct path as the - // new-format branch: `HaloConfig::zones` is required - // (no `#[serde(default)]`), so a missing/malformed value - // now produces a real "missing/invalid field zones" - // error instead. let mut obj = serde_json::Map::new(); if let Some(z) = map.get("zones") { obj.insert("zones".to_string(), z.clone()); @@ -420,12 +394,6 @@ impl<'de> Deserialize<'de> for AnimatedBackground { BackgroundPreset::Halo(cfg) } "heropattern" => { - // Constat #3 (related sink, fixed alongside): the legacy - // branch never had an arm for `heropattern` at all, so a - // *correctly spelled* `"preset": "heropattern"` written - // in the legacy flat form (no `heropattern: {...}` - // sub-object) fell through the old `_ =>` wildcard and - // silently became `gradient_shift` with `colors: []`. let mut obj = serde_json::Map::new(); for key in ["pattern", "color", "opacity", "scale"] { if let Some(v) = map.get(key) { @@ -441,13 +409,6 @@ impl<'de> Deserialize<'de> for AnimatedBackground { BackgroundPreset::Heropattern(cfg) } "gradient_shift" => { - // Constat #3, sink 3: `colors`/`gradient_type` were each - // parsed with `.ok().unwrap_or_default()` / - // `.ok().unwrap_or_else(default_bg_type)` — so even with - // `preset` spelled *correctly*, a missing or malformed - // `colors` silently produced `colors: []`, i.e. a fully - // empty gradient that paints black with no diagnostic at - // all — the exact worst-case symptom the audit names. let mut obj = serde_json::Map::new(); if let Some(c) = map.get("colors") { obj.insert("colors".to_string(), c.clone()); @@ -463,8 +424,6 @@ impl<'de> Deserialize<'de> for AnimatedBackground { })?; BackgroundPreset::GradientShift(cfg) } - // Unreachable: `preset_str` was already checked against - // `KNOWN_BACKGROUND_PRESETS` above. other => { return Err(serde::de::Error::custom(format!( "internal error: unhandled animated-background preset '{other}'" @@ -474,7 +433,6 @@ impl<'de> Deserialize<'de> for AnimatedBackground { (preset, legacy_speed) }; - // Infer legacy direction if not specified let direction = direction.or({ if speed > 0.0 && !is_new_format { match &preset { @@ -497,10 +455,6 @@ impl<'de> Deserialize<'de> for AnimatedBackground { } } -/// Deserialize the preset-specific config object for the "new" nested -/// format (`{"preset": "halo", "halo": {...}}`) — shared by -/// `AnimatedBackground::deserialize` and available for reuse. `preset_str` -/// must already be one of [`KNOWN_BACKGROUND_PRESETS`]. fn deserialize_preset_config( preset_str: &str, sub: serde_json::Value, @@ -625,7 +579,6 @@ pub struct BackgroundTransition { pub easing: EasingType, } -/// A background entry with optional template reference and transition. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BackgroundEntry { #[serde(rename = "$ref", default)] @@ -636,18 +589,6 @@ pub struct BackgroundEntry { pub overrides: serde_json::Map, } -/// Constat #5: no derived `JsonSchema` here (the `#[serde(flatten)]` map -/// makes a fully-accurate derive impossible anyway — the point of `flatten` -/// is "any other keys"), which is exactly why `Scene`/`View` reached for -/// `#[schemars(skip)]` on `background` in the first place: skip was the -/// only option with no `JsonSchema` impl to call. But `Scene`/`View` are -/// also `deny_unknown_fields` (schemars emits `additionalProperties: false` -/// for that), so skipping `background` didn't just leave it undocumented — -/// it made the *exported schema* declare invalid any scenario that actually -/// sets `scene.background` / `view.background`, which is most of them. This -/// manual impl describes the real accepted shape (`$ref` + `transition` + -/// "anything else", matching the `flatten`) so `background` can be a real -/// declared property instead. impl JsonSchema for BackgroundEntry { fn schema_name() -> String { "BackgroundEntry".to_string() @@ -667,9 +608,6 @@ impl JsonSchema for BackgroundEntry { instance_type: Some(InstanceType::Object.into()), object: Some(Box::new(ObjectValidation { properties: props, - // Mirrors `#[serde(flatten)] overrides: serde_json::Map<..>`: - // any other key (the preset config, `x`/`y`/`speed`/...) is - // genuinely accepted, not a schema gap to close. additional_properties: Some(Box::new(Schema::Bool(true))), ..Default::default() })), @@ -679,7 +617,6 @@ impl JsonSchema for BackgroundEntry { } } -/// The unified background field: color string, single entry, or multiple entries. #[derive(Debug, Clone)] pub enum BackgroundValue { Color(String), @@ -700,9 +637,6 @@ impl Serialize for BackgroundValue { } } -/// See [`BackgroundEntry`]'s `JsonSchema` impl doc comment — same reason: -/// `deserialize_background_value` is a hand-written `deserialize_with`, not -/// a derive, so there is no schema for schemars to infer without this. impl JsonSchema for BackgroundValue { fn schema_name() -> String { "BackgroundValue".to_string() @@ -734,12 +668,10 @@ impl JsonSchema for BackgroundValue { } } -/// Resolved background after template expansion — ready for rendering. #[derive(Debug, Clone, Default, Serialize)] pub struct ResolvedBackground { pub color: Option, pub animated: Vec, - /// Transition for interpolation from the previous scene's background. pub transition: Option, } @@ -772,7 +704,6 @@ fn default_bg_type() -> GradientType { GradientType::Linear } -/// Deserialize `animated-background` as either a single AnimatedBackground or a Vec. pub(crate) fn deserialize_animated_backgrounds<'de, D>( deserializer: D, ) -> Result, D::Error> @@ -809,7 +740,6 @@ where deserializer.deserialize_any(OneOrMany) } -/// Deserialize `background` as a color string, a single BackgroundEntry object, or an array. pub(crate) fn deserialize_background_value<'de, D>( deserializer: D, ) -> Result, D::Error> @@ -907,9 +837,6 @@ mod halo_zone_opacity_tests { opacity: 0.6, }; let v = serde_json::to_value(&zone).unwrap(); - // Compare as f64 with a tolerance: 0.6f32 widened to f64 is - // 0.6000000238418579, not exactly 0.6 — an f32 precision artifact, - // not a bug in the field itself. let got = v["opacity"] .as_f64() .expect("opacity must serialize as a number"); @@ -918,7 +845,6 @@ mod halo_zone_opacity_tests { #[test] fn animated_background_new_format_halo_zone_defaults_opacity() { - // New nested format: {"preset":"halo","halo":{"zones":[...]}} let bg: AnimatedBackground = serde_json::from_value(serde_json::json!({ "preset": "halo", "halo": { "zones": [{ "color": "#1E3A8A55", "x": 0.5, "y": 0.5, "radius": 0.4 }] }, @@ -929,7 +855,6 @@ mod halo_zone_opacity_tests { BackgroundPreset::Halo(cfg) => { assert_eq!(cfg.zones.len(), 1); assert_eq!(cfg.zones[0].opacity, 1.0); - // Alpha-in-hex is untouched by the schema layer — it stays in `color`. assert_eq!(cfg.zones[0].color, "#1E3A8A55"); } _ => panic!("expected Halo preset"), @@ -938,7 +863,6 @@ mod halo_zone_opacity_tests { #[test] fn animated_background_legacy_flat_format_halo_zone_defaults_opacity() { - // Legacy flat format: {"preset":"halo","zones":[...]} with no sub-object. let bg: AnimatedBackground = serde_json::from_value(serde_json::json!({ "preset": "halo", "zones": [{ "color": "#1E3A8A55", "x": 0.5, "y": 0.5, "radius": 0.4 }] @@ -953,20 +877,6 @@ mod halo_zone_opacity_tests { } } -/// Constat #3: `AnimatedBackground::deserialize` had (at least) three silent -/// sinks — an unknown `preset` name silently became `gradient_shift` with -/// `colors: []`; a malformed/mistyped `zones` array in the legacy `halo` -/// form silently emptied via `.ok().unwrap_or_default()`; and a -/// malformed/missing `colors` (or `gradient_type`) on the legacy -/// `gradient_shift` form did the exact same `.ok().unwrap_or_default()` -/// silent-empty even when `preset` was spelled *correctly* — which is the -/// worst-case symptom named in the audit: an entirely black video with zero -/// diagnostics, because an empty-colors gradient paints black. Also found -/// (and fixed alongside, same root cause: the legacy branch's `_ =>` -/// wildcard): a *correctly spelled* `"heropattern"` preset written in the -/// legacy flat form (no `heropattern: {...}` sub-object) silently fell -/// through to `gradient_shift` too, because the legacy match only had -/// explicit arms for `grid_dots`/`concentric_circles`/`halo`. #[cfg(test)] mod animated_background_silent_sink_tests { use super::*; @@ -1049,10 +959,6 @@ mod animated_background_silent_sink_tests { #[test] fn legacy_gradient_shift_missing_colors_is_a_named_error_not_a_silent_black_gradient() { - // This is the exact worst-case symptom the audit names: preset is - // spelled *correctly*, but colors is missing/malformed -> silently - // empty colors -> a fully transparent gradient that paints black, - // with no diagnostic at all. let err = serde_json::from_value::(json!({ "preset": "gradient_shift", "speed": 5 diff --git a/crates/rustmotion-core/src/schema/scenario.rs b/crates/rustmotion-core/src/schema/scenario.rs index f8267ba2..309223bb 100644 --- a/crates/rustmotion-core/src/schema/scenario.rs +++ b/crates/rustmotion-core/src/schema/scenario.rs @@ -164,11 +164,6 @@ pub struct Scenario { pub vars: crate::vars::VarSet, } -/// The wire shape of [`Scenario`] — identical field-for-field, attribute-for- -/// attribute — used only to give `Scenario` a `Deserialize` impl that runs -/// [`Scenario::propagate_time_ctx`] before handing the value to its caller. -/// Kept private: nothing outside this module should ever construct or see -/// one directly. #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct ScenarioDe { @@ -235,22 +230,6 @@ impl<'de> Deserialize<'de> for Scenario { } impl Scenario { - /// Stamps every scene reachable from this `Scenario` — its own - /// top-level `scenes`, and every view's `scenes` under `composition` — - /// with this scenario's own `bpm`/`beat_offset`/`timing`/`snap`, via - /// [`Scene::resolved_time_ctx`]/[`Scene::resolved_timing`]/ - /// [`Scene::resolved_snap`], and (issue #329) with this scenario's own - /// `vars` via [`Scene::resolved_scenario_vars`] — same reasoning, same - /// mechanism, reused rather than duplicated. Runs once, inside - /// [`Scenario::deserialize`], so it applies uniformly whether the - /// scenario came from a file, a remote `include`, or an inline `--json` - /// string. - /// - /// An `include`d file is deserialized as its own `Scenario` (see - /// `include.rs`), so this only ever propagates a scenario's own - /// declared grid — and its own `vars` — to its own scenes: an included - /// file that wants beat-synced content must declare its own `bpm`, and - /// one that wants scenario-level variables must declare its own `vars`. fn propagate_time_ctx(&mut self) { let ctx = super::time::TimeCtx { bpm: self.bpm, @@ -269,11 +248,6 @@ impl Scenario { } } -/// Stamps every [`Scene`] in `entries` (skipping `Include` directives, which -/// carry no `Scene` yet) with the given beat-grid context and scenario-level -/// variables. Free function rather than a closure over `&mut self` so it can -/// be called once for the top-level `scenes` and once per `composition` view -/// without fighting the borrow checker over `self`. fn stamp_entries( entries: &mut [SceneEntry], ctx: super::time::TimeCtx, @@ -393,15 +367,6 @@ pub struct View { #[serde(default)] pub transition: Option, /// (world) Shared background: color string, animated entry, or array. - // Constat #5: `background`'s `deserialize_with` bypasses the normal - // derive, so schemars had nothing to infer a schema from — hence the - // `#[schemars(skip)]` this used to carry. But `View` is also - // `deny_unknown_fields` (-> `additionalProperties: false` in the - // exported schema), so skipping the property didn't just leave it - // undocumented: it made the exported schema declare invalid every view - // that actually sets `background`. `BackgroundValue` now has a real - // (manual) `JsonSchema` impl — see `background.rs` — so this can be a - // normal declared property again. #[serde(default, deserialize_with = "deserialize_background_value")] pub background: Option, /// (world) Legacy shared animated backgrounds. @@ -419,34 +384,20 @@ pub struct View { pub camera_pan_duration: f64, } -/// A scenario with all includes expanded — safe to pass to the rendering pipeline. -/// -/// Deliberately does **not** carry `bpm`/`beat_offset`/`timing`/`snap` -/// itself (issue #336): `include.rs`, which builds this struct, is a file -/// no single workstream owns in this wave, so those scenario-level values -/// are threaded through per-[`Scene`] instead — -/// [`Scene::resolved_time_ctx`], [`Scene::resolved_timing`], -/// [`Scene::resolved_snap`] — populated once, for every scene reachable -/// from a `Scenario` (including through `composition`), by -/// `Scenario`'s own `Deserialize` impl. That happens before `include.rs` -/// ever runs, so it needs no cooperation from that file. #[derive(Debug)] pub struct ResolvedScenario { pub video: VideoConfig, pub audio: Vec, pub fonts: Vec, pub views: Vec, - /// Local file paths that were included during resolution (for watch mode). pub included_paths: Vec, } impl ResolvedScenario { - /// Iterate over all scenes across all views (for prefetch, validation, etc.) pub fn all_scenes(&self) -> impl Iterator { self.views.iter().flat_map(|v| v.scenes.iter()) } - /// Collect all scenes into a flat Vec (for indexed access) #[allow(dead_code)] pub fn all_scenes_vec(&self) -> Vec<&Scene> { self.all_scenes().collect() @@ -473,7 +424,7 @@ pub struct ResolvedView { /// (see its doc comment for why — M6, issue #110). #[derive(Debug, Serialize, Deserialize, JsonSchema)] #[serde(untagged)] -#[allow(clippy::large_enum_variant)] // untagged serde enum; boxing Scene would break all match arms +#[allow(clippy::large_enum_variant)] pub enum SceneEntry { /// A regular scene defined inline. Scene(Scene), @@ -481,28 +432,6 @@ pub enum SceneEntry { Include(IncludeDirective), } -/// Deserializer for `Scenario.scenes` / `View.scenes`, used in place of -/// `SceneEntry`'s derived `#[serde(untagged)]` deserialization (M6, issue -/// #110 / #102). -/// -/// `#[serde(untagged)]` deserializes by trying each variant in declaration -/// order and keeping the first one that succeeds; when *all* variants fail -/// (e.g. a scene missing its required `duration`, or a `transition.type` -/// typo three levels down) serde discards every per-variant error and -/// reports only `data did not match any variant of untagged enum -/// SceneEntry` — no scene index, no field name. The wave-2 audit called -/// this the worst diagnostic in the product. -/// -/// The two variants are unambiguous by shape — `IncludeDirective`'s only -/// required field is `include`; a `Scene` never has that key — so this -/// classifies each entry explicitly instead of trying-and-discarding, then -/// deserializes it as its concrete type and reports *that* type's real -/// error, prefixed with the entry's index in the `scenes` array. -/// -/// Note on precision: this still deserializes from an already-parsed -/// [`serde_json::Value`] (as the untagged path did too), which has no -/// source line/column to report — the fix here is naming the scene index -/// and field, not a source position that doesn't exist at this layer. fn deserialize_scene_entries<'de, D>(deserializer: D) -> Result, D::Error> where D: serde::Deserializer<'de>, @@ -582,8 +511,6 @@ pub enum AudioValue { Config(Box), } -/// Not derivable: `#[derive(Default)]`'s `#[default]` attribute only -/// accepts a unit variant, and [`AudioValue::Tracks`] carries a `Vec`. impl Default for AudioValue { fn default() -> Self { AudioValue::Tracks(Vec::new()) @@ -591,12 +518,6 @@ impl Default for AudioValue { } impl AudioValue { - /// The file-based tracks this value carries — all of them for - /// [`AudioValue::Tracks`], just the `tracks` field for - /// [`AudioValue::Config`]. What `include::resolve_includes` (outside - /// this workstream's owned files) merges across `include`d scenarios; - /// the object form's `voices`/`score`/`master` are deliberately not - /// part of that merge — see [`AudioValue::config`]'s doc. pub fn tracks(&self) -> &[AudioTrack] { match self { AudioValue::Tracks(tracks) => tracks, @@ -611,14 +532,6 @@ impl AudioValue { } } - /// The object form's full config, when `audio` is one — `None` for the - /// legacy array form. A synthesised score only ever comes from the - /// *root* scenario's own `audio`: `include.rs` merges `tracks()` across - /// `include`d files (same as before this issue), but has no equivalent - /// merge for a score, the same boundary `Scenario::bpm`/`beat_offset` - /// already draw (see `Scenario::propagate_time_ctx`'s doc) — an - /// included file that wants its own synthesised audio would need its - /// own render pass, out of scope here. pub fn config(&self) -> Option<&AudioConfig> { match self { AudioValue::Tracks(_) => None, @@ -657,18 +570,10 @@ pub struct AudioConfig { } impl AudioConfig { - /// Whether this config declares anything to synthesise — `false` for a - /// value that only uses the object form to carry `tracks` (e.g. to set - /// `bpm` alongside a purely file-based scenario, before `voices`/ - /// `score` are ever added). pub fn has_synth(&self) -> bool { !self.voices.is_empty() || !self.score.is_empty() } - /// Packages `voices`/`score`/`master` into a [`crate::audio::Score`] - /// for [`crate::audio::synth::render`]. Does not resolve `bpm`/ - /// `beat_offset` — see this struct's doc for why that fallback lives - /// with the caller instead. pub fn as_score(&self) -> crate::audio::Score { crate::audio::Score { voices: self.voices.clone(), @@ -736,10 +641,6 @@ pub struct WorldPosition { pub struct Scene { pub duration: f64, /// Unified background: color string, animated entry (with optional $ref), or array. - // Constat #5: see the identical note on `View::background` — same - // `#[schemars(skip)]` + `deny_unknown_fields` combination made the - // exported schema declare invalid every `examples/*.json` scene that - // sets `background` (which is most of them). #[serde(default, deserialize_with = "deserialize_background_value")] pub background: Option, #[serde(default)] @@ -875,9 +776,6 @@ pub enum SceneStart { At(TimePoint), } -/// The wire shape of [`SceneStart`] — see that type's doc for why this -/// exists instead of a derive. Kept private: nothing outside this module -/// should ever see one directly. #[derive(Deserialize)] #[serde(untagged)] enum SceneStartDe { @@ -969,14 +867,6 @@ pub struct CameraOrigin { pub y: f32, } -/// Every camera property `interpolate_camera_property` -/// (`crates/rustmotion/src/engine/render/scene.rs`, owned by the sibling -/// GEO workstream this wave — read-only here) actually looks up via -/// `camera.keyframes.iter().find(|k| k.property == property)`. Constat #4: -/// a `CameraKeyframe.property` outside this fixed set (or the dotted -/// `origin.x`/`origin.y` convention misspelled as `origin_x`/`originX`) -/// never matches that lookup — the keyframe track is silently ignored and -/// the camera just uses its static value for that property, with no error. const KNOWN_CAMERA_PROPERTIES: &[&str] = &["x", "y", "zoom", "rotation", "origin.x", "origin.y"]; fn validate_camera_property(value: &str) -> Result<(), E> { @@ -1317,8 +1207,6 @@ pub enum VideoCodec { Prores, } -// --- Default functions --- - fn default_version() -> String { "1.0".to_string() } @@ -1403,12 +1291,6 @@ mod annotation_tests { } } -/// M6 (issue #110 / #102): `SceneEntry` used to be a bare `#[serde(untagged)]` -/// enum, so a bad `scenes[]` entry collapsed to "data did not match any -/// variant of untagged enum SceneEntry" — no scene index, no field name. -/// `deserialize_scene_entries` replaces the auto-try-each-variant behaviour -/// with an explicit classify-then-deserialize pass that keeps the real -/// per-scene error and prefixes it with the entry's index. #[cfg(test)] mod scene_entry_error_tests { use super::*; @@ -1481,8 +1363,6 @@ mod scene_entry_error_tests { #[test] fn broken_include_directive_names_itself() { - // `scenes` on IncludeDirective must be an array of indices — a typo'd - // shape should not silently be mistaken for a Scene. let json = r#"{ "video": { "width": 100, "height": 100 }, "scenes": [ @@ -1514,12 +1394,6 @@ mod scene_entry_error_tests { } } -/// M5 (issue #110 / #102): the unknown-attribute checker only ever inspected -/// the top level of each *component*; a typo in `Scenario`/`Scene`/`View`/ -/// `VideoConfig`/`SceneLayout`/`Transition`/`Camera` (e.g. `durration` on a -/// scene, `framerate` on `video`) passed silently because nothing checked -/// those structs at all. `deny_unknown_fields` closes that gap directly at -/// parse time, for every one of them. #[cfg(test)] mod strict_schema_tests { use super::*; @@ -1595,9 +1469,6 @@ mod strict_schema_tests { #[test] fn valid_scenario_with_every_covered_struct_still_parses() { - // Regression guard: deny_unknown_fields must not reject any - // currently-valid field across Scenario/Scene/View/VideoConfig/ - // SceneLayout/Transition/Camera. let json = r##"{ "version": "1.0", "video": { "width": 100, "height": 100, "fps": 30, "background": "#000000" }, @@ -1614,16 +1485,6 @@ mod strict_schema_tests { } } -/// Constat #4 (camera half): `CameraKeyframe.property` is consumed by -/// `interpolate_camera_property` in `crates/rustmotion/src/engine/render/ -/// scene.rs` (read-only for this workstream — owned by the sibling GEO -/// workstream this wave), which looks up -/// `camera.keyframes.iter().find(|k| k.property == property)` for each of a -/// *fixed* set of six properties (`"x"`, `"y"`, `"zoom"`, `"rotation"`, -/// `"origin.x"`, `"origin.y"`). A misspelled or wrongly-cased -/// `CameraKeyframe.property` simply never matches that lookup — the track -/// silently falls back to the camera's static value and never animates, -/// with no error anywhere. #[cfg(test)] mod camera_keyframe_property_tests { use super::*; @@ -1651,11 +1512,6 @@ mod camera_keyframe_property_tests { #[test] fn misspelled_origin_property_is_a_named_error() { - // The documented dotted-compound-property convention - // (`origin.x`/`origin.y`) is easy to get wrong (`originX`, - // `origin_x`) — before this fix, any of those silently never - // animated the camera origin, with the keyframes block accepted - // and simply ignored. let json = r#"{ "property": "origin_x", "values": [ { "time": 0.0, "value": 1.0 } ] }"#; let err = serde_json::from_str::(json) .expect_err("origin_x must be rejected — the real property is origin.x"); @@ -1668,11 +1524,6 @@ mod camera_keyframe_property_tests { } } -/// Issue #336 follow-up: an unresolvable `Scene.at` must be a hard, -/// located error, not a silent fallback — grammar at deserialize time, -/// `unresolved_beat_unit` (needs `bpm`) at `rustmotion validate`'s schema -/// pass (`crates/rustmotion/src/cli/commands/validate_schema.rs`, not -/// exercised from this crate). #[cfg(test)] mod scene_at_grammar_tests { use super::*; @@ -1704,8 +1555,6 @@ mod scene_at_grammar_tests { #[test] fn grammatically_valid_beat_unit_deserializes_even_with_no_bpm() { - // Resolving "@8b" needs `bpm`; the grammar alone does not, and - // resolution is a separate, later step (validate's schema pass). let json = scenario_with_at(r#""@8b""#); let scenario: Scenario = serde_json::from_str(&json).expect("grammar-valid `at` must deserialize"); @@ -1733,7 +1582,6 @@ mod scene_at_grammar_tests { } } -/// Issue #330: `Scene.shake` and `PostEffect::Flash`. #[cfg(test)] mod scene_shake_and_flash_tests { use super::*; @@ -1774,9 +1622,6 @@ mod scene_shake_and_flash_tests { #[test] fn scene_shake_and_camera_coexist_on_the_same_scene() { - // Additive over `camera` (issue #330's own requirement): a scene - // must be able to declare both without either being rejected or - // silently dropping the other. let json = r#"{ "duration": 2.0, "children": [], diff --git a/crates/rustmotion-core/src/schema/shake.rs b/crates/rustmotion-core/src/schema/shake.rs index 0952200f..12a8b247 100644 --- a/crates/rustmotion-core/src/schema/shake.rs +++ b/crates/rustmotion-core/src/schema/shake.rs @@ -1,13 +1,3 @@ -//! Declarative camera shake (issue #330): a list of beat-synced impacts -//! instead of a hand-generated camera keyframe track — the reel this -//! feature was built for spent 155 sampled keyframes (a Python loop -//! evaluating a damped sine, pasted into the JSON) on what six `{at, -//! amplitude}` pairs now express directly. -//! -//! See [`SceneShake`] for the damped-oscillation formula and -//! `crate::engine::shake::shake_offset` for the function that evaluates it -//! at a given time. - use schemars::JsonSchema; use serde::{Deserialize, Serialize}; diff --git a/crates/rustmotion-core/src/schema/style.rs b/crates/rustmotion-core/src/schema/style.rs index 5735cda4..9cf13323 100644 --- a/crates/rustmotion-core/src/schema/style.rs +++ b/crates/rustmotion-core/src/schema/style.rs @@ -3,8 +3,6 @@ use serde::{Deserialize, Serialize}; use super::video::AnimationEffect; -// --- Card types --- - #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] #[derive(Default)] @@ -207,7 +205,6 @@ impl<'de> Deserialize<'de> for SizeDimension { } } -/// Deserialize `animation` as either a single AnimationEffect or a Vec. pub fn deserialize_animation_effects<'de, D>( deserializer: D, ) -> Result, D::Error> diff --git a/crates/rustmotion-core/src/schema/time.rs b/crates/rustmotion-core/src/schema/time.rs index f6a9d804..5eece82d 100644 --- a/crates/rustmotion-core/src/schema/time.rs +++ b/crates/rustmotion-core/src/schema/time.rs @@ -1,45 +1,13 @@ -//! The beat-grid time vocabulary (issue #336): a single point in time that -//! can be a plain number of seconds, or a small expression anchored to a -//! musical beat grid — so a cut can land on a beat instead of a -//! hand-tuned duration. -//! -//! **The one rule for anchoring** (stated once, here, and referenced rather -//! than repeated elsewhere): [`Scene::at`](super::scenario::Scene::at) is -//! absolute by definition — it always resolves via -//! [`TimePoint::resolve_absolute`], regardless of whether its value carries -//! the `@` prefix. Everything *inside* a scene (a component's `start_at`, -//! `delay`, and similar fields) is relative to that scene's own start, and -//! resolves via [`TimePoint::resolve_relative`] — *unless* its `TimePoint` -//! carries an explicit `@` prefix, which forces it onto the scenario's -//! absolute timeline regardless of where it is nested. - use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -/// What a [`TimePoint`] needs to resolve to a concrete number of seconds: -/// the scenario's beat grid, and where the enclosing scene starts on the -/// scenario's absolute timeline. #[derive(Debug, Clone, Copy, PartialEq)] pub struct TimeCtx { - /// Beats per minute, if the scenario declares a grid. `None` makes any - /// `b`-unit term in a [`TimePoint::Spec`] fail with [`TimeError::NoBpm`]. pub bpm: Option, - /// Where beat 0 sits on the scenario's absolute timeline, in seconds. - /// The grid is `beat_offset + n * 60 / bpm` — not decoration: a reel - /// whose first kick lands at 2.2s anchors its grid there, not at 0. pub beat_offset: f64, - /// Where the enclosing scene starts on the scenario's absolute - /// timeline, in seconds. Only consulted by - /// [`TimePoint::resolve_relative`]/[`TimePoint::resolve_absolute`] for a - /// [`TimePoint`] that is *not* itself absolute (no `@` prefix). pub scene_start: f64, } -/// No grid, no offset, no enclosing scene — the neutral context a plain -/// number of seconds resolves against unchanged. Exists so -/// [`crate::schema::Scene`]'s `#[serde(skip)]` copy of this type has -/// something to default to before `Scenario`'s `Deserialize` impl -/// overwrites it with the real value. impl Default for TimeCtx { fn default() -> Self { TimeCtx { @@ -50,15 +18,10 @@ impl Default for TimeCtx { } } -/// Everything that can go wrong turning a [`TimePoint`] into seconds. #[derive(Debug, Clone, PartialEq, thiserror::Error)] pub enum TimeError { - /// A `b`-unit term appeared in `{0}`, but [`TimeCtx::bpm`] is `None` — - /// the scenario never declared a beat grid to measure beats against. #[error("beat unit in `{0}` but the scenario declares no bpm")] NoBpm(String), - /// `{0}` is not a valid [`TimePoint::Spec`]: empty, a term with no - /// recognised unit (`s`/`ms`/`b`), or a term whose number doesn't parse. #[error("cannot parse time `{0}`")] Unparseable(String), } @@ -93,24 +56,10 @@ pub enum TimePoint { } impl TimePoint { - /// Seconds from `ctx.scene_start` — the natural reading for a value - /// nested inside a scene (`start_at`, `delay`, ...). A [`Spec`](TimePoint::Spec) - /// carrying the `@` prefix ignores `scene_start` and returns the same - /// absolute value [`resolve_absolute`](Self::resolve_absolute) would — - /// that is the whole point of the prefix. pub fn resolve_relative(&self, ctx: &TimeCtx) -> Result { - // `eval_sum` is already the delta from `scene_start` for a - // non-absolute value, and already the absolute value (with - // `scene_start` deliberately not added) for an `@`-prefixed one — - // both cases return it unchanged. self.eval_sum(ctx) } - /// Seconds from the scenario's start — the natural reading for - /// [`Scene::at`](super::scenario::Scene::at), which is absolute by - /// definition. A non-absolute value (no `@`) is anchored onto the - /// timeline by adding `ctx.scene_start`; an absolute one (`@`-prefixed, - /// or already a beat/second sum with `@`) is returned as-is. pub fn resolve_absolute(&self, ctx: &TimeCtx) -> Result { let value = self.eval_sum(ctx)?; if self.is_absolute() { @@ -120,11 +69,6 @@ impl TimePoint { } } - /// Whether this value is pinned to the scenario's absolute timeline - /// regardless of where it is nested — true only for a - /// [`Spec`](TimePoint::Spec) whose string starts with `@`. A bare - /// [`Seconds`](TimePoint::Seconds) is never absolute: it always resolves - /// relative to whatever `scene_start` its caller provides. pub fn is_absolute(&self) -> bool { match self { TimePoint::Seconds(_) => false, @@ -132,9 +76,6 @@ impl TimePoint { } } - /// The parsed value of this `TimePoint` in seconds, with `@` already - /// stripped — i.e. before either resolve method decides whether to add - /// `ctx.scene_start`. fn eval_sum(&self, ctx: &TimeCtx) -> Result { match self { TimePoint::Seconds(seconds) => Ok(*seconds), @@ -142,25 +83,6 @@ impl TimePoint { } } - /// Checks that this value is syntactically well-formed **without** - /// needing `bpm` — every term parses (``, a recognised - /// unit, a non-empty numeric part) whether or not a `b` term could - /// actually be *resolved* yet. This is deliberately the weaker of the - /// two checks a `b` term is subject to: - /// - /// - Grammar (this method) never needs `bpm` and can run the moment a - /// scenario is deserialized — a malformed spec like `"banana"` is a - /// typo, true regardless of what the scenario declares. - /// - Resolution (`resolve_relative`/`resolve_absolute`) needs `bpm` for - /// any `b` term, and can only run once a whole `Scenario` — not just - /// this value in isolation — is available. A grammatically valid `"@8b"` - /// in a scenario with no `bpm` fails *there*, with - /// [`TimeError::NoBpm`], not here. - /// - /// Implemented by evaluating against a placeholder context that always - /// supplies a `bpm` (so [`TimeError::NoBpm`] can never fire) — any error - /// that still comes out is therefore, by construction, a genuine - /// [`TimeError::Unparseable`]. pub fn validate_grammar(&self) -> Result<(), TimeError> { match self { TimePoint::Seconds(_) => Ok(()), @@ -189,10 +111,6 @@ enum TimeUnit { Beats, } -/// Splits `body` (already stripped of any leading `@`) into signed terms — -/// `('+' | '-' | , "")` — by scanning for -/// `+`/`-` operators. A `+`/`-` at position 0 is the first term's own sign, -/// not an operator between two terms. fn split_signed_terms(body: &str) -> Vec<(f64, String)> { let mut terms = Vec::new(); let mut sign = 1.0; @@ -212,9 +130,6 @@ fn split_signed_terms(body: &str) -> Vec<(f64, String)> { terms } -/// Splits a single term's trailing unit off its numeric part. Order matters: -/// `"ms"` is checked before the single-character `"s"`, or every -/// millisecond term would be misparsed as a malformed seconds term. fn split_unit(term: &str) -> Option<(&str, TimeUnit)> { if let Some(number) = term.strip_suffix("ms") { Some((number, TimeUnit::Millis)) @@ -301,16 +216,14 @@ mod tests { #[test] fn beat_spec_resolves_with_beat_offset() { - // bpm=120 -> 0.5s/beat; beat_offset=2.2 (the reel's real anchor). let tp = TimePoint::Spec("8b".to_string()); let c = ctx(Some(120.0), 2.2, 0.0); - let expected = 2.2 + 8.0 * 60.0 / 120.0; // 2.2 + 4.0 = 6.2 + let expected = 2.2 + 8.0 * 60.0 / 120.0; assert!((tp.resolve_relative(&c).unwrap() - expected).abs() < 1e-9); } #[test] fn compound_beat_plus_millis_spec_resolves() { - // bpm=120, beat_offset=0 -> beat 8 lands at 4.0s; +120ms -> 4.12s. let tp = TimePoint::Spec("8b+120ms".to_string()); let c = ctx(Some(120.0), 0.0, 0.0); assert!((tp.resolve_relative(&c).unwrap() - 4.12).abs() < 1e-9); @@ -320,8 +233,7 @@ mod tests { fn at_prefix_is_absolute_and_ignores_scene_start() { let tp = TimePoint::Spec("@8b".to_string()); assert!(tp.is_absolute()); - // bpm=100, beat_offset=2.2 -> beat 8 at 2.2 + 8*0.6 = 7.0s. - let c = ctx(Some(100.0), 2.2, 1000.0); // absurd scene_start to prove it's ignored + let c = ctx(Some(100.0), 2.2, 1000.0); let expected = 2.2 + 8.0 * 60.0 / 100.0; assert!((tp.resolve_relative(&c).unwrap() - expected).abs() < 1e-9); assert!((tp.resolve_absolute(&c).unwrap() - expected).abs() < 1e-9); @@ -329,7 +241,6 @@ mod tests { #[test] fn at_prefix_with_subtracted_beat_term() { - // bpm=120, beat_offset=0 -> "@2.2s-1b" = 2.2 - (0 + 1*0.5) = 1.7 let tp = TimePoint::Spec("@2.2s-1b".to_string()); let c = ctx(Some(120.0), 0.0, 0.0); assert!((tp.resolve_absolute(&c).unwrap() - 1.7).abs() < 1e-9); @@ -415,8 +326,6 @@ mod tests { #[test] fn validate_grammar_never_reports_no_bpm() { - // A beat unit's *grammar* is fine with no scenario in scope at all — - // only resolving it to a number needs `bpm`. let tp = TimePoint::Spec("@8b".to_string()); assert_eq!(tp.validate_grammar(), Ok(())); } diff --git a/crates/rustmotion-core/src/schema/video.rs b/crates/rustmotion-core/src/schema/video.rs index d93a7321..7e297947 100644 --- a/crates/rustmotion-core/src/schema/video.rs +++ b/crates/rustmotion-core/src/schema/video.rs @@ -5,8 +5,6 @@ use skia_safe::Path as SkiaPath; use super::animation::{Animation, AnimationPreset, EasingType, PresetConfig, SpringConfig}; use super::style::{FontWeight, TextAlign, VerticalAlign}; -// --- Animation effects (nested inside CssStyle as typed array) --- - /// A single animation effect. Discriminated by `"type"` in JSON. /// Each preset name is a valid type, plus special types: glow, wiggle, keyframes, motion_blur. /// @@ -19,7 +17,6 @@ use super::style::{FontWeight, TextAlign, VerticalAlign}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(tag = "name", rename_all = "snake_case")] pub enum AnimationEffect { - // --- Entrance presets --- FadeIn(AnimationTiming), FadeInUp(AnimationTiming), FadeInDown(AnimationTiming), @@ -40,7 +37,6 @@ pub enum AnimationEffect { /// element. `AnimationTiming.overshoot` sets the pulse amplitude /// (default 0.18 = 118%); 0 reduces it to a plain back-out scale-in. PopIn(AnimationTiming), - // --- Exit presets --- FadeOut(AnimationTiming), FadeOutUp(AnimationTiming), FadeOutDown(AnimationTiming), @@ -52,28 +48,22 @@ pub enum AnimationEffect { BounceOut(AnimationTiming), BlurOut(AnimationTiming), RotateOut(AnimationTiming), - // --- Continuous presets --- Pulse(AnimationTiming), Float(AnimationTiming), Shake(AnimationTiming), Spin(AnimationTiming), - // --- 3D presets --- FlipInX(AnimationTiming), FlipInY(AnimationTiming), FlipOutX(AnimationTiming), FlipOutY(AnimationTiming), TiltIn(TiltInConfig), - // --- Stroke presets --- DrawIn(AnimationTiming), StrokeReveal(AnimationTiming), - // --- Special presets --- Typewriter(AnimationTiming), WipeLeft(AnimationTiming), WipeRight(AnimationTiming), - // --- Floating/orbit presets --- #[serde(alias = "float_3d")] Float3d(AnimationTiming), - // --- Char animation presets --- CharScaleIn(CharAnimationTiming), CharFadeIn(CharAnimationTiming), CharWave(CharAnimationTiming), @@ -94,7 +84,6 @@ pub enum AnimationEffect { /// used to be read directly off `style.animation` inside /// `rustmotion_components::text::Text::paint` and therefore missed both.) CharBlurIn(CharAnimationTiming), - // --- Non-preset effects --- Glow(GlowConfig), /// A band of light sweeping across the element's own painted pixels. /// See [`ShimmerConfig`]. @@ -115,10 +104,6 @@ pub enum AnimationEffect { } impl AnimationEffect { - /// Shift the effect's start delay by `by` seconds. Used by `timeline` - /// steps, whose animations run relative to the step's `at`. Continuous - /// effects without a delay concept (glow, wiggle, orbit, motion blur) - /// are unaffected. pub fn shift_delay(&mut self, by: f64) { use AnimationEffect::*; match self { @@ -140,7 +125,6 @@ impl AnimationEffect { } } - /// If this is a preset variant, return the corresponding AnimationPreset and timing. pub fn as_preset(&self) -> Option<(AnimationPreset, &AnimationTiming)> { match self { Self::FadeIn(t) => Some((AnimationPreset::FadeIn, t)), @@ -190,15 +174,6 @@ impl AnimationEffect { } /// Timing configuration for preset animations. -// `deny_unknown_fields` (constat #8): this is the `AnimationTiming` payload -// of an internally-tagged `AnimationEffect` variant (`#[serde(tag = "name")]` -// on the enum). Serde's tagged-enum deserializer buffers the object and -// re-drives it through the variant's own `Deserialize` impl *without* the -// `name` tag key, so `deny_unknown_fields` here rejects a typo'd field (e.g. -// `duratoin`) without ever seeing/rejecting `name` itself — verified with a -// minimal repro before relying on it. Without this, `validate_attrs.rs` -// never sees inside `style.animation[*]` (it only walks component-level -// keys), so a typo silently no-ops instead of erroring. #[derive(Debug, Clone, JsonSchema, PartialEq)] pub struct AnimationTiming { /// Delay before animation starts (seconds). @@ -265,13 +240,6 @@ fn default_animation_duration() -> f64 { 0.8 } -/// The `"loop"` field as written in JSON: a bare boolean — `true` loops -/// forever, `false` (the default) plays once, exactly as before this type -/// widened — or a positive integer naming an exact play count. Only ever -/// used as the wire shape [`AnimationTimingWire`] folds into -/// [`AnimationTiming::repeat`]/[`AnimationTiming::repeat_count`] (and back, -/// for `Serialize` — see [`RepeatSpec::from_parts`]); nothing downstream -/// matches on this type directly. #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] #[serde(untagged)] enum RepeatSpec { @@ -286,9 +254,6 @@ impl Default for RepeatSpec { } impl RepeatSpec { - /// Splits the wire value into `AnimationTiming`'s two fields. `0`/`1` - /// fold back to the boolean form: a count only starts meaning anything - /// once there's a second play to differ from the first. fn into_parts(self) -> (bool, Option) { match self { RepeatSpec::Loop(b) => (b, None), @@ -297,11 +262,6 @@ impl RepeatSpec { } } - /// Inverse of [`Self::into_parts`]: reconstructs the wire value that - /// would have produced this `(repeat, repeat_count)` pair, so - /// `AnimationTiming`'s hand-written `Serialize` impl round-trips - /// through the same single `"loop"` key its `Deserialize` impl reads — - /// never a separate `repeat_count` key alongside it. fn from_parts(repeat: bool, repeat_count: Option) -> Self { match repeat_count { Some(n) => RepeatSpec::Count(n), @@ -310,16 +270,6 @@ impl RepeatSpec { } } -/// The wire shape of [`AnimationTiming`] — identical field-for-field except -/// `"loop"`, which is [`RepeatSpec`] here instead of the plain `bool` -/// [`AnimationTiming::repeat`] exposes. Exists only to give -/// `AnimationTiming` hand-written `Serialize`/`Deserialize` impls that can -/// split one JSON key into two Rust fields (`repeat`/`repeat_count`) and -/// merge them back — a derive can't express that. Kept private: nothing -/// outside this module should ever construct or see one directly. Carries -/// its own `deny_unknown_fields` so a typo'd field is still rejected -/// exactly as it was before this type existed (constat #8's guarantee, -/// preserved). #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct AnimationTimingWire { @@ -502,8 +452,6 @@ pub struct CharAnimationTiming { } impl Default for CharAnimationTiming { - /// Mirrors the serde defaults exactly, so `CharAnimationTiming::default()` - /// and `serde_json::from_value(json!({}))` describe the same animation. fn default() -> Self { Self { delay: 0.0, @@ -591,8 +539,6 @@ pub enum TextAnimDirection { } impl TextAnimDirection { - /// The `(x, y)` offset, in px, a unit sits at when its animation has not - /// started yet. `travel` is the distance the unit covers. pub fn offset(self, travel: f32) -> (f32, f32) { match self { Self::Up => (0.0, travel), @@ -639,7 +585,6 @@ fn default_char_duration() -> f32 { } impl AnimationTiming { - /// Convert to PresetConfig for compatibility with resolve_animations. pub fn to_preset_config(&self) -> PresetConfig { PresetConfig { amplitude: self.amplitude, @@ -655,24 +600,6 @@ impl AnimationTiming { } } -/// Constat #4: every `property` name `engine::animator::{apply_property, -/// get_property_value}` (read-only for this workstream — the solver logic -/// itself stays there) actually recognises for `wiggle`/`keyframes` -/// animations. Anything outside this set has always been a silent no-op in -/// the solver (`_ => {}` / `_ => 0.0`): the animation plays as if the -/// property doesn't exist, with no error and no visual signal that -/// something is wrong. `WiggleConfig.property` and `Animation.property` -/// (the latter via `KeyframesConfig.keyframes`'s `deserialize_with`, since -/// `Animation` itself lives in `schema/animation.rs`, which this workstream -/// may only touch for `deny_unknown_fields`) are validated against this set -/// at parse time instead — turning the silent no-op into a named error, so -/// a mixed-convention typo (`"translateX"`, `"positionX"`, `"Rotation"`) or -/// a wholesale unsupported name is caught immediately. -/// -/// `"color"` is included because `resolve_animations` special-cases -/// `anim.property == "color"` outside `apply_property`/`get_property_value` -/// — it is a real, solver-recognised value for `Animation`, just resolved on -/// a different path than the numeric properties. const KNOWN_MOTION_PROPERTIES: &[&str] = &[ "opacity", "position.x", @@ -704,13 +631,6 @@ const KNOWN_MOTION_PROPERTIES: &[&str] = &[ "color", ]; -/// Reject a `property` value the solver doesn't recognise, with a -/// "did-you-mean" nudge when the only mismatch is casing/separator -/// convention (`translateX` / `translate-x` vs `translate_x`) — the exact -/// trap constat #4 names: this project mixes kebab-case (CSS-style, most of -/// `CssStyle`) and snake_case (these property names) conventions, and an -/// author reasoning from the former naturally reaches for the latter's -/// kebab or camelCase spelling. fn validate_motion_property(value: &str) -> Result<(), E> { if KNOWN_MOTION_PROPERTIES.contains(&value) { return Ok(()); @@ -741,11 +661,6 @@ where Ok(s) } -/// Validates every keyframe's `property` the same way -/// [`deserialize_motion_property`] does for `WiggleConfig` — `Animation` -/// itself lives in `schema/animation.rs`, out of reach for anything beyond -/// `deny_unknown_fields` in this workstream, so the check is applied here, -/// at the one field that actually consumes `Vec` in this file. fn deserialize_validated_keyframes<'de, D>(deserializer: D) -> Result, D::Error> where D: serde::Deserializer<'de>, @@ -836,8 +751,6 @@ fn default_trail_falloff() -> f32 { 0.6 } -// --- Orbit Config --- - /// Configuration for a 3D orbit/floating animation effect. /// Creates circular or elliptical motion with pseudo-depth (scale + opacity modulation). #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] @@ -879,8 +792,6 @@ fn default_orbit_depth() -> f64 { 0.15 } -// --- Wiggle Config --- - #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct WiggleConfig { @@ -907,8 +818,6 @@ pub struct WiggleConfig { pub mode: Option, } -// --- Motion Path Config --- - /// Configuration for the `motion_path` animation effect: moves — and, /// optionally, orients — a component along an SVG path. /// @@ -993,19 +902,6 @@ pub struct MotionPathConfig { pub easing: EasingType, } -/// Reject `motion_path.path` values that cannot produce at least one -/// drawable point — the JSON-authoring analogue of "empty path" from the -/// workstream brief. Unlike [`deserialize_motion_property`], there is no -/// finite alphabet to suggest a correction from: any syntactically valid -/// (even visually nonsensical) SVG path `d` string is accepted, exactly as -/// `shape`'s `ShapeType::Path { data }` already accepts it via the same -/// `skia_safe::Path::from_svg` call — this does not invent a second path -/// grammar. -/// -/// A path that parses but has zero measured *length* (e.g. `"M50,50"`) is -/// deliberately NOT rejected here — see `MotionPathConfig`'s "Degenerate -/// paths" doc section for why, and where that case is instead surfaced (a -/// `validate_schema.rs` warning, not a parse-time error). fn deserialize_motion_path_data<'de, D>(deserializer: D) -> Result where D: serde::Deserializer<'de>, @@ -1024,8 +920,6 @@ where } } -// --- Supporting types --- - #[derive(Debug, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum ShapeType { @@ -1139,8 +1033,6 @@ pub enum ImageFit { Fill, } -// --- Shape Text --- - #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct ShapeText { pub content: String, @@ -1237,8 +1129,6 @@ pub struct TextBackground { pub corner_radius: f32, } -// --- Visual Effect Types --- - /// Glow effect (colored luminous halo around the element) #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] @@ -1431,8 +1321,6 @@ fn default_glow_intensity() -> f32 { 1.0 } -// --- Default functions --- - fn default_font_size() -> f32 { 48.0 } @@ -1478,10 +1366,6 @@ mod motion_property_tests { use super::*; use serde_json::json; - // ---- constat #4: `WiggleConfig.property` / `Animation.property` (via - // `KeyframesConfig.keyframes`) are free strings the solver silently - // no-ops on when unrecognised (RED first). ---- - #[test] fn wiggle_known_property_still_works() { let json = json!({ @@ -1499,9 +1383,6 @@ mod motion_property_tests { #[test] fn wiggle_unknown_property_is_a_named_error_not_a_silent_no_op() { - // A wholly unsupported name — the animation would otherwise play, - // resolve every frame, and simply never touch any rendered - // property: no error, no visible effect, no signal at all. let json = json!({ "name": "wiggle", "property": "skew", @@ -1515,11 +1396,6 @@ mod motion_property_tests { #[test] fn wiggle_kebab_case_property_gets_a_did_you_mean() { - // The exact trap named in constat #4: this project mixes kebab-case - // (CSS-style, most of `CssStyle`) and snake_case (these property - // names) conventions across files, so an author reasoning in - // kebab-case naturally writes `translate-x` instead of the - // solver's `translate_x` — silently inert before this fix. let json = json!({ "name": "wiggle", "property": "translate-x", @@ -1573,9 +1449,6 @@ mod motion_property_tests { #[test] fn keyframes_animation_color_property_still_works() { - // "color" is solver-recognised (special-cased in - // `resolve_animations`, outside `apply_property`), not a numeric - // motion property — must not be rejected. let json = json!({ "name": "keyframes", "keyframes": [ @@ -1639,9 +1512,6 @@ mod motion_path_schema_tests { } } - // ---- brief's "empty path" degenerate case: rejected at parse time, - // not left to silently produce a no-op or a NaN downstream. ---- - #[test] fn motion_path_rejects_an_empty_path_string() { let json = json!({ "name": "motion_path", "path": "" }); @@ -1661,11 +1531,6 @@ mod motion_path_schema_tests { ); } - // A single-point ("zero measured length") path is syntactically valid - // and must NOT be rejected at parse time — see MotionPathConfig's - // "Degenerate paths" doc section; the render-time-defined behaviour is - // covered in `engine::animator`'s tests, and the advisory warning in - // `validate_schema.rs`'s. #[test] fn motion_path_accepts_a_single_point_path() { let json = json!({ "name": "motion_path", "path": "M50,50" }); @@ -1706,12 +1571,6 @@ mod animation_timing_repeat_widening_tests { use super::*; use serde_json::json; - // ---- issue #330: `"loop"` widens from a bare bool to bool-or-integer. - // Every test in this module that only sets `"loop": true`/`false` (or - // omits it) must produce byte-identical `AnimationTiming` values to - // what the old plain-bool deserializer produced — that's the - // acceptance criterion that matters most here. ---- - #[test] fn loop_true_is_unchanged_infinite_repeat() { let json = json!({ "name": "pulse", "loop": true }); @@ -1745,8 +1604,6 @@ mod animation_timing_repeat_widening_tests { #[test] fn loop_as_a_positive_integer_sets_repeat_and_the_count() { - // GSAP's `repeat: 11` means 11 *re*plays — 12 plays total. This - // field counts total plays, so the JSON author writes 12. let t: AnimationTiming = serde_json::from_value(json!({ "loop": 12 })).unwrap(); assert!(t.repeat, "a finite count still loops — see the field doc"); assert_eq!(t.repeat_count, Some(12)); @@ -1807,14 +1664,6 @@ mod animation_timing_repeat_widening_tests { #[test] fn a_finite_repeat_count_round_trips_through_a_single_loop_key() { - // Regression: `AnimationTiming` used to derive `Serialize` - // directly off its own fields, which emitted a *separate* - // `"repeat_count"` key alongside `"loop"` — a shape - // `AnimationTimingWire`'s `deny_unknown_fields` (rightly) never - // accepted on the way back in, so a scenario that had merely been - // parsed and re-serialized (e.g. by tooling, or `--fix`) failed to - // parse again. `AnimationTiming` now hand-writes `Serialize` to - // fold back onto one `"loop"` key, matching `Deserialize` exactly. let t: AnimationTiming = serde_json::from_value(json!({ "loop": 12 })).unwrap(); let json = serde_json::to_value(&t).unwrap(); assert!( diff --git a/crates/rustmotion-core/src/traits/animatable.rs b/crates/rustmotion-core/src/traits/animatable.rs index 65d25f8a..3ee604be 100644 --- a/crates/rustmotion-core/src/traits/animatable.rs +++ b/crates/rustmotion-core/src/traits/animatable.rs @@ -1,11 +1,8 @@ use crate::schema::{AnimationEffect, TimelineStep}; -/// Trait for components that support animation. pub trait Animatable { fn animation_effects(&self) -> &[AnimationEffect]; - /// Timed animation steps (`timeline` field): each step's animations are - /// resolved with `delay += step.at`. Empty by default. fn timeline_steps(&self) -> &[TimelineStep] { &[] } diff --git a/crates/rustmotion-core/src/traits/backgrounded.rs b/crates/rustmotion-core/src/traits/backgrounded.rs index 055ee647..081cf6e5 100644 --- a/crates/rustmotion-core/src/traits/backgrounded.rs +++ b/crates/rustmotion-core/src/traits/backgrounded.rs @@ -1,14 +1,11 @@ -/// Trait for components that support a background color. pub trait Backgrounded { fn background(&self) -> Option<&str>; } -/// Mutable access to background — needed by builder traits. pub trait BackgroundedMut: Backgrounded { fn set_background(&mut self, bg: Option); } -/// Builder API for background. pub trait BackgroundedExt: BackgroundedMut + Sized { fn bg(mut self, color: impl Into) -> Self { self.set_background(Some(color.into())); diff --git a/crates/rustmotion-core/src/traits/bordered.rs b/crates/rustmotion-core/src/traits/bordered.rs index 025387f4..157b4314 100644 --- a/crates/rustmotion-core/src/traits/bordered.rs +++ b/crates/rustmotion-core/src/traits/bordered.rs @@ -12,17 +12,14 @@ fn default_border_width() -> f32 { 1.0 } -/// Trait for components that support a border. pub trait Bordered { fn border(&self) -> Option<&Border>; } -/// Mutable access to border — needed by builder traits. pub trait BorderedMut: Bordered { fn set_border(&mut self, border: Option); } -/// Builder API for border. pub trait BorderedExt: BorderedMut + Sized { fn border_color(mut self, color: impl Into, width: f32) -> Self { self.set_border(Some(Border { diff --git a/crates/rustmotion-core/src/traits/clipped.rs b/crates/rustmotion-core/src/traits/clipped.rs index fcf90874..733d91d5 100644 --- a/crates/rustmotion-core/src/traits/clipped.rs +++ b/crates/rustmotion-core/src/traits/clipped.rs @@ -1,4 +1,3 @@ -/// Trait for components that clip their content to their bounds. pub trait Clipped { fn clip(&self) -> bool { false diff --git a/crates/rustmotion-core/src/traits/painter.rs b/crates/rustmotion-core/src/traits/painter.rs index af2c0f9e..07516096 100644 --- a/crates/rustmotion-core/src/traits/painter.rs +++ b/crates/rustmotion-core/src/traits/painter.rs @@ -1,36 +1,21 @@ -//! `Painter` trait — replaces the old `Widget::render`. A component implements -//! `Painter` to draw its **content** onto a Skia canvas. Box decorations -//! (background, border, shadow) are handled generically by the paint pass. -//! -//! Layout is computed by taffy from the component's `CssStyle`; the resulting -//! [`BoxLayout`] is passed in. - use skia_safe::Canvas; use crate::engine::animator::AnimatedProperties; use crate::engine::box_tree::AvailableSpace; use crate::engine::layout_pass::BoxLayout; -/// Frame-level info passed into every paint call. #[derive(Debug, Clone, Copy)] pub struct PaintCtx { - /// Seconds since this *scene* started. What animation progress, reveals - /// and every timed effect are expressed in. pub time: f64, - /// Seconds since the start of the scenario (of the view, in a `world` - /// view). Only the audio-reactive painters need it — the audio analysis is - /// indexed on that timeline, not on each scene's own. pub scenario_time: f64, pub scene_duration: f64, pub frame_index: u32, pub fps: u32, pub video_width: u32, pub video_height: u32, - /// Stagger offset accumulated by parent containers (seconds). pub stagger_offset: f64, } -/// Available space hint passed to `intrinsic_size` (proxy for taffy's enum). #[derive(Debug, Clone, Copy)] pub struct AvailableSize { pub width: AvailableSpace, @@ -43,20 +28,7 @@ pub struct MeasureCtx { pub video_height: u32, } -/// Component paint contract. pub trait Painter { - /// Paint the component's *content* into the canvas. The canvas is - /// already translated to the content-box origin and clipped if - /// `overflow: hidden` was set. Generic decorations (bg, border, - /// shadow) are already painted by the engine. - /// - /// `props` carries the per-component animation state at the current - /// frame. Outer paint properties (transform / opacity / filter) have - /// already been applied to the canvas by the engine via CSS overrides; - /// `props` exposes the *internal-only* fields (`draw_progress`, - /// `stroke_width`, `char_animation`, `visible_chars*`, `font_size`, - /// `color`, `border_radius`, etc.) that components use to drive their - /// own painting. fn paint_content( &self, canvas: &Canvas, @@ -65,9 +37,6 @@ pub trait Painter { ctx: &PaintCtx, ); - /// Optional intrinsic measurement for leaves like `text`, `image`, - /// `table`. Return `None` to let taffy compute the size from the - /// CSS style alone. `available` mirrors taffy's `AvailableSpace`. fn intrinsic_size(&self, _available: AvailableSize, _ctx: &MeasureCtx) -> Option<(f32, f32)> { None } diff --git a/crates/rustmotion-core/src/traits/rounded.rs b/crates/rustmotion-core/src/traits/rounded.rs index ee66672d..8ca82aae 100644 --- a/crates/rustmotion-core/src/traits/rounded.rs +++ b/crates/rustmotion-core/src/traits/rounded.rs @@ -1,14 +1,11 @@ -/// Trait for components that support rounded corners. pub trait Rounded { fn corner_radius(&self) -> f32; } -/// Mutable access to corner radius — needed by builder traits. pub trait RoundedMut: Rounded { fn set_corner_radius(&mut self, radius: f32); } -/// Builder API for corner radius. pub trait RoundedExt: RoundedMut + Sized { fn rounded(mut self, radius: f32) -> Self { self.set_corner_radius(radius); diff --git a/crates/rustmotion-core/src/traits/shadowed.rs b/crates/rustmotion-core/src/traits/shadowed.rs index 81fb64c6..860d4bd2 100644 --- a/crates/rustmotion-core/src/traits/shadowed.rs +++ b/crates/rustmotion-core/src/traits/shadowed.rs @@ -12,17 +12,14 @@ pub struct Shadow { pub blur: f32, } -/// Trait for components that support a drop shadow. pub trait Shadowed { fn shadow(&self) -> Option<&Shadow>; } -/// Mutable access to shadow — needed by builder traits. pub trait ShadowedMut: Shadowed { fn set_shadow(&mut self, shadow: Option); } -/// Builder API for shadow. pub trait ShadowedExt: ShadowedMut + Sized { fn shadow_sm(mut self) -> Self { self.set_shadow(Some(Shadow { diff --git a/crates/rustmotion-core/src/traits/styled.rs b/crates/rustmotion-core/src/traits/styled.rs index f13dbc46..9cba93bd 100644 --- a/crates/rustmotion-core/src/traits/styled.rs +++ b/crates/rustmotion-core/src/traits/styled.rs @@ -1,12 +1,9 @@ use crate::css::CssStyle; -/// Object-safe trait for components that carry a `CssStyle`. -/// Used by the box-tree builder to read styling. pub trait Styled { fn style_config(&self) -> &CssStyle; } -/// Mutable access to style config. pub trait StyledMut: Styled { fn style_config_mut(&mut self) -> &mut CssStyle; } diff --git a/crates/rustmotion-core/src/traits/timed.rs b/crates/rustmotion-core/src/traits/timed.rs index 21b802e6..19d29a61 100644 --- a/crates/rustmotion-core/src/traits/timed.rs +++ b/crates/rustmotion-core/src/traits/timed.rs @@ -11,7 +11,6 @@ pub struct TimingConfig { pub end_at: Option, } -/// Trait for components that support timed visibility. pub trait Timed { fn timing(&self) -> (Option, Option); } diff --git a/crates/rustmotion-core/src/variables.rs b/crates/rustmotion-core/src/variables.rs index 9c0a26a9..669a6442 100644 --- a/crates/rustmotion-core/src/variables.rs +++ b/crates/rustmotion-core/src/variables.rs @@ -6,8 +6,6 @@ use serde_json::Value; use crate::error::RustmotionError; use crate::schema::{VariableDefinition, VariableType}; -/// Whether `value`'s JSON type matches `var_type` — a `number`-typed -/// variable's default or override must actually be a JSON number, etc. fn value_matches_declared_type(value: &Value, var_type: &VariableType) -> bool { match var_type { VariableType::String => value.is_string(), @@ -39,13 +37,6 @@ fn declared_type_name(var_type: &VariableType) -> &'static str { } } -/// Build the final variable map: start from defaults, then apply overrides. -/// Returns an error if an override references a variable not in the -/// definitions, or if a default/override's JSON type doesn't match the -/// variable's declared `type` — the `type` field on `config` entries used to -/// be decorative (schema/scenario.rs's own `VariableDefinition::var_type` -/// was parsed and never read), so `{ "type": "number", "default": "oops" }` -/// silently accepted a string. This is the sole enforcement point. fn merge_variables( definitions: &HashMap, overrides: Option<&HashMap>, @@ -53,7 +44,6 @@ fn merge_variables( ) -> Result> { let mut merged = HashMap::with_capacity(definitions.len()); - // Start with defaults for (name, def) in definitions { if !value_matches_declared_type(&def.default, &def.var_type) { return Err(RustmotionError::Generic(format!( @@ -66,7 +56,6 @@ fn merge_variables( merged.insert(name.clone(), def.default.clone()); } - // Apply overrides if let Some(ovr) = overrides { for (name, value) in ovr { let def = definitions @@ -90,16 +79,6 @@ fn merge_variables( Ok(merged) } -/// Recursively substitute variable references in a JSON value tree. -/// -/// `pub(crate)` (not private) so `crate::expand` can reuse the exact same -/// `$name` / `{"$var": "name"}` / interpolation semantics for component-param -/// and `for-each` item/index bindings, rather than re-implementing a second, -/// subtly-different substitution pass. Same reason `"config"` is skipped here -/// (see the loop below): a component-template clone can itself contain a -/// nested `use`'s `props` block — deliberately *not* named `config`, so this -/// skip does not swallow it (see `expand.rs` module doc for why `props` was -/// chosen over `config` for that field). pub(crate) fn substitute( value: &mut Value, vars: &HashMap, @@ -107,24 +86,20 @@ pub(crate) fn substitute( ) -> Result<()> { match value { Value::String(s) => { - // Check for exact match "$name" (whole-string replacement, preserves type) if let Some(var_name) = parse_single_var_ref(s) { if let Some(replacement) = vars.get(var_name) { *value = replacement.clone(); return Ok(()); } - // Not in vars — leave as-is for find_unresolved to catch return Ok(()); } - // Check for escaped $$ or interpolation if s.contains('$') { let result = interpolate_string(s, vars, path)?; *s = result; } } Value::Object(map) => { - // Check for { "$var": "name" } pattern if map.len() == 1 { if let Some(var_name_val) = map.get("$var") { if let Some(var_name) = var_name_val.as_str() { @@ -132,13 +107,11 @@ pub(crate) fn substitute( *value = replacement.clone(); return Ok(()); } - // Not found — leave as-is return Ok(()); } } } - // Recurse into object values, but skip "variables" key (don't substitute in definitions) let keys: Vec = map.keys().cloned().collect(); for key in keys { if key == "config" { @@ -159,27 +132,21 @@ pub(crate) fn substitute( Ok(()) } -/// Parse a string that is exactly "$name" (single variable reference, no interpolation). -/// Returns the variable name without the leading $. fn parse_single_var_ref(s: &str) -> Option<&str> { let s = s.trim(); if !s.starts_with('$') || s.starts_with("$$") { return None; } let name = &s[1..]; - // Must be a simple identifier (alphanumeric + underscore) if name.is_empty() || !name.chars().all(|c| c.is_alphanumeric() || c == '_') { return None; } - // Only match if the entire string is just "$name" — no surrounding text if s.len() != 1 + name.len() { return None; } Some(name) } -/// Perform string interpolation: replace $name occurrences within a larger string. -/// Handles $$ escape sequences. fn interpolate_string(s: &str, vars: &HashMap, path: &str) -> Result { let mut result = String::with_capacity(s.len()); let mut chars = s.chars().peekable(); @@ -187,11 +154,9 @@ fn interpolate_string(s: &str, vars: &HashMap, path: &str) -> Res while let Some(ch) = chars.next() { if ch == '$' { if chars.peek() == Some(&'$') { - // Escaped $$ → literal $ chars.next(); result.push('$'); } else { - // Try to read a variable name let mut name = String::new(); while let Some(&c) = chars.peek() { if c.is_alphanumeric() || c == '_' { @@ -202,7 +167,6 @@ fn interpolate_string(s: &str, vars: &HashMap, path: &str) -> Res } } if name.is_empty() { - // Lone $ not followed by identifier — keep as-is result.push('$'); } else if let Some(val) = vars.get(&name) { match val { @@ -217,7 +181,6 @@ fn interpolate_string(s: &str, vars: &HashMap, path: &str) -> Res } } } else { - // Unknown variable — keep original text for find_unresolved result.push('$'); result.push_str(&name); } @@ -230,7 +193,6 @@ fn interpolate_string(s: &str, vars: &HashMap, path: &str) -> Res Ok(result) } -/// Scan a Value tree for unresolved $variable references after substitution. pub fn find_unresolved(value: &Value) -> Vec { let mut unresolved = Vec::new(); find_unresolved_recursive(value, &mut unresolved); @@ -240,24 +202,6 @@ pub fn find_unresolved(value: &Value) -> Vec { fn find_unresolved_recursive(value: &Value, out: &mut Vec) { match value { Value::String(s) => { - // An `"= ..."` string is an arithmetic expression (see - // `rustmotion_core::expr`'s module doc), not `$name` reference - // content this scan understands — it has its own free-variable - // resolution (`Scope::var`, evaluated by - // `crates/rustmotion/src/loader.rs`'s `fold_static_expressions` - // and, for included files, `include.rs`) and its own error type - // (`ExprError::UnknownIdent`, surfaced as a precisely-located - // hard error, not a warning). Scanning inside it for bare `$word` - // occurrences would flag every reserved scope name this - // substitution pass was never meant to resolve — `$W`, `$H`, - // `$fps`, `$duration`, `$t`, `$T`, `$beat`, and any - // expression-only variable an author declares — as a false - // "unresolved variable" on every single scenario that uses one, - // whether or not the expression fold that runs later actually - // resolves it. `Expr::parse`/`fold_static_expressions` are the - // authority on whether an expression's identifiers are valid; - // this scan defers to them entirely rather than duplicating (and - // getting wrong) a second, narrower opinion. if s.trim_start().starts_with('=') { return; } @@ -265,7 +209,7 @@ fn find_unresolved_recursive(value: &Value, out: &mut Vec) { while let Some(ch) = chars.next() { if ch == '$' { if chars.peek() == Some(&'$') { - chars.next(); // skip escaped + chars.next(); } else { let mut name = String::new(); while let Some(&c) = chars.peek() { @@ -284,7 +228,6 @@ fn find_unresolved_recursive(value: &Value, out: &mut Vec) { } } Value::Object(map) => { - // Check for { "$var": "name" } if map.len() == 1 { if let Some(val) = map.get("$var") { if let Some(name) = val.as_str() { @@ -294,23 +237,6 @@ fn find_unresolved_recursive(value: &Value, out: &mut Vec) { } } for (key, v) in map { - // `config` holds the declarations themselves, never references. - // - // `template` / `props` / `components` hold the bodies of the - // template directives, whose `$name`s are bound by - // `expand::expand_directives` — which runs *after* this pass. - // Scanning them here reports every correct binding as an - // unresolved typo: the canonical `for-each` example emits six - // such warnings, each accusing the author of a mistake they - // did not make. Warnings that are reliably wrong teach the - // reader to ignore warnings, which would cost more than the - // scan is worth. - // - // Nothing is lost: `expand_directives` re-runs this same scan - // once expansion is done and these keys no longer exist, so a - // genuine typo inside a template is still reported — with the - // benefit of naming it after substitution, where the leftover - // is unambiguous. if !matches!(key.as_str(), "config" | "template" | "props" | "components") { find_unresolved_recursive(v, out); } @@ -325,18 +251,6 @@ fn find_unresolved_recursive(value: &Value, out: &mut Vec) { } } -/// Apply variable substitution to a JSON Value. -/// Extracts the "variables" definitions, merges with optional overrides, then substitutes. -/// -/// When a `config` block is present, overrides must reference declared variables (unknown -/// names produce `UndefinedVariable`). -/// -/// When there is **no** `config` block but `overrides` are provided (e.g. from the CLI for -/// an HTML scenario that cannot carry a `config` key), the overrides are applied as raw -/// value substitutions without type declarations — any `$name` found in the document is -/// replaced by the override value as-is. Unresolved references after this pass are ignored -/// (no `UnresolvedVariable` error), because the document may legitimately contain no -/// variable references at all. pub fn apply_variables( value: &mut Value, overrides: Option<&HashMap>, @@ -346,7 +260,6 @@ pub fn apply_variables( match definitions { Some(defs) => { - // Validate that every definition has a default for (name, def) in &defs { if def.default.is_null() { return Err(RustmotionError::VariableMissingDefault { @@ -357,15 +270,12 @@ pub fn apply_variables( } let merged = merge_variables(&defs, overrides, path)?; - // Remove "config" key from the value so it doesn't interfere with deserialization if let Value::Object(map) = value { map.remove("config"); } substitute(value, &merged, path)?; } None => { - // No config block. If overrides were supplied (e.g. from the CLI for an HTML - // scenario), apply them as raw substitutions — no declaration required. if let Some(ovr) = overrides { if !ovr.is_empty() { substitute(value, ovr, path)?; @@ -374,29 +284,7 @@ pub fn apply_variables( } } - // Constat #7: `find_unresolved` used to run — and hard-fail the whole - // render/validate on its first hit — *only* inside the `Some(defs)` - // branch above, so the exact same leftover `$word` (a price tag, a - // terminal `$PATH`, a shell `$HOME`) was harmless in a document with no - // `config` block and fatal the moment an unrelated `config` block - // existed anywhere else in the same file. `find_unresolved` cannot - // structurally tell a genuine unresolved-reference typo apart from - // incidental literal-`$` content — by construction, every name in - // `defs` above is always present in `merged` (defaults ∪ overrides), so - // `substitute` can never leave a *declared* variable name unresolved; - // everything `find_unresolved` can still find here is, definitionally, - // *not* one of the variables this document declared. So: run the same - // scan unconditionally (fixing the "depends on an unrelated key" - // inconsistency), but report it as a loud warning rather than aborting - // the whole document — same fail-loud-not-silent contract already used - // elsewhere in this workstream (see `css::units::px_or_warn`), applied - // here because a hard rejection would break any existing scenario that - // legitimately has a `$` in its content and would newly break every one - // of those the moment it also gained a `config` block. for name in find_unresolved(value) { - // Reuse `UnresolvedVariable`'s existing `Display` message (see - // `error.rs`) for the warning text instead of hand-rolling a new - // one — this is the same diagnostic, just no longer fatal. let diagnostic = RustmotionError::UnresolvedVariable { name, path: path.to_string(), @@ -411,12 +299,10 @@ pub fn apply_variables( Ok(()) } -/// For standalone rendering: apply defaults only (no overrides). pub fn apply_defaults(value: &mut Value) -> Result<()> { apply_variables(value, None, "") } -/// Extract variable definitions from a JSON value (if present). fn extract_variable_definitions( value: &Value, ) -> Result>> { @@ -589,12 +475,6 @@ mod tests { assert!(unresolved.contains(&"also_missing".to_string())); } - /// An `"= ..."` expression is a different sub-language with its own - /// identifier resolution (`rustmotion_core::expr`) — this scan must not - /// flag `$W`/`$H`/reserved scope names, or any other expression - /// variable, as an unresolved `$var` reference. A leading `=` after - /// trimming whitespace is enough to opt the whole string out, regardless - /// of which names appear inside it. #[test] fn find_unresolved_does_not_scan_inside_an_expression_string() { let val = json!({ @@ -633,7 +513,6 @@ mod tests { val["scenes"][0]["children"][0]["content"], json!("Color is #FF0000") ); - // "config" key should be removed assert!(val.get("config").is_none()); } @@ -648,7 +527,6 @@ mod tests { let mut vars = HashMap::new(); vars.insert("name".to_string(), json!("resolved")); substitute(&mut val, &vars, "test").unwrap(); - // "config" block should be untouched assert_eq!(val["config"]["name"]["default"], json!("$not_a_ref")); assert_eq!(val["text"], json!("resolved")); } @@ -675,12 +553,6 @@ mod tests { assert_eq!(val["text"], json!("Count: 42 items")); } - // ---- constat #7: literal `$` fatality must not depend on an unrelated - // `config` key (RED first) ---- - - /// A document with **no** `config` block and a literal `$` in unrelated - /// content (a `list` item's `$PATH`) — this already succeeds today - /// (the bug is the *other* direction; this locks in it keeps working). fn doc_with_literal_dollar_no_config() -> serde_json::Value { json!({ "video": { "width": 1080, "height": 1920 }, @@ -694,12 +566,6 @@ mod tests { }) } - /// The exact same literal-`$` content, but the document also happens to - /// declare an unrelated `config` block (e.g. because it's a reusable - /// template with one templated field). Before the fix, this made - /// `apply_variables` return `Err(UnresolvedVariable)` and abort the - /// entire render/validate — for content the config block has nothing to - /// do with. fn doc_with_literal_dollar_and_unrelated_config() -> serde_json::Value { json!({ "config": { @@ -722,7 +588,6 @@ mod tests { let mut doc = doc_with_literal_dollar_no_config(); apply_defaults(&mut doc) .expect("a literal '$' in list/text content with no config block must not be fatal"); - // Content is left as-is: nothing declared these as variables. assert_eq!( doc["scenes"][0]["children"][0]["items"][0], json!("echo $PATH") @@ -731,12 +596,6 @@ mod tests { #[test] fn literal_dollar_with_unrelated_config_block_must_not_be_fatal() { - // RED before the fix: this currently returns - // `Err(UnresolvedVariable { name: "PATH", .. })` (or "HOME", or - // "100", whichever `find_unresolved` reaches first) purely because - // *some* config block exists elsewhere in the same document — the - // exact inconsistency named in constat #7. The declared `$title` - // variable must still resolve correctly either way. let mut doc = doc_with_literal_dollar_and_unrelated_config(); apply_defaults(&mut doc).expect( "a literal '$' in unrelated content must not become fatal just because the \ @@ -755,10 +614,6 @@ mod tests { #[test] fn undeclared_override_is_still_a_hard_error_unaffected_by_the_fix() { - // The other half of `apply_variables`'s error surface (an override - // key that doesn't match any declared variable) is a genuine, - // unambiguous user error — unrelated to the literal-`$`-in-content - // ambiguity — and must remain a hard error. let mut doc = json!({ "config": { "title": { "type": "string", "default": "Demo" } }, "video": { "width": 1, "height": 1 }, diff --git a/crates/rustmotion-core/src/vars/mod.rs b/crates/rustmotion-core/src/vars/mod.rs index 00a676b3..51026770 100644 --- a/crates/rustmotion-core/src/vars/mod.rs +++ b/crates/rustmotion-core/src/vars/mod.rs @@ -1,75 +1,3 @@ -//! Scenario and scene variables (issue #329): a scalar declared once, -//! animated on the scenario's absolute timeline independently of any one -//! scene, and readable from any [`crate::expr`] expression as an ordinary -//! `$name`. -//! -//! # Why this exists -//! -//! An [`crate::expr::Expr`] has no memory of its own — every evaluation -//! starts from the same free variables the caller's [`crate::expr::Scope`] -//! happens to answer for that frame. That is fine for `$t`-driven motion -//! (a wiggle, a fade) but it cannot produce a value that keeps moving -//! *across* scene boundaries, or that several unrelated expressions need to -//! agree on simultaneously — a rotation every edge of a wireframe reads its -//! own opacity from, a draw-on progress a dozen strokes share, a counter -//! driving both a number and a bar's width. Asked how one of the reels this -//! chantier is chasing was built, its author described exactly this -//! indirection: GSAP animates a plain `{x, y, s, r}` object, and a function -//! reads it on every frame to produce attributes. This module is that -//! object: a named, animated scalar, resolved once per frame into a -//! [`crate::expr::Scope`] every expression can read. -//! -//! # Declaring a variable -//! -//! ```json -//! "vars": { -//! "keyDraw": { "default": 0, -//! "animation": [{ "at": "@4.85s", "to": 1, "duration": "3b", "easing": "ease_in_out" }] } -//! } -//! ``` -//! -//! See [`VarDef`] and [`VarKeyframe`] for the full shape. `at` and -//! `duration` are [`crate::schema::time::TimePoint`] — the scenario's beat -//! grid, if it declares one (`bpm`/`beat_offset`), so a variable's tween -//! can land on a beat exactly like a scene cut can. A variable with an -//! empty (or absent) `animation` is a constant — see [`VarDef::is_static`] -//! and [`dynamic_names`] for how a loader is expected to fold it away -//! entirely, the same as any other literal. -//! -//! # Resolving it -//! -//! [`VarTable::compile`] turns a whole [`VarSet`] into cheap-to-sample -//! numbers once, at load; [`VarTable::value_at`] then answers any absolute -//! time in O(keyframes). See [`track`]'s module doc for the exact -//! before-first/after-last/gap rule and why a `"b"`-unit `duration` is -//! resolved differently from an `at`. -//! -//! # Reading it from an expression -//! -//! [`VarScope`] implements [`crate::expr::Scope`] over one or two compiled -//! [`VarTable`]s (scenario-level, and an optional scene-level one that -//! shadows it) at one instant `t`. See [`scope`]'s module doc for how it is -//! meant to compose with a node-reference-answering `Scope` (the -//! `engine::deps` side of this chantier) inside a single composite context, -//! and for why a scene-local variable referenced from another scene surfaces -//! as the ordinary "unknown identifier" error rather than a dedicated one. -//! -//! # What is *not* here -//! -//! This module owns none of: the `Scenario`/`Scene` fields that carry a -//! [`VarSet`] (`schema::scenario::Scenario`/`Scene` — see this crate's -//! `vars`-partition notes for the exact line to add), the per-frame -//! dependency graph that decides *when* to re-resolve a [`VarTable`] and -//! feeds its output into [`VarScope`] (`engine::deps`), or the loader change -//! that keeps an expression naming a [`dynamic_names`] variable from being -//! folded at load — `crates/rustmotion/src/loader.rs`'s -//! `fold_static_expressions` only knows the fixed `t`/`T`/`beat`/`duration` -//! list today (see [`crate::expr::Expr::is_static`]'s doc) and needs to -//! additionally consult [`dynamic_names`] before folding any expression, or -//! `"= $keyDraw * 360"` fails to load instead of surviving to the per-frame -//! tier. This module only guarantees the *information* — [`dynamic_names`] -//! — is there to consult. - pub mod schema; pub mod scope; pub mod track; @@ -86,15 +14,6 @@ mod tests { use crate::schema::time::{TimeCtx, TimePoint}; use std::f64::consts::TAU; - /// The acceptance test named in this workstream's brief: a `for-each` - /// over edges, each edge's opacity an expression over a rotation - /// variable, rendering as something that actually rotates. The - /// `for-each`/JSON side of this can't be exercised yet — this crate's - /// `vars` partition does not own `Scenario.vars` (see the module doc's - /// "What is not here") — so this builds the same shape directly: a - /// [`VarSet`] with one `rotY` variable, and eight per-edge opacity - /// expressions, exactly what a `for-each` over 8 items would expand to - /// once its own `$i`/`$count` are folded and only `$rotY` is left live. #[test] fn wireframe_edges_rotate_via_a_shared_variable() { let mut vars = VarSet::new(); @@ -118,14 +37,6 @@ mod tests { let scenario = VarTable::compile(&vars, &ctx).unwrap(); const EDGE_COUNT: usize = 8; - // Per-edge opacity by (simulated) depth: a Y-rotation wireframe's - // classic "front edges brighter than back edges" shading, exactly - // the kind of `for-each`-generated, per-edge-static, cos(rotY + angle) - // expression the issue's crystal example describes. - // `rotY` is animated, so it belongs in `dynamic_names` — the signal - // a loader must consult before folding any expression that reads - // it (see this module's "What is not here" section: `Expr::is_static` - // alone cannot tell `$rotY` apart from a genuine constant). assert_eq!(dynamic_names(&vars).collect::>(), vec!["rotY"]); let edge_exprs: Vec = (0..EDGE_COUNT) @@ -153,8 +64,6 @@ mod tests { frames.push(opacities); } - // It actually rotates: consecutive frames inside the tween must - // differ — a frozen wireframe would repeat the same opacities. for (i, pair) in frames.windows(2).enumerate() { if i < 4 { assert_ne!( @@ -165,23 +74,12 @@ mod tests { ); } } - // After the last keyframe (t=4) it holds at TAU, matching "Value - // outside the keyframes" in `track`'s module doc — not a wrap-around - // back to `default`. assert_eq!( frames[4], frames[5], "must hold, not reset, past the last keyframe" ); } - /// Deliverable 5: an un-animated variable folds like any other literal. - /// `Expr::is_static` only special-cases the fixed `t`/`T`/`beat`/`duration` - /// names (frozen in `expr::eval::is_dynamic_var_name`) — an arbitrary - /// `$name` it has never heard of is, by its own contract, eligible to - /// fold already. `dynamic_names` is the other half: the set a loader - /// must additionally treat as non-foldable for the *animated* ones, - /// since `Expr::is_static` alone cannot tell `$constant` and `$rotY` - /// apart. #[test] fn unanimated_variable_is_classified_static_and_folds() { let mut vars = VarSet::new(); @@ -208,8 +106,6 @@ mod tests { let dynamic: Vec<&str> = dynamic_names(&vars).collect(); assert_eq!(dynamic, vec!["rotY"]); - // `Expr::is_static` itself, unaware of `vars`, already agrees a - // bare `$constant` reference is fold-eligible. let constant_expr = Expr::parse("= $constant * 2").unwrap(); assert!(constant_expr.is_static()); diff --git a/crates/rustmotion-core/src/vars/schema.rs b/crates/rustmotion-core/src/vars/schema.rs index 6aaa17e6..0374e172 100644 --- a/crates/rustmotion-core/src/vars/schema.rs +++ b/crates/rustmotion-core/src/vars/schema.rs @@ -1,9 +1,3 @@ -//! The declared shape of a scenario or scene variable: [`VarDef`] and its -//! [`VarKeyframe`] animation segments. See the [module doc](super) for the -//! resolver that turns this into numbers and the -//! [`Scope`](crate::expr::Scope) implementation that lets an expression -//! read them. - use std::collections::BTreeMap; use schemars::JsonSchema; @@ -12,20 +6,6 @@ use serde::{Deserialize, Serialize}; use crate::schema::animation::EasingType; use crate::schema::time::TimePoint; -/// The `vars` map a scenario or a scene declares: variable name to -/// definition. `BTreeMap` rather than a `HashMap` so serialization and the -/// generated JSON schema are deterministic — the reason the rest of this -/// crate's schema types reach for it over a `HashMap` whenever map order -/// could otherwise vary between runs. -/// -/// Not to be confused with `config`/`VariableDefinition` in -/// [`crate::variables`]: that mechanism substitutes a whole JSON value -/// (string, number, boolean, object or array) once, at load, from a -/// `--var` override or a `for-each` binding. A [`VarDef`] is narrower -/// (always a scalar `f64`) and wider (it can move on the timeline) — the -/// two share the `$name` sigil deliberately, but answer different -/// questions: "what value was this configured with" versus "what is this -/// worth right now". pub type VarSet = BTreeMap; /// A single declared variable: the value it holds before any keyframe @@ -51,11 +31,6 @@ pub struct VarDef { } impl VarDef { - /// True when this variable has no `animation` — a constant. Mirrors - /// [`crate::expr::Expr::is_static`]: a caller that gets `true` back may - /// substitute `default` once, at load, and never revisit this variable - /// for the rest of the render. See [`dynamic_names`] for the hook a - /// loader's static-folding pass needs to act on this. pub fn is_static(&self) -> bool { self.animation.is_empty() } @@ -114,14 +89,6 @@ fn default_duration() -> TimePoint { TimePoint::Seconds(0.0) } -/// Every name in `vars` whose [`VarDef::is_static`] is false — the set a -/// loader's static-folding pass (`crates/rustmotion/src/loader.rs`'s -/// `is_dynamic_var_name`) needs to treat as dynamic in addition to its own -/// fixed list (`t`, `T`, `beat`, `duration`), so an expression reading an -/// animated variable is kept for the per-frame tier instead of being -/// folded to whatever value it happened to hold at load time. A variable -/// with no `animation` is intentionally *not* included here — it is a -/// constant and the loader is free to fold expressions that only read it. pub fn dynamic_names(vars: &VarSet) -> impl Iterator { vars.iter() .filter(|(_, def)| !def.is_static()) diff --git a/crates/rustmotion-core/src/vars/scope.rs b/crates/rustmotion-core/src/vars/scope.rs index 6ee3ef6c..59cc4448 100644 --- a/crates/rustmotion-core/src/vars/scope.rs +++ b/crates/rustmotion-core/src/vars/scope.rs @@ -1,66 +1,7 @@ -//! [`VarScope`]: the [`Scope`] implementation an expression actually reads -//! `$name` through, composing a scenario-wide [`VarTable`] with an optional -//! scene-level one that shadows it. - use crate::expr::Scope; use super::track::VarTable; -/// Reads declared scenario/scene variables for one absolute instant `t`. -/// -/// Scene-level shadows scenario-level, by name: a scene that redeclares a -/// scenario variable's name gets its own value for that name inside that -/// scene, and the scenario's is invisible there (not summed, not merged — -/// entirely replaced). -/// -/// # Scene isolation -/// -/// A [`VarScope`] built for scene B never holds scene A's table, so an -/// expression in scene A that names a variable declared only in scene B's -/// `vars` gets `None` back from [`VarScope::var`] — exactly what it would -/// get for a name that was never declared anywhere. -/// [`Scope::var`](crate::expr::Scope::var)'s own doc already treats those -/// two cases as indistinguishable ("`None` means not defined in this -/// scope" turns into the same [`crate::expr::ExprError::UnknownIdent`] a -/// genuinely unknown name produces), which is what makes a cross-scene -/// variable reference surface as the same error a cross-scene node -/// reference does — this module does not need to special-case it, only to -/// never construct a [`VarScope`] that can see another scene's table. -/// -/// # Composing with `node_prop` -/// -/// This type answers [`Scope::var`] only; [`Scope::node_prop`] keeps its -/// default `None`. A caller that also needs node references (see -/// `engine::deps`) does not wrap a [`VarScope`] inside another `Scope` impl -/// — a `Scope` is consumed behind `&dyn Scope`, and trait objects don't -/// compose that way — it instead holds a [`VarScope`] (or the two -/// [`VarTable`]s and a `t`, if that is more convenient for its own -/// lifetimes) as a field alongside its node lookups, and its own `var` -/// implementation tries [`VarScope::resolve`] first, falling back to -/// whatever else it answers for. [`VarScope::resolve`] is exposed as a -/// plain method — not only reachable through the `Scope` impl — precisely -/// so it can be called that way without going through a trait object: -/// -/// ``` -/// use rustmotion_core::expr::Scope; -/// use rustmotion_core::vars::VarScope; -/// -/// struct EngineScope<'a> { -/// vars: VarScope<'a>, -/// // ... node lookups owned elsewhere ... -/// } -/// -/// impl Scope for EngineScope<'_> { -/// fn var(&self, name: &str) -> Option { -/// self.vars.resolve(name) /* .or_else(|| self.node_derived(name)) */ -/// } -/// -/// fn node_prop(&self, id: &str, prop: &str) -> Option { -/// let _ = (id, prop); -/// None // delegate to the node-dependency graph here -/// } -/// } -/// ``` pub struct VarScope<'a> { scenario: &'a VarTable, scene: Option<&'a VarTable>, @@ -68,15 +9,10 @@ pub struct VarScope<'a> { } impl<'a> VarScope<'a> { - /// `scenario` is visible everywhere; `scene`, when given, shadows it by - /// name for expressions evaluated inside that one scene. `t` is the - /// absolute scenario time (seconds) this scope answers for. pub fn new(scenario: &'a VarTable, scene: Option<&'a VarTable>, t: f64) -> Self { VarScope { scenario, scene, t } } - /// Resolve `name`, scene table first. Usable directly, without going - /// through the [`Scope`] trait object — see the composing note above. pub fn resolve(&self, name: &str) -> Option { self.scene .and_then(|scene| scene.value_at(name, self.t)) @@ -128,13 +64,6 @@ mod tests { ); let scope = VarScope::new(&scenario, None, 0.5); - // `Expr::is_static` only special-cases the fixed `t`/`T`/`beat`/ - // `duration` names — it has no notion of a declared `vars` set, so - // in isolation it still says `true` here. That is expected, not a - // bug: a caller must additionally consult - // `crate::vars::dynamic_names` before folding, which is exactly - // what makes `keyDraw` land on the dynamic side — see this - // module's doc and `mod.rs`'s "What is not here" section. let expr = Expr::parse("= $keyDraw * 360").unwrap(); assert!(expr.is_static()); assert_eq!(expr.eval(&scope).unwrap(), 180.0); @@ -166,9 +95,6 @@ mod tests { #[test] fn scene_local_variable_is_invisible_outside_its_scene() { - // Scene B declares `onlyInB`; a scope built without scene B's - // table (as if evaluating an expression in scene A) must not see - // it — same `None` a genuinely unknown name produces. let scenario = VarTable::compile(&VarSet::new(), &ctx()).unwrap(); let scene_b = table_with( "onlyInB", @@ -177,7 +103,7 @@ mod tests { animation: Vec::new(), }, ); - let _ = &scene_b; // would be passed as `scene` only while evaluating scene B + let _ = &scene_b; let scope_in_scene_a = VarScope::new(&scenario, None, 0.0); assert_eq!(scope_in_scene_a.resolve("onlyInB"), None); @@ -189,4 +115,35 @@ mod tests { crate::expr::ExprError::UnknownIdent("onlyInB".to_string()) ); } + + #[test] + fn a_host_scope_can_hold_a_var_scope_and_delegate_to_resolve() { + struct EngineScope<'a> { + vars: VarScope<'a>, + } + + impl crate::expr::Scope for EngineScope<'_> { + fn var(&self, name: &str) -> Option { + self.vars.resolve(name) + } + + fn node_prop(&self, id: &str, prop: &str) -> Option { + let _ = (id, prop); + None + } + } + + let scenario = table_with( + "W", + VarDef { + default: 1080.0, + animation: Vec::new(), + }, + ); + let host = EngineScope { + vars: VarScope::new(&scenario, None, 0.0), + }; + assert_eq!(crate::expr::Scope::var(&host, "W"), Some(1080.0)); + assert_eq!(crate::expr::Scope::node_prop(&host, "n", "x"), None); + } } diff --git a/crates/rustmotion-core/src/vars/track.rs b/crates/rustmotion-core/src/vars/track.rs index 63b18afe..fec52cef 100644 --- a/crates/rustmotion-core/src/vars/track.rs +++ b/crates/rustmotion-core/src/vars/track.rs @@ -1,42 +1,3 @@ -//! Compiling a [`VarSet`] into cheap-to-sample numbers. -//! -//! [`VarTable::compile`] resolves every [`TimePoint`](crate::schema::time::TimePoint) -//! once, at load, so [`VarTable::value_at`] does nothing more per frame than -//! a linear scan of a handful of plain-`f64` [`Segment`]s and one call to -//! [`crate::engine::animator::ease`] — no string parsing, no -//! [`TimePoint`](crate::schema::time::TimePoint) arithmetic, no -//! [`TimeError`] to propagate, ever again. -//! -//! # Value outside the keyframes -//! -//! Before a variable's first keyframe fires, it is worth [`VarDef::default`] -//! — the variable simply hasn't started moving yet. After its last -//! keyframe's segment ends, it holds at that keyframe's `to` forever, the -//! same way a CSS animation with `animation-fill-mode: forwards` behaves — -//! it does not snap back to `default`. Between two keyframes whose segments -//! don't touch (the second's `at` is later than the first's -//! `at + duration`), the variable holds at the first's `to` for the gap: a -//! keyframe names when the variable *starts* moving again, not a hole where -//! its value is undefined. -//! -//! # Duration is a span, not a point -//! -//! [`VarKeyframe::at`](super::VarKeyframe::at) is resolved via -//! `TimePoint::resolve_absolute`: a `"b"`-unit term there names a position -//! on the beat grid, [`TimeCtx::beat_offset`] included, by -//! [`TimePoint`](crate::schema::time::TimePoint)'s own contract (see -//! `schema::time`'s module doc). `VarKeyframe::duration`, however, is a -//! *length* of time — "3 beats long" — and must not pick up `beat_offset`, -//! which is where beat 0 sits, not a scale factor on a span. Resolving -//! `"3b"` through `TimePoint::resolve_relative` unchanged would compute -//! `beat_offset + 3 * 60 / bpm` for that single term (every `b`-unit term -//! adds the full `beat_offset` independently — see `eval_spec` in -//! `schema::time`), silently shifting every animated variable's tween -//! length by the scenario's beat offset. [`VarTrack::compile`] instead -//! resolves `duration` against a copy of the scenario's [`TimeCtx`] with -//! `beat_offset` zeroed, so `"3b"` means exactly `3 * 60 / bpm` seconds -//! regardless of where the grid itself is anchored. - use std::collections::BTreeMap; use crate::engine::animator::ease; @@ -45,19 +6,14 @@ use crate::schema::time::{TimeCtx, TimeError}; use super::schema::{VarDef, VarSet}; -/// Everything that can go wrong compiling a [`VarSet`] into a [`VarTable`]. #[derive(Debug, Clone, PartialEq, thiserror::Error)] pub enum VarsError { - /// A keyframe's `at` or `duration` failed to resolve — most commonly a - /// `"b"`-unit term with no `bpm` declared on the scenario. #[error("variable `{name}`: {source}")] Time { name: String, #[source] source: TimeError, }, - /// Keyframes must be strictly increasing by resolved `at`; this one - /// wasn't. #[error( "variable `{name}`: keyframe {index} starts at {at}s, which is not after keyframe {prev_index}'s start ({prev_at}s) — keyframes must be strictly increasing" )] @@ -68,7 +24,6 @@ pub enum VarsError { prev_index: usize, prev_at: f64, }, - /// A keyframe's `duration` resolved to a negative number of seconds. #[error("variable `{name}`: keyframe {index} has a negative duration ({duration}s)")] NegativeDuration { name: String, @@ -77,10 +32,6 @@ pub enum VarsError { }, } -/// One resolved tween, in plain seconds — no -/// [`TimePoint`](crate::schema::time::TimePoint) left to re-parse. `start` -/// is the value going into the segment: [`VarDef::default`] -/// for the first one, the previous segment's `to` for every other one. #[derive(Debug, Clone, PartialEq)] struct Segment { at: f64, @@ -91,10 +42,6 @@ struct Segment { } impl Segment { - /// `t` is expected to already be inside `[at, at + duration]`; a `t` - /// outside that range is still handled safely (the ratio clamps to - /// `0.0`/`1.0`) but callers should prefer [`VarTrack::value_at`]'s - /// explicit before/after/gap handling instead of relying on that. fn value_at(&self, t: f64) -> f64 { if self.duration <= 0.0 { return self.to; @@ -105,9 +52,6 @@ impl Segment { } } -/// A single variable's compiled animation: its `default` plus the resolved -/// [`Segment`]s built from its [`VarKeyframe`]s, ready to sample at any -/// absolute time with [`VarTrack::value_at`]. #[derive(Debug, Clone, PartialEq)] pub struct VarTrack { default: f64, @@ -115,17 +59,12 @@ pub struct VarTrack { } impl VarTrack { - /// Compile a single [`VarDef`] against the scenario's beat grid. `name` - /// is used only to attribute errors. pub fn compile(name: &str, def: &VarDef, ctx: &TimeCtx) -> Result { let mut segments = Vec::with_capacity(def.animation.len()); let mut running = def.default; let mut prev: Option<(usize, f64)> = None; for (index, kf) in def.animation.iter().enumerate() { - // A variable has no enclosing scene of its own — `at` always - // resolves against the scenario's absolute timeline, `@` - // prefix or not. See the struct doc on `VarKeyframe::at`. let at_ctx = TimeCtx { scene_start: 0.0, ..*ctx @@ -151,7 +90,6 @@ impl VarTrack { } prev = Some((index, at)); - // `duration` is a span, not a point — see the module doc. let duration_ctx = TimeCtx { beat_offset: 0.0, scene_start: 0.0, @@ -188,9 +126,6 @@ impl VarTrack { }) } - /// This variable's value at absolute time `t` seconds — see the module - /// doc's "Value outside the keyframes" section for the before-first, - /// gap and after-last rules. pub fn value_at(&self, t: f64) -> f64 { let mut value = self.default; for seg in &self.segments { @@ -207,19 +142,12 @@ impl VarTrack { } } -/// A whole [`VarSet`] — all of a scenario's vars, or all of one scene's — -/// compiled once. Cheap to hold onto for an entire render: -/// [`VarTable::compile`] is the only place -/// [`TimePoint`](crate::schema::time::TimePoint) arithmetic happens. #[derive(Debug, Clone, PartialEq, Default)] pub struct VarTable { tracks: BTreeMap, } impl VarTable { - /// Compile every variable in `vars` against the scenario's beat grid. - /// Fails on the first variable whose keyframes don't resolve — see - /// [`VarsError`]. pub fn compile(vars: &VarSet, ctx: &TimeCtx) -> Result { let mut tracks = BTreeMap::new(); for (name, def) in vars { @@ -228,13 +156,10 @@ impl VarTable { Ok(VarTable { tracks }) } - /// `name`'s value at absolute time `t` seconds, or `None` if this table - /// has no variable by that name. pub fn value_at(&self, name: &str, t: f64) -> Option { self.tracks.get(name).map(|track| track.value_at(t)) } - /// Every variable name this table declares. pub fn names(&self) -> impl Iterator { self.tracks.keys().map(String::as_str) } @@ -277,13 +202,11 @@ mod tests { #[test] fn value_at_several_sampled_instants_across_the_tween() { let def = linear_0_to_1_over_3_beats(); - // bpm=120 -> 0.5s/beat -> duration = 3 * 0.5 = 1.5s, spanning - // [4.85, 6.35]. let track = VarTrack::compile("keyDraw", &def, &ctx(Some(120.0), 0.0)).unwrap(); - assert_eq!(track.value_at(4.85), 0.0); // exactly at `at` - assert!((track.value_at(5.6) - 0.5).abs() < 1e-9); // halfway - assert_eq!(track.value_at(6.35), 1.0); // exactly at `at + duration` + assert_eq!(track.value_at(4.85), 0.0); + assert!((track.value_at(5.6) - 0.5).abs() < 1e-9); + assert_eq!(track.value_at(6.35), 1.0); } #[test] @@ -296,14 +219,10 @@ mod tests { #[test] fn beat_offset_does_not_leak_into_duration() { - // Same track, but the grid is anchored 2.2s in. `at` (absolute, - // "@4.85s") is untouched by beat_offset since it's a plain seconds - // spec; the "3b" duration must still be exactly 1.5s, not - // 1.5 + 2.2. let def = linear_0_to_1_over_3_beats(); let track = VarTrack::compile("keyDraw", &def, &ctx(Some(120.0), 2.2)).unwrap(); assert_eq!(track.value_at(4.85), 0.0); - assert_eq!(track.value_at(6.35), 1.0); // 4.85 + 1.5, not 4.85 + 3.7 + assert_eq!(track.value_at(6.35), 1.0); } #[test] @@ -330,8 +249,6 @@ mod tests { assert_eq!(track.value_at(1.0), 0.0); assert_eq!(track.value_at(1.5), 5.0); assert_eq!(track.value_at(2.0), 10.0); - // Gap between segment 1's end (2.0) and segment 2's start (3.0): - // holds at the first segment's `to`. assert_eq!(track.value_at(2.5), 10.0); assert_eq!(track.value_at(3.0), 10.0); assert_eq!(track.value_at(3.5), 5.0); @@ -368,13 +285,11 @@ mod tests { }], }; let track = VarTrack::compile("v", &def, &ctx(None, 0.0)).unwrap(); - // ease_in_out_cubic(0.25) is well below the linear 0.25. let quarter = track.value_at(0.25); assert!( quarter < 0.25, "expected eased value below linear, got {quarter}" ); - // Midpoint of an odd-symmetric ease-in-out curve is still 0.5. assert!((track.value_at(0.5) - 0.5).abs() < 1e-9); } diff --git a/crates/rustmotion-core/tests/audit_ws_a.rs b/crates/rustmotion-core/tests/audit_ws_a.rs index 7ad93749..9e25151e 100644 --- a/crates/rustmotion-core/tests/audit_ws_a.rs +++ b/crates/rustmotion-core/tests/audit_ws_a.rs @@ -1,6 +1,3 @@ -//! Regression tests for the workstream A (animation & paint) audit findings -//! tracked in issue #220: RM-01, RM-09, RM-10, RM-37. - use rustmotion_core::css::style::{ Background, BackgroundLayer, BoxShadow, Color as CssColor, CssStyle, Display, FlexDirection, GradientStop, Position, Size as CSize, @@ -72,8 +69,6 @@ fn probe(buf: &[u8], w: u32, x: usize, y: usize) -> (u8, u8, u8) { (buf[i], buf[i + 1], buf[i + 2]) } -// ---- RM-01: opacity layer must not clip the node's own outset box-shadow ---- - fn card_with_shadow(opacity: Option) -> BoxNode { let css = CssStyle { position: Some(Position::Absolute), @@ -106,12 +101,6 @@ fn card_with_shadow(opacity: Option) -> BoxNode { #[test] fn opacity_layer_does_not_clip_own_outset_box_shadow() { - // 100x100 white card at (50,50) on a 200x200 black canvas, outset - // box-shadow (red, spread 20, blur 0 -> hard-edged halo rect from - // (30,30) to (170,170)). Probe point (100,45) sits in the halo band - // above the card, outside its own border-box. `opacity: 0.999` forces - // the opacity/filter SaveLayerRec open without visibly dimming the - // probed color. let opaque = { let mut root = root_node(200.0, 200.0, "#000000", vec![card_with_shadow(None)]); render_pixels(&mut root, 200, 200) @@ -134,8 +123,6 @@ fn opacity_layer_does_not_clip_own_outset_box_shadow() { ); } -// ---- RM-09: underdamped spring step response must start from rest ---- - fn spring_config(damping: f64, stiffness: f64, mass: f64) -> SpringConfig { SpringConfig { damping, @@ -148,10 +135,6 @@ fn spring_config(damping: f64, stiffness: f64, mass: f64) -> SpringConfig { #[test] fn underdamped_spring_step_response_starts_at_rest() { - // Shipped defaults named in the audit finding: damping=15, stiffness=100, - // mass=1 -> zeta=0.75 (underdamped). A step response that starts from - // rest has ~0 velocity at t=0; a wrong sine-argument formula produces a - // jolt of about 6.21/s instead. let config = spring_config(15.0, 100.0, 1.0); let h = 1e-5; let v0 = spring_value(0.0, &config); @@ -167,9 +150,6 @@ fn underdamped_spring_step_response_starts_at_rest() { #[test] fn underdamped_spring_matches_the_analytic_closed_form() { - // Reference computed independently of the engine's implementation from - // the textbook closed form for an underdamped step response: - // 1 - e^{-zeta*omega*t} * [cos(omega_d*t) + (zeta*omega/omega_d)*sin(omega_d*t)] let damping = 15.0_f64; let stiffness = 100.0_f64; let mass = 1.0_f64; @@ -192,8 +172,6 @@ fn underdamped_spring_matches_the_analytic_closed_form() { } } -// ---- RM-10: linear-gradient(180deg, ...) must put the first stop at the top ---- - fn gradient_card(w: f32, h: f32, angle: f32) -> BoxNode { let css = CssStyle { width: Some(CSize::Length(CLP::Px(w))), @@ -226,8 +204,6 @@ fn gradient_card(w: f32, h: f32, angle: f32) -> BoxNode { #[test] fn linear_gradient_180deg_puts_the_first_stop_at_the_top() { - // CSS: `angle: 180` ("to bottom") points the gradient line downward, so - // the first stop lands at the top and the last stop at the bottom. let mut root = gradient_card(100.0, 100.0, 180.0); let buf = render_pixels(&mut root, 100, 100); @@ -243,18 +219,8 @@ fn linear_gradient_180deg_puts_the_first_stop_at_the_top() { ); } -// ---- RM-37: `spring_settle_time` must not be rescanned on every `spring_value` call ---- - #[test] fn spring_settle_time_is_memoized_not_rescanned_every_call() { - // `SpringConfig::duration` routes every `spring_value` sample through - // `spring_settle_time`'s 2k-20k-step coarse-then-bisect scan (see - // animator.rs). The scan result depends only on the spring's own - // (damping, stiffness, mass, threshold) — invariant across every frame - // an animation is sampled at — so repeating it per call is pure waste. - // Measured uncached on this parameter set: 2000 calls take ~530ms in a - // debug build; memoized, the same 2000 calls (one real scan, the rest - // cache hits) complete in well under a tenth of that. let config = SpringConfig { damping: 37.0, stiffness: 733.0, diff --git a/crates/rustmotion-core/tests/audit_ws_h.rs b/crates/rustmotion-core/tests/audit_ws_h.rs index 4f02d02f..ed3f03d6 100644 --- a/crates/rustmotion-core/tests/audit_ws_h.rs +++ b/crates/rustmotion-core/tests/audit_ws_h.rs @@ -1,11 +1,5 @@ -//! Regression tests for the workstream H (untrusted scenario ingestion) -//! audit findings that live in `rustmotion-core`: RM-41, RM-42, RM-43. - use rustmotion_core::engine::renderer::{extract_video_frame, http_agent}; -// ---- RM-41: no timeout on any HTTP call — a bare `ureq::get` always uses -// the default, untimed agent, so a stalled host hangs a render forever ---- - #[test] fn shared_http_agent_has_finite_global_and_connect_timeouts() { let timeouts = http_agent().config().timeouts(); @@ -20,17 +14,8 @@ fn shared_http_agent_has_finite_global_and_connect_timeouts() { ); } -// ---- RM-42: a scenario's `video.src` reaches `ffmpeg -i` verbatim, with no -// protocol allowlist — a remote-looking src turns into an SSRF primitive ---- - #[test] fn extract_video_frame_rejects_a_remote_src_before_it_ever_reaches_ffmpeg() { - // Asserting `is_err()` alone would also pass for the wrong reason: on a - // machine with no route to this address, ffmpeg itself fails to connect - // and returns a non-zero exit status. The fix under test is that the - // src is refused *before* any subprocess runs at all — so the assertion - // has to be on the specific rejection message, which only the fix - // produces; an ffmpeg spawn/exit failure would carry a different one. let err = extract_video_frame( "http://169.254.169.254/latest/meta-data/iam/security-credentials/", 0.0, @@ -56,10 +41,6 @@ fn extract_video_frame_does_not_reject_a_plain_local_path() { ); } -// ---- RM-43: `for-each` expansion has a depth ceiling but no node budget — -// nesting is multiplicative, so a handful of small arrays nested a few -// levels deep can declare a product in the millions ---- - #[test] fn for_each_node_budget_rejects_a_declared_product_that_exceeds_the_cap() { fn items(n: usize) -> serde_json::Value { @@ -70,12 +51,6 @@ fn for_each_node_budget_rejects_a_declared_product_that_exceeds_the_cap() { ) } - // Three levels of 200 elements nested directly in each other's - // `template.children`: a declared product of 200^3 = 8,000,000 nodes, - // comfortably past a low-millions cap. The array literals themselves - // (200 small JSON objects, three times) are cheap to build — the - // assertion is that expansion refuses the *product*, not that it - // finishes computing it. let mut doc = serde_json::json!({ "video": { "width": 100, "height": 100 }, "scenes": [{ diff --git a/crates/rustmotion-core/tests/audit_ws_j.rs b/crates/rustmotion-core/tests/audit_ws_j.rs index 4da80594..1b4ec71d 100644 --- a/crates/rustmotion-core/tests/audit_ws_j.rs +++ b/crates/rustmotion-core/tests/audit_ws_j.rs @@ -1,6 +1,3 @@ -//! Regression tests for the workstream J (layout pass & CSS unit resolution) -//! audit findings: RM-26, RM-27. - use std::sync::{Arc, Mutex}; use rustmotion_core::css::style::{CssStyle, Edges, Size as CSize}; @@ -9,13 +6,6 @@ use rustmotion_core::css::units::{Length, LengthPercentage as CLP}; use rustmotion_core::engine::box_tree::{AvailableSpace, BoxNode, IntrinsicMeasure}; use rustmotion_core::engine::layout_pass::run_layout; -// ---- RM-26: `em` on layout properties must resolve against the element's -// own (inherited) font-size, not a constant 16px ---- - -/// A card sized by its parent's explicit `font-size` (48px) inherits that -/// font-size down to its own layout resolution even though it never sets -/// `font-size` itself. Its `padding: "1em"` must therefore resolve to 48px -/// (`1 * inherited_font_size`), not the pre-fix constant 16px. #[test] fn em_padding_resolves_against_inherited_font_size_not_a_constant_16px() { let mut root = BoxNode::container( @@ -48,9 +38,6 @@ fn em_padding_resolves_against_inherited_font_size_not_a_constant_16px() { assert_eq!(content_w, 200.0 - 2.0 * 48.0); } -// ---- RM-27: a leaf's `IntrinsicMeasure::measure` must receive `known` and -// `available` in the same (content-box) coordinate space ---- - type RecordedCall = ((Option, Option), (AvailableSpace, AvailableSpace)); #[derive(Default)] @@ -69,13 +56,6 @@ impl IntrinsicMeasure for RecordingIntrinsic { } } -/// A leaf with 20px uniform padding, stretched to its column-flex parent's -/// full 300px content width but auto-height (so taffy must measure its -/// intrinsic height with the width already resolved). Any call where -/// `known.0` is definite must agree with `available.0` when that is also -/// definite: both describe the same box, and per taffy 0.10.1's own -/// `compute_leaf_layout`, `available_space` has already had padding+border -/// subtracted before reaching the measure function. #[test] fn measure_fn_known_and_available_agree_on_content_box_width() { use rustmotion_core::css::style::{AlignItems, Display, FlexDirection}; diff --git a/crates/rustmotion-core/tests/audit_ws_k.rs b/crates/rustmotion-core/tests/audit_ws_k.rs index b12e52c7..d5526c4e 100644 --- a/crates/rustmotion-core/tests/audit_ws_k.rs +++ b/crates/rustmotion-core/tests/audit_ws_k.rs @@ -1,23 +1,11 @@ -//! Regression tests for the workstream K (docs, schema, CI) audit findings: -//! RM-02, RM-47, RM-52. - use std::collections::BTreeSet; use std::fs; use std::path::{Path, PathBuf}; -/// Text immediately following the component count in the README's -/// Architecture section (see `README.md`'s "rustmotion ships N components, -/// each implementing the `Painter` trait" sentence). const README_COUNT_MARKER: &str = " components, each implementing the `Painter` trait"; -/// Text immediately following the component count in -/// `crates/rustmotion-components/Cargo.toml`'s `description` — the string -/// crates.io displays for the published crate. const CARGO_TOML_COUNT_MARKER: &str = " components)\""; -/// Read the integer that appears immediately before `marker` in `haystack`, -/// skipping trailing whitespace. Panics with the marker text on failure so a -/// reworded sentence names exactly what moved instead of a bare parse error. fn number_before(haystack: &str, marker: &str, haystack_name: &str) -> u32 { let idx = haystack.find(marker).unwrap_or_else(|| { panic!( @@ -37,11 +25,6 @@ fn number_before(haystack: &str, marker: &str, haystack_name: &str) -> u32 { }) } -/// Count the variants of `pub enum Component` in -/// `crates/rustmotion-components/src/lib.rs`, by counting non-empty, -/// non-attribute lines between its opening `{` and closing `}`. Every -/// variant in that enum is declared on its own line (`Name(Type),`); the -/// only other lines in the block are `#[serde(...)]` attributes. fn count_component_variants(lib_rs: &str) -> usize { let start_marker = "pub enum Component {"; let start = lib_rs.find(start_marker).unwrap_or_else(|| { @@ -58,12 +41,6 @@ fn count_component_variants(lib_rs: &str) -> usize { .count() } -/// RM-02 / RM-47: README.md and `rustmotion-components/Cargo.toml` (the text -/// crates.io shows for the published crate) both claimed "51 components" -/// while `Component` actually had 60 variants — and nothing kept the two in -/// sync. Locks the documented counts to the real one so a future component -/// addition/removal that forgets to update the docs fails CI instead of -/// drifting silently again. #[test] fn documented_component_count_matches_enum_variant_count() { let lib_rs = include_str!("../../rustmotion-components/src/lib.rs"); @@ -93,13 +70,6 @@ fn documented_component_count_matches_enum_variant_count() { ); } -/// Public schema fields that parse successfully but are not read anywhere -/// outside `crates/rustmotion-core/src/schema/` — kept out of -/// `every_public_schema_field_is_read_somewhere_or_allowlisted`'s failure so -/// a *known, tracked* gap doesn't block CI, while a *new* one still does. -/// Each entry names the finding that tracks closing it and the reason it -/// isn't closed by workstream K itself. Removing an entry once the field is -/// wired (or deleted) is the expected way this list shrinks. const KNOWN_INERT_FIELDS: &[(&str, &str)] = &[ ( "codec", @@ -158,7 +128,6 @@ const KNOWN_INERT_FIELDS: &[(&str, &str)] = &[ ), ]; -/// `crates/rustmotion-core` -> `crates` -> ``. fn workspace_root() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) .parent() @@ -167,7 +136,6 @@ fn workspace_root() -> PathBuf { .to_path_buf() } -/// Recursively collect every `.rs` file under `dir`, skipping `target/`. fn collect_rs_files(dir: &Path, out: &mut Vec) { let entries = fs::read_dir(dir).unwrap_or_else(|e| panic!("read_dir {}: {e}", dir.display())); for entry in entries { @@ -184,13 +152,6 @@ fn collect_rs_files(dir: &Path, out: &mut Vec) { } } -/// Extract the names of `pub : ,`-style struct fields from Rust -/// source text. Deliberately crude (no parser): looks for lines whose -/// trimmed text starts with `"pub "` and contains a `:` before any -/// non-identifier character, which matches plain field declarations -/// (`pub width: u32,`) while excluding `pub fn`/`pub struct`/`pub enum` -/// (no top-level `:`, or one buried behind non-identifier characters like -/// `(`/`<`/`&`/spaces that fail the identifier check below). fn extract_pub_field_names(source: &str) -> Vec { let mut names = Vec::new(); for line in source.lines() { @@ -213,14 +174,6 @@ fn extract_pub_field_names(source: &str) -> Vec { names } -/// RM-52: nothing stopped a schema field from parsing successfully and then -/// being read by no code path — RM-50 (`VideoConfig.codec`) and RM-51 -/// (`MotionBlurConfig.intensity`) are exactly that defect, and the format's -/// credibility as an LLM generation target rests on a field either doing -/// something or failing to parse. This test greps the workspace for a -/// member-access on every public schema field name and fails if one isn't -/// found anywhere outside its own definition — unless it's in -/// `KNOWN_INERT_FIELDS`, so a newly introduced inert field still fails CI. #[test] fn every_public_schema_field_is_read_somewhere_or_allowlisted() { let root = workspace_root(); diff --git a/crates/rustmotion-core/tests/chromatic_wipe.rs b/crates/rustmotion-core/tests/chromatic_wipe.rs index 271b2271..c7f261a2 100644 --- a/crates/rustmotion-core/tests/chromatic_wipe.rs +++ b/crates/rustmotion-core/tests/chromatic_wipe.rs @@ -1,20 +1,9 @@ -//! `chromatic_wipe`: a fast slide whose reveal edge splits into red and cyan -//! at the peak and recombines as it lands. -//! -//! The two properties worth pinning are that `aberration: 0` degrades to a -//! plain slide (so the knob genuinely goes to zero rather than to "a bit -//! less"), and that the split is *gone* by the time the transition ends — -//! a residual channel offset on the last frame would bleed a coloured fringe -//! into the scene that follows. - use rustmotion_core::engine::transition::{apply_transition, TransitionOptions}; use rustmotion_core::schema::{TransitionDirection, TransitionType}; const W: u32 = 64; const H: u32 = 48; -/// Two flat frames, so any channel offset shows up as a colour that is in -/// neither of them. fn frames() -> (Vec, Vec) { let a: Vec = (0..W * H).flat_map(|_| [200u8, 200, 200, 255]).collect(); let b: Vec = (0..W * H).flat_map(|_| [40u8, 40, 40, 255]).collect(); @@ -62,8 +51,6 @@ fn aberration_zero_is_a_plain_slide() { #[test] fn the_split_peaks_in_the_middle_and_is_gone_at_both_ends() { let o = opts(1.0, TransitionDirection::Left); - // At the ends the frames are whole, so the composite matches a plain - // slide exactly. assert_eq!( composite(0.0, &o), slide(0.0), @@ -74,7 +61,6 @@ fn the_split_peaks_in_the_middle_and_is_gone_at_both_ends() { slide(1.0), "no split on the last frame — a residual fringe would bleed into the next scene" ); - // In the middle, the seam between the two frames has to differ. assert_ne!( composite(0.5, &o), slide(0.5), @@ -89,7 +75,6 @@ fn a_bigger_aberration_splits_further() { let loud = composite(mid, &opts(2.0, TransitionDirection::Left)); let plain = slide(mid); - // Distance from the un-split composite: more aberration, more difference. let distance = |x: &[u8]| -> u64 { x.iter() .zip(plain.iter()) @@ -106,9 +91,6 @@ fn a_bigger_aberration_splits_further() { #[test] fn every_direction_lands_on_the_incoming_frame() { - // Whichever way it travels, the transition has to *finish*: a direction - // whose arithmetic left the incoming frame off-screen at progress 1 - // would end the cut on the wrong scene. let (_, b) = frames(); for direction in [ TransitionDirection::Left, diff --git a/crates/rustmotion-core/tests/exported_schema_examples.rs b/crates/rustmotion-core/tests/exported_schema_examples.rs index 387625ec..2abf8039 100644 --- a/crates/rustmotion-core/tests/exported_schema_examples.rs +++ b/crates/rustmotion-core/tests/exported_schema_examples.rs @@ -1,20 +1,3 @@ -//! Constat #5: `rustmotion schema` used to export a `Scene`/`View` with -//! `additionalProperties: false` (from `deny_unknown_fields`) but no -//! `background` property (from `#[schemars(skip)]` on a field with no -//! `JsonSchema` impl for its type). The two combined meant the exported -//! schema declared *every* scenario in `examples/` that uses `background` -//! invalid — the schema is exactly what generators (LLMs) are meant to -//! target, so this is the sink 3 users can't work around. -//! -//! This test is a minimal, dependency-free (no external JSON-Schema crate — -//! adding one is out of this workstream's file scope) structural validator: -//! it understands exactly the subset of JSON Schema draft-07 that -//! `schemars` 0.8 actually emits for this codebase (`$ref`, `definitions`, -//! `type`, `properties`/`additionalProperties`/`required`, `items`, -//! `oneOf`/`anyOf`/`allOf`, `enum`). It is not a general-purpose validator, -//! but it is precise about the one thing constat #5 is about: -//! `additionalProperties: false` combined with a missing declared property. - use serde_json::Value; use std::collections::BTreeSet; use std::path::PathBuf; @@ -23,7 +6,6 @@ fn examples_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../examples") } -/// Resolve a `$ref` like `#/definitions/Scene` against the schema root. fn resolve<'a>(root: &'a Value, ref_str: &str) -> &'a Value { let path = ref_str.strip_prefix("#/").unwrap_or(ref_str); let mut cur = root; @@ -35,12 +17,7 @@ fn resolve<'a>(root: &'a Value, ref_str: &str) -> &'a Value { cur } -/// Validate `instance` against `schema` (a node within `root`). Appends a -/// human-readable message to `errors` for every violation found, prefixed -/// with `path`. This intentionally does not stop at the first violation -/// (matches how the equivalent Python `jsonschema` run was cross-checked). fn check(root: &Value, schema: &Value, instance: &Value, path: &str, errors: &mut Vec) { - // `true` / `{}` accept anything. if schema.as_bool() == Some(true) { return; } @@ -71,7 +48,7 @@ fn check(root: &Value, schema: &Value, instance: &Value, path: &str, errors: &mu let mut sub_errors = Vec::new(); check(root, variant, instance, path, &mut sub_errors); if sub_errors.is_empty() { - return; // one matching variant is enough + return; } if best.as_ref().is_none_or(|b| sub_errors.len() < b.len()) { best = Some(sub_errors); @@ -153,17 +130,6 @@ fn check(root: &Value, schema: &Value, instance: &Value, path: &str, errors: &mu } } -/// Every `examples/*.json` file must validate against the schema -/// `rustmotion schema` exports (i.e. `generate_json_schema()` — the CLI -/// command only additionally wires `Scene.children` to the `Component` -/// union, which is irrelevant to constat #5's `background` defect and out -/// of this workstream's file scope to reproduce here). -/// -/// `ferriskey-presentation.json` is excluded: it fails plain `rustmotion -/// validate` today for an unrelated, pre-existing geometry overflow (issue -/// #157, out of this workstream's scope) — but per the baseline run below, -/// it has zero *schema* violations even before this fix, so excluding it -/// from the loop changes nothing about what this test proves. #[test] fn all_examples_validate_against_the_exported_schema() { let schema = rustmotion_core::schema::generate_json_schema(); @@ -208,11 +174,6 @@ fn all_examples_validate_against_the_exported_schema() { ); } -/// Narrower, more direct regression lock for the exact defect: `Scene` and -/// `View` must both declare `background` as a property in the exported -/// schema. Kept alongside the full-document check above because this is -/// the precise structural fact constat #5 is about, independent of whatever -/// else may be in the document. #[test] fn scene_and_view_schema_both_declare_a_background_property() { let schema = rustmotion_core::schema::generate_json_schema(); diff --git a/crates/rustmotion-core/tests/spring_duration.rs b/crates/rustmotion-core/tests/spring_duration.rs index 631cb4d0..cc9d90ec 100644 --- a/crates/rustmotion-core/tests/spring_duration.rs +++ b/crates/rustmotion-core/tests/spring_duration.rs @@ -1,13 +1,3 @@ -//! Issue #167 lot E: `SpringConfig::duration`/`rest_threshold` and -//! `engine::animator::spring_rest_time` are meant to be used from outside -//! `rustmotion-core` (by `rustmotion`'s `info` command, in particular). -//! This is a black-box check that the public surface actually works end to -//! end — the white-box coverage (settle-time correctness against a -//! brute-force reference, over/under-damped edge cases, shape preservation) -//! lives in `crates/rustmotion-core/src/engine/animator.rs`'s -//! `spring_duration_tests` module, since it needs access to private solver -//! internals that this crate does not expose. - use rustmotion_core::engine::animator::{spring_rest_time, spring_value}; use rustmotion_core::schema::SpringConfig; @@ -44,9 +34,6 @@ fn spring_config_duration_round_trips_through_json() { #[test] fn a_spring_without_duration_settles_on_its_own_schedule() { - // damping=6, stiffness=120, mass=1: the same underdamped spring used - // throughout the crate's internal tests (elastic_in / kf_anim_spring's - // sibling). Its natural settle time is a couple seconds, not 0.8s. let config = spring(6.0, 120.0, 1.0, None); let natural_rest = spring_rest_time(&config); assert!( diff --git a/crates/rustmotion-html/src/element.rs b/crates/rustmotion-html/src/element.rs index 0722aa9d..3d7e185c 100644 --- a/crates/rustmotion-html/src/element.rs +++ b/crates/rustmotion-html/src/element.rs @@ -9,15 +9,7 @@ const KNOWN_NATIVE_ATTRS: &[&str] = &["style", "anim"]; enum TagKind { Container, Text, - /// Tags that never visually render in real HTML either (`

"##; @@ -264,11 +240,6 @@ fn inline_formatting_tags_still_flatten_into_the_parent_text() { ); } -// --------------------------------------------------------------------------- -// RM-12: studio HTML write-back silently deletes everything outside -// in the author's file. -// --------------------------------------------------------------------------- - #[test] fn write_back_preserves_doctype_head_and_surrounding_comments() { let html = concat!( diff --git a/crates/rustmotion-html/tests/silent_loss_regressions.rs b/crates/rustmotion-html/tests/silent_loss_regressions.rs index 69175d6e..559d0a40 100644 --- a/crates/rustmotion-html/tests/silent_loss_regressions.rs +++ b/crates/rustmotion-html/tests/silent_loss_regressions.rs @@ -1,22 +1,5 @@ -//! Regression tests for the 4 confirmed audit findings on the HTML dialect -//! ("lot html"): each used to degrade silently (wrong-but-valid JSON, -//! `validate` exiting 0) instead of failing loudly or naming the loss. -//! -//! Each test reproduces the exact (or closest faithful) scenario from the -//! audit brief and asserts the *fixed* behavior — refuse (named error) or, for -//! constructs with zero real-HTML visual equivalent, silently skip only when -//! that skip matches what any HTML author already expects from a browser. - use rustmotion_html::{html_to_scenario_value, HtmlError}; -// --------------------------------------------------------------------------- -// Constat 1:

Styled by class

"##; @@ -27,10 +10,6 @@ fn style_block_is_refused_not_painted() { ); } -/// `

Real content

"##; @@ -46,9 +25,6 @@ fn script_alone_is_skipped_not_painted() { assert_eq!(children[0]["content"], serde_json::json!("Real content")); } -/// A `

Hi

"##; @@ -60,13 +36,6 @@ fn root_level_style_block_is_also_refused() { ); } -// --------------------------------------------------------------------------- -// Constat 2: a nested inside a container must never silently vanish. -// --------------------------------------------------------------------------- - -/// Exact reproduction from the brief: a `` wrapped in a `
` used to disappear entirely — `validate` reported "1 -/// scene(s)" / "Duration: 2.0s", silently dropping 3 seconds of video. #[test] fn scene_nested_in_a_wrapper_div_is_refused_not_dropped() { let html = r#"

inside a wrapper

top level

"#; @@ -78,12 +47,6 @@ fn scene_nested_in_a_wrapper_div_is_refused_not_dropped() { } } -/// Stronger, more realistic repro flagged by the adversarial verifier: an -/// unclosed `` (a common typo, and the dialect's most reachable formatting -/// element) makes html5ever's formatting-element reconstruction nest BOTH -/// scenes inside ``. Before the fix this produced a generic `NoScenes` -/// (all scenes swallowed) with no indication of the real cause; the fix must -/// name `` as the offending parent instead. #[test] fn scenes_nested_via_html5_error_recovery_on_unclosed_b_are_named() { let html = r#"note

A

B

"#; @@ -95,14 +58,6 @@ fn scenes_nested_via_html5_error_recovery_on_unclosed_b_are_named() { } } -/// Even gnarlier repro: an unclosed `

` before the scenes. HTML5's -/// implicit-close-on-heading rule pops `

` (and the still-open `` -/// with it) as soon as the `

` inside the first scene is seen, orphaning -/// that `

` as a stray root-level sibling and leaving `scene[0]` with zero -/// children — content silently vanishes *inside* a scene that still "exists" -/// (scene count preserved, so the old `NoScenes` guard never fired). The -/// `` node itself is still nested inside `

` in the DOM, so refusing -/// on nested-scene detection catches this too. #[test] fn scene_content_lost_via_unclosed_p_is_refused() { let html = r#"

note

A

B

"#; @@ -114,10 +69,6 @@ fn scene_content_lost_via_unclosed_p_is_refused() { } } -/// A `` element remains the one legitimate exception: it is a real -/// element of the dialect, and html5ever nests siblings inside it as a -/// parsing quirk, not authoring error — scenes placed after a `` -/// declaration must keep working exactly as before. #[test] fn scene_after_font_declaration_still_works() { let html = r##" @@ -128,14 +79,6 @@ fn scene_after_font_declaration_still_works() { assert_eq!(v["scenes"][0]["duration"], serde_json::json!(2)); } -// --------------------------------------------------------------------------- -// Constat 3: unknown HTML tags with a real dialect equivalent must never -// silently degrade into an empty
. -// --------------------------------------------------------------------------- - -/// Exact reproduction from the brief: ``/`` used to transpile into -/// `{"type":"div"}` / `{"type":"div","children":[{"type":"div"}]}` — content -/// (`src`, shapes) entirely lost, `validate` exiting 0. #[test] fn img_tag_is_refused_not_an_empty_div() { let html = r##""##; @@ -164,8 +107,6 @@ fn svg_tag_is_refused_not_an_empty_div() { } } -/// `