Expose native shape stroke controls with staged previews (#2218)

* Expose native shape stroke controls with staged previews

* Document shape stroke defaults in MCP control schema

* Regenerate docs/scorecard.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: optimalbytee <optimalbytee@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
optimalbytee
2026-10-10 06:42:52 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 optimalbytee Brandon Thomas
parent 291f30da55
commit ae96037672
38 changed files with 1506 additions and 42 deletions
+3 -1
View File
@@ -237,7 +237,9 @@ pub struct UiSetParams {
/// grid, diagonal, triangle, goldenRatio, goldenSpiral), cropOverlayShow (auto, always, never),
/// cropOverlayOrientation (0..3), cropShield (an object patching show_cropped_area, enabled,
/// color ("matchCanvas" or "custom"), custom_color ([r, g, b]), opacity (0..100) and
/// auto_adjust). Other fields are an error.
/// auto_adjust), shapeStroke (an object patching width, align, cap, join, miterLimit,
/// dashes, dashOffset and opacity; null disables the stroke). Shape stroke fields use
/// `shape.edit` names and units; colour comes from `tools.setColors`. Other fields are an error.
pub fields: Value,
}
+26
View File
@@ -263,6 +263,32 @@ impl Default for ShapeStroke {
}
}
impl ShapeStroke {
/// Limits accepted by interactive commands. Importers retain original descriptor data;
/// renderers must additionally bound their work for arbitrary file-provided geometry.
pub fn validate(&self) -> Result<(), &'static str> {
if !self.width.is_finite() || !(0.0..=1_000_000.0).contains(&self.width) {
return Err("stroke width must be finite and between 0 and 1000000 px");
}
if !self.opacity.is_finite() || !(0.0..=1.0).contains(&self.opacity) {
return Err("stroke opacity must be between 0 and 100%");
}
if !self.miter_limit.is_finite() || !(1.0..=500.0).contains(&self.miter_limit) {
return Err("miter limit must be between 1 and 500");
}
if !self.dash_offset.is_finite() || self.dash_offset.abs() > 1_000_000.0 {
return Err("dash offset must be finite and within ±1000000 widths");
}
if self.dashes.len() > 32 || self.dashes.iter().any(|d| !d.is_finite() || !(0.0..=10000.0).contains(d)) {
return Err("dash pattern must contain at most 32 finite lengths between 0 and 10000 widths");
}
if !self.dashes.is_empty() && self.dashes.iter().sum::<f32>() < 0.01 {
return Err("dash pattern total must be at least 0.01 widths");
}
Ok(())
}
}
/// Parametric ("live") shape the path was generated from, kept for re-editing (Properties panel).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
+55 -6
View File
@@ -306,19 +306,34 @@ fn stroke_json(s: &ShapeStroke) -> Value {
}
/// Applies stroke keys onto `base` (`null` → no stroke).
fn parse_stroke(v: &Value, base: Option<ShapeStroke>, fg: Color) -> std::result::Result<Option<ShapeStroke>, String> {
pub fn parse_stroke(v: &Value, base: Option<ShapeStroke>, fg: Color) -> std::result::Result<Option<ShapeStroke>, String> {
if v.is_null() || v.as_bool() == Some(false) {
return Ok(None);
}
if !v.is_object() {
return Err("stroke must be an object or null".into());
}
for (key, lo, hi) in [("width", 0.0, 1_000_000.0), ("opacity", 0.0, 100.0), ("miterLimit", 1.0, 500.0), ("dashOffset", -1_000_000.0, 1_000_000.0)] {
if let Some(value) = v.get(key)
&& !value.as_f64().is_some_and(|n| n.is_finite() && (lo..=hi).contains(&n))
{
return Err(format!("`{key}` must be a finite number between {lo} and {hi}"));
}
}
for key in ["align", "cap", "join"] {
if v.get(key).is_some_and(|value| !value.is_string()) {
return Err(format!("`{key}` must be a string"));
}
}
let mut s = base.unwrap_or(ShapeStroke { paint: Fill::Solid(fg), ..Default::default() });
if let Some(w) = f64p(v, "width") {
s.width = w.max(0.0) as f32;
s.width = w as f32;
}
if let Some(c) = v.get("color").or_else(|| v.get("fill")) {
s.paint = parse_fill(c)?.ok_or("stroke colour cannot be null")?;
}
if let Some(o) = f64p(v, "opacity") {
s.opacity = (o / 100.0).clamp(0.0, 1.0) as f32;
s.opacity = (o / 100.0) as f32;
}
if let Some(a) = v.get("align").and_then(Value::as_str) {
s.align = match a {
@@ -345,18 +360,27 @@ fn parse_stroke(v: &Value, base: Option<ShapeStroke>, fg: Color) -> std::result:
};
}
if let Some(m) = f64p(v, "miterLimit") {
s.miter_limit = m.max(1.0) as f32;
s.miter_limit = m as f32;
}
if let Some(d) = v.get("dashes") {
s.dashes = match d {
Value::Null => Vec::new(),
Value::Array(a) => a.iter().filter_map(Value::as_f64).map(|x| x.max(0.0) as f32).collect(),
Value::Array(a) if a.len() <= 32 => a
.iter()
.map(|x| {
x.as_f64()
.filter(|n| n.is_finite() && (0.0..=10000.0).contains(n))
.map(|x| x as f32)
.ok_or_else(|| "dash lengths must be finite numbers between 0 and 10000 widths".to_string())
})
.collect::<std::result::Result<Vec<_>, _>>()?,
_ => return Err("`dashes` must be an array (multiples of the width)".into()),
};
}
if let Some(o) = f64p(v, "dashOffset") {
s.dash_offset = o as f32;
}
s.validate()?;
Ok(Some(s))
}
@@ -433,6 +457,20 @@ pub fn refresh_shape(doc: &Document, sh: &mut ShapeLayer) {
sh.cache = Some(vector::render_shape(sh, doc.pixel_format(), doc.bounds()));
}
/// A staged stroke edit rendered with the same validation and cache refresh as `shape.edit`.
/// The caller owns this snapshot; the session and its history remain untouched.
pub fn preview_shape_stroke(doc: &Document, id: LayerId, params: &Value) -> Result<Document> {
let mut result = doc.clone();
let layer = result.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
let LayerContent::Shape(shape) = &mut layer.content else {
return Err(EngineError::Other("preview target is not a shape layer".into()));
};
shape.stroke = parse_stroke(params, shape.stroke.clone(), Color::BLACK).map_err(|e| bad("shape.edit", e))?;
vector::flatten::validate_shape(&shape.path, shape.stroke.as_ref()).map_err(|e| bad("shape.edit", e))?;
refresh_shape(doc, shape);
Ok(result)
}
/// Moves a layer's vector content by `(dx, dy)`: shape paths (re-rendered) and linked vector
/// masks, recursing into groups. For the Move tool / `layer.move`.
pub fn translate_vectors(doc: &Document, l: &mut Layer, dx: f64, dy: f64) {
@@ -519,6 +557,12 @@ fn shape_info(s: &Session, id: LayerId) -> Result<Value> {
}
pub(crate) fn with_shape<R>(s: &mut Session, id: LayerId, label: &str, f: impl FnOnce(&mut ShapeLayer, &mut Layer) -> Result<R>) -> Result<R> {
if let Some(st) = s.active() {
let locks = st.doc.effective_locks(id);
if locks.all || locks.pixels {
return Err(EngineError::Other("shape layer is locked".into()));
}
}
s.edit(label, |doc, _| {
let snapshot = doc.clone();
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
@@ -529,7 +573,9 @@ pub(crate) fn with_shape<R>(s: &mut Session, id: LayerId, label: &str, f: impl F
return Err(EngineError::Other(format!("layer {} is not a shape layer", id.0)));
};
let r = f(&mut sh, l);
refresh_shape(&snapshot, &mut sh);
if r.is_ok() {
refresh_shape(&snapshot, &mut sh);
}
l.content = LayerContent::Shape(sh);
r
})
@@ -567,6 +613,7 @@ fn shape_create(s: &mut Session, p: &Value) -> Result<Value> {
Some(v) => parse_stroke(v, None, fgc).map_err(|e| bad(CMD, e))?,
None => None,
};
vector::flatten::validate_shape(&path, stroke.as_ref()).map_err(|e| bad(CMD, e))?;
// Add to an existing shape layer with a path operation (Shape tool in combine/subtract… mode).
if let Some(target) = p.get("addTo").and_then(Value::as_u64) {
let op = p.get("op").and_then(Value::as_str).map_or(Some(PathOp::Combine), op_from).ok_or_else(|| bad(CMD, "unknown `op`"))?;
@@ -578,6 +625,7 @@ fn shape_create(s: &mut Session, p: &Value) -> Result<Value> {
}
sh.live = None;
sh.psd_raw = None;
vector::flatten::validate_shape(&sh.path, sh.stroke.as_ref()).map_err(|e| bad(CMD, e))?;
Ok(())
})?;
return shape_info(s, id);
@@ -644,6 +692,7 @@ fn shape_edit(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(n) = name {
layer.name = n;
}
vector::flatten::validate_shape(&sh.path, sh.stroke.as_ref()).map_err(|e| bad(CMD, e))?;
Ok(())
})?;
shape_info(s, id)
+83
View File
@@ -2,6 +2,89 @@ use serde_json::json;
use super::*;
#[test]
fn stroke_validation_rejects_hostile_values_without_editing_history() {
let mut s = session(100, 80, 8);
let id = s.execute("shape.create", json!({"rect": [20, 20, 50, 30], "stroke": {"width": 4}})).unwrap()["layer"].as_u64().unwrap();
let before = s.active().unwrap().doc.clone();
let history = s.active().unwrap().history.past_len();
let invalid = [
json!(true),
json!([]),
json!({"width": -1}),
json!({"width": 1e100}),
json!({"opacity": 101}),
json!({"cap": "invalid"}),
json!({"cap": 2}),
json!({"join": "invalid"}),
json!({"align": "invalid"}),
json!({"miterLimit": 0}),
json!({"miterLimit": 501}),
json!({"dashes": [4, "x"]}),
json!({"dashes": [-1, 2]}),
json!({"dashes": [0, 0]}),
json!({"dashes": vec![1; 33]}),
json!({"dashOffset": 1e100}),
json!({"width": "4"}),
json!({"width": 0.00001, "dashes": [0.01, 0.01]}),
];
for stroke in invalid {
assert!(s.execute("shape.edit", json!({"layer": id, "stroke": stroke})).is_err(), "{stroke}");
assert_eq!(s.active().unwrap().doc, before);
assert_eq!(s.active().unwrap().history.past_len(), history);
assert!(s.execute("shape.create", json!({"rect": [20, 20, 50, 30], "stroke": stroke})).is_err(), "{stroke}");
}
assert!(s.execute("shape.edit", json!({"layer": id, "rect": [1e100, 0, 20, 20]})).is_err());
assert_eq!(s.active().unwrap().doc, before);
}
#[test]
fn complete_stroke_edits_preserve_paint_and_geometry_and_undo_at_every_depth() {
for depth in [8, 16, 32] {
let mut s = session(100, 80, depth);
let id = s
.execute("shape.create", json!({"kind": "polygon", "sides": 3, "rect": [20, 20, 50, 30], "stroke": {"width": 4, "color": "#1122dd"}}))
.unwrap()["layer"]
.as_u64()
.unwrap();
let original = shape(&s, id);
let history = s.active().unwrap().history.past_len();
let patch = json!({"align":"outside", "cap":"square", "join":"bevel", "miterLimit":2, "opacity":35, "dashes":[4,2,1,3], "dashOffset":-0.5});
s.execute("shape.edit", json!({"layer": id, "stroke": patch})).unwrap();
let changed = shape(&s, id);
assert_eq!(changed.path, original.path);
assert_eq!(changed.stroke.as_ref().unwrap().paint, original.stroke.as_ref().unwrap().paint);
assert_eq!(changed.stroke.as_ref().unwrap().dashes, [4.0, 2.0, 1.0, 3.0]);
assert_ne!(changed.cache, original.cache);
assert_eq!(s.active().unwrap().history.past_len(), history + 1);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(shape(&s, id), original);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(shape(&s, id), changed);
}
}
#[test]
fn stroke_edits_respect_shape_locks() {
let mut s = session(80, 60, 8);
let id = s.execute("shape.create", json!({"rect":[10,10,40,30], "stroke":{"width":3}})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.lockLayers", json!({"all":true})).unwrap();
let before = s.active().unwrap().doc.clone();
assert!(s.execute("shape.edit", json!({"layer":id,"stroke":{"dashes":[4,2]}})).is_err());
assert_eq!(s.active().unwrap().doc, before);
}
#[test]
fn adding_components_validates_the_existing_strokes_combined_workload() {
let mut s = session(80, 60, 8);
let id = s.execute("shape.create", json!({"rect":[0,0,1,1], "stroke":{"width":0.001,"dashes":[1,1]}})).unwrap()["layer"].as_u64().unwrap();
let before = s.active().unwrap().doc.clone();
let history = s.active().unwrap().history.past_len();
assert!(s.execute("shape.create", json!({"addTo":id,"rect":[20,20,100,80]})).is_err());
assert_eq!(s.active().unwrap().doc, before);
assert_eq!(s.active().unwrap().history.past_len(), history);
}
fn session(w: u32, h: u32, depth: u32) -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": w, "height": h, "background": "white", "depth": depth})).unwrap();
+30
View File
@@ -11,6 +11,36 @@ use photocraft_raster::Rgba8Image;
const MODES: [ColorMode; 4] = [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab];
#[test]
fn custom_shape_stroke_roundtrip_keeps_every_style_field() {
for mode in MODES {
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let mut d = rich_doc(mode, depth);
let stroke = photocraft_doc::ShapeStroke {
width: 5.5,
opacity: 0.35,
align: photocraft_doc::StrokeAlign::Outside,
cap: photocraft_doc::LineCap::Square,
join: photocraft_doc::LineJoin::Bevel,
miter_limit: 2.0,
dashes: vec![0.0, 2.0, 4.0, 1.0, 1.5, 3.0],
dash_offset: -0.5,
..Default::default()
};
d.layers.push(Layer::new(
"Custom stroke",
LayerContent::Shape(photocraft_doc::ShapeLayer {
path: photocraft_doc::Path::new(vec![photocraft_doc::Subpath::polygon(&[(10.0, 10.0), (40.0, 10.0), (25.0, 40.0)])]),
stroke: Some(stroke),
..Default::default()
}),
));
let bytes = save_to_bytes(&d, &SaveOptions::default()).unwrap();
assert_eq!(load_from_bytes(&bytes).unwrap(), d);
}
}
}
fn check_zip(mode: ColorMode, depth: SampleType) {
let doc = rich_doc(mode, depth);
let bytes = save_to_bytes(&doc, &SaveOptions::default()).unwrap();
+36
View File
@@ -135,6 +135,42 @@ fn shape_layer_typed_roundtrip() {
}
}
#[test]
fn custom_stroke_styles_roundtrip_through_psd_vstk() {
for align in [StrokeAlign::Inside, StrokeAlign::Center, StrokeAlign::Outside] {
for (cap, join) in [(LineCap::Butt, LineJoin::Miter), (LineCap::Round, LineJoin::Round), (LineCap::Square, LineJoin::Bevel)] {
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let mut d = doc();
d.depth = depth;
let want = ShapeStroke {
width: 5.0,
paint: Fill::Solid(Color::rgb(0.0, 0.0, 1.0)),
opacity: 0.5,
align,
cap,
join,
miter_limit: 2.0,
dashes: vec![0.0, 2.0, 4.0, 1.0],
dash_offset: -0.5,
};
let sh = ShapeLayer {
path: Path::new(vec![Subpath::polygon(&[(32.0, 32.0), (160.0, 32.0), (96.0, 160.0)])]),
stroke: Some(want.clone()),
..Default::default()
};
d.layers.push(shape_layer(sh.clone()));
let back = roundtrip(&d);
let LayerContent::Shape(result) = &back.layers[1].content else { panic!("not a shape") };
assert_eq!(result.stroke.as_ref().unwrap(), &want);
let clip = d.bounds();
let before = photocraft_vector::CompiledShape::new(&sh, 0.01, clip).render_rgba(clip);
let after = photocraft_vector::CompiledShape::new(result, 0.01, clip).render_rgba(clip);
assert_eq!(before, after);
}
}
}
}
#[test]
fn vector_mask_roundtrip_and_removal() {
let mut d = doc();
+3
View File
@@ -768,6 +768,9 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> (std::sync::Ar
if let Some(shown) = crate::solid_fill_ui::display_doc(app, idx) {
return shown;
}
if let Some(shown) = crate::shape_stroke_ui::display_doc(app, idx) {
return shown;
}
if let Some(shown) = crate::type_transform::display_doc(app, idx) {
return shown;
}
+47 -1
View File
@@ -74,7 +74,7 @@ pub enum Outcome {
/// field's value is validated before the first one is applied, so a typo, an unknown field, a
/// bad value or a bad nested key can't reply with success while nothing — or only half of it —
/// changed (#412).
pub const UI_SET_FIELDS: [&str; 29] = [
pub const UI_SET_FIELDS: [&str; 30] = [
"tool",
"panels",
"dock",
@@ -104,6 +104,7 @@ pub const UI_SET_FIELDS: [&str; 29] = [
"cropOverlayShow",
"cropOverlayOrientation",
"cropShield",
"shapeStroke",
];
/// Most clicks one `ui.click` may queue (#982). Each click is a press and a release that the app
@@ -308,6 +309,20 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest)
// rejected call applies none of its fields (#412's guarantee, down to the values
// and nested keys).
let applied = (|| -> std::result::Result<Value, String> {
let shape_stroke = match p.get("shapeStroke") {
Some(v) => {
if let Some(object) = v.as_object() {
let keys = ["width", "opacity", "align", "cap", "join", "miterLimit", "dashes", "dashOffset"];
if let Some(key) = object.keys().find(|key| !keys.contains(&key.as_str())) {
return Err(format!("unknown shapeStroke field `{key}`"));
}
}
let o = &app.ui.tool_options;
let base = o.shape_stroke.stroke(o.stroke_width, photocraft_doc::Fill::Solid(photocraft_doc::Color::BLACK));
Some(photocraft_engine::vector_cmds::parse_stroke(v, Some(base), photocraft_doc::Color::BLACK)?)
}
None => None,
};
let tool = match s("tool") {
Some(t) => Tool::from_name(t).map(Some).ok_or_else(|| format!("unknown tool `{t}`"))?,
None => None,
@@ -431,6 +446,14 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest)
};
// Apply (nothing below can fail).
if let Some(stroke) = shape_stroke {
if let Some(stroke) = stroke {
app.ui.tool_options.stroke_width = stroke.width;
app.ui.tool_options.shape_stroke = crate::shape_stroke_ui::StrokeOptions::from(&stroke);
} else {
app.ui.tool_options.stroke_width = 0.0;
}
}
if let Some(t) = tool {
app.ui.tool = t;
// Each tool keeps its own brush (#218), so switch it in before `brushSize`
@@ -903,6 +926,7 @@ pub fn inspect(app: &PhotocraftApp, ctx: &egui::Context) -> Value {
"window": {"width": screen.width(), "height": screen.height(), "pixelsPerPoint": ctx.pixels_per_point()},
"tool": app.ui.tool,
"toolOptions": app.ui.tool_options,
"strokeEditor": app.ui.stroke_editor,
"magnetic": app.ui.magnetic,
"textEdit": app.ui.text_edit,
"typeTransform": app.ui.type_transform,
@@ -1066,6 +1090,28 @@ mod tests {
}
}
#[test]
fn shape_stroke_defaults_are_drivable_and_validate_atomically() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
let ctx = egui::Context::default();
let result = call(
&mut app,
&ctx,
"ui.set",
json!({"tool":"Triangle", "shapeStroke":{"width":5,"cap":"round","join":"bevel","dashes":[0,2,4,1],"dashOffset":-0.5}}),
);
assert_eq!(result["ok"], true);
assert_eq!(app.ui.tool_options.stroke_width, 5.0);
assert_eq!(app.ui.tool_options.shape_stroke.dashes, [0.0, 2.0, 4.0, 1.0]);
let before = app.ui.clone();
let bad = call(&mut app, &ctx, "ui.set", json!({"tool":"Rectangle","shapeStroke":{"dashes":[0,0]}}));
assert_eq!(bad["ok"], false);
assert_eq!(app.ui, before);
assert_eq!(call(&mut app, &ctx, "ui.set", json!({"shapeStroke":{"colour":"#ff0000"}}))["ok"], false);
assert_eq!(call(&mut app, &ctx, "ui.set", json!({"shapeStroke":null}))["ok"], true);
assert_eq!(app.ui.tool_options.stroke_width, 0.0);
}
#[test]
fn shell_dialogs_are_listed_and_driven_through_ui_dialog() {
// #1004: Window › Workspace and View dialogs live in `ui.shell.dialog`, and ui.inspect and
+21
View File
@@ -2375,6 +2375,27 @@ fontWeight Italic Kurzíva
XOR XOR
Color Burn (Paint.NET) Ztmavit barvy (Paint.NET)
Color Dodge (Paint.NET) Zesvětlit barvy (Paint.NET)
Stroke Options Volby tahu
Solid Plný
Dashed Čárkovaný
Dotted Tečkovaný
Alignment Zarovnání
Caps Konce
Butt / Flat Plochý
Corners Rohy
Miter Ostrý
Round Kulatý
Bevel Zkosený
Miter limit Limit ostrého rohu
Dashed line Čárkovaná čára
Dash Čárka
Gap Mezera
Dash / gap lengths × stroke width Délky čárky / mezery × šířka tahu
Add pair Přidat pár
Remove pair Odebrat pár
Dash offset Posun čárek
Open paths use centered strokes Otevřené cesty používají vystředěné tahy
Use finite lengths and a positive pattern total Použijte konečné délky a kladný součet vzoru
No subject found — try Object Selection or the Lasso Nebyl nalezen žádný objekt — zkuste Výběr objektu nebo Laso
Quick Selection found nothing there Rychlý výběr zde nic nenašel
Object Selection found nothing there — try a tighter rectangle Výběr objektu zde nic nenašel — zkuste těsnější obdélník
Can't render this file because it contains an unexpected character in line 6 and column 47.
+21
View File
@@ -2414,6 +2414,27 @@ fontWeight Italic Kursiv
XOR XOR
Color Burn (Paint.NET) Farbig nachbelichten (Paint.NET)
Color Dodge (Paint.NET) Farbig abwedeln (Paint.NET)
Stroke Options Konturoptionen
Solid Durchgezogen
Dashed Gestrichelt
Dotted Gepunktet
Alignment Ausrichtung
Caps Enden
Butt / Flat Stumpf / Flach
Corners Ecken
Miter Gehrung
Round Rund
Bevel Abgeschrägt
Miter limit Gehrungsgrenze
Dashed line Gestrichelte Linie
Dash Strich
Gap Lücke
Dash / gap lengths × stroke width Strich- / Lückenlängen × Konturbreite
Add pair Paar hinzufügen
Remove pair Paar entfernen
Dash offset Strichversatz
Open paths use centered strokes Offene Pfade verwenden zentrierte Konturen
Use finite lengths and a positive pattern total Endliche Längen und eine positive Mustersumme verwenden
No subject found — try Object Selection or the Lasso Kein Motiv gefunden — versuchen Sie das Objektauswahlwerkzeug oder das Lasso
Quick Selection found nothing there Die Schnellauswahl hat dort nichts gefunden
Object Selection found nothing there — try a tighter rectangle Die Objektauswahl hat dort nichts gefunden — versuchen Sie ein engeres Rechteck
Can't render this file because it contains an unexpected character in line 6 and column 47.
+19
View File
@@ -3486,6 +3486,25 @@ fontWeight Thin Πολύ λεπτά
XOR XOR
Color Burn (Paint.NET) Κάψιμο χρώματος (Paint.NET)
Color Dodge (Paint.NET) Φωτισμός χρώματος (Paint.NET)
Stroke Options Επιλογές περιγράμματος
Solid Συνεχές
Dashed Διακεκομμένο
Dotted Με τελείες
Caps Άκρα
Butt / Flat Επίπεδο
Miter Οξεία
Round Στρογγυλή
Bevel Λοξή
Miter limit Όριο οξείας γωνίας
Dashed line Διακεκομμένη γραμμή
Dash Παύλα
Gap Κενό
Dash / gap lengths × stroke width Μήκη παύλας / κενού × πλάτος περιγράμματος
Add pair Προσθήκη ζεύγους
Remove pair Αφαίρεση ζεύγους
Dash offset Μετατόπιση παύλας
Open paths use centered strokes Οι ανοικτές διαδρομές χρησιμοποιούν κεντραρισμένα περιγράμματα
Use finite lengths and a positive pattern total Χρησιμοποιήστε πεπερασμένα μήκη και θετικό άθροισμα μοτίβου
No subject found — try Object Selection or the Lasso Δεν βρέθηκε θέμα — δοκιμάστε την Επιλογή αντικειμένου ή το Λάσο
Quick Selection found nothing there Η Γρήγορη επιλογή δεν βρήκε τίποτα εκεί
Object Selection found nothing there — try a tighter rectangle Η Επιλογή αντικειμένου δεν βρήκε τίποτα εκεί — δοκιμάστε ένα πιο στενό ορθογώνιο
Can't render this file because it contains an unexpected character in line 966 and column 18.
+21
View File
@@ -2443,6 +2443,27 @@ fontWeight Italic Cursiva
XOR XOR
Color Burn (Paint.NET) Sobreexponer color (Paint.NET)
Color Dodge (Paint.NET) Aclarar color (Paint.NET)
Stroke Options Opciones de trazo
Solid Continuo
Dashed Discontinuo
Dotted Punteado
Alignment Alineación
Caps Extremos
Butt / Flat Plano
Corners Esquinas
Miter Inglete
Round Redondo
Bevel Bisel
Miter limit Límite de inglete
Dashed line Línea discontinua
Dash Guion
Gap Espacio
Dash / gap lengths × stroke width Longitudes de guion / espacio × anchura del trazo
Add pair Añadir par
Remove pair Quitar par
Dash offset Desplazamiento de guiones
Open paths use centered strokes Los trazados abiertos usan trazos centrados
Use finite lengths and a positive pattern total Usa longitudes finitas y un total de patrón positivo
No subject found — try Object Selection or the Lasso No se encontró ningún sujeto — pruebe Selección de objeto o el Lazo
Quick Selection found nothing there Selección rápida no encontró nada ahí
Object Selection found nothing there — try a tighter rectangle Selección de objeto no encontró nada ahí — pruebe un rectángulo más ajustado
Can't render this file because it contains an unexpected character in line 4 and column 47.
+20
View File
@@ -2796,6 +2796,26 @@ fontWeight Italic Italique
XOR XOR
Color Burn (Paint.NET) Densité couleur + (Paint.NET)
Color Dodge (Paint.NET) Densité couleur − (Paint.NET)
Stroke Options Options du contour
Solid Continu
Dashed Tirets
Dotted Pointillé
Caps Extrémités
Butt / Flat Plat
Corners Angles
Miter Pointe
Round Arrondi
Bevel Biseau
Miter limit Limite de pointe
Dashed line Ligne en tirets
Dash Tiret
Gap Espace
Dash / gap lengths × stroke width Longueurs des tirets / espaces × largeur du contour
Add pair Ajouter une paire
Remove pair Retirer une paire
Dash offset Décalage des tirets
Open paths use centered strokes Les tracés ouverts utilisent des contours centrés
Use finite lengths and a positive pattern total Utiliser des longueurs finies et un total de motif positif
No subject found — try Object Selection or the Lasso Aucun sujet trouvé — essayez la Sélection d'objet ou le Lasso
Quick Selection found nothing there La Sélection rapide n'a rien trouvé à cet endroit
Object Selection found nothing there — try a tighter rectangle La Sélection d'objet n'a rien trouvé à cet endroit — essayez un rectangle plus serré
Can't render this file because it contains an unexpected character in line 6 and column 47.
+20
View File
@@ -2783,6 +2783,26 @@ fontWeight Italic Miring
XOR XOR
Color Burn (Paint.NET) Color Burn (Paint.NET)
Color Dodge (Paint.NET) Color Dodge (Paint.NET)
Stroke Options Opsi goresan
Solid Utuh
Dashed Putus-putus
Dotted Titik-titik
Caps Ujung
Butt / Flat Datar
Corners Sudut
Miter Tajam
Round Bulat
Bevel Miring
Miter limit Batas sudut tajam
Dashed line Garis putus-putus
Dash Garis
Gap Celah
Dash / gap lengths × stroke width Panjang garis / celah × lebar goresan
Add pair Tambah pasangan
Remove pair Hapus pasangan
Dash offset Offset garis
Open paths use centered strokes Jalur terbuka menggunakan goresan tengah
Use finite lengths and a positive pattern total Gunakan panjang terbatas dan total pola positif
No subject found — try Object Selection or the Lasso Subjek tidak ditemukan — coba Seleksi Objek atau Laso
Quick Selection found nothing there Seleksi Cepat tidak menemukan apa pun di sana
Object Selection found nothing there — try a tighter rectangle Seleksi Objek tidak menemukan apa pun di sana — coba persegi panjang yang lebih rapat
Can't render this file because it contains an unexpected character in line 4 and column 47.
+21
View File
@@ -2445,6 +2445,27 @@ fontWeight Italic Corsivo
XOR XOR
Color Burn (Paint.NET) Colore brucia (Paint.NET)
Color Dodge (Paint.NET) Colore scherma (Paint.NET)
Stroke Options Opzioni traccia
Solid Continuo
Dashed Tratteggiato
Dotted Punteggiato
Alignment Allineamento
Caps Estremità
Butt / Flat Piatto
Corners Angoli
Miter Spigolo
Round Arrotondato
Bevel Smusso
Miter limit Limite spigolo
Dashed line Linea tratteggiata
Dash Tratto
Gap Spazio
Dash / gap lengths × stroke width Lunghezze tratto / spazio × larghezza traccia
Add pair Aggiungi coppia
Remove pair Rimuovi coppia
Dash offset Scostamento tratteggio
Open paths use centered strokes I tracciati aperti usano tracce centrate
Use finite lengths and a positive pattern total Usa lunghezze finite e un totale del motivo positivo
No subject found — try Object Selection or the Lasso Nessun soggetto trovato — prova Selezione oggetto o il Lazo
Quick Selection found nothing there Selezione rapida non ha trovato nulla lì
Object Selection found nothing there — try a tighter rectangle Selezione oggetto non ha trovato nulla lì — prova un rettangolo più stretto
1 # Italian (it) catalog. Columns, separated by a TAB: context <TAB> source <TAB> translation
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
+19
View File
@@ -3399,6 +3399,25 @@ fontWeight Italic 斜体
XOR XOR
Color Burn (Paint.NET) 焼き込みカラー (Paint.NET)
Color Dodge (Paint.NET) 覆い焼きカラー (Paint.NET)
Stroke Options 線のオプション
Solid 実線
Dashed 破線
Dotted 点線
Caps 線端
Butt / Flat 平端
Miter マイター
Round 丸型
Bevel ベベル
Miter limit マイター制限
Dashed line 破線
Dash 線分
Gap 間隔
Dash / gap lengths × stroke width 線分 / 間隔の長さ × 線幅
Add pair 組を追加
Remove pair 組を削除
Dash offset 破線のオフセット
Open paths use centered strokes 開いたパスの線は中央に配置されます
Use finite lengths and a positive pattern total 有限の長さと正のパターン合計を使用してください
No subject found — try Object Selection or the Lasso 被写体が見つかりません — オブジェクト選択ツールまたはなげなわツールを試してください
Quick Selection found nothing there クイック選択ではそこに何も見つかりませんでした
Object Selection found nothing there — try a tighter rectangle オブジェクト選択ではそこに何も見つかりませんでした — より狭い長方形で試してください
Can't render this file because it contains an unexpected character in line 6 and column 47.
+20
View File
@@ -2954,6 +2954,26 @@ fontWeight Italic 기울임
XOR XOR
Color Burn (Paint.NET) 색상 번 (Paint.NET)
Color Dodge (Paint.NET) 색상 닷지 (Paint.NET)
Stroke Options 획 옵션
Solid 실선
Dashed 파선
Dotted 점선
Alignment 정렬
Caps 끝 모양
Butt / Flat 평평한 끝
Miter 마이터
Round 원형
Bevel 베벨
Miter limit 마이터 한도
Dashed line 파선
Dash 선
Gap 간격
Dash / gap lengths × stroke width 선 / 간격 길이 × 획 너비
Add pair 쌍 추가
Remove pair 쌍 제거
Dash offset 파선 오프셋
Open paths use centered strokes 열린 패스는 가운데 획을 사용합니다
Use finite lengths and a positive pattern total 유한한 길이와 양수 패턴 합계를 사용하세요
No subject found — try Object Selection or the Lasso 피사체를 찾을 수 없습니다 — 개체 선택 또는 올가미를 사용해 보세요
Quick Selection found nothing there 빠른 선택으로 해당 위치에서 아무것도 찾지 못했습니다
Object Selection found nothing there — try a tighter rectangle 개체 선택으로 해당 위치에서 아무것도 찾지 못했습니다 — 더 좁은 사각형을 사용해 보세요
1 # Korean (ko) catalog. UTF-8: context<TAB>English source<TAB>translation.
2954
2955
2956
2957
2958
2959
2960
2961
2962
2963
2964
2965
2966
2967
2968
2969
2970
2971
2972
2973
2974
2975
2976
2977
2978
2979
+19
View File
@@ -3558,6 +3558,25 @@ cameraRaw Whites Witten
Color Dodge (Paint.NET) Kleur tegenhouden (Paint.NET)
Pro Medium Pro Medium
Studio Light Studio Light
Stroke Options Lijnopties
Solid Ononderbroken
Dashed Gestreept
Dotted Gestippeld
Caps Uiteinden
Butt / Flat Plat
Miter Verstek
Round Rond
Bevel Afgeschuind
Miter limit Versteklimiet
Dashed line Gestreepte lijn
Dash Streep
Gap Ruimte
Dash / gap lengths × stroke width Streep- / ruimtelengte × lijnbreedte
Add pair Paar toevoegen
Remove pair Paar verwijderen
Dash offset Streepverschuiving
Open paths use centered strokes Open paden gebruiken gecentreerde lijnen
Use finite lengths and a positive pattern total Gebruik eindige lengtes en een positief patroontotaal
No subject found — try Object Selection or the Lasso Geen onderwerp gevonden — probeer Objectselectie of de Lasso
Quick Selection found nothing there Snelle selectie heeft daar niets gevonden
Object Selection found nothing there — try a tighter rectangle Objectselectie heeft daar niets gevonden — probeer een krappere rechthoek
Can't render this file because it contains an unexpected character in line 8 and column 34.
+19
View File
@@ -3398,6 +3398,25 @@ cameraRaw Roughness Chropowatość
XOR XOR
Color Burn (Paint.NET) Wypalanie koloru (Paint.NET)
Color Dodge (Paint.NET) Rozjaśnianie koloru (Paint.NET)
Stroke Options Opcje obrysu
Solid Ciągły
Dashed Kreskowany
Dotted Kropkowany
Caps Zakończenia
Butt / Flat Płaskie
Miter Ostre
Round Okrągłe
Bevel Ścięte
Miter limit Limit ostrego narożnika
Dashed line Linia kreskowana
Dash Kreska
Gap Odstęp
Dash / gap lengths × stroke width Długości kreski / odstępu × szerokość obrysu
Add pair Dodaj parę
Remove pair Usuń parę
Dash offset Przesunięcie kresek
Open paths use centered strokes Otwarte ścieżki używają wyśrodkowanych obrysów
Use finite lengths and a positive pattern total Użyj skończonych długości i dodatniej sumy wzoru
No subject found — try Object Selection or the Lasso Nie znaleziono obiektu — spróbuj Zaznaczania obiektu lub Lassa
Quick Selection found nothing there Szybkie zaznaczanie nic tam nie znalazło
Object Selection found nothing there — try a tighter rectangle Zaznaczanie obiektu nic tam nie znalazło — spróbuj ciaśniejszego prostokąta
Can't render this file because it contains an unexpected character in line 6 and column 47.
+20
View File
@@ -2804,6 +2804,26 @@ fontWeight Italic Itálico
XOR XOR
Color Burn (Paint.NET) Superexposição de cores (Paint.NET)
Color Dodge (Paint.NET) Subexposição de cores (Paint.NET)
Stroke Options Opções de traçado
Solid Contínuo
Dashed Tracejado
Dotted Pontilhado
Caps Pontas
Butt / Flat Plano
Corners Cantos
Miter Junção em ângulo
Round Redondo
Bevel Chanfrado
Miter limit Limite da junção
Dashed line Linha tracejada
Dash Traço
Gap Espaço
Dash / gap lengths × stroke width Comprimentos de traço / espaço × largura do traçado
Add pair Adicionar par
Remove pair Remover par
Dash offset Deslocamento do tracejado
Open paths use centered strokes Caminhos abertos usam traçados centralizados
Use finite lengths and a positive pattern total Use comprimentos finitos e um total de padrão positivo
No subject found — try Object Selection or the Lasso Nenhum assunto encontrado — tente Seleção de objeto ou o Laço
Quick Selection found nothing there A Seleção rápida não encontrou nada ali
Object Selection found nothing there — try a tighter rectangle A Seleção de objeto não encontrou nada ali — tente um retângulo mais justo
Can't render this file because it contains an unexpected character in line 6 and column 47.
+20
View File
@@ -2651,6 +2651,26 @@ fontWeight Italic Курсив
XOR XOR
Color Burn (Paint.NET) Затемнение основы (Paint.NET)
Color Dodge (Paint.NET) Осветление основы (Paint.NET)
Stroke Options Параметры обводки
Solid Сплошная
Dashed Штриховая
Dotted Пунктирная
Caps Концы
Butt / Flat Плоские
Corners Углы
Miter Острые
Round Круглые
Bevel Скошенные
Miter limit Предел острого угла
Dashed line Штриховая линия
Dash Штрих
Gap Пробел
Dash / gap lengths × stroke width Длины штриха / пробела × ширина обводки
Add pair Добавить пару
Remove pair Удалить пару
Dash offset Смещение штрихов
Open paths use centered strokes Открытые контуры используют обводку по центру
Use finite lengths and a positive pattern total Используйте конечные длины и положительную сумму шаблона
No subject found — try Object Selection or the Lasso Объект не найден — попробуйте «Выделение объектов» или «Лассо»
Quick Selection found nothing there Быстрое выделение ничего здесь не нашло
Object Selection found nothing there — try a tighter rectangle Выделение объектов ничего здесь не нашло — попробуйте более узкий прямоугольник
Can't render this file because it contains an unexpected character in line 6 and column 47.
+19
View File
@@ -3485,6 +3485,25 @@ cameraRaw Masking Маскування
Color Dodge (Paint.NET) Освітлення основи (Paint.NET)
Pro Medium Pro Medium
Studio Light Studio Light
Stroke Options Параметри обведення
Solid Суцільне
Dashed Штрихове
Dotted Пунктирне
Caps Кінці
Butt / Flat Пласкі
Miter Гострі
Round Круглі
Bevel Скошені
Miter limit Межа гострого кута
Dashed line Штрихова лінія
Dash Штрих
Gap Проміжок
Dash / gap lengths × stroke width Довжини штриха / проміжку × ширина обведення
Add pair Додати пару
Remove pair Видалити пару
Dash offset Зсув штрихів
Open paths use centered strokes Відкриті контури використовують обведення по центру
Use finite lengths and a positive pattern total Використовуйте скінченні довжини й додатну суму візерунка
No subject found — try Object Selection or the Lasso Об'єкт не знайдено — спробуйте «Виділення об'єктів» або «Ласо»
Quick Selection found nothing there Швидке виділення нічого тут не знайшло
Object Selection found nothing there — try a tighter rectangle Виділення об'єктів нічого тут не знайшло — спробуйте вужчий прямокутник
Can't render this file because it contains an unexpected character in line 1312 and column 18.
+21
View File
@@ -2383,6 +2383,27 @@ fontWeight Italic 斜体
XOR XOR
Color Burn (Paint.NET) 颜色加深 (Paint.NET)
Color Dodge (Paint.NET) 颜色减淡 (Paint.NET)
Stroke Options 描边选项
Solid 实线
Dashed 虚线
Dotted 点线
Alignment 对齐
Caps 端点
Butt / Flat 平头
Corners 拐角
Miter 斜接
Round 圆角
Bevel 斜角
Miter limit 斜接限制
Dashed line 虚线
Dash 线段
Gap 间隔
Dash / gap lengths × stroke width 线段 / 间隔长度 × 描边宽度
Add pair 添加一对
Remove pair 移除一对
Dash offset 虚线偏移
Open paths use centered strokes 开放路径使用居中描边
Use finite lengths and a positive pattern total 请使用有限长度和正数图案总长度
No subject found — try Object Selection or the Lasso 未找到主体 — 请尝试对象选择工具或套索工具
Quick Selection found nothing there 快速选择在该处未找到任何内容
Object Selection found nothing there — try a tighter rectangle 对象选择在该处未找到任何内容 — 请尝试更紧凑的矩形
1 # Simplified Chinese (zh-hans) catalog. UTF-8, TAB-separated columns:
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
+21
View File
@@ -2352,6 +2352,27 @@ fontWeight Italic 斜體
XOR XOR
Color Burn (Paint.NET) 加深顏色 (Paint.NET)
Color Dodge (Paint.NET) 加亮顏色 (Paint.NET)
Stroke Options 筆畫選項
Solid 實線
Dashed 虛線
Dotted 點線
Alignment 對齊
Caps 端點
Butt / Flat 平頭
Corners 轉角
Miter 斜接
Round 圓角
Bevel 斜角
Miter limit 斜接限制
Dashed line 虛線
Dash 線段
Gap 間隔
Dash / gap lengths × stroke width 線段 / 間隔長度 × 筆畫寬度
Add pair 新增一對
Remove pair 移除一對
Dash offset 虛線偏移
Open paths use centered strokes 開放路徑使用置中筆畫
Use finite lengths and a positive pattern total 請使用有限長度和正數圖樣總長度
No subject found — try Object Selection or the Lasso 找不到主體 — 請嘗試物件選取工具或套索工具
Quick Selection found nothing there 快速選取在該處找不到任何內容
Object Selection found nothing there — try a tighter rectangle 物件選取在該處找不到任何內容 — 請嘗試更緊密的矩形
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3
View File
@@ -134,6 +134,7 @@ pub mod screen_picker;
pub mod scrollbars;
pub mod served_fonts;
pub mod shape_dialog;
pub mod shape_stroke_ui;
pub mod shortcut_dispatch;
pub mod shortcuts;
mod sizing;
@@ -390,6 +391,7 @@ pub struct PhotocraftApp {
pub(crate) blend_preview: Option<blend_preview::BlendPreview>,
/// Patch Tool drag: the healed document at the pointer (`patch_preview`).
pub(crate) patch_preview: Option<patch_preview::PatchPreview>,
pub(crate) shape_stroke_preview: Option<shape_stroke_ui::ShapeStrokePreview>,
/// The pixels a Magnetic Lasso border follows (`magnetic_lasso_ui`).
pub(crate) magnetic: magnetic_lasso_ui::Runtime,
/// The next tool `Down` is a right-button drag that erases (see `paint_mouse`).
@@ -584,6 +586,7 @@ impl PhotocraftApp {
move_preview: None,
blend_preview: None,
patch_preview: None,
shape_stroke_preview: None,
magnetic: Default::default(),
secondary_erase: false,
defer_live_stroke: false,
+34
View File
@@ -98,6 +98,40 @@ fn drags_still_draw_and_tools_without_a_dialog_ignore_clicks() {
assert_eq!(layers(&app), before + 1);
}
#[test]
fn click_dialogs_and_drags_keep_complete_stroke_defaults() {
let style = crate::shape_stroke_ui::StrokeOptions {
align: photocraft_doc::StrokeAlign::Outside,
cap: photocraft_doc::LineCap::Square,
join: photocraft_doc::LineJoin::Bevel,
miter_limit: 7.0,
dashes: vec![3.0, 1.0, 0.0, 2.0],
dash_offset: -0.75,
opacity: 0.6,
};
for tool in [Tool::Rectangle, Tool::EllipseShape, Tool::Triangle, Tool::Polygon] {
for click in [false, true] {
let mut app = app();
app.ui.tool_options.stroke_width = 7.0;
app.ui.tool_options.shape_stroke = style.clone();
let history = app.session.active().unwrap().history.past_len();
press(&mut app, tool, [20.0, 30.0], if click { [20.0, 30.0] } else { [120.0, 130.0] });
if click {
let id = only_dialog(&app);
crate::dialogs::confirm(&mut app, id).unwrap();
} else {
assert!(app.ui.dialogs.is_empty());
}
let st = app.session.active().unwrap();
let LayerContent::Shape(sh) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!("expected shape") };
let stroke = sh.stroke.as_ref().unwrap();
assert_eq!(crate::shape_stroke_ui::StrokeOptions::from(stroke), style, "{tool:?}, click={click}");
assert_eq!(stroke.width, 7.0);
assert_eq!(st.history.past_len(), history + 1);
}
}
}
#[test]
fn click_slop_scales_with_zoom() {
let pts = |d: f64| vec![[10.0, 10.0, 1.0], [10.0 + d, 10.0, 1.0]];
+479
View File
@@ -0,0 +1,479 @@
//! Shape stroke geometry controls shared by the tool defaults and Properties.
use egui::{Sense, Stroke, vec2};
use photocraft_doc::{LineCap, LineJoin, ShapeStroke, StrokeAlign};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::theme::Tokens;
/// Colour and width stay in their existing controls. These options never replace a shape's paint.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct StrokeOptions {
pub align: StrokeAlign,
pub cap: LineCap,
pub join: LineJoin,
pub miter_limit: f32,
pub dashes: Vec<f32>,
pub dash_offset: f32,
pub opacity: f32,
}
impl Default for StrokeOptions {
fn default() -> Self {
Self::from(&ShapeStroke::default())
}
}
impl From<&ShapeStroke> for StrokeOptions {
fn from(s: &ShapeStroke) -> Self {
Self { align: s.align, cap: s.cap, join: s.join, miter_limit: s.miter_limit, dashes: s.dashes.clone(), dash_offset: s.dash_offset, opacity: s.opacity }
}
}
impl StrokeOptions {
pub fn params(&self) -> Value {
json!({"align": format!("{:?}", self.align).to_ascii_lowercase(),
"cap": format!("{:?}", self.cap).to_ascii_lowercase(), "join": format!("{:?}", self.join).to_ascii_lowercase(),
"miterLimit": self.miter_limit, "dashes": self.dashes, "dashOffset": self.dash_offset, "opacity": self.opacity * 100.0})
}
pub fn stroke(&self, width: f32, paint: photocraft_doc::Fill) -> ShapeStroke {
ShapeStroke {
width,
paint,
align: self.align,
cap: self.cap,
join: self.join,
miter_limit: self.miter_limit,
dashes: self.dashes.clone(),
dash_offset: self.dash_offset,
opacity: self.opacity,
}
}
fn label(&self) -> &'static str {
if self.dashes.is_empty() {
"Solid"
} else if self.dashes == [4.0, 2.0] {
"Dashed"
} else if self.dashes == [0.0, 2.0] && self.cap == LineCap::Round {
"Dotted"
} else {
"Custom"
}
}
fn preset(&mut self, name: &str) {
self.dash_offset = 0.0;
self.dashes = match name {
"Dashed" => vec![4.0, 2.0],
"Dotted" => {
self.cap = LineCap::Round;
vec![0.0, 2.0]
}
_ => Vec::new(),
};
}
fn valid(&self) -> bool {
self.stroke(3.0, photocraft_doc::Fill::Solid(photocraft_doc::Color::BLACK)).validate().is_ok()
}
}
#[derive(Clone)]
struct DragCache {
shape: photocraft_doc::ShapeLayer,
clip: egui::Rect,
image_rect: egui::Rect,
texture: egui::TextureHandle,
}
/// The drag uses the committed vector rasterizer. Cache by geometry/style/visible region, and
/// cap proxy resolution so oversized windows cannot allocate a document-sized preview.
pub fn paint_shape_preview(painter: &egui::Painter, shape: &photocraft_doc::ShapeLayer) {
let clip = painter.clip_rect();
if !clip.is_finite() || clip.is_negative() {
return;
}
let id = egui::Id::new("shape-drag-render");
let cached = painter.ctx().data(|d| d.get_temp::<DragCache>(id));
let cache = cached.filter(|c| c.shape == *shape && c.clip == clip).or_else(|| {
let scale = (2048.0 / clip.width().max(clip.height()).max(1.0)).min(1.0);
let mut proxy = shape.clone();
let tr = photocraft_geom::Affine { m: [scale as f64, 0.0, 0.0, scale as f64, -(clip.left() * scale) as f64, -(clip.top() * scale) as f64] };
proxy.path = proxy.path.transform(&tr);
if let Some(stroke) = proxy.stroke.as_mut() {
stroke.width *= scale;
}
let canvas = photocraft_geom::Rect::from_size(photocraft_geom::Size::new((clip.width() * scale).ceil() as u32, (clip.height() * scale).ceil() as u32));
let compiled = photocraft_vector::CompiledShape::new(&proxy, photocraft_vector::DEFAULT_TOLERANCE, canvas);
let bounds = compiled.bounds()?.intersect(&canvas);
if bounds.is_empty() {
return None;
}
let rgba = compiled.render_rgba(bounds);
let bytes: Vec<u8> = rgba.iter().flat_map(|p| p.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)).collect();
let texture = painter.ctx().load_texture(
"shape-drag",
egui::ColorImage::from_rgba_unmultiplied([bounds.width() as usize, bounds.height() as usize], &bytes),
egui::TextureOptions::LINEAR,
);
let screen = |x: i32, y: i32| clip.min + vec2(x as f32, y as f32) / scale;
Some(DragCache {
shape: shape.clone(),
clip,
image_rect: egui::Rect::from_min_max(screen(bounds.x0, bounds.y0), screen(bounds.x1, bounds.y1)),
texture,
})
});
if let Some(cache) = cache {
painter.image(cache.texture.id(), cache.image_rect, egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), egui::Color32::WHITE);
painter.ctx().data_mut(|d| d.insert_temp(id, cache));
}
}
/// Transient editor content is still plain serde state, visible to inspection.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct StrokeEditor {
pub key: String,
pub draft: StrokeOptions,
pub original: StrokeOptions,
pub target: Option<(photocraft_doc::DocId, photocraft_doc::LayerId)>,
}
pub(crate) struct ShapeStrokePreview {
doc: photocraft_doc::DocId,
revision: u64,
target: photocraft_doc::LayerId,
options: StrokeOptions,
shown: std::sync::Arc<photocraft_doc::Document>,
key: u64,
}
/// Cache the selected shape preview by revision and draft. Cancel simply drops the snapshot.
pub(crate) fn display_doc(app: &mut crate::PhotocraftApp, idx: usize) -> Option<(std::sync::Arc<photocraft_doc::Document>, u64)> {
let active = app.session.active_index() == Some(idx);
if !active {
return None;
}
let editor = app.ui.stroke_editor.as_ref().filter(|e| active && e.target.is_some() && e.draft != e.original && e.draft.valid());
let Some(editor) = editor else {
app.shape_stroke_preview = None;
return None;
};
let (doc, target) = editor.target?;
let st = app.session.documents().get(idx)?;
let current = st.doc.layer(target).and_then(|l| match &l.content {
photocraft_doc::LayerContent::Shape(sh) => sh.stroke.as_ref().map(StrokeOptions::from),
_ => None,
});
let locks = st.doc.effective_locks(target);
if st.doc.id != doc || st.active_layer != Some(target) || locks.all || locks.pixels || current.as_ref() != Some(&editor.original) {
app.shape_stroke_preview = None;
app.ui.stroke_editor = None;
return None;
}
let fresh =
app.shape_stroke_preview.as_ref().is_some_and(|p| p.doc == st.doc.id && p.revision == st.revision && p.target == target && p.options == editor.draft);
if !fresh {
let shown = photocraft_engine::vector_cmds::preview_shape_stroke(&st.doc, target, &editor.draft.params()).ok()?;
// Hash all cache inputs so returning to an earlier draft is deterministic, and a
// reopened editor cannot alias a different document revision's GPU output.
use std::hash::{Hash, Hasher};
let mut hash = std::collections::hash_map::DefaultHasher::new();
(st.doc.id, st.revision, target, editor.key.as_str(), editor.draft.params().to_string()).hash(&mut hash);
let key = (1u64 << 60) | (hash.finish() & ((1u64 << 60) - 1));
app.shape_stroke_preview =
Some(ShapeStrokePreview { doc: st.doc.id, revision: st.revision, target, options: editor.draft.clone(), shown: std::sync::Arc::new(shown), key });
}
app.shape_stroke_preview.as_ref().map(|p| (p.shown.clone(), p.key))
}
/// Staged editor: outside click/Escape cancels; Apply returns one complete command patch.
pub fn button(
ui: &mut egui::Ui,
key: &str,
current: &StrokeOptions,
editor: &mut Option<StrokeEditor>,
closed: bool,
corners: bool,
target: Option<(photocraft_doc::DocId, photocraft_doc::LayerId)>,
) -> Option<StrokeOptions> {
let response = ui.add_sized(vec2(96.0, 22.0), egui::Button::new(tl!(current.label()))).on_hover_text(tl!("Stroke Options"));
let c = response.rect.right_center() - vec2(10.0, 0.0);
ui.painter().add(egui::Shape::line(vec![c - vec2(3.0, 1.5), c + vec2(0.0, 1.5), c + vec2(3.0, -1.5)], Stroke::new(1.0, Tokens::get(ui.ctx()).text_dim)));
if response.clicked() {
let draft = if current.valid() { current.clone() } else { StrokeOptions::default() };
*editor = Some(StrokeEditor { key: key.into(), draft, original: current.clone(), target });
}
let mut result = None;
let mut dismissed = false;
egui::Popup::from_toggle_button_response(&response).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
let Some(e) = editor.as_mut().filter(|e| e.key == key && e.original == *current && e.target == target) else {
ui.close();
return;
};
let s = &mut e.draft;
ui.set_width(260.0);
ui.label(egui::RichText::new(tl!("Stroke Options")).strong());
let body_height = (ui.ctx().content_rect().height() - 150.0).clamp(120.0, 520.0);
// Keep the footer stable when presets add/remove dash fields or a scroll bar appears.
egui::ScrollArea::vertical().max_height(body_height).min_scrolled_height(body_height).auto_shrink([false, false]).show(ui, |ui| {
for name in ["Solid", "Dashed", "Dotted"] {
let mut sample = s.clone();
sample.preset(name);
ui.horizontal(|ui| {
if ui.selectable_label(s.label() == name, tl!(name)).clicked() {
s.preset(name);
}
preview(ui, &sample, 154.0, false);
});
}
ui.separator();
ui.add_enabled_ui(closed, |ui| {
choice(ui, "Alignment", &mut s.align, &[(StrokeAlign::Inside, "Inside"), (StrokeAlign::Center, "Center"), (StrokeAlign::Outside, "Outside")]);
});
if !closed {
ui.label(egui::RichText::new(tl!("Open paths use centered strokes")).small());
}
ui.add_enabled_ui(!closed || !s.dashes.is_empty(), |ui| {
choice(ui, "Caps", &mut s.cap, &[(LineCap::Butt, "Butt / Flat"), (LineCap::Round, "Round"), (LineCap::Square, "Square")]);
});
ui.add_enabled_ui(corners, |ui| {
choice(ui, "Corners", &mut s.join, &[(LineJoin::Miter, "Miter"), (LineJoin::Round, "Round"), (LineJoin::Bevel, "Bevel")]);
});
ui.add_enabled_ui(corners && s.join == LineJoin::Miter, |ui| {
number(ui, "Miter limit", &mut s.miter_limit, 1.0..=500.0, "×");
});
let mut opacity = s.opacity * 100.0;
number(ui, "Opacity", &mut opacity, 0.0..=100.0, "%");
s.opacity = opacity / 100.0;
ui.separator();
let mut dashed = !s.dashes.is_empty();
if ui.checkbox(&mut dashed, tl!("Dashed line")).changed() {
s.preset(if dashed { "Dashed" } else { "Solid" });
}
if dashed {
ui.label(egui::RichText::new(tl!("Dash / gap lengths × stroke width")).small());
for (i, pair) in s.dashes.chunks_mut(2).enumerate() {
ui.push_id(i, |ui| {
ui.horizontal(|ui| {
for (j, value) in pair.iter_mut().enumerate() {
ui.label(tl!(if j == 0 { "Dash" } else { "Gap" }));
crate::widgets::value_field(ui, value, 0.0..=10000.0, "×", 68.0);
}
});
});
}
ui.horizontal(|ui| {
if ui.add_enabled(s.dashes.len() < 32, egui::Button::new(tl!("Add pair"))).clicked() {
if s.dashes.len() % 2 == 1 {
s.dashes.push(2.0);
} else {
s.dashes.extend([4.0, 2.0]);
}
}
if ui.add_enabled(s.dashes.len() > 2, egui::Button::new(tl!("Remove pair"))).clicked() {
s.dashes.truncate(s.dashes.len().saturating_sub(2));
}
});
number(ui, "Dash offset", &mut s.dash_offset, -1_000_000.0..=1_000_000.0, "×");
}
preview(ui, s, 260.0, true);
});
let valid = s.valid();
if !valid {
ui.colored_label(Tokens::get(ui.ctx()).text_dim, tl!("Use finite lengths and a positive pattern total"));
}
ui.horizontal(|ui| {
if ui.add_enabled(valid, egui::Button::new(tl!("Apply"))).clicked() {
result = Some(s.clone());
dismissed = true;
ui.close();
}
if ui.button(tl!("Cancel")).clicked() {
dismissed = true;
ui.close();
}
});
});
if dismissed {
egui::Popup::close_id(ui.ctx(), egui::Popup::default_response_id(&response));
*editor = None;
}
if editor.as_ref().is_some_and(|e| e.key == key) && !egui::Popup::is_id_open(ui.ctx(), egui::Popup::default_response_id(&response)) {
*editor = None;
}
result
}
fn choice<T: Copy + PartialEq>(ui: &mut egui::Ui, label: &str, value: &mut T, choices: &[(T, &str)]) {
ui.horizontal(|ui| {
ui.label(tl!(label));
egui::ComboBox::from_id_salt(label).width(140.0).selected_text(choices.iter().find(|(v, _)| v == value).map_or("", |(_, n)| tl!(*n))).show_ui(
ui,
|ui| {
for (v, n) in choices {
ui.selectable_value(value, *v, tl!(*n));
}
},
);
});
}
fn number(ui: &mut egui::Ui, label: &str, value: &mut f32, range: std::ops::RangeInclusive<f32>, unit: &str) {
ui.horizontal(|ui| {
ui.label(tl!(label));
crate::widgets::value_field(ui, value, range, unit, 78.0);
});
}
#[derive(Clone)]
struct SampleCache {
options: StrokeOptions,
width: usize,
closed: bool,
color: egui::Color32,
texture: egui::TextureHandle,
}
/// A tiny cached raster sample uses the actual vector renderer, including AA, opacity and joins.
fn preview(ui: &mut egui::Ui, options: &StrokeOptions, width: f32, corners: bool) {
let t = Tokens::get(ui.ctx());
let height = if corners { 68.0 } else { 22.0 };
let (rect, response) = ui.allocate_exact_size(vec2(width, height), Sense::hover());
ui.painter().rect_filled(rect, t.radius_sm, t.field);
let id = response.id.with("stroke-preview");
let w = width as usize;
let mut sample = options.clone();
if !corners {
sample.align = StrokeAlign::Center;
}
let cached = ui.data(|d| d.get_temp::<SampleCache>(id));
let cache = cached.filter(|c| c.options == sample && c.width == w && c.closed == corners && c.color == t.text).unwrap_or_else(|| {
let path = if corners {
photocraft_vector::shapes::rounded_rect(16.0, 16.0, width as f64 - 32.0, 36.0, [0.0; 4])
} else {
let mut sp = photocraft_doc::Subpath::polygon(&[(12.0, 11.0), (width as f64 - 12.0, 11.0)]);
sp.closed = false;
photocraft_doc::Path::new(vec![sp])
};
let c = t.text;
let shape =
photocraft_doc::ShapeLayer {
path,
stroke: Some(sample.stroke(
3.0,
photocraft_doc::Fill::Solid(photocraft_doc::Color::rgba(c.r() as f32 / 255.0, c.g() as f32 / 255.0, c.b() as f32 / 255.0, 1.0)),
)),
..Default::default()
};
let r = photocraft_geom::Rect::from_size(photocraft_geom::Size::new(w as u32, height as u32));
let pixels = photocraft_vector::CompiledShape::new(&shape, photocraft_vector::DEFAULT_TOLERANCE, r).render_rgba(r);
let bytes: Vec<u8> = pixels.iter().flat_map(|p| p.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)).collect();
let texture =
ui.ctx().load_texture("stroke-preview", egui::ColorImage::from_rgba_unmultiplied([w, height as usize], &bytes), egui::TextureOptions::LINEAR);
SampleCache { options: sample, width: w, closed: corners, color: t.text, texture }
});
ui.painter().image(cache.texture.id(), rect, egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), egui::Color32::WHITE);
ui.painter().rect_stroke(rect, t.radius_sm, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside);
ui.data_mut(|d| d.insert_temp(id, cache));
}
#[cfg(test)]
mod tests {
use super::*;
use egui_kittest::{Harness, kittest::Queryable};
struct State {
current: StrokeOptions,
editor: Option<StrokeEditor>,
applied: usize,
}
fn harness() -> Harness<'static, State> {
Harness::builder().with_size(vec2(500.0, 800.0)).build_ui_state(
|ui, s: &mut State| {
if let Some(next) = button(ui, "test", &s.current, &mut s.editor, true, true, None) {
s.current = next;
s.applied += 1;
}
},
State { current: Default::default(), editor: None, applied: 0 },
)
}
#[test]
fn preset_preview_is_staged_cancelled_or_applied_once() {
let mut h = harness();
h.run();
h.get_by_label("Solid").click();
h.run();
h.get_by_label("Dotted").click();
h.run_steps(3);
assert!(h.state().current.dashes.is_empty());
assert_eq!(h.state().editor.as_ref().unwrap().draft.dashes, [0.0, 2.0]);
let cancel_rect = h.get_by_label("Cancel").rect();
h.get_by_label("Cancel").click();
h.run_steps(3);
assert!(h.state().editor.is_none(), "Cancel at {cancel_rect:?}; remaining: {:?}", h.query_all_by_label("Cancel").map(|n| n.rect()).collect::<Vec<_>>());
assert_eq!(h.state().applied, 0);
h.get_by_label("Solid").click();
h.run();
h.get_by_label("Dotted").click();
h.run_steps(3);
h.get_by_label("Apply").click();
h.run();
assert_eq!(h.state().current.cap, LineCap::Round);
assert_eq!(h.state().current.dashes, [0.0, 2.0]);
assert_eq!(h.state().applied, 1);
}
#[test]
fn options_serde_defaults_keep_old_tool_settings_and_full_stroke() {
assert_eq!(serde_json::from_value::<StrokeOptions>(json!({})).unwrap(), StrokeOptions::default());
let mut style = StrokeOptions::default();
style.preset("Dotted");
style.dashes = vec![0.0, 2.0, 4.0, 1.0];
style.dash_offset = -0.5;
let stroke = style.stroke(5.0, photocraft_doc::Fill::Solid(photocraft_doc::Color::BLACK));
assert_eq!(StrokeOptions::from(&stroke), style);
assert_eq!(serde_json::from_value::<StrokeOptions>(serde_json::to_value(&style).unwrap()).unwrap(), style);
}
#[test]
fn canvas_preview_is_cached_and_matches_commit_at_all_depths() {
for depth in [8, 16, 32] {
let mut app = crate::PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width":80,"height":60,"depth":depth})).unwrap();
let id = app.run("shape.create", json!({"rect":[15,15,45,30],"stroke":{"width":5,"color":"#ff0000"}})).unwrap()["layer"].as_u64().unwrap();
let target = photocraft_doc::LayerId(id);
let original = StrokeOptions::default();
let mut draft = original.clone();
draft.preset("Dotted");
draft.join = LineJoin::Round;
draft.opacity = 0.5;
let committed = app.session.active().unwrap().doc.clone();
let count = app.session.active().unwrap().history.past_len();
app.ui.stroke_editor = Some(StrokeEditor { key: "test".into(), original, draft: draft.clone(), target: Some((committed.id, target)) });
let idx = app.session.active_index().unwrap();
let first = display_doc(&mut app, idx).unwrap();
let next = display_doc(&mut app, idx).unwrap();
assert!(std::sync::Arc::ptr_eq(&first.0, &next.0));
assert_eq!(first.1, next.1);
assert_eq!(app.session.active().unwrap().doc, committed);
assert_eq!(app.session.active().unwrap().history.past_len(), count);
app.ui.stroke_editor = None;
assert!(display_doc(&mut app, idx).is_none());
app.run("shape.edit", json!({"layer":id,"stroke":draft.params()})).unwrap();
assert_eq!(app.session.active().unwrap().doc, first.0);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.active().unwrap().doc, committed);
app.ui.stroke_editor = Some(StrokeEditor { key: "test".into(), original: StrokeOptions::default(), draft, target: Some((committed.id, target)) });
app.run("file.new", json!({"width":80,"height":60,"depth":depth})).unwrap();
let other = app.session.active_index().unwrap();
assert!(display_doc(&mut app, other).is_none());
assert!(app.ui.stroke_editor.is_none());
}
}
}
+6
View File
@@ -505,6 +505,8 @@ pub struct ToolOptions {
/// radius, polygon sides, line weight.
pub shape_fill: bool,
pub stroke_width: f32,
#[serde(default)]
pub shape_stroke: crate::shape_stroke_ui::StrokeOptions,
pub corner_radius: f32,
pub polygon_sides: u32,
pub line_weight: f32,
@@ -659,6 +661,7 @@ impl Default for ToolOptions {
vector_mode: "path".into(),
shape_fill: true,
stroke_width: 0.0,
shape_stroke: Default::default(),
corner_radius: 0.0,
polygon_sides: 5,
line_weight: 3.0,
@@ -897,6 +900,8 @@ pub struct UiState {
/// Pen path under construction.
#[serde(default)]
pub pen: Option<crate::vector_ui::PenPath>,
#[serde(default)]
pub stroke_editor: Option<crate::shape_stroke_ui::StrokeEditor>,
/// Direct Selection tool: selected anchors and the drag in progress (#790).
#[serde(default)]
pub direct_selection: crate::direct_select::DirectSelection,
@@ -1036,6 +1041,7 @@ impl Default for UiState {
shell: Default::default(),
layer_filter: Vec::new(),
pen: None,
stroke_editor: None,
direct_selection: Default::default(),
selected_path: None,
panels: Panels::default(),
+117 -24
View File
@@ -44,7 +44,13 @@ fn hex(c: [f32; 4]) -> String {
fn stroke_param(app: &PhotocraftApp) -> Value {
let o = &app.ui.tool_options;
if o.stroke_width > 0.0 { json!({"width": o.stroke_width, "color": hex(app.session.tools.background)}) } else { Value::Null }
if o.stroke_width <= 0.0 {
return Value::Null;
}
let mut p = o.shape_stroke.params();
p["width"] = json!(o.stroke_width);
p["color"] = json!(hex(app.session.tools.background));
p
}
/// Geometry of a Shape-tool drag as `shape.create` params, without fill and stroke (None when too
@@ -113,19 +119,40 @@ pub fn create_shape(app: &mut PhotocraftApp, mut geometry: Value) -> Result<Valu
app.run("shape.create", geometry)
}
/// Shape-tool drag preview: the shape `finish_shape` will create, filled and stroked, under its
/// path outline. Custom shapes show their box outline only.
/// Shape-tool drag preview using the same path and vector stroke geometry as the commit.
pub fn draw_shape_preview(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, tool: Tool, start: [f64; 2], end: [f64; 2], mods: egui::Modifiers) {
let o = &app.ui.tool_options;
let Some(path) = shape_geometry(o, tool, start, end, mods).and_then(|p| photocraft_engine::vector_cmds::shape_path(&p).ok()) else { return };
let custom = tool == Tool::CustomShape;
let Some(geometry) = shape_geometry(o, tool, start, end, mods) else { return };
let path = if tool == Tool::CustomShape {
let Some(rect) = geometry.get("rect").and_then(|r| serde_json::from_value::<[f64; 4]>(r.clone()).ok()) else { return };
let groups = photocraft_engine::presets::shapes::all_groups(&app.session);
let Some(shape) = groups.iter().flat_map(|g| &g.items).find(|s| s.name == app.ui.presets_ui.shape()) else { return };
photocraft_engine::presets::shapes::fit(&shape.path, rect, mods.shift)
} else {
let Ok(path) = photocraft_engine::vector_cmds::shape_path(&geometry) else { return };
path
};
// ponytail: preview colours skip the canvas's colour management; the commit renders them exactly.
let fill = if o.shape_fill && !custom { rgb32(app.session.tools.foreground) } else { Color32::TRANSPARENT };
let stroke = if o.stroke_width > 0.0 && !custom { Stroke::new(o.stroke_width * xf.zoom, rgb32(app.session.tools.background)) } else { Stroke::NONE };
let fill = if o.shape_fill { rgb32(app.session.tools.foreground) } else { Color32::TRANSPARENT };
{
let origin = xf.to_screen(0.0, 0.0);
let x = xf.to_screen(1.0, 0.0) - origin;
let y = xf.to_screen(0.0, 1.0) - origin;
let transform = Affine { m: [x.x as f64, x.y as f64, y.x as f64, y.y as f64, origin.x as f64, origin.y as f64] };
let c = app.session.tools.background;
let shape = photocraft_doc::ShapeLayer {
path: path.transform(&transform),
fill: (fill != Color32::TRANSPARENT).then(|| {
photocraft_doc::Fill::Solid(photocraft_doc::Color::rgba(fill.r() as f32 / 255.0, fill.g() as f32 / 255.0, fill.b() as f32 / 255.0, 1.0))
}),
stroke: (o.stroke_width > 0.0)
.then(|| o.shape_stroke.stroke(o.stroke_width * xf.zoom, photocraft_doc::Fill::Solid(photocraft_doc::Color::rgba(c[0], c[1], c[2], c[3])))),
..Default::default()
};
crate::shape_stroke_ui::paint_shape_preview(painter, &shape);
}
let accent = Tokens::get(painter.ctx()).accent;
for (pts, _) in path_lines(&path, &|q| xf.to_screen(q[0] as f32, q[1] as f32)) {
// Every tool's own shape is convex (custom shapes aren't, and draw no fill).
painter.add(egui::Shape::convex_polygon(pts.clone(), fill, stroke));
// The accent path over a dark halo: visible on any pixels (#172).
painter.extend(crate::tool_feedback::contrast_path(pts, true, accent));
}
@@ -216,8 +243,14 @@ pub fn pen_commit(app: &mut PhotocraftApp, closed: bool) {
let path = pen_to_json(&pen, closed);
let r = if app.ui.tool_options.vector_mode == "shape" {
let fill = if closed && app.ui.tool_options.shape_fill { json!(hex(app.session.tools.foreground)) } else { Value::Null };
let stroke =
if closed { stroke_param(app) } else { json!({"width": app.ui.tool_options.stroke_width.max(1.0), "color": hex(app.session.tools.foreground)}) };
let stroke = if closed {
stroke_param(app)
} else {
let mut stroke = app.ui.tool_options.shape_stroke.params();
stroke["width"] = json!(app.ui.tool_options.stroke_width.max(1.0));
stroke["color"] = json!(hex(app.session.tools.foreground));
stroke
};
app.run("shape.create", json!({"kind": "path", "path": path, "fill": fill, "stroke": stroke}))
} else if let Some((id, existing)) = targeted_vector_mask(app) {
// A targeted vector mask takes the new subpath (#196), as in Photoshop.
@@ -529,6 +562,12 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo
let opts = [("path".to_string(), tl!("Path")), ("shape".to_string(), tl!("Shape"))];
crate::widgets::dropdown(ui, "pen-mode", &mut o.vector_mode, &opts, 80.0);
crate::widgets::vline(ui, 22.0);
if o.vector_mode == "shape" {
crate::widgets::value_field(ui, &mut o.stroke_width, 0.0..=288.0, "px", 58.0);
if let Some(style) = crate::shape_stroke_ui::button(ui, "pen-stroke", &o.shape_stroke, &mut app.ui.stroke_editor, false, true, None) {
o.shape_stroke = style;
}
}
lbl(
ui,
&crate::i18n::fmt(
@@ -547,7 +586,12 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo
ui.painter().rect_filled(r, 2.0, rgb32(app.session.tools.foreground));
ui.painter().rect_stroke(r, 2.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Outside);
lbl(ui, tl!("Stroke:"));
let bg = app.session.tools.background;
let stroke_color = swatch(ui, Some(&photocraft_doc::Fill::Solid(photocraft_doc::Color::rgba(bg[0], bg[1], bg[2], bg[3]))), tl!("Stroke color"));
crate::widgets::value_field(ui, &mut o.stroke_width, 0.0..=288.0, "px", 58.0);
if let Some(style) = crate::shape_stroke_ui::button(ui, "tool-stroke", &o.shape_stroke, &mut app.ui.stroke_editor, true, tool != Tool::EllipseShape, None) {
o.shape_stroke = style;
}
match tool {
Tool::Rectangle => {
crate::widgets::vline(ui, 22.0);
@@ -573,6 +617,14 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo
}
_ => {}
}
if let Some(color) = stroke_color {
if color == "none" {
app.ui.tool_options.stroke_width = 0.0;
} else if let Err(e) = app.run("tools.setColors", json!({"background": color})) {
app.ui.status = e;
app.ui.status_error = true;
}
}
true
}
@@ -661,6 +713,12 @@ fn corner_radii_patch(radii: [f64; 4], corner: usize, radius: f32) -> Value {
/// Properties panel for a shape layer: Appearance (fill, stroke) and live shape geometry.
pub fn shape_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: photocraft_doc::LayerId) {
if app.session.active().is_some_and(|st| {
let locks = st.doc.effective_locks(id);
locks.all || locks.pixels
}) {
ui.disable();
}
let Some(sh) = app.session.active().and_then(|s| s.doc.layer(id)).and_then(|l| match &l.content {
LayerContent::Shape(sh) => Some(sh.clone()),
_ => None,
@@ -669,7 +727,8 @@ pub fn shape_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: photocra
};
let t = Tokens::get(ui.ctx());
let mut edit: Option<Value> = None;
let key = |k: &str| format!("shape-{}-{k}", id.0);
let doc_id = app.session.active().map(|st| st.doc.id);
let key = |k: &str| format!("shape-{doc_id:?}-{}-{k}", id.0);
// The shared collapsible section headers (#155).
if crate::props_layout::section(ui, "appearance", tl!("Appearance")) {
ui.horizontal(|ui| {
@@ -690,19 +749,21 @@ pub fn shape_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: photocra
if crate::widgets::value_field(ui, &mut w, 0.0..=288.0, "px", 60.0).changed() {
edit = Some(if w <= 0.0 { json!({"stroke": null}) } else { json!({"stroke": {"width": w}, "coalesce": key("stroke-w")}) });
}
if sh.stroke.is_some() {
let mut align = match sh.stroke.as_ref().map(|s| s.align) {
Some(photocraft_doc::vector::StrokeAlign::Inside) => "inside",
Some(photocraft_doc::vector::StrokeAlign::Outside) => "outside",
_ => "center",
}
.to_string();
let opts = [("inside".to_string(), "Inside"), ("center".to_string(), "Center"), ("outside".to_string(), "Outside")];
if crate::widgets::dropdown(ui, &key("align"), &mut align, &opts, 84.0) {
edit = Some(json!({"stroke": {"align": align}}));
}
}
});
if let Some(stroke) = &sh.stroke {
let style = crate::shape_stroke_ui::StrokeOptions::from(stroke);
if let Some(next) = crate::shape_stroke_ui::button(
ui,
&key("style"),
&style,
&mut app.ui.stroke_editor,
sh.path.subpaths.iter().all(|s| s.closed),
!matches!(sh.live, Some(photocraft_doc::LiveShape::Ellipse { .. })),
app.session.active().map(|st| (st.doc.id, id)),
) {
edit = Some(json!({"stroke": next.params()}));
}
}
}
if let Some(live) = &sh.live
&& crate::props_layout::section(ui, "liveShape", tl!("Shape"))
@@ -1019,6 +1080,38 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
mod tests {
use super::*;
#[test]
fn all_shape_tools_and_open_pen_use_the_complete_stroke_defaults() {
for tool in [Tool::Rectangle, Tool::EllipseShape, Tool::Triangle, Tool::Polygon, Tool::Line, Tool::CustomShape] {
let mut a = app();
a.ui.tool_options.stroke_width = 5.0;
a.ui.tool_options.shape_stroke = crate::shape_stroke_ui::StrokeOptions {
cap: photocraft_doc::LineCap::Square,
join: photocraft_doc::LineJoin::Bevel,
dashes: vec![4.0, 2.0, 1.0, 3.0],
dash_offset: -0.5,
opacity: 0.4,
..Default::default()
};
let history = a.session.active().unwrap().history.past_len();
finish_shape(&mut a, tool, [20.0, 20.0], [120.0, 100.0], Default::default());
let info = a.run("shape.info", json!({})).unwrap();
assert_eq!(info["stroke"]["dashes"], json!([4.0, 2.0, 1.0, 3.0]));
assert_eq!(info["stroke"]["cap"], "square");
assert_eq!(info["stroke"]["width"], 5.0);
assert_eq!(a.session.active().unwrap().history.past_len(), history + 1);
a.run("edit.undo", json!({})).unwrap();
a.run("edit.redo", json!({})).unwrap();
assert_eq!(a.run("shape.info", json!({})).unwrap(), info);
a.ui.tool_options.vector_mode = "shape".into();
a.ui.pen = Some(PenPath { knots: vec![[[30.0, 40.0]; 3], [[80.0, 40.0]; 3]], ..Default::default() });
pen_commit(&mut a, false);
let open = a.run("shape.info", json!({})).unwrap();
assert_eq!(open["stroke"]["dashes"], info["stroke"]["dashes"]);
assert_eq!(open["path"]["subpaths"][0]["closed"], false);
}
}
fn app() -> PhotocraftApp {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap();
+22
View File
@@ -0,0 +1,22 @@
//! Cold shape rendering on a synthetic 24 MP canvas, excluding fixture construction.
//! Run in release mode; keep concurrent CPU-heavy work stopped while measuring.
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_doc::{LineCap, ShapeLayer, ShapeStroke};
use photocraft_geom::{Rect, Size};
use std::time::Instant;
fn main() {
let clip = Rect::from_size(Size::new(6000, 4000));
for (name, dashes, cap) in [("solid", vec![], LineCap::Butt), ("dashed", vec![4.0, 2.0], LineCap::Butt), ("dotted", vec![0.0, 2.0], LineCap::Round)] {
let shape = ShapeLayer {
path: photocraft_vector::shapes::rounded_rect(32.0, 32.0, 5936.0, 3936.0, [0.0; 4]),
stroke: Some(ShapeStroke { width: 8.0, dashes, cap, ..Default::default() }),
..Default::default()
};
for _ in 0..3 {
let start = Instant::now();
let surface = photocraft_vector::render_shape(&shape, PixelFormat::new(ColorMode::Rgb, SampleType::U8, true), clip);
println!("{name}: {:.3} ms, bounds {:?}", start.elapsed().as_secs_f64() * 1000.0, surface.content_bounds());
}
}
}
+39
View File
@@ -70,6 +70,45 @@ fn eval(seg: &[Point; 4], t: f64) -> (f64, f64) {
(a * p0.x + b * c1.x + c * c2.x + d * p1.x, a * p0.y + b * c1.y + c * c2.y + d * p1.y)
}
/// Validate before interactive rendering: finite coordinates and a bounded tessellation and
/// dash workload. Arc length is bounded by the cubic control polygon, without allocating pixels.
pub fn validate_shape(path: &Path, stroke: Option<&photocraft_doc::ShapeStroke>) -> Result<(), &'static str> {
let knots = path.subpaths.iter().try_fold(0usize, |n, s| n.checked_add(s.knots.len())).ok_or("too many path knots")?;
if knots > 4096 || path.subpaths.len() > 4096 {
return Err("shape path exceeds 4096 knots/subpaths");
}
let mut points = 0usize;
let mut length = 0.0;
for sub in &path.subpaths {
for k in &sub.knots {
for p in [k.anchor, k.in_ctrl, k.out_ctrl] {
if !p.x.is_finite() || !p.y.is_finite() || p.x.abs() > 1_000_000.0 || p.y.abs() > 1_000_000.0 {
return Err("shape coordinates must be finite and within ±1000000 px");
}
}
}
points = points.saturating_add(1);
for seg in sub.segments() {
points = points.saturating_add(if is_line(&seg) { 1 } else { pieces(&seg, crate::DEFAULT_TOLERANCE) });
length += seg.windows(2).map(|w| (w[1].x - w[0].x).hypot(w[1].y - w[0].y)).sum::<f64>();
}
}
if points > 100_000 {
return Err("shape tessellation exceeds 100000 points");
}
if let Some(s) = stroke {
s.validate()?;
if s.width > 0.0 && !s.dashes.is_empty() {
let period = s.dashes.iter().map(|d| f64::from(*d)).sum::<f64>() * f64::from(s.width);
let transitions = (length / period + 2.0) * s.dashes.len() as f64 + points as f64;
if !transitions.is_finite() || transitions > 100_000.0 {
return Err("dash pattern is too dense for this path (100000 step budget)");
}
}
}
Ok(())
}
/// Flattens one subpath. Closed subpaths do not repeat the first point at the end.
pub fn flatten_subpath(s: &Subpath, tol: f64) -> Polyline {
let mut out = Polyline { closed: s.closed, ..Default::default() };
+78 -8
View File
@@ -100,11 +100,14 @@ fn circle(c: P, r: f64, tol: f64) -> Vec<P> {
/// Stroke outline pieces for `lines`. Fill them with the non-zero rule.
pub fn stroke_polygons(lines: &[Polyline], style: &StrokeStyle, tol: f64) -> Vec<Vec<P>> {
let mut out = Vec::new();
if style.width <= 0.0 || !style.width.is_finite() {
if style.width <= 0.0 || !style.width.is_finite() || !style.miter_limit.is_finite() || style.miter_limit < 1.0 {
return out;
}
for pl in lines {
if style.dashes.iter().any(|d| *d > 0.0) {
if pl.pts.iter().any(|p| !p.0.is_finite() || !p.1.is_finite()) {
continue;
}
if !style.dashes.is_empty() {
for dash in dash_polyline(pl, &style.dashes, style.dash_offset) {
stroke_one(&dash, style, tol, &mut out);
}
@@ -210,7 +213,7 @@ fn stroke_one(pl: &Polyline, style: &StrokeStyle, tol: f64, out: &mut Vec<Vec<P>
(jn, true) => jn,
(_, false) => LineJoin::Miter,
};
join_piece(v, d1, d2, hw, join, if knot[j] { style.miter_limit } else { f64::INFINITY }, tol, out);
join_piece(v, d1, d2, hw, join, if knot[j] { style.miter_limit.min(500.0) } else { f64::INFINITY }, tol, out);
}
}
if !closed {
@@ -291,14 +294,24 @@ fn cap_piece(p: P, dir: P, hw: f64, cap: LineCap, tol: f64, out: &mut Vec<Vec<P>
/// Splits a polyline into dashes (open polylines). `pattern` alternates on/off lengths in px.
pub fn dash_polyline(pl: &Polyline, pattern: &[f64], offset: f64) -> Vec<Polyline> {
let mut pat: Vec<f64> = pattern.iter().map(|d| d.max(0.0)).collect();
if pattern.is_empty() {
return vec![pl.clone()];
}
if pattern.len() > 32
|| !offset.is_finite()
|| pattern.iter().any(|d| !d.is_finite() || *d < 0.0)
|| pl.pts.iter().any(|p| !p.0.is_finite() || !p.1.is_finite())
{
return Vec::new();
}
let mut pat = pattern.to_vec();
if pat.len() % 2 == 1 {
let copy = pat.clone();
pat.extend(copy);
}
let total: f64 = pat.iter().sum();
if total <= 1e-9 || pl.pts.len() < 2 {
return vec![pl.clone()];
if !total.is_finite() || total <= 1e-9 || pl.pts.len() < 2 {
return Vec::new();
}
let mut pts = pl.pts.clone();
let mut knots = pl.knot.clone();
@@ -331,10 +344,15 @@ pub fn dash_polyline(pl: &Polyline, pattern: &[f64], offset: f64) -> Vec<Polylin
let mut pos = 0.0;
while seg - pos > 1e-12 {
guard += 1;
if guard > 10_000_000 {
break;
if guard > 100_000 {
// Bound allocations and time, including file-provided patterns. Commands
// reject patterns that exceed this budget before document mutation.
return Vec::new();
}
let step = left.min(seg - pos);
if step > 0.0 && pos + step == pos {
return Vec::new();
}
pos += step;
left -= step;
let at_end = seg - pos <= 1e-12;
@@ -362,6 +380,25 @@ pub fn dash_polyline(pl: &Polyline, pattern: &[f64], offset: f64) -> Vec<Polylin
{
out.push(c);
}
if pl.closed {
// A dash that crosses the closing vertex has a join there, not two caps.
// Preserve knot flags when stitching the tail onto the first dash.
let seam = pl.pts.first().copied();
let wraps = out.first().and_then(|d| d.pts.first()).copied() == seam && out.last().and_then(|d| d.pts.last()).copied() == seam;
if wraps && out.len() == 1 {
if let Some(dash) = out.first_mut() {
dash.closed = true;
}
} else if wraps
&& out.len() > 1
&& let Some(mut tail) = out.pop()
&& let Some(first) = out.first_mut()
{
tail.pts.extend(first.pts.iter().skip(1).copied());
tail.knot.extend(first.knot.iter().skip(1).copied());
*first = tail;
}
}
out
}
@@ -397,4 +434,37 @@ mod tests {
}
}
}
#[test]
fn closed_dashes_have_a_join_at_the_seam() {
let square = line(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)], true);
let dashes = dash_polyline(&square, &[8.0, 4.0], 2.0);
assert_eq!(dashes.len(), 3);
let crossing = &dashes[0];
assert_eq!(crossing.pts, [(0.0, 6.0), (0.0, 0.0), (6.0, 0.0)]);
assert_eq!(crossing.knot, [true, true, true]);
let full = dash_polyline(&square, &[80.0, 2.0], 0.0);
assert_eq!(full.len(), 1);
assert!(full[0].closed);
}
#[test]
fn invalid_and_too_dense_dashes_finish_with_bounded_work() {
let pl = line(&[(0.0, 0.0), (100.0, 0.0)], false);
for pattern in [vec![0.0, 0.0], vec![f64::INFINITY, 1.0], vec![f64::NAN, 1.0], vec![-1.0, 2.0], vec![1.0; 33], vec![1e-8, 1e-8]] {
assert!(dash_polyline(&pl, &pattern, 0.0).is_empty());
}
assert!(dash_polyline(&pl, &[2.0, 1.0], f64::INFINITY).is_empty());
assert!(dash_polyline(&line(&[(f64::NAN, 0.0), (1.0, 0.0)], false), &[2.0, 1.0], 0.0).is_empty());
}
#[test]
fn nonuniform_patterns_and_negative_offsets_repeat_by_arc_length() {
let pl = line(&[(0.0, 0.0), (30.0, 0.0)], false);
let d = dash_polyline(&pl, &[4.0, 2.0, 1.0, 3.0], -10.0);
assert_eq!(
d.iter().map(|p| (p.pts.first().unwrap().0, p.pts.last().unwrap().0)).collect::<Vec<_>>(),
[(0.0, 4.0), (6.0, 7.0), (10.0, 14.0), (16.0, 17.0), (20.0, 24.0), (26.0, 27.0)]
);
}
}
+25
View File
@@ -168,6 +168,31 @@ fn closed_rect_stroke_and_alignment() {
assert!((alpha_area(&shape(StrokeAlign::Center)) - (110.0f64.powi(2) - 90.0f64.powi(2))).abs() < 0.05);
}
#[test]
fn aligned_dashes_keep_width_units_and_open_paths_stay_centered() {
let clip = Rect::new(0, 0, 100, 100);
let square = shapes::rect(10.0, 10.0, 80.0, 80.0);
for (align, y, other_y) in [(StrokeAlign::Inside, 11, 7), (StrokeAlign::Center, 9, 6), (StrokeAlign::Outside, 7, 11)] {
let sh = ShapeLayer {
path: square.clone(),
stroke: Some(ShapeStroke { width: 4.0, align, dashes: vec![2.0, 2.0], ..Default::default() }),
..Default::default()
};
let rgba = CompiledShape::new(&sh, DEFAULT_TOLERANCE, clip).render_rgba(clip);
assert!(rgba[(y * 100 + 13) as usize][3] > 0.99);
assert!(rgba[(y * 100 + 21) as usize][3] < 0.01);
assert!(rgba[(other_y * 100 + 13) as usize][3] < 0.01);
}
let open = Path::new(vec![Subpath::polyline(&[(10.0, 50.0), (90.0, 50.0)])]);
let render = |align| {
let sh =
ShapeLayer { path: open.clone(), stroke: Some(ShapeStroke { width: 4.0, align, cap: LineCap::Round, ..Default::default() }), ..Default::default() };
CompiledShape::new(&sh, DEFAULT_TOLERANCE, clip).render_rgba(clip)
};
assert_eq!(render(StrokeAlign::Center), render(StrokeAlign::Inside));
assert_eq!(render(StrokeAlign::Center), render(StrokeAlign::Outside));
}
#[test]
fn dashes_cover_expected_fraction() {
let line = Path::new(vec![Subpath::polyline(&[(0.0, 50.0), (120.0, 50.0)])]);
+7 -1
View File
@@ -1,5 +1,11 @@
# Control protocol
Shape stroke defaults are drivable with `ui.set {shapeStroke: {width, align, cap, join,
miterLimit, dashes, dashOffset, opacity}}`; partial fields merge, null disables the stroke.
Values use the existing `shape.edit` names and units. Colour uses `tools.setColors`.
`ui.inspect` exposes `toolOptions.shape_stroke`, `stroke_width`, and `strokeEditor`.
See [Shape stroke options](shape-strokes.md) for limits, preview and persistence behavior.
The desktop app listens on `127.0.0.1:<port>` (loopback only). Start it with a token file so the credential is not exposed in the process command line:
```sh
@@ -37,7 +43,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i
- `engine.execute {command, params}`: run any engine or UI command by id. Engine commands run directly with their default params and never open a dialog. Use `ui.menu.invoke` for menu-click behaviour, which opens a command's dialog when no params are given. `params` must be a JSON object (omit it or pass `null` for none); an array, string, number or boolean is an error naming the command, in every transport (control channel, `serve`, MCP and the CLI)
- `engine.commands`: list commands with enablement
- `ui.inspect`: full UI state (tool, panels, views, dialogs, windows, window size). The menu tree is not included; use `ui.menu.list`. `brushPicker` is `null` while closed and `{pos: [x, y], list: {collapsed, filter, view, renaming}}` while the Brush Preset picker is open (coordinates in screen points). `view` holds the View/Window/Type preferences: `screen_mode`, `extras`, `show` and `snap_to` flags, `flip_horizontal`, `arrange` (Window › Arrange layout), pixel aspect, font preview size, language options. `perf.timings.gpuInfo` holds the graphics adapter, backend, driver, the backend chosen at launch and why, the canvas renderer (`gpu`/`cpu`) and, after a device loss, `lost` (`help.systemInfo` returns the same as `info`)
- `ui.set {tool?, panels?, dockTabs?, dock?, dockWidth?, colorPanel?, maskTarget?, vectorMaskTarget?, selectionMode?, zoom?, center?, rotation?, fit?, theme?, brushSize?, brushSection?, brushTab?, brushesView?, brushPicker?, brushPickerView?, gradientBlendMode?, gradientClassic?}`: change UI state; any other field is an error, checked before anything changes (`zoom` is the canvas zoom where 1 = 100%, one document pixel per physical display pixel, so a scaled display doesn't magnify the image; `theme` is `pro`, `proMedium`, `studio`, `studioLight` or `classic`; `selectionMode` is the selection tools' options-bar mode, 0 New, 1 Add, 2 Subtract, 3 Intersect; `brushSection` indexes the Brush Settings sections, `brushTab` 0 = Brush Settings, 1 = Brushes; `brushesView` and `brushPickerView` are `list` or `grid` for the Brushes panel and the Brush Preset picker; `brushPicker` is `[x, y]` in screen points to open the Brush Preset picker there (as a right-click with a painting tool does) or `null` to close it, and picking a preset in it is `tools.setBrush {"preset": name}`; `dock` is `{order: ["layers", …], heights: {"properties": 180}, collapsed: ["color"]}`, the right-dock groups top to bottom, their heights in points and the groups collapsed to their tab strip; `dockWidth` sets the right dock width in points, clamped to 250..520; `colorPanel` is `{background}`, whether the Color panel edits the background colour; `maskTarget` targets the active layer's mask, which, like Photoshop, swaps in the foreground/background pair kept for masks (white/black until one is picked) and swaps the pixels' pair back when the pixels are targeted again)
- `ui.set {tool?, panels?, dockTabs?, dock?, dockWidth?, colorPanel?, maskTarget?, vectorMaskTarget?, selectionMode?, zoom?, center?, rotation?, fit?, theme?, brushSize?, brushSection?, brushTab?, brushesView?, brushPicker?, brushPickerView?, gradientBlendMode?, gradientClassic?, shapeStroke?}`: change UI state; any other field is an error, checked before anything changes (`zoom` is the canvas zoom where 1 = 100%, one document pixel per physical display pixel, so a scaled display doesn't magnify the image; `theme` is `pro`, `proMedium`, `studio`, `studioLight` or `classic`; `selectionMode` is the selection tools' options-bar mode, 0 New, 1 Add, 2 Subtract, 3 Intersect; `brushSection` indexes the Brush Settings sections, `brushTab` 0 = Brush Settings, 1 = Brushes; `brushesView` and `brushPickerView` are `list` or `grid` for the Brushes panel and the Brush Preset picker; `brushPicker` is `[x, y]` in screen points to open the Brush Preset picker there (as a right-click with a painting tool does) or `null` to close it, and picking a preset in it is `tools.setBrush {"preset": name}`; `dock` is `{order: ["layers", …], heights: {"properties": 180}, collapsed: ["color"]}`, the right-dock groups top to bottom, their heights in points and the groups collapsed to their tab strip; `dockWidth` sets the right dock width in points, clamped to 250..520; `colorPanel` is `{background}`, whether the Color panel edits the background colour; `maskTarget` targets the active layer's mask, which, like Photoshop, swaps in the foreground/background pair kept for masks (white/black until one is picked) and swaps the pixels' pair back when the pixels are targeted again)
- `typeTransform` is `null` outside a temporary Ctrl/Cmd Type-tool drag. During a drag it reports its source document/revision, original affine, oriented `frame` (layer, local bounds, document-space corners and pivot), and captured `gesture`. Drive it with `ui.pointer` and `command: true` at `down`; `move`/`up` may release the modifier. The document/history remain unchanged during preview, and `up` applies one `type.edit` within `textEdit`'s session. Pressing Escape during the drag cancels only the preview. This pointer state is never restored from saved UI state.
- `ui.menu.invoke {id, params?, wait?}` / `ui.menu.list`: activate a menu item by id; list the menu tree. A menu item that starts a background job (a filter without a dialog, such as Blur More) replies with the job's result once it has been applied; with `"wait": false` the reply is `{job, pending: true}` at once
- `ui.set` also accepts `gradientBlendMode` (a layer blend mode name, such as `Difference`) and `gradientClassic` (boolean) for the Gradient tool options bar.
+6
View File
@@ -92,6 +92,12 @@ Confidence: moderate — the next users of 0.2.x will move these numbers either
### By dimension
2026-10-10: [Shape stroke controls](shape-strokes.md) expose existing Solid/Dashed/Dotted,
custom dash/gap, offset, caps, joins, alignment, miter-limit and opacity capabilities in
Shape/Pen options and Properties. Properties stages a cached canvas preview before one
undoable edit. Gradient fill controls from #1053 remain open; the Line tool still draws a
filled bar. This extends UI reachability and does not claim Photoshop-authored stroke parity.
| Dimension | Measured / evidence (2026-10-05) | Grade | Notes |
|---|---|---|---|
| Menu wiring | 626/626 menu items dispatch a command (`parity.md`) | high but shallow | Says nothing about behaviour. |
+2 -1
View File
@@ -8,7 +8,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
|---|---:|---:|---:|---|
| [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 15 not run, 7 not measurable yet (baseline 2026-10-05) |
| [File compatibility](#file-compatibility) | 5 | 11 | 11 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 |
| [Tools](#tools) | 19 | 15 | 6 | |
| [Tools](#tools) | 20 | 15 | 6 | |
| [Workspace and UI](#workspace-and-ui) | 5 | 7 | 11 | settings that do nothing: 46 of 148 (target 0) |
| [Type](#type) | 0 | 3 | 6 | |
| [Automation](#automation) | 3 | 2 | 6 | 627 of 627 menu items live (100.0%) |
@@ -169,6 +169,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
| TOOL-214-19 | Crop: arrow keys nudge the crop box (Shift 10 px), X swaps portrait/landscape, W x H readout while drawing or resizing | done | [#1919](https://github.com/storytold/photocraft/issues/1919) | crop_ui.rs keys/nudge/swap_orientation (ahead of the selection nudge and tools.swapColors in shortcuts.rs; X also swaps the options-bar ratio) and sizing (canvas.rs draws it with the marquee readout); turned frames included |
| TOOL-214-21 | Crop: Straighten button in the options bar and Cmd/Ctrl-drag draw a line; the frame turns so the line ends up level (or plumb when nearer vertical), with an angle readout | done | [#1919](https://github.com/storytold/photocraft/issues/1919) | crop_straighten.rs: one-shot options-bar button, Cmd/Ctrl held is a temporary Straighten (ui.pointer command flag); the Ruler's straighten_angle convention; the frame is fitted to the largest of its proportions inside the image (Classic Mode); a pending edit committed by Enter |
| TOOL-214-22 | Crop gear menu: Show Cropped Area (H), Enable Crop Shield with Match Canvas / custom colour, opacity and Auto Adjust Opacity | done | [#1919](https://github.com/storytold/photocraft/issues/1919) | crop_shield.rs: fill() resolves the shield (canvas.rs paints it around upright and turned frames), gear popup in the options bar, H ahead of the Hand key in shortcuts.rs, Custom… opens the Color Picker; ToolOptions crop_shield, ui.set cropShield. No Classic Mode / Auto Center Preview (PhotoCraft is always Classic Mode) |
| TOOL-1053-1 | Shape stroke controls: solid/dashed/dotted, custom dash/gap/offset, caps, joins, alignment, miter limit and opacity | done | [#1053](https://github.com/storytold/photocraft/issues/1053) | shape_stroke_ui.rs: shared staged editor, renderer samples, cached Properties canvas preview; existing shape.create/edit/info; serde tool defaults, ui.set shapeStroke; pcraft and vstk style round trips. Gradient fill UI and saved stroke libraries remain open; Line retains filled-bar geometry |
| TOOL-213-19 | Remove tool: paint over an object (or circle it) and it is filled from its surroundings | partial | [#213](https://github.com/storytold/photocraft/issues/213) | paint.remove (retouch_cmds/remove.rs, background job) + Tool::Remove in the J flyout; loops close over what they enclose; fill by non-local patch completion with texture features, onion-peel start, best-patch copy and gradient-domain seam hiding (algo/nonlocal.rs, Newson et al. IPOL 2017); sparse holes in tiles (algo/remove.rs). remove_quality on 20 holes in public-domain photos: the fill keeps 0.89 of the texture it replaces (0.54 with Content-Aware Fill's completion), edge gradient 1.03 of the original's; remove_bench 24 MP: 100 px ring 120 ms, 1000 px ring 2.5 s, 3000 px line 1.7 s. Missing: generative AI mode, Remove after each stroke off, live preview of the result |
## Workspace and UI
+77
View File
@@ -0,0 +1,77 @@
# Shape stroke options
PhotoCraft's Shape tool options bar has a Stroke Options button beside the existing width
and colour controls. It sets defaults for new Rectangle, Ellipse, Triangle, Polygon, Line
and Custom Shape layers. Pen in Shape mode uses the same style defaults; open Pen shapes retain
their existing foreground colour and minimum 1 px width. To edit an existing shape,
use Properties → Appearance → Stroke Options.
The editor offers Solid, Dashed (4 widths on, 2 off), Dotted (zero-length dashes with round
caps, spaced every 2 widths), and custom repeating dash/gap sequences. Dash lengths and
offsets are multiples of the stroke width; width itself is in document pixels. Odd-length
imported sequences repeat twice, following the existing renderer convention. Up to 16
dash/gap pairs can be edited, with Add pair / Remove pair. Zero-length dashes draw dots with
Round caps and squares with Square caps; Butt caps draw nothing at a zero-length dash.
Caps are Butt / Flat, Round, and Square. They affect open endpoints and individual dashed
segments. Closed solid shapes have no endpoints, so their cap control is disabled. Corners
are Miter, Round, or Bevel; Miter limit is a ratio, 1–500 (the existing default remains 100).
Sharp miters exceeding the limit become bevels. Ellipse has no sharp corners, so its corner
controls are disabled. The Line tool retains its existing filled-bar geometry: strokes follow
the bar's closed outline. Use an open Pen shape for a centerline with endpoint caps.
Inside / Center / Outside alignment applies to paths whose subpaths are all closed. Inside
and Outside clip a double-width stroke against the shape's fill area and respect the existing
fill rule and component operations. Open and mixed open/closed paths use Center; their
alignment control is disabled. Dash lengths continue to use the specified width, including
for Inside and Outside. Opacity is independent of width and paint.
The editor stages changes. Its compact line and rectangle previews use the real vector
renderer. Properties also previews the selected shape on the regular colour-managed canvas,
cached by document revision and draft. Apply commits one `shape.edit` history step; Cancel,
Escape, or dismissing the popup discards the preview. Changing documents/layers, locking the
target, or changing the source style options invalidates the staged editor. Tool-default changes
do not edit the document. Shape
drag previews use the vector renderer, with a cached screen proxy capped at 2048 pixels on
the longest axis; like the prior drag overlay, its colour swatches bypass monitor-profile
conversion. Committed shapes and the Properties preview use the normal compositor.
Finite values are required: width 0–1,000,000 px, opacity 0–100%, dash/gap lengths
0–10,000 widths, offset ±1,000,000 widths, at most 32 lengths, and a nonempty pattern total
of at least 0.01 widths. Interactive commands reject geometry beyond ±1,000,000 px,
over 4096 knots/subpaths, more than 100,000 tessellation points, or a dash workload over
100,000 steps. File-supplied invalid/dense patterns are bounded during rendering. There are
no custom saved preset libraries in this change; style defaults use existing serde UI state.
All fields already exist in `ShapeStroke` and the native manifest/conversion. Old `.pcraft`
files retain their serde defaults; saves preserve all style fields. PSD import/export maps
the same fields through `vstk` (line width, dash set/offset, cap, join, alignment, miter limit,
opacity, and paint); existing unknown layer blocks and original compatible vector records
remain handled by the existing I/O path. Stroke overprint, non-Normal PSD vector-stroke
blend modes, corner-fitting dash optimization, and Photoshop's saved stroke preset libraries
are not implemented. No Photoshop-authored oracle establishes exact stroke UI parity here.
Automation: `shape.create`, `shape.edit`, and `shape.info` expose document strokes. The
control channel also supports `ui.set {shapeStroke: {width, align, cap, join, miterLimit,
dashes, dashOffset, opacity}}` for tool defaults, merging partial fields; null sets width to
zero. Set tool colours with `tools.setColors`. `ui.inspect` reports `toolOptions.shape_stroke`,
`toolOptions.stroke_width`, and the staged `strokeEditor`. Keyboard focus, standard number
fields, ComboBoxes, and Apply/Cancel buttons remain native egui controls.
## Upstream and public references
- [Issue #1053](https://github.com/storytold/photocraft/issues/1053) documents the missing
stroke controls and existing engine/model support. Gradient fill UI remains separate work.
- [Issue #1931](https://github.com/storytold/photocraft/issues/1931) requests sharp stroke options.
- [PR #233](https://github.com/storytold/photocraft/pull/233),
[#397](https://github.com/storytold/photocraft/pull/397), and
[#1495](https://github.com/storytold/photocraft/pull/1495) already address stroke compositing,
hairlines, and depth preservation; their implementations are reused.
- [PR #2031](https://github.com/storytold/photocraft/pull/2031) supplies the click-to-create
dialogs. Both dialogs and drag creation use its shared `create_shape` helper, which receives
the complete stroke defaults. A gesture regression checks both paths and their undo steps.
- Adobe's [Photoshop shape fill/stroke guide](https://helpx.adobe.com/photoshop/desktop/draw-shapes-paths/create-shapes/fill-and-stroke-shapes.html)
describes the tool options and Properties entry points, and its
[cap/join reference](https://helpx.adobe.com/ca/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/change-the-caps-or-joins-of-a-line.html)
defines endpoint and corner behavior and the 1–500 miter range. Dash units reuse
PhotoCraft's existing documented `ShapeStroke`/`vstk` convention.
+7
View File
@@ -278,6 +278,13 @@ status = "done"
issue = 1919
note = "crop_shield.rs: fill() resolves the shield (canvas.rs paints it around upright and turned frames), gear popup in the options bar, H ahead of the Hand key in shortcuts.rs, Custom… opens the Color Picker; ToolOptions crop_shield, ui.set cropShield. No Classic Mode / Auto Center Preview (PhotoCraft is always Classic Mode)"
[[item]]
id = "TOOL-1053-1"
target = "Shape stroke controls: solid/dashed/dotted, custom dash/gap/offset, caps, joins, alignment, miter limit and opacity"
status = "done"
issue = 1053
note = "shape_stroke_ui.rs: shared staged editor, renderer samples, cached Properties canvas preview; existing shape.create/edit/info; serde tool defaults, ui.set shapeStroke; pcraft and vstk style round trips. Gradient fill UI and saved stroke libraries remain open; Line retains filled-bar geometry"
[[item]]
id = "TOOL-213-19"
target = "Remove tool: paint over an object (or circle it) and it is filled from its surroundings"