From 415b7051d239ba01ecb51b9de93179c8219e7adc Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Mon, 28 Sep 2026 09:41:44 +0200 Subject: [PATCH 1/2] feat(3d): curved layout-surface containers and an orthographic dot_map globe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both halves of #387 — a dome of flat content and a real globe — need the same thing a per-node transform/perspective can't give: one shared vanishing point (or one shared hemisphere test) for a whole set of children, not a private one per node. style.layout-surface (cylinder|sphere) projects a container's direct children onto a curved surface at paint time only — taffy still lays out the grid flat, so nothing about layout itself changes. Each child gets a translate3d computed from its normalized offset from the container's center, run through the same css_perspective_m44/pre_concat machinery apply_plane_camera already uses for the camera's own 3D tilt (#400), just applied per child instead of per plane. Depth ordering is left at declaration/z-index order — a wide enough arc or turntable rotation can make a far cell paint over a near one, which #93 owns generally and isn't fixed here. dot_map gains projection: "orthographic", the closed-form lat/lng-to-screen projection plus the far-hemisphere visibility test a clip-path circle can't express (it just crops a flat map, it never curves). rotate.lng/lat aim the globe, limb_shading darkens towards the edge, and arcs are great-circle paths slerped in 3D and lifted off the surface, culled the same way as the dots so they don't get drawn straight through the globe. The historical flat mapping is untouched code, gated behind the projection's default, and pinned byte-identical by test. Both features are proven with pixel renders, not just the underlying geometry: a projected grid's columns are shown to differ in width where a flat grid's don't, and an orthographic globe's dot spacing is shown to converge towards the limb where the flat map's doesn't. --- crates/rustmotion-components/src/dot_map.rs | 621 +++++++++++++++++- crates/rustmotion-core/src/css/style.rs | 153 +++++ .../rustmotion-core/src/engine/paint_pass.rs | 468 ++++++++++++- .../skills/rules/dot-map-orthographic.md | 65 ++ .../rustmotion/skills/rules/layout-surface.md | 69 ++ 5 files changed, 1371 insertions(+), 5 deletions(-) create mode 100644 crates/rustmotion/skills/rules/dot-map-orthographic.md create mode 100644 crates/rustmotion/skills/rules/layout-surface.md diff --git a/crates/rustmotion-components/src/dot_map.rs b/crates/rustmotion-components/src/dot_map.rs index aa4c077..9dd525d 100644 --- a/crates/rustmotion-components/src/dot_map.rs +++ b/crates/rustmotion-components/src/dot_map.rs @@ -12,6 +12,148 @@ use rustmotion_core::engine::renderer::{ use rustmotion_core::schema::TimelineStep; use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig}; +const LIMB_VISIBILITY_EPSILON: f64 = 0.0; +const ARC_STEPS: usize = 96; +const ARC_STROKE_WIDTH: f32 = 2.5; +const ARC_DEFAULT_ALTITUDE: f32 = 0.12; +const ARC_DEFAULT_COLOR: &str = "#F97316"; +const ORTHOGRAPHIC_DISC_MARGIN: f32 = 0.94; + +fn default_arc_draw_in() -> f32 { + 1.0 +} + +/// `dot_map.projection`. `Equirectangular` is the historical flat mapping +/// (`lng`/`lat` scaled linearly onto the box); `Orthographic` renders the map +/// as a globe seen from outside, with the far hemisphere culled and dots +/// converging towards the limb — a `clip-path: circle` on the flat map only +/// cuts a disc out of it, it never curves. +#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum DotMapProjection { + #[default] + Equirectangular, + Orthographic, +} + +/// A `rotate.lng` value: either a fixed longitude, or a linear sweep from +/// `from` to `to` starting at the component's own `start_at`, over +/// `duration` seconds (defaults to `animation_duration`). +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +#[serde(untagged)] +pub enum RotationAngle { + Fixed(f32), + Animated { + from: f32, + to: f32, + #[serde(default)] + duration: Option, + }, +} + +impl RotationAngle { + fn value_at(&self, time: f64, start_at: f64, fallback_duration: f64) -> f32 { + match self { + RotationAngle::Fixed(v) => *v, + RotationAngle::Animated { from, to, duration } => { + let dur = duration.unwrap_or(fallback_duration).max(0.0001); + let elapsed = (time - start_at).max(0.0); + let p = (elapsed / dur).clamp(0.0, 1.0) as f32; + from + (to - from) * p + } + } + } +} + +/// `dot_map.rotate` in `orthographic` mode: `lng` is rotation about the +/// pole (the sub-observer meridian), `lat` tilts which parallel faces the +/// camera. Both default to 0 (looking at `(0, 0)`). +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct GlobeRotation { + #[serde(default)] + pub lng: Option, + #[serde(default)] + pub lat: Option, +} + +/// A great-circle arc lifted off the globe's surface, drawn between two +/// `[lat, lng]` endpoints. `draw_in` reveals the arc from `from` towards +/// `to` (1.0 = fully drawn), the counterpart of `draw_progress` on `line`/ +/// `arrow` — a plain static fraction here, since an arc's shape (which +/// hemisphere it crosses) already changes as the globe itself rotates. +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct GreatCircleArc { + pub from: [f64; 2], + pub to: [f64; 2], + #[serde(default = "default_arc_draw_in")] + pub draw_in: f32, + #[serde(default)] + pub color: Option, + /// How far the arc lifts off the surface at its midpoint, as a fraction + /// of the globe's radius. Default 0.12. + #[serde(default)] + pub altitude: Option, +} + +fn latlng_to_unit(lat_deg: f64, lng_deg: f64) -> (f64, f64, f64) { + let lat = lat_deg.to_radians(); + let lng = lng_deg.to_radians(); + (lat.cos() * lng.cos(), lat.cos() * lng.sin(), lat.sin()) +} + +fn unit_to_latlng(v: (f64, f64, f64)) -> (f64, f64) { + let lat = v.2.clamp(-1.0, 1.0).asin().to_degrees(); + let lng = v.1.atan2(v.0).to_degrees(); + (lat, lng) +} + +fn slerp_unit(a: (f64, f64, f64), b: (f64, f64, f64), t: f64) -> (f64, f64, f64) { + let dot = (a.0 * b.0 + a.1 * b.1 + a.2 * b.2).clamp(-1.0, 1.0); + let theta = dot.acos(); + if theta.abs() < 1e-9 { + return a; + } + let sin_theta = theta.sin(); + let wa = ((1.0 - t) * theta).sin() / sin_theta; + let wb = (t * theta).sin() / sin_theta; + ( + a.0 * wa + b.0 * wb, + a.1 * wa + b.1 * wb, + a.2 * wa + b.2 * wb, + ) +} + +/// The closed-form orthographic projection: `(lat, lng)` centred on +/// `(center_lat, center_lng)`, returned as normalized `(x, y)` on a unit +/// sphere (multiply by the disc's pixel radius) plus `cos_c`, the cosine of +/// the angular distance from the sub-observer point — `1.0` at the centre +/// of the disc, `0.0` at the limb, negative on the far hemisphere. `None` +/// means the point is on the far hemisphere and must not be drawn. +fn orthographic_project( + lat_deg: f64, + lng_deg: f64, + center_lat_deg: f64, + center_lng_deg: f64, +) -> Option<(f64, f64, f64)> { + let phi = lat_deg.to_radians(); + let lambda = lng_deg.to_radians(); + let phi1 = center_lat_deg.to_radians(); + let lambda0 = center_lng_deg.to_radians(); + let dlambda = lambda - lambda0; + let cos_c = phi1.sin() * phi.sin() + phi1.cos() * phi.cos() * dlambda.cos(); + if cos_c < LIMB_VISIBILITY_EPSILON { + return None; + } + let x = phi.cos() * dlambda.sin(); + let y = phi1.cos() * phi.sin() - phi1.sin() * phi.cos() * dlambda.cos(); + Some((x, y, cos_c)) +} + +fn limb_shade(limb_shading: Option, cos_c: f64) -> f32 { + let strength = limb_shading.unwrap_or(0.0).clamp(0.0, 1.0); + (1.0 - strength * (1.0 - cos_c as f32)).clamp(0.0, 1.0) +} + fn default_background_color() -> String { "#0F172A".to_string() } @@ -102,6 +244,23 @@ pub struct DotMap { pub animated: bool, #[serde(default = "default_animation_duration")] pub animation_duration: f64, + /// `equirectangular` (default, flat — unchanged) or `orthographic` (a + /// globe seen from outside: closed-form lat/lng-to-screen projection, + /// far hemisphere culled). + #[serde(default)] + pub projection: DotMapProjection, + /// Orthographic-only: which meridian/parallel faces the camera. Inert + /// under `equirectangular`. + #[serde(default)] + pub rotate: Option, + /// Orthographic-only: darkens dots towards the limb, 0 (none, default) + /// to 1 (full black at the very edge). Inert under `equirectangular`. + #[serde(default)] + pub limb_shading: Option, + /// Orthographic-only: great-circle arcs lifted off the surface. Inert + /// under `equirectangular`. + #[serde(default)] + pub arcs: Vec, #[serde(flatten)] pub timing: TimingConfig, #[serde(default)] @@ -134,7 +293,257 @@ impl DotMap { 1.0 - (1.0 - p).powi(3) } - fn paint(&self, canvas: &Canvas, layout_w: f32, layout_h: f32, time: f64) { + fn paint(&self, canvas: &Canvas, layout_w: f32, layout_h: f32, ctx: &PaintCtx) { + match self.projection { + DotMapProjection::Equirectangular => { + self.paint_equirectangular(canvas, layout_w, layout_h, ctx.time) + } + DotMapProjection::Orthographic => { + self.paint_orthographic(canvas, layout_w, layout_h, ctx.time) + } + } + } + + fn globe_center(&self, time: f64) -> (f64, f64) { + let start_at = self.timing.start_at.unwrap_or(0.0); + let center_lng = self + .rotate + .as_ref() + .and_then(|r| r.lng.as_ref()) + .map(|a| a.value_at(time, start_at, self.animation_duration) as f64) + .unwrap_or(0.0); + let center_lat = self + .rotate + .as_ref() + .and_then(|r| r.lat) + .map(|v| v as f64) + .unwrap_or(0.0); + (center_lat, center_lng) + } + + fn paint_orthographic(&self, canvas: &Canvas, layout_w: f32, layout_h: f32, time: f64) { + let w = layout_w; + let h = layout_h; + let progress = self.progress_at(time); + + 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); + + let radius = (w.min(h) * 0.5 * ORTHOGRAPHIC_DISC_MARGIN).max(1.0); + let center = (w / 2.0, h / 2.0); + let (center_lat, center_lng) = self.globe_center(time); + + if self.show_world { + self.paint_world_dots_orthographic(canvas, center, radius, center_lat, center_lng); + } + + self.paint_points_orthographic( + canvas, center, radius, center_lat, center_lng, progress, time, + ); + self.paint_arcs_orthographic(canvas, center, radius, center_lat, center_lng); + } + + fn paint_world_dots_orthographic( + &self, + canvas: &Canvas, + center: (f32, f32), + radius: f32, + center_lat: f64, + center_lng: f64, + ) { + let mut world_paint = paint_from_hex(&self.world_dot_color); + world_paint.set_style(PaintStyle::Fill); + world_paint.set_anti_alias(true); + + let step_rad = (self.dot_spacing.max(1.0) as f64 / radius as f64).max(0.002); + let step_deg = step_rad.to_degrees().max(0.5); + + const MAX_STEPS_PER_AXIS: u32 = 2048; + let lat_steps = ((170.0 / step_deg) as u32 + 1).min(MAX_STEPS_PER_AXIS); + let lng_steps = ((360.0 / step_deg) as u32 + 1).min(MAX_STEPS_PER_AXIS); + + for lat_i in 0..=lat_steps { + let lat = LAT_MIN + lat_i as f64 * step_deg; + if lat > LAT_MAX { + continue; + } + for lng_i in 0..=lng_steps { + let lng = -180.0 + lng_i as f64 * step_deg; + if lng > 180.0 { + continue; + } + if !geo_is_land(lat, lng) { + continue; + } + let Some((x, y, cos_c)) = orthographic_project(lat, lng, center_lat, center_lng) + else { + continue; + }; + let shade = limb_shade(self.limb_shading, cos_c); + let px = center.0 + x as f32 * radius; + let py = center.1 - y as f32 * radius; + world_paint.set_alpha_f(shade); + canvas.draw_circle((px, py), self.dot_radius, &world_paint); + } + } + } + + #[allow(clippy::too_many_arguments)] + fn paint_points_orthographic( + &self, + canvas: &Canvas, + center: (f32, f32), + radius: f32, + center_lat: f64, + center_lng: f64, + progress: f32, + time: f64, + ) { + let default_color = "#3B82F6"; + let default_dot_size = 10.0_f32; + let label_font_size = 12.0_f32; + let point_count = self.points.len(); + + let font_style = skia_safe::FontStyle::normal(); + let Ok(typeface) = typeface_with_fallback("Inter", font_style) else { + return; + }; + let label_font = skia_safe::Font::from_typeface(typeface, label_font_size); + let emoji_font = + emoji_typeface().map(|tf| skia_safe::Font::from_typeface(tf, label_font_size)); + + for (i, point) in self.points.iter().enumerate() { + let dot_alpha = if self.animated { + let stagger_delay = if point_count > 1 { + (i as f64 / point_count as f64) * 0.6 + } else { + 0.0 + }; + let dot_progress = ((time - stagger_delay) / (self.animation_duration * 0.4)) + .clamp(0.0, 1.0) as f32; + dot_progress * progress + } else { + 1.0 + }; + + if dot_alpha <= 0.0 { + continue; + } + + let Some((x, y, cos_c)) = + orthographic_project(point.lat, point.lng, center_lat, center_lng) + else { + continue; + }; + let dot_alpha = dot_alpha * limb_shade(self.limb_shading, cos_c); + let px = center.0 + x as f32 * radius; + let py = center.1 - y as f32 * radius; + let dot_size = point.size.unwrap_or(default_dot_size); + let color_str = point.color.as_deref().unwrap_or(default_color); + + if point.pulse.unwrap_or(false) { + let num_rings = 2; + for ring in 0..num_rings { + let phase = ((time * 1.5 + ring as f64 * 0.5).fract()) as f32; + let ring_radius = dot_size * (1.0 + phase * 2.5); + let ring_alpha = (1.0 - phase).max(0.0) * 0.4 * dot_alpha; + + let mut pulse_paint = paint_from_hex(color_str); + pulse_paint.set_style(PaintStyle::Stroke); + pulse_paint.set_stroke_width(2.0); + pulse_paint.set_anti_alias(true); + pulse_paint.set_alpha_f(ring_alpha); + canvas.draw_circle((px, py), ring_radius, &pulse_paint); + } + } + + 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); + + let mut border_paint = paint_from_hex("#FFFFFF"); + border_paint.set_style(PaintStyle::Stroke); + border_paint.set_stroke_width(1.5); + border_paint.set_anti_alias(true); + border_paint.set_alpha_f(dot_alpha * 0.6); + canvas.draw_circle((px, py), dot_size / 2.0, &border_paint); + + if let Some(label) = &point.label { + let mut label_paint = paint_from_hex("#FFFFFF"); + label_paint.set_anti_alias(true); + label_paint.set_alpha_f(dot_alpha * 0.9); + + let text_w = measure_text_with_fallback(label, &label_font, &emoji_font, 0.0); + let label_x = px - text_w / 2.0; + let label_y = py + dot_size / 2.0 + label_font_size + 4.0; + + draw_text_with_fallback( + canvas, + label, + &label_font, + &emoji_font, + 0.0, + label_x, + label_y, + &label_paint, + ); + } + } + } + + fn paint_arcs_orthographic( + &self, + canvas: &Canvas, + center: (f32, f32), + radius: f32, + center_lat: f64, + center_lng: f64, + ) { + for arc in &self.arcs { + let max_t = arc.draw_in.clamp(0.0, 1.0) as f64; + if max_t <= 0.0 { + continue; + } + let a = latlng_to_unit(arc.from[0], arc.from[1]); + let b = latlng_to_unit(arc.to[0], arc.to[1]); + let altitude = arc.altitude.unwrap_or(ARC_DEFAULT_ALTITUDE); + let color = arc.color.as_deref().unwrap_or(ARC_DEFAULT_COLOR); + + let mut paint = paint_from_hex(color); + paint.set_style(PaintStyle::Stroke); + paint.set_stroke_width(ARC_STROKE_WIDTH); + paint.set_anti_alias(true); + paint.set_stroke_cap(skia_safe::PaintCap::Round); + + let mut prev: Option<(f32, f32)> = None; + for i in 0..=ARC_STEPS { + let s = (i as f64 / ARC_STEPS as f64) * max_t; + let v = slerp_unit(a, b, s); + let lift = altitude as f64 * (std::f64::consts::PI * s).sin(); + let (lat, lng) = unit_to_latlng(v); + + match orthographic_project(lat, lng, center_lat, center_lng) { + Some((x, y, cos_c)) => { + let r = radius * (1.0 + lift as f32); + let px = center.0 + x as f32 * r; + let py = center.1 - y as f32 * r; + if let Some(prev_pt) = prev { + paint.set_alpha_f(limb_shade(self.limb_shading, cos_c)); + canvas.draw_line(prev_pt, (px, py), &paint); + } + prev = Some((px, py)); + } + None => prev = None, + } + } + } + } + + fn paint_equirectangular(&self, canvas: &Canvas, layout_w: f32, layout_h: f32, time: f64) { let w = layout_w; let h = layout_h; let progress = self.progress_at(time); @@ -274,6 +683,214 @@ impl Painter for DotMap { _props: &AnimatedProperties, ctx: &PaintCtx, ) { - self.paint(canvas, layout.width, layout.height, ctx.time); + self.paint(canvas, layout.width, layout.height, ctx); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_ctx(time: f64) -> PaintCtx { + PaintCtx { + time, + scenario_time: time, + scene_duration: 2.0, + frame_index: 0, + fps: 30, + video_width: 400, + video_height: 400, + stagger_offset: 0.0, + } + } + + fn render(map: &DotMap, w: u32, h: u32, time: f64) -> Vec { + let mut surface = skia_safe::surfaces::raster_n32_premul((w as i32, h as i32)).unwrap(); + map.paint(surface.canvas(), w as f32, h as f32, &test_ctx(time)); + let info = skia_safe::ImageInfo::new( + (w as i32, h as i32), + skia_safe::ColorType::RGBA8888, + skia_safe::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)); + buf + } + + fn dot_map_from(extra: serde_json::Value) -> DotMap { + let mut base = serde_json::json!({ + "points": [], + "animated": false, + }); + base.as_object_mut() + .unwrap() + .extend(extra.as_object().unwrap().clone()); + serde_json::from_value(base).expect("dot_map json must deserialize") + } + + #[test] + fn projection_defaults_to_equirectangular() { + assert_eq!( + DotMapProjection::default(), + DotMapProjection::Equirectangular + ); + let map = dot_map_from(serde_json::json!({})); + assert_eq!(map.projection, DotMapProjection::Equirectangular); + assert!(map.rotate.is_none()); + assert!(map.limb_shading.is_none()); + assert!(map.arcs.is_empty()); + } + + #[test] + fn omitting_the_new_fields_renders_byte_identically_to_declaring_them_at_default() { + let implicit = dot_map_from(serde_json::json!({})); + let explicit = dot_map_from(serde_json::json!({ + "projection": "equirectangular", + })); + let out_implicit = render(&implicit, 300, 300, 0.4); + let out_explicit = render(&explicit, 300, 300, 0.4); + assert_eq!( + out_implicit, out_explicit, + "a dot_map with no projection field must render exactly as one that spells out \ + the default explicitly — the new fields must not perturb the historical path" + ); + } + + #[test] + fn orthographic_hides_the_far_hemisphere() { + assert!(orthographic_project(0.0, 0.0, 0.0, 0.0).is_some()); + assert!( + orthographic_project(0.0, 170.0, 0.0, 0.0).is_none(), + "a point 170 degrees around the globe from the sub-observer point is on the far side" + ); + assert!(orthographic_project(0.0, 89.0, 0.0, 0.0).is_some()); + assert!(orthographic_project(0.0, 91.0, 0.0, 0.0).is_none()); + } + + #[test] + fn orthographic_cos_c_is_one_at_centre_and_zero_at_the_limb() { + let (_, _, centre) = orthographic_project(0.0, 0.0, 0.0, 0.0).unwrap(); + let (_, _, limb) = orthographic_project(0.0, 90.0, 0.0, 0.0).unwrap(); + assert!((centre - 1.0).abs() < 1e-9); + assert!(limb.abs() < 1e-9); + } + + #[test] + fn dot_spacing_converges_towards_the_limb() { + let radius = 500.0_f64; + let screen_dx = |lng_a: f64, lng_b: f64| { + let (xa, _, _) = orthographic_project(0.0, lng_a, 0.0, 0.0).unwrap(); + let (xb, _, _) = orthographic_project(0.0, lng_b, 0.0, 0.0).unwrap(); + ((xb - xa) * radius).abs() + }; + let centre_gap = screen_dx(-2.5, 2.5); + let edge_gap = screen_dx(77.5, 82.5); + assert!( + edge_gap < centre_gap * 0.9, + "an orthographic globe's dots must converge towards the limb: a 5 degree step must \ + map to a measurably smaller screen gap near the edge than at the centre (not just \ + float noise) — centre={centre_gap:.3}px, edge={edge_gap:.3}px" + ); + + let flat_dx = |lng_a: f64, lng_b: f64| ((lng_b - lng_a) / 360.0 * 800.0).abs(); + assert_eq!( + flat_dx(-2.5, 2.5), + flat_dx(77.5, 82.5), + "sanity: the flat equirectangular mapping this replaces has no such convergence" + ); + } + + #[test] + fn a_rotated_view_recentres_which_longitude_is_visible() { + assert!(orthographic_project(0.0, 100.0, 0.0, 0.0).is_none()); + assert!( + orthographic_project(0.0, 100.0, 0.0, 100.0).is_some(), + "rotating the sub-observer longitude to 100 degrees must bring that meridian \ + into view" + ); + } + + #[test] + fn limb_shading_darkens_towards_the_edge_and_is_inert_when_unset() { + assert_eq!(limb_shade(None, 0.0), 1.0); + assert_eq!(limb_shade(Some(1.0), 1.0), 1.0); + assert_eq!(limb_shade(Some(1.0), 0.0), 0.0); + assert!((limb_shade(Some(0.5), 0.0) - 0.5).abs() < 1e-6); + } + + #[test] + fn slerp_and_unit_conversion_round_trip_a_few_points() { + for (lat, lng) in [(0.0, 0.0), (45.0, -30.0), (-60.0, 120.0), (10.0, 179.0)] { + let v = latlng_to_unit(lat, lng); + let (lat2, lng2) = unit_to_latlng(v); + assert!((lat - lat2).abs() < 1e-6, "lat round trip: {lat} vs {lat2}"); + assert!((lng - lng2).abs() < 1e-6, "lng round trip: {lng} vs {lng2}"); + } + } + + #[test] + fn rotation_animates_linearly_from_start_at_to_the_fallback_duration() { + let angle = RotationAngle::Animated { + from: -20.0, + to: 40.0, + duration: None, + }; + assert_eq!(angle.value_at(0.0, 0.0, 1.5), -20.0); + assert_eq!(angle.value_at(1.5, 0.0, 1.5), 40.0); + assert!((angle.value_at(0.75, 0.0, 1.5) - 10.0).abs() < 0.001); + } + + #[test] + fn an_orthographic_globe_paints_a_visibly_different_frame_from_the_flat_map() { + let flat = dot_map_from(serde_json::json!({ + "points": [{"lat": 48.85, "lng": 2.35, "size": 20.0}], + "show_world": false, + })); + let globe = dot_map_from(serde_json::json!({ + "projection": "orthographic", + "points": [{"lat": 48.85, "lng": 2.35, "size": 20.0}], + "show_world": false, + })); + let out_flat = render(&flat, 300, 300, 1.0); + let out_globe = render(&globe, 300, 300, 1.0); + assert_ne!( + out_flat, out_globe, + "an orthographic dot_map must not paint the flat equirectangular frame" + ); + } + + fn has_orange_pixel(buf: &[u8]) -> bool { + buf.as_chunks::<4>() + .0 + .iter() + .any(|p| p[0] > 200 && p[1] > 90 && p[1] < 180 && p[2] < 60) + } + + #[test] + fn an_arc_on_the_near_side_is_visible_but_one_entirely_on_the_far_side_is_culled() { + let visible = dot_map_from(serde_json::json!({ + "projection": "orthographic", + "points": [], + "show_world": false, + "arcs": [{"from": [10.0, -30.0], "to": [10.0, 30.0], "draw_in": 1.0}], + })); + let hidden = dot_map_from(serde_json::json!({ + "projection": "orthographic", + "points": [], + "show_world": false, + "arcs": [{"from": [10.0, 150.0], "to": [10.0, 170.0], "draw_in": 1.0}], + })); + let out_visible = render(&visible, 300, 300, 1.0); + let out_hidden = render(&hidden, 300, 300, 1.0); + assert!( + has_orange_pixel(&out_visible), + "an arc entirely on the near hemisphere must paint its (default orange) stroke" + ); + assert!( + !has_orange_pixel(&out_hidden), + "an arc entirely on the far hemisphere must be culled, not drawn straight through \ + the globe" + ); } } diff --git a/crates/rustmotion-core/src/css/style.rs b/crates/rustmotion-core/src/css/style.rs index 3bba26a..151c63b 100644 --- a/crates/rustmotion-core/src/css/style.rs +++ b/crates/rustmotion-core/src/css/style.rs @@ -167,6 +167,16 @@ pub struct CssStyle { /// governs its whole subtree). Not inherited via cascade. pub depth: Option, + /// Projects this container's direct children onto a cylinder or sphere + /// instead of the flat plane taffy laid them out on (issue #387). Layout + /// itself is untouched — columns and rows are still computed flat — this + /// only bends where each child is *painted*, around one vanishing point + /// shared by the whole container, which is what makes it different from + /// giving every child its own `transform`/`perspective`: those never + /// agree on a horizon, so a tilted grid of them stays a flat trapezoid. + /// See `rules/layout-surface.md`. + pub layout_surface: Option, + pub overflow: Option, pub overflow_x: Option, pub overflow_y: Option, @@ -286,6 +296,8 @@ struct CssStyleWire { depth: Option, + layout_surface: Option, + overflow: Option, overflow_x: Option, overflow_y: Option, @@ -1247,6 +1259,147 @@ pub enum TransformFn { }, } +/// `style.layout-surface` (issue #387): the shape a container's direct +/// children are projected onto at paint time, plus the one perspective and +/// turntable rotation shared by all of them. `radius`/`arc-x`/`arc-y` are +/// pixels/degrees, not `Length`s — this bends a fixed layout, it does not +/// participate in it. +/// +/// `Cylinder` only curves around its `arc-x` (a horizontal wrap, like a +/// scroll); a child's vertical position is left exactly where taffy put it. +/// `Sphere` curves around both `arc-x` and `arc-y`, which is what a dome +/// needs. Neither closes into a full globe — `arc-x`/`arc-y` describe a +/// patch, typically well under 360°; wrapping dot data onto an actual closed +/// globe is `dot_map`'s `projection: "orthographic"` instead (a different +/// problem: culling a far hemisphere, not bending a flat grid). +/// +/// **Depth ordering.** Children are painted in the same declaration/`z-index` +/// order as a flat container — a cell that curves behind another one does +/// not sort behind it. For an `arc-x`/`arc-y` patch well under 180° (the +/// intended use: a dome of cards, not a fully enclosing sphere) every child +/// still faces the camera, so this is not visible; a wide enough arc or +/// enough `rotate-y` to turn cells past the limb can make a far cell paint +/// over a near one. Tracked generally by #93, not solved here. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "kind", rename_all = "kebab-case")] +pub enum LayoutSurface { + Cylinder { + /// Cylinder radius in pixels. + radius: f32, + /// Total angular sweep across the container's width, in degrees. + #[serde(default)] + arc_x: f32, + /// Shared vanishing-point distance in pixels, `None` = orthographic + /// (curved, but no foreshortening). + #[serde(default)] + perspective: Option, + #[serde(default)] + rotate_x: Option, + #[serde(default)] + rotate_y: Option, + }, + Sphere { + /// Sphere radius in pixels. + radius: f32, + /// Total angular sweep across the container's width, in degrees. + #[serde(default)] + arc_x: f32, + /// Total angular sweep across the container's height, in degrees. + #[serde(default)] + arc_y: f32, + /// Shared vanishing-point distance in pixels, `None` = orthographic + /// (curved, but no foreshortening). + #[serde(default)] + perspective: Option, + #[serde(default)] + rotate_x: Option, + #[serde(default)] + rotate_y: Option, + }, +} + +impl LayoutSurface { + pub fn radius(&self) -> f32 { + match self { + LayoutSurface::Cylinder { radius, .. } | LayoutSurface::Sphere { radius, .. } => { + *radius + } + } + } + + pub fn arc_x(&self) -> f32 { + match self { + LayoutSurface::Cylinder { arc_x, .. } | LayoutSurface::Sphere { arc_x, .. } => *arc_x, + } + } + + pub fn arc_y(&self) -> f32 { + match self { + LayoutSurface::Cylinder { .. } => 0.0, + LayoutSurface::Sphere { arc_y, .. } => *arc_y, + } + } + + pub fn perspective(&self) -> Option { + match self { + LayoutSurface::Cylinder { perspective, .. } + | LayoutSurface::Sphere { perspective, .. } => *perspective, + } + } + + pub fn rotate_x(&self) -> Option<&SurfaceAngle> { + match self { + LayoutSurface::Cylinder { rotate_x, .. } | LayoutSurface::Sphere { rotate_x, .. } => { + rotate_x.as_ref() + } + } + } + + pub fn rotate_y(&self) -> Option<&SurfaceAngle> { + match self { + LayoutSurface::Cylinder { rotate_y, .. } | LayoutSurface::Sphere { rotate_y, .. } => { + rotate_y.as_ref() + } + } + } + + pub fn is_sphere(&self) -> bool { + matches!(self, LayoutSurface::Sphere { .. }) + } +} + +/// A `layout-surface` `rotate-x`/`rotate-y` value: either a constant tilt in +/// degrees, or a linear turntable sweep from `from` to `to` starting at +/// scene time 0, over `duration` seconds (defaults to the scene's own +/// duration, so "the dome turns slowly" needs no explicit timing). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(untagged)] +pub enum SurfaceAngle { + Fixed(f32), + Animated { + from: f32, + to: f32, + #[serde(default)] + duration: Option, + }, +} + +impl SurfaceAngle { + pub fn value_at(&self, time: f64, scene_duration: f64) -> f32 { + match self { + SurfaceAngle::Fixed(v) => *v, + SurfaceAngle::Animated { from, to, duration } => { + let dur = duration + .map(|d| d as f64) + .unwrap_or(scene_duration) + .max(0.0001); + let p = (time / dur).clamp(0.0, 1.0) as f32; + from + (to - from) * p + } + } + } +} + #[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)] #[serde(default, deny_unknown_fields, rename_all = "kebab-case")] pub struct TransformOrigin { diff --git a/crates/rustmotion-core/src/engine/paint_pass.rs b/crates/rustmotion-core/src/engine/paint_pass.rs index b62756c..5686c4a 100644 --- a/crates/rustmotion-core/src/engine/paint_pass.rs +++ b/crates/rustmotion-core/src/engine/paint_pass.rs @@ -8,7 +8,8 @@ use skia_safe::{ use crate::css::style::{ Background, BackgroundLayer, BorderEdges, BorderRadius, BorderStyle, BoxShadow, ClipPath, - Color, CssStyle, Edges, Material, MaterialPreset, Overflow, TransformFn, TransformOrigin, + Color, CssStyle, Edges, LayoutSurface, Material, MaterialPreset, Overflow, TransformFn, + TransformOrigin, }; use crate::css::units::{ parse_origin_component, Length, LengthContext, LengthPercentage, ParsedLength, @@ -77,6 +78,127 @@ fn apply_plane_camera(canvas: &Canvas, cam: &PlaneCamera, depth: f32, viewport: ); } +#[derive(Debug, Clone, Copy)] +enum SurfaceKind { + Cylinder, + Sphere, +} + +struct LayoutSurfaceFrame { + kind: SurfaceKind, + radius: f32, + arc_x_deg: f32, + arc_y_deg: f32, + pivot: (f32, f32), + half_w: f32, + half_h: f32, + perspective: Option, + rotate_x_deg: f32, + rotate_y_deg: f32, +} + +impl LayoutSurfaceFrame { + fn new(surface: &LayoutSurface, container: &BoxLayout, frame: &PaintFrame) -> Self { + let (cx, cy, cw, ch) = container.content_box(); + let rotate_x_deg = surface + .rotate_x() + .map(|a| a.value_at(frame.time, frame.scene_duration)) + .unwrap_or(0.0); + let rotate_y_deg = surface + .rotate_y() + .map(|a| a.value_at(frame.time, frame.scene_duration)) + .unwrap_or(0.0); + Self { + kind: if surface.is_sphere() { + SurfaceKind::Sphere + } else { + SurfaceKind::Cylinder + }, + radius: surface.radius(), + arc_x_deg: surface.arc_x(), + arc_y_deg: surface.arc_y(), + pivot: (cx + cw / 2.0, cy + ch / 2.0), + half_w: (cw / 2.0).max(0.001), + half_h: (ch / 2.0).max(0.001), + perspective: surface.perspective(), + rotate_x_deg, + rotate_y_deg, + } + } + + fn child_delta(&self, nu: f32, nv: f32) -> (f32, f32, f32) { + layout_surface_child_delta( + self.kind, + self.radius, + self.arc_x_deg, + self.arc_y_deg, + nu, + nv, + self.half_w, + self.half_h, + ) + } + + fn apply(&self, canvas: &Canvas, child: &BoxLayout) { + let nu = ((child.cx() - self.pivot.0) / self.half_w).clamp(-1.0, 1.0); + let nv = ((child.cy() - self.pivot.1) / self.half_h).clamp(-1.0, 1.0); + let (dx, dy, dz) = self.child_delta(nu, nv); + + let mut m = M44::new_identity(); + m.pre_concat(&M44::translate(self.pivot.0, self.pivot.1, 0.0)); + if let Some(d) = self.perspective { + m.pre_concat(&css_perspective_m44(d.max(1.0))); + } + if self.rotate_y_deg.abs() > 0.001 { + m.pre_concat(&M44::rotate( + V3::new(0.0, 1.0, 0.0), + self.rotate_y_deg.to_radians(), + )); + } + if self.rotate_x_deg.abs() > 0.001 { + m.pre_concat(&M44::rotate( + V3::new(1.0, 0.0, 0.0), + self.rotate_x_deg.to_radians(), + )); + } + m.pre_concat(&M44::translate(dx, dy, dz)); + m.pre_concat(&M44::translate(-self.pivot.0, -self.pivot.1, 0.0)); + canvas.concat_44(&m); + } +} + +#[allow(clippy::too_many_arguments)] +fn layout_surface_child_delta( + kind: SurfaceKind, + radius: f32, + arc_x_deg: f32, + arc_y_deg: f32, + nu: f32, + nv: f32, + half_w: f32, + half_h: f32, +) -> (f32, f32, f32) { + let nu = nu.clamp(-1.0, 1.0); + let nv = nv.clamp(-1.0, 1.0); + let theta_x = nu * arc_x_deg.to_radians() / 2.0; + let flat_x = nu * half_w; + match kind { + SurfaceKind::Sphere => { + let theta_y = nv * arc_y_deg.to_radians() / 2.0; + let flat_y = nv * half_h; + let target_x = radius * theta_x.sin() * theta_y.cos(); + let target_y = radius * theta_y.sin(); + let target_z = radius * (theta_x.cos() * theta_y.cos() - 1.0); + (target_x - flat_x, target_y - flat_y, target_z) + } + SurfaceKind::Cylinder => { + let target_x = radius * theta_x.sin(); + let target_z = radius * (theta_x.cos() - 1.0); + (target_x - flat_x, 0.0, target_z) + } + } +} + #[derive(Debug, Clone, Copy, PartialEq)] pub struct HitRect { pub x: f32, @@ -439,8 +561,24 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u 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 { - paint_node(canvas, &node.children[i], ctx, tree_depth + 1); + match node.css.layout_surface.as_ref() { + Some(surface) => { + let surface_frame = LayoutSurfaceFrame::new(surface, box_layout, ctx.frame); + for &i in &indices { + let child = &node.children[i]; + canvas.save(); + if let Some(child_layout) = ctx.layout.get(child.id) { + surface_frame.apply(canvas, child_layout); + } + paint_node(canvas, child, ctx, tree_depth + 1); + canvas.restore(); + } + } + None => { + for &i in &indices { + paint_node(canvas, &node.children[i], ctx, tree_depth + 1); + } + } } if opened_overflow_clip { @@ -5110,3 +5248,327 @@ mod animated_transform_tests { assert!((tx0 - tx1).abs() > 1.0 || (ty0 - ty1).abs() > 1.0); } } + +#[cfg(test)] +mod layout_surface_tests { + use super::*; + + use crate::css::style::{ + Background, Color as CssColor, CssStyle, Display, FlexDirection, GridTrack, LayoutSurface, + Size as CSize, SurfaceAngle, + }; + use crate::css::taffy_bridge::ConversionContext; + use crate::css::units::LengthPercentage as CLP; + use crate::engine::box_tree::{BoxKind, BoxNode}; + use crate::engine::layout_pass::run_layout; + + fn test_frame(w: u32, h: u32) -> PaintFrame { + PaintFrame { + light: Default::default(), + time: 0.0, + scenario_time: 0.0, + frame_index: 0, + fps: 30, + video_width: w, + video_height: h, + scene_duration: 1.0, + camera: None, + } + } + + fn render_pixels(root: &mut BoxNode, w: u32, h: u32, time: f64) -> Vec { + root.assign_ids(0); + let layout = run_layout(root, (w as f32, h as f32), &ConversionContext::default()); + let mut surface = skia_safe::surfaces::raster_n32_premul((w as i32, h as i32)).unwrap(); + let mut frame = test_frame(w, h); + frame.time = time; + paint_tree(surface.canvas(), root, &layout, &frame, &NoopDispatcher); + let info = skia_safe::ImageInfo::new( + (w as i32, h as i32), + skia_safe::ColorType::RGBA8888, + skia_safe::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)); + buf + } + + fn colored_cell(color: &str) -> BoxNode { + BoxNode { + id: 0, + kind: BoxKind::Container, + css: CssStyle { + background: Some(Background::Color(CssColor::String(color.to_string()))), + ..Default::default() + }, + children: vec![], + intrinsic: None, + source_path: None, + window: None, + } + } + + fn three_column_grid(w: f32, h: f32, layout_surface: Option) -> BoxNode { + BoxNode { + id: 0, + kind: BoxKind::Container, + css: CssStyle { + display: Some(Display::Grid), + width: Some(CSize::Length(CLP::Px(w))), + height: Some(CSize::Length(CLP::Px(h))), + grid_template_columns: Some(vec![ + GridTrack::Fr(1.0), + GridTrack::Fr(1.0), + GridTrack::Fr(1.0), + ]), + layout_surface, + ..Default::default() + }, + children: vec![ + colored_cell("#ff0000"), + colored_cell("#00ff00"), + colored_cell("#0000ff"), + ], + intrinsic: None, + source_path: None, + window: None, + } + } + + fn root_with(child: BoxNode, w: f32, h: f32) -> BoxNode { + BoxNode { + id: 0, + kind: BoxKind::Container, + css: CssStyle { + display: Some(Display::Flex), + flex_direction: Some(FlexDirection::Column), + width: Some(CSize::Length(CLP::Px(w))), + height: Some(CSize::Length(CLP::Px(h))), + background: Some(Background::Color(CssColor::String("#000000".into()))), + ..Default::default() + }, + children: vec![child], + intrinsic: None, + source_path: None, + window: None, + } + } + + fn channel_extent( + buf: &[u8], + w: u32, + h: u32, + is_match: impl Fn(u8, u8, u8) -> bool, + ) -> Option<(usize, usize)> { + let mut lo = usize::MAX; + let mut hi = 0usize; + let mut found = false; + for y in 0..h { + for x in 0..w { + let i = ((y * w + x) * 4) as usize; + if is_match(buf[i], buf[i + 1], buf[i + 2]) { + lo = lo.min(x as usize); + hi = hi.max(x as usize); + found = true; + } + } + } + found.then_some((lo, hi)) + } + + fn is_red(r: u8, g: u8, b: u8) -> bool { + r > 150 && g < 80 && b < 80 + } + fn is_green(r: u8, g: u8, b: u8) -> bool { + g > 150 && r < 80 && b < 80 + } + fn is_blue(r: u8, g: u8, b: u8) -> bool { + b > 150 && r < 80 && g < 80 + } + + fn sphere(radius: f32, arc_x: f32, arc_y: f32, perspective: Option) -> LayoutSurface { + LayoutSurface::Sphere { + radius, + arc_x, + arc_y, + perspective, + rotate_x: None, + rotate_y: None, + } + } + + #[test] + fn no_layout_surface_leaves_a_flat_grid_byte_identical() { + let mut a = root_with(three_column_grid(900.0, 300.0, None), 900.0, 300.0); + let mut b = root_with(three_column_grid(900.0, 300.0, None), 900.0, 300.0); + let out_a = render_pixels(&mut a, 900, 300, 0.0); + let out_b = render_pixels(&mut b, 900, 300, 0.0); + assert_eq!( + out_a, out_b, + "a container with no layout-surface must render the exact same bytes on every run \ + (no feature-flag leakage into the default path)" + ); + + let widths: Vec = [is_red, is_green, is_blue] + .iter() + .map(|f| { + let (lo, hi) = channel_extent(&out_a, 900, 300, *f).expect("cell must be visible"); + hi - lo + 1 + }) + .collect(); + assert_eq!( + widths[0], widths[1], + "flat grid: every column must be exactly as wide as the others" + ); + assert_eq!( + widths[1], widths[2], + "flat grid: every column must be exactly as wide as the others" + ); + } + + #[test] + fn a_sphere_surface_makes_the_side_columns_narrower_than_the_centre_one() { + let mut flat = root_with(three_column_grid(900.0, 300.0, None), 900.0, 300.0); + let mut curved = root_with( + three_column_grid(900.0, 300.0, Some(sphere(700.0, 140.0, 0.0, Some(1400.0)))), + 900.0, + 300.0, + ); + let flat_out = render_pixels(&mut flat, 900, 300, 0.0); + let curved_out = render_pixels(&mut curved, 900, 300, 0.0); + + assert_ne!( + flat_out, curved_out, + "declaring a layout-surface on the container must change the rendered frame" + ); + + let (red_lo, red_hi) = + channel_extent(&curved_out, 900, 300, is_red).expect("left column must be visible"); + let (green_lo, green_hi) = + channel_extent(&curved_out, 900, 300, is_green).expect("centre column must be visible"); + let red_w = red_hi - red_lo + 1; + let green_w = green_hi - green_lo + 1; + + assert!( + red_w < green_w, + "a column near the arc's edge must foreshorten more than the centre column: \ + edge={red_w}px, centre={green_w}px" + ); + } + + #[test] + fn layout_surface_child_delta_is_zero_at_the_centre_of_the_arc() { + let (dx, dy, dz) = layout_surface_child_delta( + SurfaceKind::Sphere, + 1000.0, + 120.0, + 60.0, + 0.0, + 0.0, + 400.0, + 200.0, + ); + assert_eq!((dx, dy, dz), (0.0, 0.0, 0.0)); + } + + #[test] + fn sphere_delta_recedes_in_z_away_from_the_centre() { + let centre = layout_surface_child_delta( + SurfaceKind::Sphere, + 1000.0, + 140.0, + 0.0, + 0.0, + 0.0, + 400.0, + 200.0, + ); + let mid = layout_surface_child_delta( + SurfaceKind::Sphere, + 1000.0, + 140.0, + 0.0, + 0.5, + 0.0, + 400.0, + 200.0, + ); + let edge = layout_surface_child_delta( + SurfaceKind::Sphere, + 1000.0, + 140.0, + 0.0, + 1.0, + 0.0, + 400.0, + 200.0, + ); + assert_eq!( + centre.2, 0.0, + "the centre of the arc sits at the reference depth" + ); + assert!( + mid.2 < 0.0 && edge.2 < mid.2, + "depth must recede monotonically away from the centre: mid={:?} edge={:?}", + mid.2, + edge.2 + ); + } + + #[test] + fn adjacent_cell_gaps_shrink_towards_the_edge_of_the_arc() { + let half_w = 450.0; + let x_at = |nu: f32| { + let (dx, _, _) = layout_surface_child_delta( + SurfaceKind::Sphere, + 700.0, + 150.0, + 0.0, + nu, + 0.0, + half_w, + 1.0, + ); + nu * half_w + dx + }; + let centre_gap = (x_at(0.1) - x_at(-0.1)).abs(); + let edge_gap = (x_at(1.0) - x_at(0.8)).abs(); + assert!( + edge_gap < centre_gap, + "a projected grid's cells must not all be the same width — a step near the limb \ + must map to a smaller screen gap than the same step at the centre: \ + centre_gap={centre_gap:.2}px, edge_gap={edge_gap:.2}px" + ); + } + + #[test] + fn cylinder_ignores_the_vertical_axis() { + let (_, dy, _) = layout_surface_child_delta( + SurfaceKind::Cylinder, + 1000.0, + 140.0, + 999.0, + 0.3, + 0.7, + 400.0, + 200.0, + ); + assert_eq!( + dy, 0.0, + "a cylinder only wraps its arc-x — a child's vertical position must be untouched" + ); + } + + #[test] + fn rotate_y_animates_linearly_from_scene_start_to_the_scene_duration() { + let angle = SurfaceAngle::Animated { + from: -20.0, + to: 40.0, + duration: None, + }; + assert_eq!(angle.value_at(0.0, 2.0), -20.0); + assert_eq!(angle.value_at(2.0, 2.0), 40.0); + assert!((angle.value_at(1.0, 2.0) - 10.0).abs() < 0.001); + } +} diff --git a/crates/rustmotion/skills/rules/dot-map-orthographic.md b/crates/rustmotion/skills/rules/dot-map-orthographic.md new file mode 100644 index 0000000..ff535a5 --- /dev/null +++ b/crates/rustmotion/skills/rules/dot-map-orthographic.md @@ -0,0 +1,65 @@ +# `dot_map` en globe : `projection: "orthographic"` + +Par défaut `dot_map` est une carte plate (équirectangulaire — `lng`/`lat` +mis à l'échelle linéairement sur la box). Un `clip-path: circle` dessus ne +fait que découper un disque dans cette carte plate : les points restent +alignés sur une grille linéaire, ils ne convergent jamais vers le bord. Pour +un vrai globe vu de l'extérieur, il faut `projection: "orthographic"`. + +```json +{ "type": "dot_map", "projection": "orthographic", + "rotate": { "lng": { "from": -20, "to": 40 }, "lat": 15 }, + "limb_shading": 0.6, + "points": [{ "lat": 48.85, "lng": 2.35, "label": "Paris" }], + "arcs": [{ "from": [48.85, 2.35], "to": [40.71, -74.0], "draw_in": 0.8 }] } +``` + +## Ce que fait la projection + +`orthographic_project(lat, lng, lat_centre, lng_centre)` est la formule +fermée standard (pas une approximation) : elle renvoie une position normée +sur la sphère unité plus `cos_c`, le cosinus de la distance angulaire au +point sous l'observateur (`1.0` au centre du disque, `0.0` au limbe, +négatif sur l'hémisphère caché). Un point à `cos_c < 0` n'est pas dessiné — +c'est la culling de l'hémisphère lointain que le `clip-path` ne peut pas +faire, faute de savoir qu'il y a une sphère dessous. + +| Champ | Défaut | Rôle | +|---|---|---| +| `projection` | `"equirectangular"` | `"orthographic"` active tout ce qui suit ; sinon ils sont inertes | +| `rotate.lng` | `0` | méridien face caméra ; fixe ou `{ "from", "to", "duration"? }` (linéaire depuis `start_at`, `duration` par défaut = `animation_duration`) — la rotation autour du pôle | +| `rotate.lat` | `0` | parallèle face caméra ; fixe uniquement | +| `limb_shading` | absent | assombrit les points vers le bord, `0` (rien) à `1` (noir au tout bord) | +| `arcs[].draw_in` | `1.0` | fraction de l'arc tracée depuis `from`, comme `draw_progress` sur `line`/`arrow` mais figée (pas pilotée par le temps) | +| `arcs[].altitude` | `0.12` | hauteur du décollement au sommet de l'arc, en fraction du rayon | + +Le rayon du disque est calculé depuis la box (`min(largeur, hauteur) * 0.94 +/ 2`), centré. `dot_spacing`/`dot_radius` gardent leur sens : l'espacement +en pixels est converti en pas angulaire au rayon du disque, ce qui fait que +les points sont semés à angle constant sur la sphère — leur espacement à +l'écran, lui, **rétrécit vers le limbe**, exactement l'effet recherché. + +## Les arcs suivent un grand cercle, pas une ligne droite à l'écran + +Chaque arc est interpolé en 3D (slerp entre les deux vecteurs unitaires +`from`/`to`), pas en lat/lng — une interpolation linéaire en lat/lng ne suit +pas un grand cercle. Chaque échantillon repasse par +`orthographic_project` : un arc qui traverse l'horizon est coupé en +segments visibles, jamais tracé tout droit à travers le globe. Un arc +entièrement sur l'hémisphère caché ne peint rien du tout. + +## Piège : `rotate`/`limb_shading`/`arcs` sont inertes en mode plat + +Les déclarer sans `projection: "orthographic"` ne fait rien — la carte +plate historique reste strictement identique à avant #387, à l'octet près +(c'est testé). Le mode plat n'a pas de notion d'hémisphère caché ni de +limbe : ces champs n'ont de sens que pour un globe. + +## Ce qui n'y est pas + +Pas de tampon de profondeur entre les points, les arcs et les dots du fond : +ils sont peints dans l'ordre (fond, puis points, puis arcs), qui suffit tant +que les arcs restent au-dessus du globe — comme `layout-surface`, aucun tri +par profondeur véritable n'est fait. Voir +[layout-surface.md](layout-surface.md) pour l'autre moitié de #387, qui +partage la même limite et la même justification (#93). diff --git a/crates/rustmotion/skills/rules/layout-surface.md b/crates/rustmotion/skills/rules/layout-surface.md new file mode 100644 index 0000000..acf7582 --- /dev/null +++ b/crates/rustmotion/skills/rules/layout-surface.md @@ -0,0 +1,69 @@ +# Poser une grille sur un cylindre ou une sphère : `style.layout-surface` + +`transform` (`rotate-x`/`rotate-y`) plus `perspective` s'appliquent à un +nœud, mais ce nœud reste un plan. Donner sa propre rotation à chaque carte +d'un `for-each` ne le résout pas non plus : chaque carte a **son propre** +point de fuite, donc une grille inclinée reste un trapèze plat au lieu de se +courber — rien ne les fait converger vers un horizon commun. `layout-surface` +résout exactement ça : c'est une propriété du **conteneur**, pas de l'enfant. + +```json +{ "type": "div", "style": { + "display": "grid", + "grid-template-columns": ["1fr","1fr","1fr","1fr","1fr","1fr","1fr","1fr","1fr"], + "layout-surface": { + "kind": "sphere", "radius": 1400, "arc_x": 120, "arc_y": 60, + "perspective": 1600, "rotate_y": { "from": -15, "to": 15 } + } }, + "children": [ "… neuf vignettes, disposées normalement par taffy …" ] } +``` + +## Ce que taffy voit, ce que la caméra voit + +Le layout ne change pas : taffy calcule les colonnes/lignes exactement comme +sans `layout-surface`. La courbure n'existe qu'au **paint** — chaque enfant +direct du conteneur est repeint à sa position projetée sur le cylindre ou la +sphère, autour d'**un seul** point de fuite partagé par tout le conteneur. +C'est la même machinery que le tilt de caméra (#400, `apply_plane_camera` / +`css_perspective_m44`) appliquée par enfant plutôt que par plan entier — pas +une deuxième implémentation de la projection. + +| Champ | Obligatoire | Rôle | +|---|---|---| +| `kind` | oui | `"cylinder"` (courbe seulement `arc_x`, la position verticale de chaque enfant n'est pas touchée) ou `"sphere"` (courbe `arc_x` **et** `arc_y`) | +| `radius` | oui | rayon en pixels | +| `arc_x` | non (0) | balayage angulaire total sur la largeur, en degrés | +| `arc_y` | non (0, `sphere` seulement) | balayage angulaire total sur la hauteur, en degrés | +| `perspective` | non | distance du point de fuite en pixels ; absent = courbe sans raccourcissement (orthographique) | +| `rotate_x` / `rotate_y` | non | bascule d'ensemble de la surface, en degrés ; fixe (`15`) ou animée (`{ "from": -15, "to": 15 }`, linéaire de `scene_duration` par défaut, ou `duration` explicite) | + +Le centre de l'arc (`nu = nv = 0`, la cellule du milieu) reste à sa taille et +sa position normales — c'est la référence. Les cellules s'éloignent de plus +en plus du centre à mesure qu'elles approchent des bords de l'arc, ce qui +raccourcit leur largeur apparente sous la perspective : une grille projetée +n'a **pas** des cellules toutes de la même largeur, contrairement à une +grille plate. + +## Piège : `cylinder` n'a pas de deuxième axe + +`arc_y` n'existe que sur `sphere`. Un `cylinder` avec des rangées ne les +courbe pas verticalement — c'est voulu (un cylindre ne s'enroule que sur un +axe) : mettre `sphere` si les deux axes doivent se courber. + +## Ordre de peinture : pas de tri en profondeur + +Les enfants sont peints dans l'ordre de déclaration (`z-index`), comme un +conteneur plat — une cellule qui se courbe derrière une autre ne passe pas +automatiquement derrière elle au rendu. Pour un patch bien en-dessous de +180° (l'usage visé : un dôme de cartes, pas une sphère qui s'enroule +entièrement), chaque cellule fait toujours face à la caméra et ça ne se voit +pas. Un `arc_x`/`arc_y` très large, ou assez de `rotate_y` pour faire +franchir le limbe à une cellule, peut faire peindre une cellule lointaine +par-dessus une plus proche. Suivi général par #93, non traité ici. + +## Ce qui n'y est pas + +Pas de repli sur `taffy` pour re-calculer un flex/grid une fois courbé — le +flux reste celui d'un conteneur plat, seul le paint change. Un enfant qui a +lui-même un `transform` garde son comportement normal, appliqué *après* la +projection de la surface (les deux se composent). From ffdfc55f7967ba6b441beef1bb9bc85db2ebd9e5 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Mon, 28 Sep 2026 09:47:45 +0200 Subject: [PATCH 2/2] docs(skills): link the layout-surface and orthographic dot_map rules --- crates/rustmotion/CLAUDE.md | 2 +- crates/rustmotion/skills/SKILL.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/rustmotion/CLAUDE.md b/crates/rustmotion/CLAUDE.md index 3aea5db..e8b826b 100644 --- a/crates/rustmotion/CLAUDE.md +++ b/crates/rustmotion/CLAUDE.md @@ -84,7 +84,7 @@ La vue **`world`** est le seul mécanisme qui produit une continuité réelle en - `stat` — carte KPI composite (valeur + label + tendance + sparkline) - `heatmap` — grille colorée type GitHub contributions - `treemap` — rectangles proportionnels (slice-and-dice) -- `dot_map` — carte mondiale en dot-pattern avec points de données, pulse, lat/lng +- `dot_map` — carte mondiale en dot-pattern avec points de données, pulse, lat/lng. `projection: "orthographic"` en fait un globe, avec occultation de l'hémisphère caché et arcs en grand cercle — voir [rules/dot-map-orthographic.md](.claude/skills/rustmotion/rules/dot-map-orthographic.md). - `progress` — barre linéaire ou circulaire - `counter` — compteur animé (standalone uniquement, pas dans les cards) - `number_wheel` — digits that scroll like a mechanical odometer and land on the figure. Not to be confused with `counter`, which interpolates a value and rewrites the number (its glyphs jump). Le réglage se fait par `digits`, `duration` et `easing` sur le composant. diff --git a/crates/rustmotion/skills/SKILL.md b/crates/rustmotion/skills/SKILL.md index b86a909..bde0f23 100644 --- a/crates/rustmotion/skills/SKILL.md +++ b/crates/rustmotion/skills/SKILL.md @@ -238,6 +238,8 @@ Read individual rule files for detailed explanations, GOOD/BAD examples, and con - [rules/chromatic-aberration.md](rules/chromatic-aberration.md) - Per-element red/cyan fringe on arrival: `chromatic_aberration`'s `amount`, how its curve differs from `chromatic_wipe`'s, and the `amount`-not-`amplitude` trap - [rules/inflated-material.md](rules/inflated-material.md) - `material: "inflated"`: shading derived from the clipped silhouette, so each branch of a star gets its own relief — and why `bevel` must stay small relative to the shape - [rules/material-and-light.md](rules/material-and-light.md) - Lit surfaces: `style.material`'s three presets, the scene-wide `light` that makes them agree, and why the material follows the box and not a `shape`'s own geometry +- [rules/layout-surface.md](rules/layout-surface.md) - Project a flat grid onto a cylinder or sphere with one shared vanishing point — and why children still paint in declaration order +- [rules/dot-map-orthographic.md](rules/dot-map-orthographic.md) - `dot_map` as a globe: orthographic projection, far-hemisphere culling, great-circle arcs and limb shading - [rules/camera-3d.md](rules/camera-3d.md) - Tilt a whole shot with `camera.rotate_x`/`rotate_y`/`perspective`: one shared vanishing point, and rotation scaled by each plane's `style.depth` - [rules/depth-of-field.md](rules/depth-of-field.md) - Defocus by plane: `camera.focus`/`aperture` on the `style.depth` scale, rack focus by keyframe, and why nothing moves without distinct depths - [rules/char-animation-rich-and-gradient-text.md](rules/char-animation-rich-and-gradient-text.md) - `char_*` presets on `rich_text` and `gradient_text`: stagger across span boundaries, per-span `ink_from`, and why `ink_from` is inert on a gradient