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:
parent
418c4acf3b
commit
bfbe3b161d
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
Loading…
Reference in a new issue