fix(canvas): codex review round 4 — arc clamp, path hit-test, drag cleanup

Addresses the remaining round-4 codex findings (the `short_src`
byte-slice panic + the arc-handle reverse-iteration fixes already
landed via an earlier sweep).

- `cmd_set_ellipse_arc` clamps `sweep_angle` to ±360° — an API /
  MCP sweep beyond a full turn just over-draws; it now persists a
  sane single-revolution value.
- Path hit-test (`point_in_node`) follows the flattened, bezier-
  aware outline instead of the bounding box: a curved or thin path
  no longer selects empty bbox space, a zero-height stroked path
  stays clickable, and a filled closed path is hittable across its
  interior (new `point_in_polygon` even-odd test).
- The viewport-less `apply_release` now commits / clears
  `path_anchor_drag` + `arc_handle_drag` (parity with
  `apply_release_with_viewport`) so a drag can't leak across that
  release path.

op-editor-core 242 / op-editor-ui 157 / op-host-native 21 tests green.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Kayshen-X 2026-05-17 23:00:56 +08:00
parent 7112cb68a6
commit 74d8065cc2
3 changed files with 54 additions and 1 deletions

View file

@ -340,7 +340,9 @@ impl EditorState {
e.start_angle = Some(a);
}
if let Some(a) = sweep_angle {
e.sweep_angle = Some(a);
// A sweep beyond a full turn just over-draws —
// clamp to a single revolution either direction.
e.sweep_angle = Some(a.clamp(-360.0, 360.0));
}
if let Some(r) = inner_radius {
e.inner_radius = Some(r);

View file

@ -157,6 +157,25 @@ fn point_in_node(node: &SceneNode, local: Point2D, bounds: Rect, zoom: f32) -> b
let screen_slack = 4.0 / zoom.max(0.0001);
return distance_point_to_segment(local, from, to) <= stroke_half + screen_slack;
}
// Paths hit-test against their flattened (bezier-aware) outline,
// not the bounding box — a curved / thin path otherwise selects
// empty bbox space, and a zero-height stroked path (which fails
// the positive-area gate below) stays clickable.
if matches!(node.kind, NodeKind::Path) {
let poly = crate::widgets::canvas_viewport_paint::flatten_path(node);
if poly.len() < 2 {
return false;
}
let stroke_half = node.stroke.map(|s| s.width / 2.0).unwrap_or(1.0);
let slack = 4.0 / zoom.max(0.0001);
for seg in poly.windows(2) {
if distance_point_to_segment(local, seg[0], seg[1]) <= stroke_half + slack {
return true;
}
}
// A filled closed path is also hittable across its interior.
return node.path_closed && node.fill.is_some() && point_in_polygon(local, &poly);
}
// Non-line kinds need real positive area on both axes.
if bounds.size.x <= 0.0 || bounds.size.y <= 0.0 {
return false;
@ -232,6 +251,23 @@ fn point_in_triangle(p: Point2D, a: Point2D, b: Point2D, c: Point2D) -> bool {
!(has_neg && has_pos)
}
/// Even-odd ray-cast point-in-polygon test over a closed vertex ring.
fn point_in_polygon(p: Point2D, poly: &[Point2D]) -> bool {
if poly.len() < 3 {
return false;
}
let mut inside = false;
let mut j = poly.len() - 1;
for i in 0..poly.len() {
let (a, b) = (poly[i], poly[j]);
if (a.y > p.y) != (b.y > p.y) && p.x < (b.x - a.x) * (p.y - a.y) / (b.y - a.y) + a.x {
inside = !inside;
}
j = i;
}
inside
}
/// Shortest distance from `p` to the segment `a``b`.
fn distance_point_to_segment(p: Point2D, a: Point2D, b: Point2D) -> f32 {
let dx = b.x - a.x;

View file

@ -607,6 +607,21 @@ impl WidgetHostNative {
// Same story as marquee — no viewport, drop the candidate.
return true;
}
// Path-anchor / arc-handle drags — commit the history snapshot
// when they actually moved (parity with the with-viewport
// release; without this a stale drag would leak).
if let Some(drag) = self.path_anchor_drag.take() {
if drag.moved {
self.editor_state.history_push_past(drag.pre_drag_snapshot);
}
return true;
}
if let Some(drag) = self.arc_handle_drag.take() {
if drag.moved {
self.editor_state.history_push_past(drag.pre_drag_snapshot);
}
return true;
}
// Chat drag without viewport — drop it (best effort).
if self.chat_drag.take().is_some() {
return true;