From ae960376722411f4b3cf52d25b54dd8b52a7e8c8 Mon Sep 17 00:00:00 2001 From: optimalbytee Date: Sat, 10 Oct 2026 11:42:52 +0100 Subject: [PATCH] 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 --------- Co-authored-by: optimalbytee Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/automation/src/server.rs | 4 +- crates/doc/src/vector.rs | 26 ++ crates/engine/src/vector_cmds.rs | 61 ++- crates/engine/src/vector_cmds/tests.rs | 83 ++++ crates/format/tests/roundtrip.rs | 30 ++ crates/io/tests/vector.rs | 36 ++ crates/ui-egui/src/canvas.rs | 3 + crates/ui-egui/src/control.rs | 48 ++- crates/ui-egui/src/i18n/cs.tsv | 21 + crates/ui-egui/src/i18n/de.tsv | 21 + crates/ui-egui/src/i18n/el.tsv | 19 + crates/ui-egui/src/i18n/es.tsv | 21 + crates/ui-egui/src/i18n/fr.tsv | 20 + crates/ui-egui/src/i18n/id.tsv | 20 + crates/ui-egui/src/i18n/it.tsv | 21 + crates/ui-egui/src/i18n/ja.tsv | 19 + crates/ui-egui/src/i18n/ko.tsv | 20 + crates/ui-egui/src/i18n/nl.tsv | 19 + crates/ui-egui/src/i18n/pl.tsv | 19 + crates/ui-egui/src/i18n/pt-br.tsv | 20 + crates/ui-egui/src/i18n/ru.tsv | 20 + crates/ui-egui/src/i18n/uk.tsv | 19 + crates/ui-egui/src/i18n/zh-hans.tsv | 21 + crates/ui-egui/src/i18n/zh-hant.tsv | 21 + crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/shape_dialog_tests.rs | 34 ++ crates/ui-egui/src/shape_stroke_ui.rs | 479 +++++++++++++++++++++++ crates/ui-egui/src/state.rs | 6 + crates/ui-egui/src/vector_ui.rs | 141 +++++-- crates/vector/examples/stroke_bench.rs | 22 ++ crates/vector/src/flatten.rs | 39 ++ crates/vector/src/stroke.rs | 86 +++- crates/vector/src/tests.rs | 25 ++ docs/control-protocol.md | 8 +- docs/roadmap.md | 6 + docs/scorecard.md | 3 +- docs/shape-strokes.md | 77 ++++ scorecard/tools.toml | 7 + 38 files changed, 1506 insertions(+), 42 deletions(-) create mode 100644 crates/ui-egui/src/shape_stroke_ui.rs create mode 100644 crates/vector/examples/stroke_bench.rs create mode 100644 docs/shape-strokes.md diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index 66efd5d3b5..4f160d75c6 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -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, } diff --git a/crates/doc/src/vector.rs b/crates/doc/src/vector.rs index e33a17c78a..f36df185a3 100644 --- a/crates/doc/src/vector.rs +++ b/crates/doc/src/vector.rs @@ -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::() < 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")] diff --git a/crates/engine/src/vector_cmds.rs b/crates/engine/src/vector_cmds.rs index 9d0b1c5b8d..c4f3433740 100644 --- a/crates/engine/src/vector_cmds.rs +++ b/crates/engine/src/vector_cmds.rs @@ -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, fg: Color) -> std::result::Result, String> { +pub fn parse_stroke(v: &Value, base: Option, fg: Color) -> std::result::Result, 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, 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::, _>>()?, _ => 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 { + 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 { } pub(crate) fn with_shape(s: &mut Session, id: LayerId, label: &str, f: impl FnOnce(&mut ShapeLayer, &mut Layer) -> Result) -> Result { + 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(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 { 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 { } 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 { 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) diff --git a/crates/engine/src/vector_cmds/tests.rs b/crates/engine/src/vector_cmds/tests.rs index 645252f5b0..97e1b8a29e 100644 --- a/crates/engine/src/vector_cmds/tests.rs +++ b/crates/engine/src/vector_cmds/tests.rs @@ -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(); diff --git a/crates/format/tests/roundtrip.rs b/crates/format/tests/roundtrip.rs index 4acf97de7b..322ebe0b4d 100644 --- a/crates/format/tests/roundtrip.rs +++ b/crates/format/tests/roundtrip.rs @@ -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(); diff --git a/crates/io/tests/vector.rs b/crates/io/tests/vector.rs index 489574745d..594a1827e8 100644 --- a/crates/io/tests/vector.rs +++ b/crates/io/tests/vector.rs @@ -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(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 08b495fe95..1c6be59b33 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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; } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index b58705db84..68cdd907e9 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -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 { + 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 diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index d91d3a715d..5392e8406f 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index ffca857f42..65891326bb 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 5ff515f27d..7e462ccc2f 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -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 Η Επιλογή αντικειμένου δεν βρήκε τίποτα εκεί — δοκιμάστε ένα πιο στενό ορθογώνιο diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 76dde17e1b..de002e2a8e 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index f94d4a982c..72d5efe500 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -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é diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index f9974abd95..07a73a8b44 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index ab43fdb86a..dabbd7591e 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 36cab85125..b1235113c5 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -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 オブジェクト選択ではそこに何も見つかりませんでした — より狭い長方形で試してください diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 616f6f72b1..a656f02bc4 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -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 개체 선택으로 해당 위치에서 아무것도 찾지 못했습니다 — 더 좁은 사각형을 사용해 보세요 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 9252ecf72c..4e53198c42 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 901f1bfe98..4c7a26483f 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 9036f2c16c..c7af44bbbc 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 0ba9ff0d11..1d01f7c2c7 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -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 Выделение объектов ничего здесь не нашло — попробуйте более узкий прямоугольник diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 86b88a73dd..d03c615ee5 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -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 Виділення об'єктів нічого тут не знайшло — спробуйте вужчий прямокутник diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index f8a381a8c9..70beaddaef 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -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 对象选择在该处未找到任何内容 — 请尝试更紧凑的矩形 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 89cd8ce41c..835b2a2c3f 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -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 物件選取在該處找不到任何內容 — 請嘗試更緊密的矩形 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0f1f1b5664..7fae6886a9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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, /// Patch Tool drag: the healed document at the pointer (`patch_preview`). pub(crate) patch_preview: Option, + pub(crate) shape_stroke_preview: Option, /// 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, diff --git a/crates/ui-egui/src/shape_dialog_tests.rs b/crates/ui-egui/src/shape_dialog_tests.rs index 48fba50897..fde2cf78c7 100644 --- a/crates/ui-egui/src/shape_dialog_tests.rs +++ b/crates/ui-egui/src/shape_dialog_tests.rs @@ -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]]; diff --git a/crates/ui-egui/src/shape_stroke_ui.rs b/crates/ui-egui/src/shape_stroke_ui.rs new file mode 100644 index 0000000000..efae5146d6 --- /dev/null +++ b/crates/ui-egui/src/shape_stroke_ui.rs @@ -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, + 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::(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 = 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, + 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, 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, + closed: bool, + corners: bool, + target: Option<(photocraft_doc::DocId, photocraft_doc::LayerId)>, +) -> Option { + 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(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, 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::(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 = 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, + 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::>()); + 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::(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::(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()); + } + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 8b8028690f..63cd0e1da2 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -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, + #[serde(default)] + pub stroke_editor: Option, /// 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(), diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index 929cf3d8fe..0b733528cd 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -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(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 = 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(); diff --git a/crates/vector/examples/stroke_bench.rs b/crates/vector/examples/stroke_bench.rs new file mode 100644 index 0000000000..3de9c4893e --- /dev/null +++ b/crates/vector/examples/stroke_bench.rs @@ -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()); + } + } +} diff --git a/crates/vector/src/flatten.rs b/crates/vector/src/flatten.rs index 5639841b73..b3fd0a90f6 100644 --- a/crates/vector/src/flatten.rs +++ b/crates/vector/src/flatten.rs @@ -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::(); + } + } + 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::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() }; diff --git a/crates/vector/src/stroke.rs b/crates/vector/src/stroke.rs index 8aa966ed19..dba9473e8f 100644 --- a/crates/vector/src/stroke.rs +++ b/crates/vector/src/stroke.rs @@ -100,11 +100,14 @@ fn circle(c: P, r: f64, tol: f64) -> Vec

