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/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 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).