feat(canvas): render ellipse arcs / pie / donut sectors

Ellipse nodes with authored arc geometry (`start_angle` /
`sweep_angle` / `inner_radius`) were always painted as a full oval —
the arc fields never reached the painter.

- NodePayload + SceneNode gain `arc_start_angle` / `arc_sweep_angle`
  / `arc_inner_radius`; threaded through `ellipse_to_payload` and
  `node_payload_to_scene`.
- canvas_viewport_paint: new `arc_polygon` tessellator (pie wedge =
  centre + outer arc; donut sector = outer arc + reversed inner arc)
  + `paint_ellipse` — full oval when no arc is authored, otherwise a
  filled/stroked polygon. A 360° sweep with no donut hole still
  short-circuits to the plain oval path.

Prep for the arc canvas drag-handle UI. op-editor-ui 143 /
op-pen-loader 21 / op-host-desktop 64 tests green.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Kayshen-X 2026-05-17 17:10:50 +08:00
parent 418c4acf3b
commit bfbe3b161d
5 changed files with 133 additions and 7 deletions

View file

@ -190,6 +190,13 @@ pub struct SceneNode {
/// populated for `Path` (and any kind the painter walks as
/// points). Empty otherwise.
pub points: Vec<Point2D>,
/// Ellipse arc start angle in degrees. `None` = full ellipse.
pub arc_start_angle: Option<f32>,
/// Ellipse arc sweep angle in degrees. `None` = full ellipse.
pub arc_sweep_angle: Option<f32>,
/// Ellipse donut-hole radius (0.0..=1.0 fraction). `None` / 0 =
/// solid.
pub arc_inner_radius: Option<f32>,
/// Drop-shadow / effects painted behind the node's fill.
pub effects: Vec<Effect>,
/// 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,

View file

@ -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<Point2D> {
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);
}
}

View file

@ -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,

View file

@ -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,

View file

@ -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<f32>,
/// Ellipse arc sweep angle in degrees (`None` = full ellipse).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub arc_sweep_angle: Option<f32>,
/// 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<f32>,
#[serde(default)]
pub hidden: bool,
#[serde(default)]