diff --git a/crates/op-editor-ui/src/layout_scene.rs b/crates/op-editor-ui/src/layout_scene.rs index 1bc3ef7f2..f52209db6 100644 --- a/crates/op-editor-ui/src/layout_scene.rs +++ b/crates/op-editor-ui/src/layout_scene.rs @@ -190,6 +190,13 @@ pub struct SceneNode { /// populated for `Path` (and any kind the painter walks as /// points). Empty otherwise. pub points: Vec, + /// Ellipse arc start angle in degrees. `None` = full ellipse. + pub arc_start_angle: Option, + /// Ellipse arc sweep angle in degrees. `None` = full ellipse. + pub arc_sweep_angle: Option, + /// Ellipse donut-hole radius (0.0..=1.0 fraction). `None` / 0 = + /// solid. + pub arc_inner_radius: Option, /// Drop-shadow / effects painted behind the node's fill. pub effects: Vec, /// Whether the node (and its subtree) is hidden — the painter @@ -264,6 +271,9 @@ impl SceneNode { font_weight: 0, text_wrap: false, points: Vec::new(), + arc_start_angle: None, + arc_sweep_angle: None, + arc_inner_radius: None, effects: Vec::new(), hidden: false, locked: false, diff --git a/crates/op-editor-ui/src/widgets/canvas_viewport_paint.rs b/crates/op-editor-ui/src/widgets/canvas_viewport_paint.rs index 8a14802c6..1f35416ed 100644 --- a/crates/op-editor-ui/src/widgets/canvas_viewport_paint.rs +++ b/crates/op-editor-ui/src/widgets/canvas_viewport_paint.rs @@ -45,6 +45,71 @@ fn paint_drop_shadows(cx: &mut PaintCx<'_>, node: &SceneNode, world_rect: Rect, } } +/// Tessellate an ellipse arc / pie / donut-sector into a closed +/// polygon outline. `start_deg` / `sweep_deg` use the screen +/// convention (0° = +X, positive = clockwise); `inner` is the +/// donut-hole radius as a 0.0..=1.0 fraction. +pub(crate) fn arc_polygon(rect: Rect, start_deg: f32, sweep_deg: f32, inner: f32) -> Vec { + let cx_pt = rect.origin.x + rect.size.x / 2.0; + let cy_pt = rect.origin.y + rect.size.y / 2.0; + let rx = rect.size.x / 2.0; + let ry = rect.size.y / 2.0; + // ~1 segment per 4° of sweep, clamped to a sane range. + let segs = ((sweep_deg.abs() / 4.0).ceil() as usize).clamp(2, 512); + let point = |frac: f32, scale: f32| -> Point2D { + let ang = (start_deg + sweep_deg * frac).to_radians(); + Point2D::new( + cx_pt + rx * scale * ang.cos(), + cy_pt + ry * scale * ang.sin(), + ) + }; + let mut poly = Vec::with_capacity(segs * 2 + 2); + if inner > 0.001 { + // Annular sector: outer arc start→end, inner arc end→start. + for i in 0..=segs { + poly.push(point(i as f32 / segs as f32, 1.0)); + } + for i in (0..=segs).rev() { + poly.push(point(i as f32 / segs as f32, inner)); + } + } else { + // Pie wedge: centre + outer arc. + poly.push(Point2D::new(cx_pt, cy_pt)); + for i in 0..=segs { + poly.push(point(i as f32 / segs as f32, 1.0)); + } + } + poly +} + +/// Paint an Ellipse node — a full oval when no arc geometry is +/// authored, otherwise a tessellated pie / arc / donut sector. +fn paint_ellipse(cx: &mut PaintCx<'_>, node: &SceneNode, world_rect: Rect, zoom: f32) { + let inner = node.arc_inner_radius.unwrap_or(0.0).clamp(0.0, 1.0); + let has_arc = node.arc_start_angle.is_some() || node.arc_sweep_angle.is_some() || inner > 0.001; + let sweep = node.arc_sweep_angle.unwrap_or(360.0); + // A full-circle sweep with no donut hole is just a plain oval. + if !has_arc || (sweep.abs() >= 359.9 && inner <= 0.001) { + if let Some(fill) = node.fill { + cx.backend.fill_oval(world_rect, fill); + } + if let Some(stroke) = node.stroke { + cx.backend + .stroke_oval(world_rect, stroke.color, stroke.width * zoom); + } + return; + } + let start = node.arc_start_angle.unwrap_or(0.0); + let poly = arc_polygon(world_rect, start, sweep, inner); + if let Some(fill) = node.fill { + cx.backend.fill_polygon(&poly, fill); + } + if let Some(stroke) = node.stroke { + cx.backend + .stroke_polygon(&poly, stroke.color, stroke.width * zoom); + } +} + /// Recursively paint one resolved [`SceneNode`] and its subtree. /// /// `viewport_origin` is the canvas-rect origin shifted by the @@ -134,13 +199,7 @@ pub fn paint_node( paint_fill_then_stroke(cx, node, world_rect, zoom, node.fill); } NodeKind::Ellipse => { - if let Some(fill) = node.fill { - cx.backend.fill_oval(world_rect, fill); - } - if let Some(stroke) = node.stroke { - cx.backend - .stroke_oval(world_rect, stroke.color, stroke.width * zoom); - } + paint_ellipse(cx, node, world_rect, zoom); } NodeKind::Polygon => { // Default triangle: top-centre, bottom-left, bottom-right. @@ -275,3 +334,40 @@ fn paint_text_node( } } } + +#[cfg(test)] +mod arc_tests { + use super::arc_polygon; + use crate::Rect; + + #[test] + fn pie_polygon_starts_at_centre() { + // 100×100 rect at origin → centre (50, 50). + let poly = arc_polygon(Rect::xywh(0.0, 0.0, 100.0, 100.0), 0.0, 90.0, 0.0); + assert_eq!(poly[0].x, 50.0); + assert_eq!(poly[0].y, 50.0); + // First arc point at 0° = +X edge → (100, 50). + assert!((poly[1].x - 100.0).abs() < 0.01); + assert!((poly[1].y - 50.0).abs() < 0.01); + } + + #[test] + fn donut_polygon_has_outer_and_inner_rings() { + let poly = arc_polygon(Rect::xywh(0.0, 0.0, 100.0, 100.0), 0.0, 360.0, 0.5); + // segs for 360° = 90; outer (segs+1) + inner (segs+1) points. + assert_eq!(poly.len(), 2 * (90 + 1)); + // An inner-ring point sits at half the radius from centre. + let last = poly[poly.len() - 1]; + let dist = ((last.x - 50.0).powi(2) + (last.y - 50.0).powi(2)).sqrt(); + assert!((dist - 25.0).abs() < 0.5, "inner radius ~25, got {dist}"); + } + + #[test] + fn quarter_sweep_end_point_at_90_degrees() { + // start 0°, sweep 90° → last outer point at +Y edge (50, 100). + let poly = arc_polygon(Rect::xywh(0.0, 0.0, 100.0, 100.0), 0.0, 90.0, 0.0); + let last = poly[poly.len() - 1]; + assert!((last.x - 50.0).abs() < 0.01); + assert!((last.y - 100.0).abs() < 0.01); + } +} diff --git a/crates/op-pen-loader/src/adapter.rs b/crates/op-pen-loader/src/adapter.rs index fbc2f9b01..b2accbf71 100644 --- a/crates/op-pen-loader/src/adapter.rs +++ b/crates/op-pen-loader/src/adapter.rs @@ -434,6 +434,11 @@ fn ellipse_to_payload(n: &EllipseNode) -> NodePayload { p.fill_type = first_fill_type(n.fill.as_deref()); p.stroke = stroke_to_payload(n.stroke.as_ref()); p.corner_radius = n.corner_radius.unwrap_or(0.0) as f32; + // Arc geometry — only carried when authored, so a plain ellipse + // still paints as a full oval. + p.arc_start_angle = n.start_angle.map(|a| a as f32); + p.arc_sweep_angle = n.sweep_angle.map(|a| a as f32); + p.arc_inner_radius = n.inner_radius.map(|r| r as f32); p } @@ -640,6 +645,9 @@ fn base_payload(base: &PenNodeBase, kind: &str) -> NodePayload { text: None, rotation: (base.rotation.unwrap_or(0.0) as f32).to_radians(), corner_radius: 0.0, + arc_start_angle: None, + arc_sweep_angle: None, + arc_inner_radius: None, hidden: !base.visible.unwrap_or(true), locked: base.locked.unwrap_or(false), collapsed: false, diff --git a/crates/op-pen-loader/src/layout_scene.rs b/crates/op-pen-loader/src/layout_scene.rs index 5dd1e81d8..1460b885b 100644 --- a/crates/op-pen-loader/src/layout_scene.rs +++ b/crates/op-pen-loader/src/layout_scene.rs @@ -111,6 +111,9 @@ fn node_payload_to_scene(node: &NodePayload, var_table: &VariableTable) -> Scene .iter() .map(|p| Point2D::new(p[0], p[1])) .collect(), + arc_start_angle: node.arc_start_angle, + arc_sweep_angle: node.arc_sweep_angle, + arc_inner_radius: node.arc_inner_radius, effects: crate::effects::effects_from_payload_ref(&node.effects), hidden: node.hidden, locked: node.locked, diff --git a/crates/op-pen-loader/src/payload.rs b/crates/op-pen-loader/src/payload.rs index 549d536e9..85f898a52 100644 --- a/crates/op-pen-loader/src/payload.rs +++ b/crates/op-pen-loader/src/payload.rs @@ -55,6 +55,15 @@ pub struct NodePayload { pub rotation: f32, #[serde(default)] pub corner_radius: f32, + /// Ellipse arc start angle in degrees (`None` = full ellipse). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub arc_start_angle: Option, + /// Ellipse arc sweep angle in degrees (`None` = full ellipse). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub arc_sweep_angle: Option, + /// Ellipse donut-hole radius, 0.0..=1.0 fraction of the radius. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub arc_inner_radius: Option, #[serde(default)] pub hidden: bool, #[serde(default)]