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 <noreply@anthropic.com>
This commit is contained in:
PanPDXandClaude Opus 5.5 committed 2026-10-15 08:52:38 -07:00
1 parent 7040fdc233
commit da1764082e
4 files changed
+43 -5

No files matched your search

+3 -2
View File
@@ -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() }
+31
View File
@@ -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);
}
+6 -1
View File
@@ -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;
+3 -2
View File
@@ -342,8 +342,9 @@ pub(crate) fn path_target(app: &PhotocraftApp, x: f64, y: f64) -> Option<PathCli
};
// The canvas hover runs this every frame and `hit` samples every segment, so a path with more
// anchors than this is not a click target (the click and the pointer agree: plain point
// text). 4096 anchors is far beyond hand-drawn paths and shapes yet keeps a frame cheap.
const MAX_TARGET_KNOTS: usize = 4096;
// text). 4096 anchors is far beyond hand-drawn paths and shapes yet keeps a frame cheap, and
// it is the most `type.fillPath` accepts, so every area click target can be filled.
const MAX_TARGET_KNOTS: usize = photocraft_text::area::MAX_AREA_KNOTS;
if path.subpaths.iter().map(|s| s.knots.len()).fold(0usize, usize::saturating_add) > MAX_TARGET_KNOTS {
return None;
}