From da1764082edaa42cdbbcaaea909b76d850158dbf Mon Sep 17 00:00:00 2001 From: PanPDX Date: Thu, 15 Oct 2026 08:52:38 -0700 Subject: [PATCH] fix(type): refuse area paths over 4096 anchors as too many anchor points type.fillPath accepted up to 100,000 anchors, but each curved segment flattens to 16 edges against a 65,536-edge cap, so a big curved path failed with the misleading "the path encloses no area". The anchor limit now lives in photocraft_text::area as MAX_AREA_KNOTS (4096, the Type tool's existing click cap) and the edge cap is derived from it (4096 x 17), so every path within the limit fits even with every segment curved, and a bigger one gets "the path has too many anchor points". The Type tool's click cap now names the same constant, so a path it offers as an area target can always be filled. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/type_area_cmds.rs | 5 ++-- crates/engine/src/type_area_cmds/tests.rs | 31 +++++++++++++++++++++++ crates/text/src/area.rs | 7 ++++- crates/ui-egui/src/type_tool.rs | 5 ++-- 4 files changed, 43 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/type_area_cmds.rs b/crates/engine/src/type_area_cmds.rs index 09b22b0fca..af04aaa4f0 100644 --- a/crates/engine/src/type_area_cmds.rs +++ b/crates/engine/src/type_area_cmds.rs @@ -13,8 +13,9 @@ use crate::{EngineError, Result, Session}; const CMD: &str = "type.fillPath"; -/// Largest total anchor count of a path area type can fill (as for type on a path). -const MAX_AREA_KNOTS: usize = 100_000; +// Largest total anchor count of a path area type can fill. Within it every path fits the area +// edge cap, so a big curved path gets "too many anchor points" rather than "encloses no area". +use photocraft_text::area::MAX_AREA_KNOTS; fn bad(m: &str) -> EngineError { EngineError::BadParams { cmd: CMD.into(), msg: m.into() } diff --git a/crates/engine/src/type_area_cmds/tests.rs b/crates/engine/src/type_area_cmds/tests.rs index 2c43125731..7ca20cf7c6 100644 --- a/crates/engine/src/type_area_cmds/tests.rs +++ b/crates/engine/src/type_area_cmds/tests.rs @@ -166,3 +166,34 @@ fn area_type_saves_to_psd_as_paragraph_text_with_a_warning() { assert!(matches!(t.shape, TextShape::Box { width, height, .. } if (width - 200.0).abs() < 0.5 && (height - 150.0).abs() < 0.5), "{:?}", t.shape); let _ = id; } + +#[test] +fn too_many_anchors_is_its_own_error() { + // A ring of `n` smooth (curved) anchors around (200, 150). + let ring = |n: usize| { + let p = |a: f64, r: f64| photocraft_geom::Point::new(200.0 + r * a.cos(), 150.0 + r * a.sin()); + let step = std::f64::consts::TAU / n as f64; + let knots = (0..n) + .map(|i| { + let a = i as f64 * step; + let (anchor, d) = (p(a, 100.0), 100.0 * step / 3.0); + let (tx, ty) = (-a.sin() * d, a.cos() * d); + let at = |k: f64| photocraft_geom::Point::new(anchor.x + k * tx, anchor.y + k * ty); + photocraft_doc::vector::Knot::smooth(anchor, at(-1.0), at(1.0)) + }) + .collect(); + Path::new(vec![Subpath { closed: true, knots, op: PathOp::Combine }]) + }; + let mut s = session(8); + let id = text_layer(&mut s, WORDS); + // Every anchor up to the limit fits, even with every segment curved. + set_work_path(&mut s, ring(MAX_AREA_KNOTS)); + s.execute("type.fillPath", json!({})).unwrap(); + assert!(matches!(text(&s, id).shape, TextShape::Area { .. })); + // One more is refused for what it is, not as a path that encloses nothing. + let id = text_layer(&mut s, WORDS); + set_work_path(&mut s, ring(MAX_AREA_KNOTS + 1)); + let e = s.execute("type.fillPath", json!({})).unwrap_err().to_string(); + assert!(e.contains("too many anchor points"), "{e}"); + assert_eq!(text(&s, id).shape, TextShape::Point); +} diff --git a/crates/text/src/area.rs b/crates/text/src/area.rs index c616a02bba..407065355d 100644 --- a/crates/text/src/area.rs +++ b/crates/text/src/area.rs @@ -12,8 +12,13 @@ use photocraft_geom::Point; /// Pieces each curved segment is cut into (about 0.1 px off a 300 px bubble's curve). const CURVE_PIECES: usize = 16; +/// Most anchors an area path can have: the most `type.fillPath` accepts and a Type tool click +/// targets (`path_target` in ui-egui). +pub const MAX_AREA_KNOTS: usize = 4096; /// Largest number of edges an area keeps, so a hostile path can't make every line scan millions. -pub const MAX_AREA_EDGES: usize = 65_536; +/// A closed subpath of `n` anchors gives at most `CURVE_PIECES * n + 1` edges, so every path +/// within [`MAX_AREA_KNOTS`] fits, even with every segment curved. +pub const MAX_AREA_EDGES: usize = MAX_AREA_KNOTS * (CURVE_PIECES + 1); /// Most shape components an area keeps: every row folds each one in, so a path of thousands of /// tiny shapes can't make each line cost thousands of interval merges. pub const MAX_AREA_COMPONENTS: usize = 4096; diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index f790625531..a8f4058fb1 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -342,8 +342,9 @@ pub(crate) fn path_target(app: &PhotocraftApp, x: f64, y: f64) -> Option MAX_TARGET_KNOTS { return None; }