{ /// Stroke outline pieces for `lines`. Fill them with the non-zero rule. pub fn stroke_polygons(lines: &[Polyline], style: &StrokeStyle, tol: f64) -> Vec> { 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 (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 /// 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 { - let mut pat: Vec = 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 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 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::>(), + [(0.0, 4.0), (6.0, 7.0), (10.0, 14.0), (16.0, 17.0), (20.0, 24.0), (26.0, 27.0)] + ); + } } diff --git a/crates/vector/src/tests.rs b/crates/vector/src/tests.rs index 0dc5514ec1..0a49cbeac9 100644 --- a/crates/vector/src/tests.rs +++ b/crates/vector/src/tests.rs @@ -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)])]); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 6f7b58232c..9c5fc107a9 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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:` (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. diff --git a/docs/roadmap.md b/docs/roadmap.md index 182c521f09..f2fc843fdb 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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. | diff --git a/docs/scorecard.md b/docs/scorecard.md index 84e2c056bb..186e9ef675 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -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 diff --git a/docs/shape-strokes.md b/docs/shape-strokes.md new file mode 100644 index 0000000000..0e3aeaed91 --- /dev/null +++ b/docs/shape-strokes.md @@ -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. diff --git a/scorecard/tools.toml b/scorecard/tools.toml index fdda94529b..27648245ea 100644 --- a/scorecard/tools.toml +++ b/scorecard/tools.toml @@ -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"