mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
Expose native shape stroke controls with staged previews (#2218)
* Expose native shape stroke controls with staged previews * Document shape stroke defaults in MCP control schema * Regenerate docs/scorecard.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: optimalbytee <optimalbytee@users.noreply.github.com> Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
optimalbytee
Brandon Thomas
parent
291f30da55
commit
ae96037672
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -263,6 +263,32 @@ impl Default for ShapeStroke {
|
||||
}
|
||||
}
|
||||
|
||||
impl ShapeStroke {
|
||||
/// Limits accepted by interactive commands. Importers retain original descriptor data;
|
||||
/// renderers must additionally bound their work for arbitrary file-provided geometry.
|
||||
pub fn validate(&self) -> Result<(), &'static str> {
|
||||
if !self.width.is_finite() || !(0.0..=1_000_000.0).contains(&self.width) {
|
||||
return Err("stroke width must be finite and between 0 and 1000000 px");
|
||||
}
|
||||
if !self.opacity.is_finite() || !(0.0..=1.0).contains(&self.opacity) {
|
||||
return Err("stroke opacity must be between 0 and 100%");
|
||||
}
|
||||
if !self.miter_limit.is_finite() || !(1.0..=500.0).contains(&self.miter_limit) {
|
||||
return Err("miter limit must be between 1 and 500");
|
||||
}
|
||||
if !self.dash_offset.is_finite() || self.dash_offset.abs() > 1_000_000.0 {
|
||||
return Err("dash offset must be finite and within ±1000000 widths");
|
||||
}
|
||||
if self.dashes.len() > 32 || self.dashes.iter().any(|d| !d.is_finite() || !(0.0..=10000.0).contains(d)) {
|
||||
return Err("dash pattern must contain at most 32 finite lengths between 0 and 10000 widths");
|
||||
}
|
||||
if !self.dashes.is_empty() && self.dashes.iter().sum::<f32>() < 0.01 {
|
||||
return Err("dash pattern total must be at least 0.01 widths");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Parametric ("live") shape the path was generated from, kept for re-editing (Properties panel).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
|
||||
@@ -306,19 +306,34 @@ fn stroke_json(s: &ShapeStroke) -> Value {
|
||||
}
|
||||
|
||||
/// Applies stroke keys onto `base` (`null` → no stroke).
|
||||
fn parse_stroke(v: &Value, base: Option<ShapeStroke>, fg: Color) -> std::result::Result<Option<ShapeStroke>, String> {
|
||||
pub fn parse_stroke(v: &Value, base: Option<ShapeStroke>, fg: Color) -> std::result::Result<Option<ShapeStroke>, String> {
|
||||
if v.is_null() || v.as_bool() == Some(false) {
|
||||
return Ok(None);
|
||||
}
|
||||
if !v.is_object() {
|
||||
return Err("stroke must be an object or null".into());
|
||||
}
|
||||
for (key, lo, hi) in [("width", 0.0, 1_000_000.0), ("opacity", 0.0, 100.0), ("miterLimit", 1.0, 500.0), ("dashOffset", -1_000_000.0, 1_000_000.0)] {
|
||||
if let Some(value) = v.get(key)
|
||||
&& !value.as_f64().is_some_and(|n| n.is_finite() && (lo..=hi).contains(&n))
|
||||
{
|
||||
return Err(format!("`{key}` must be a finite number between {lo} and {hi}"));
|
||||
}
|
||||
}
|
||||
for key in ["align", "cap", "join"] {
|
||||
if v.get(key).is_some_and(|value| !value.is_string()) {
|
||||
return Err(format!("`{key}` must be a string"));
|
||||
}
|
||||
}
|
||||
let mut s = base.unwrap_or(ShapeStroke { paint: Fill::Solid(fg), ..Default::default() });
|
||||
if let Some(w) = f64p(v, "width") {
|
||||
s.width = w.max(0.0) as f32;
|
||||
s.width = w as f32;
|
||||
}
|
||||
if let Some(c) = v.get("color").or_else(|| v.get("fill")) {
|
||||
s.paint = parse_fill(c)?.ok_or("stroke colour cannot be null")?;
|
||||
}
|
||||
if let Some(o) = f64p(v, "opacity") {
|
||||
s.opacity = (o / 100.0).clamp(0.0, 1.0) as f32;
|
||||
s.opacity = (o / 100.0) as f32;
|
||||
}
|
||||
if let Some(a) = v.get("align").and_then(Value::as_str) {
|
||||
s.align = match a {
|
||||
@@ -345,18 +360,27 @@ fn parse_stroke(v: &Value, base: Option<ShapeStroke>, fg: Color) -> std::result:
|
||||
};
|
||||
}
|
||||
if let Some(m) = f64p(v, "miterLimit") {
|
||||
s.miter_limit = m.max(1.0) as f32;
|
||||
s.miter_limit = m as f32;
|
||||
}
|
||||
if let Some(d) = v.get("dashes") {
|
||||
s.dashes = match d {
|
||||
Value::Null => Vec::new(),
|
||||
Value::Array(a) => a.iter().filter_map(Value::as_f64).map(|x| x.max(0.0) as f32).collect(),
|
||||
Value::Array(a) if a.len() <= 32 => a
|
||||
.iter()
|
||||
.map(|x| {
|
||||
x.as_f64()
|
||||
.filter(|n| n.is_finite() && (0.0..=10000.0).contains(n))
|
||||
.map(|x| x as f32)
|
||||
.ok_or_else(|| "dash lengths must be finite numbers between 0 and 10000 widths".to_string())
|
||||
})
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?,
|
||||
_ => return Err("`dashes` must be an array (multiples of the width)".into()),
|
||||
};
|
||||
}
|
||||
if let Some(o) = f64p(v, "dashOffset") {
|
||||
s.dash_offset = o as f32;
|
||||
}
|
||||
s.validate()?;
|
||||
Ok(Some(s))
|
||||
}
|
||||
|
||||
@@ -433,6 +457,20 @@ pub fn refresh_shape(doc: &Document, sh: &mut ShapeLayer) {
|
||||
sh.cache = Some(vector::render_shape(sh, doc.pixel_format(), doc.bounds()));
|
||||
}
|
||||
|
||||
/// A staged stroke edit rendered with the same validation and cache refresh as `shape.edit`.
|
||||
/// The caller owns this snapshot; the session and its history remain untouched.
|
||||
pub fn preview_shape_stroke(doc: &Document, id: LayerId, params: &Value) -> Result<Document> {
|
||||
let mut result = doc.clone();
|
||||
let layer = result.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
let LayerContent::Shape(shape) = &mut layer.content else {
|
||||
return Err(EngineError::Other("preview target is not a shape layer".into()));
|
||||
};
|
||||
shape.stroke = parse_stroke(params, shape.stroke.clone(), Color::BLACK).map_err(|e| bad("shape.edit", e))?;
|
||||
vector::flatten::validate_shape(&shape.path, shape.stroke.as_ref()).map_err(|e| bad("shape.edit", e))?;
|
||||
refresh_shape(doc, shape);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Moves a layer's vector content by `(dx, dy)`: shape paths (re-rendered) and linked vector
|
||||
/// masks, recursing into groups. For the Move tool / `layer.move`.
|
||||
pub fn translate_vectors(doc: &Document, l: &mut Layer, dx: f64, dy: f64) {
|
||||
@@ -519,6 +557,12 @@ fn shape_info(s: &Session, id: LayerId) -> Result<Value> {
|
||||
}
|
||||
|
||||
pub(crate) fn with_shape<R>(s: &mut Session, id: LayerId, label: &str, f: impl FnOnce(&mut ShapeLayer, &mut Layer) -> Result<R>) -> Result<R> {
|
||||
if let Some(st) = s.active() {
|
||||
let locks = st.doc.effective_locks(id);
|
||||
if locks.all || locks.pixels {
|
||||
return Err(EngineError::Other("shape layer is locked".into()));
|
||||
}
|
||||
}
|
||||
s.edit(label, |doc, _| {
|
||||
let snapshot = doc.clone();
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
@@ -529,7 +573,9 @@ pub(crate) fn with_shape<R>(s: &mut Session, id: LayerId, label: &str, f: impl F
|
||||
return Err(EngineError::Other(format!("layer {} is not a shape layer", id.0)));
|
||||
};
|
||||
let r = f(&mut sh, l);
|
||||
refresh_shape(&snapshot, &mut sh);
|
||||
if r.is_ok() {
|
||||
refresh_shape(&snapshot, &mut sh);
|
||||
}
|
||||
l.content = LayerContent::Shape(sh);
|
||||
r
|
||||
})
|
||||
@@ -567,6 +613,7 @@ fn shape_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(v) => parse_stroke(v, None, fgc).map_err(|e| bad(CMD, e))?,
|
||||
None => None,
|
||||
};
|
||||
vector::flatten::validate_shape(&path, stroke.as_ref()).map_err(|e| bad(CMD, e))?;
|
||||
// Add to an existing shape layer with a path operation (Shape tool in combine/subtract… mode).
|
||||
if let Some(target) = p.get("addTo").and_then(Value::as_u64) {
|
||||
let op = p.get("op").and_then(Value::as_str).map_or(Some(PathOp::Combine), op_from).ok_or_else(|| bad(CMD, "unknown `op`"))?;
|
||||
@@ -578,6 +625,7 @@ fn shape_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
sh.live = None;
|
||||
sh.psd_raw = None;
|
||||
vector::flatten::validate_shape(&sh.path, sh.stroke.as_ref()).map_err(|e| bad(CMD, e))?;
|
||||
Ok(())
|
||||
})?;
|
||||
return shape_info(s, id);
|
||||
@@ -644,6 +692,7 @@ fn shape_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(n) = name {
|
||||
layer.name = n;
|
||||
}
|
||||
vector::flatten::validate_shape(&sh.path, sh.stroke.as_ref()).map_err(|e| bad(CMD, e))?;
|
||||
Ok(())
|
||||
})?;
|
||||
shape_info(s, id)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ pub enum Outcome {
|
||||
/// field's value is validated before the first one is applied, so a typo, an unknown field, a
|
||||
/// bad value or a bad nested key can't reply with success while nothing — or only half of it —
|
||||
/// changed (#412).
|
||||
pub const UI_SET_FIELDS: [&str; 29] = [
|
||||
pub const UI_SET_FIELDS: [&str; 30] = [
|
||||
"tool",
|
||||
"panels",
|
||||
"dock",
|
||||
@@ -104,6 +104,7 @@ pub const UI_SET_FIELDS: [&str; 29] = [
|
||||
"cropOverlayShow",
|
||||
"cropOverlayOrientation",
|
||||
"cropShield",
|
||||
"shapeStroke",
|
||||
];
|
||||
|
||||
/// Most clicks one `ui.click` may queue (#982). Each click is a press and a release that the app
|
||||
@@ -308,6 +309,20 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest)
|
||||
// rejected call applies none of its fields (#412's guarantee, down to the values
|
||||
// and nested keys).
|
||||
let applied = (|| -> std::result::Result<Value, String> {
|
||||
let shape_stroke = match p.get("shapeStroke") {
|
||||
Some(v) => {
|
||||
if let Some(object) = v.as_object() {
|
||||
let keys = ["width", "opacity", "align", "cap", "join", "miterLimit", "dashes", "dashOffset"];
|
||||
if let Some(key) = object.keys().find(|key| !keys.contains(&key.as_str())) {
|
||||
return Err(format!("unknown shapeStroke field `{key}`"));
|
||||
}
|
||||
}
|
||||
let o = &app.ui.tool_options;
|
||||
let base = o.shape_stroke.stroke(o.stroke_width, photocraft_doc::Fill::Solid(photocraft_doc::Color::BLACK));
|
||||
Some(photocraft_engine::vector_cmds::parse_stroke(v, Some(base), photocraft_doc::Color::BLACK)?)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let tool = match s("tool") {
|
||||
Some(t) => Tool::from_name(t).map(Some).ok_or_else(|| format!("unknown tool `{t}`"))?,
|
||||
None => None,
|
||||
@@ -431,6 +446,14 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest)
|
||||
};
|
||||
|
||||
// Apply (nothing below can fail).
|
||||
if let Some(stroke) = shape_stroke {
|
||||
if let Some(stroke) = stroke {
|
||||
app.ui.tool_options.stroke_width = stroke.width;
|
||||
app.ui.tool_options.shape_stroke = crate::shape_stroke_ui::StrokeOptions::from(&stroke);
|
||||
} else {
|
||||
app.ui.tool_options.stroke_width = 0.0;
|
||||
}
|
||||
}
|
||||
if let Some(t) = tool {
|
||||
app.ui.tool = t;
|
||||
// Each tool keeps its own brush (#218), so switch it in before `brushSize`
|
||||
@@ -903,6 +926,7 @@ pub fn inspect(app: &PhotocraftApp, ctx: &egui::Context) -> Value {
|
||||
"window": {"width": screen.width(), "height": screen.height(), "pixelsPerPoint": ctx.pixels_per_point()},
|
||||
"tool": app.ui.tool,
|
||||
"toolOptions": app.ui.tool_options,
|
||||
"strokeEditor": app.ui.stroke_editor,
|
||||
"magnetic": app.ui.magnetic,
|
||||
"textEdit": app.ui.text_edit,
|
||||
"typeTransform": app.ui.type_transform,
|
||||
@@ -1066,6 +1090,28 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_stroke_defaults_are_drivable_and_validate_atomically() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
let result = call(
|
||||
&mut app,
|
||||
&ctx,
|
||||
"ui.set",
|
||||
json!({"tool":"Triangle", "shapeStroke":{"width":5,"cap":"round","join":"bevel","dashes":[0,2,4,1],"dashOffset":-0.5}}),
|
||||
);
|
||||
assert_eq!(result["ok"], true);
|
||||
assert_eq!(app.ui.tool_options.stroke_width, 5.0);
|
||||
assert_eq!(app.ui.tool_options.shape_stroke.dashes, [0.0, 2.0, 4.0, 1.0]);
|
||||
let before = app.ui.clone();
|
||||
let bad = call(&mut app, &ctx, "ui.set", json!({"tool":"Rectangle","shapeStroke":{"dashes":[0,0]}}));
|
||||
assert_eq!(bad["ok"], false);
|
||||
assert_eq!(app.ui, before);
|
||||
assert_eq!(call(&mut app, &ctx, "ui.set", json!({"shapeStroke":{"colour":"#ff0000"}}))["ok"], false);
|
||||
assert_eq!(call(&mut app, &ctx, "ui.set", json!({"shapeStroke":null}))["ok"], true);
|
||||
assert_eq!(app.ui.tool_options.stroke_width, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_dialogs_are_listed_and_driven_through_ui_dialog() {
|
||||
// #1004: Window › Workspace and View dialogs live in `ui.shell.dialog`, and ui.inspect and
|
||||
|
||||
@@ -2375,6 +2375,27 @@ fontWeight Italic Kurzíva
|
||||
XOR XOR
|
||||
Color Burn (Paint.NET) Ztmavit barvy (Paint.NET)
|
||||
Color Dodge (Paint.NET) Zesvětlit barvy (Paint.NET)
|
||||
Stroke Options Volby tahu
|
||||
Solid Plný
|
||||
Dashed Čárkovaný
|
||||
Dotted Tečkovaný
|
||||
Alignment Zarovnání
|
||||
Caps Konce
|
||||
Butt / Flat Plochý
|
||||
Corners Rohy
|
||||
Miter Ostrý
|
||||
Round Kulatý
|
||||
Bevel Zkosený
|
||||
Miter limit Limit ostrého rohu
|
||||
Dashed line Čárkovaná čára
|
||||
Dash Čárka
|
||||
Gap Mezera
|
||||
Dash / gap lengths × stroke width Délky čárky / mezery × šířka tahu
|
||||
Add pair Přidat pár
|
||||
Remove pair Odebrat pár
|
||||
Dash offset Posun čárek
|
||||
Open paths use centered strokes Otevřené cesty používají vystředěné tahy
|
||||
Use finite lengths and a positive pattern total Použijte konečné délky a kladný součet vzoru
|
||||
No subject found — try Object Selection or the Lasso Nebyl nalezen žádný objekt — zkuste Výběr objektu nebo Laso
|
||||
Quick Selection found nothing there Rychlý výběr zde nic nenašel
|
||||
Object Selection found nothing there — try a tighter rectangle Výběr objektu zde nic nenašel — zkuste těsnější obdélník
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2414,6 +2414,27 @@ fontWeight Italic Kursiv
|
||||
XOR XOR
|
||||
Color Burn (Paint.NET) Farbig nachbelichten (Paint.NET)
|
||||
Color Dodge (Paint.NET) Farbig abwedeln (Paint.NET)
|
||||
Stroke Options Konturoptionen
|
||||
Solid Durchgezogen
|
||||
Dashed Gestrichelt
|
||||
Dotted Gepunktet
|
||||
Alignment Ausrichtung
|
||||
Caps Enden
|
||||
Butt / Flat Stumpf / Flach
|
||||
Corners Ecken
|
||||
Miter Gehrung
|
||||
Round Rund
|
||||
Bevel Abgeschrägt
|
||||
Miter limit Gehrungsgrenze
|
||||
Dashed line Gestrichelte Linie
|
||||
Dash Strich
|
||||
Gap Lücke
|
||||
Dash / gap lengths × stroke width Strich- / Lückenlängen × Konturbreite
|
||||
Add pair Paar hinzufügen
|
||||
Remove pair Paar entfernen
|
||||
Dash offset Strichversatz
|
||||
Open paths use centered strokes Offene Pfade verwenden zentrierte Konturen
|
||||
Use finite lengths and a positive pattern total Endliche Längen und eine positive Mustersumme verwenden
|
||||
No subject found — try Object Selection or the Lasso Kein Motiv gefunden — versuchen Sie das Objektauswahlwerkzeug oder das Lasso
|
||||
Quick Selection found nothing there Die Schnellauswahl hat dort nichts gefunden
|
||||
Object Selection found nothing there — try a tighter rectangle Die Objektauswahl hat dort nichts gefunden — versuchen Sie ein engeres Rechteck
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3486,6 +3486,25 @@ fontWeight Thin Πολύ λεπτά
|
||||
XOR XOR
|
||||
Color Burn (Paint.NET) Κάψιμο χρώματος (Paint.NET)
|
||||
Color Dodge (Paint.NET) Φωτισμός χρώματος (Paint.NET)
|
||||
Stroke Options Επιλογές περιγράμματος
|
||||
Solid Συνεχές
|
||||
Dashed Διακεκομμένο
|
||||
Dotted Με τελείες
|
||||
Caps Άκρα
|
||||
Butt / Flat Επίπεδο
|
||||
Miter Οξεία
|
||||
Round Στρογγυλή
|
||||
Bevel Λοξή
|
||||
Miter limit Όριο οξείας γωνίας
|
||||
Dashed line Διακεκομμένη γραμμή
|
||||
Dash Παύλα
|
||||
Gap Κενό
|
||||
Dash / gap lengths × stroke width Μήκη παύλας / κενού × πλάτος περιγράμματος
|
||||
Add pair Προσθήκη ζεύγους
|
||||
Remove pair Αφαίρεση ζεύγους
|
||||
Dash offset Μετατόπιση παύλας
|
||||
Open paths use centered strokes Οι ανοικτές διαδρομές χρησιμοποιούν κεντραρισμένα περιγράμματα
|
||||
Use finite lengths and a positive pattern total Χρησιμοποιήστε πεπερασμένα μήκη και θετικό άθροισμα μοτίβου
|
||||
No subject found — try Object Selection or the Lasso Δεν βρέθηκε θέμα — δοκιμάστε την Επιλογή αντικειμένου ή το Λάσο
|
||||
Quick Selection found nothing there Η Γρήγορη επιλογή δεν βρήκε τίποτα εκεί
|
||||
Object Selection found nothing there — try a tighter rectangle Η Επιλογή αντικειμένου δεν βρήκε τίποτα εκεί — δοκιμάστε ένα πιο στενό ορθογώνιο
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 966 and column 18.
|
@@ -2443,6 +2443,27 @@ fontWeight Italic Cursiva
|
||||
XOR XOR
|
||||
Color Burn (Paint.NET) Sobreexponer color (Paint.NET)
|
||||
Color Dodge (Paint.NET) Aclarar color (Paint.NET)
|
||||
Stroke Options Opciones de trazo
|
||||
Solid Continuo
|
||||
Dashed Discontinuo
|
||||
Dotted Punteado
|
||||
Alignment Alineación
|
||||
Caps Extremos
|
||||
Butt / Flat Plano
|
||||
Corners Esquinas
|
||||
Miter Inglete
|
||||
Round Redondo
|
||||
Bevel Bisel
|
||||
Miter limit Límite de inglete
|
||||
Dashed line Línea discontinua
|
||||
Dash Guion
|
||||
Gap Espacio
|
||||
Dash / gap lengths × stroke width Longitudes de guion / espacio × anchura del trazo
|
||||
Add pair Añadir par
|
||||
Remove pair Quitar par
|
||||
Dash offset Desplazamiento de guiones
|
||||
Open paths use centered strokes Los trazados abiertos usan trazos centrados
|
||||
Use finite lengths and a positive pattern total Usa longitudes finitas y un total de patrón positivo
|
||||
No subject found — try Object Selection or the Lasso No se encontró ningún sujeto — pruebe Selección de objeto o el Lazo
|
||||
Quick Selection found nothing there Selección rápida no encontró nada ahí
|
||||
Object Selection found nothing there — try a tighter rectangle Selección de objeto no encontró nada ahí — pruebe un rectángulo más ajustado
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -2796,6 +2796,26 @@ fontWeight Italic Italique
|
||||
XOR XOR
|
||||
Color Burn (Paint.NET) Densité couleur + (Paint.NET)
|
||||
Color Dodge (Paint.NET) Densité couleur − (Paint.NET)
|
||||
Stroke Options Options du contour
|
||||
Solid Continu
|
||||
Dashed Tirets
|
||||
Dotted Pointillé
|
||||
Caps Extrémités
|
||||
Butt / Flat Plat
|
||||
Corners Angles
|
||||
Miter Pointe
|
||||
Round Arrondi
|
||||
Bevel Biseau
|
||||
Miter limit Limite de pointe
|
||||
Dashed line Ligne en tirets
|
||||
Dash Tiret
|
||||
Gap Espace
|
||||
Dash / gap lengths × stroke width Longueurs des tirets / espaces × largeur du contour
|
||||
Add pair Ajouter une paire
|
||||
Remove pair Retirer une paire
|
||||
Dash offset Décalage des tirets
|
||||
Open paths use centered strokes Les tracés ouverts utilisent des contours centrés
|
||||
Use finite lengths and a positive pattern total Utiliser des longueurs finies et un total de motif positif
|
||||
No subject found — try Object Selection or the Lasso Aucun sujet trouvé — essayez la Sélection d'objet ou le Lasso
|
||||
Quick Selection found nothing there La Sélection rapide n'a rien trouvé à cet endroit
|
||||
Object Selection found nothing there — try a tighter rectangle La Sélection d'objet n'a rien trouvé à cet endroit — essayez un rectangle plus serré
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2783,6 +2783,26 @@ fontWeight Italic Miring
|
||||
XOR XOR
|
||||
Color Burn (Paint.NET) Color Burn (Paint.NET)
|
||||
Color Dodge (Paint.NET) Color Dodge (Paint.NET)
|
||||
Stroke Options Opsi goresan
|
||||
Solid Utuh
|
||||
Dashed Putus-putus
|
||||
Dotted Titik-titik
|
||||
Caps Ujung
|
||||
Butt / Flat Datar
|
||||
Corners Sudut
|
||||
Miter Tajam
|
||||
Round Bulat
|
||||
Bevel Miring
|
||||
Miter limit Batas sudut tajam
|
||||
Dashed line Garis putus-putus
|
||||
Dash Garis
|
||||
Gap Celah
|
||||
Dash / gap lengths × stroke width Panjang garis / celah × lebar goresan
|
||||
Add pair Tambah pasangan
|
||||
Remove pair Hapus pasangan
|
||||
Dash offset Offset garis
|
||||
Open paths use centered strokes Jalur terbuka menggunakan goresan tengah
|
||||
Use finite lengths and a positive pattern total Gunakan panjang terbatas dan total pola positif
|
||||
No subject found — try Object Selection or the Lasso Subjek tidak ditemukan — coba Seleksi Objek atau Laso
|
||||
Quick Selection found nothing there Seleksi Cepat tidak menemukan apa pun di sana
|
||||
Object Selection found nothing there — try a tighter rectangle Seleksi Objek tidak menemukan apa pun di sana — coba persegi panjang yang lebih rapat
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -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
|
||||
|
||||
|
@@ -3399,6 +3399,25 @@ fontWeight Italic 斜体
|
||||
XOR XOR
|
||||
Color Burn (Paint.NET) 焼き込みカラー (Paint.NET)
|
||||
Color Dodge (Paint.NET) 覆い焼きカラー (Paint.NET)
|
||||
Stroke Options 線のオプション
|
||||
Solid 実線
|
||||
Dashed 破線
|
||||
Dotted 点線
|
||||
Caps 線端
|
||||
Butt / Flat 平端
|
||||
Miter マイター
|
||||
Round 丸型
|
||||
Bevel ベベル
|
||||
Miter limit マイター制限
|
||||
Dashed line 破線
|
||||
Dash 線分
|
||||
Gap 間隔
|
||||
Dash / gap lengths × stroke width 線分 / 間隔の長さ × 線幅
|
||||
Add pair 組を追加
|
||||
Remove pair 組を削除
|
||||
Dash offset 破線のオフセット
|
||||
Open paths use centered strokes 開いたパスの線は中央に配置されます
|
||||
Use finite lengths and a positive pattern total 有限の長さと正のパターン合計を使用してください
|
||||
No subject found — try Object Selection or the Lasso 被写体が見つかりません — オブジェクト選択ツールまたはなげなわツールを試してください
|
||||
Quick Selection found nothing there クイック選択ではそこに何も見つかりませんでした
|
||||
Object Selection found nothing there — try a tighter rectangle オブジェクト選択ではそこに何も見つかりませんでした — より狭い長方形で試してください
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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 개체 선택으로 해당 위치에서 아무것도 찾지 못했습니다 — 더 좁은 사각형을 사용해 보세요
|
||||
|
||||
|
@@ -3558,6 +3558,25 @@ cameraRaw Whites Witten
|
||||
Color Dodge (Paint.NET) Kleur tegenhouden (Paint.NET)
|
||||
Pro Medium Pro Medium
|
||||
Studio Light Studio Light
|
||||
Stroke Options Lijnopties
|
||||
Solid Ononderbroken
|
||||
Dashed Gestreept
|
||||
Dotted Gestippeld
|
||||
Caps Uiteinden
|
||||
Butt / Flat Plat
|
||||
Miter Verstek
|
||||
Round Rond
|
||||
Bevel Afgeschuind
|
||||
Miter limit Versteklimiet
|
||||
Dashed line Gestreepte lijn
|
||||
Dash Streep
|
||||
Gap Ruimte
|
||||
Dash / gap lengths × stroke width Streep- / ruimtelengte × lijnbreedte
|
||||
Add pair Paar toevoegen
|
||||
Remove pair Paar verwijderen
|
||||
Dash offset Streepverschuiving
|
||||
Open paths use centered strokes Open paden gebruiken gecentreerde lijnen
|
||||
Use finite lengths and a positive pattern total Gebruik eindige lengtes en een positief patroontotaal
|
||||
No subject found — try Object Selection or the Lasso Geen onderwerp gevonden — probeer Objectselectie of de Lasso
|
||||
Quick Selection found nothing there Snelle selectie heeft daar niets gevonden
|
||||
Object Selection found nothing there — try a tighter rectangle Objectselectie heeft daar niets gevonden — probeer een krappere rechthoek
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 34.
|
@@ -3398,6 +3398,25 @@ cameraRaw Roughness Chropowatość
|
||||
XOR XOR
|
||||
Color Burn (Paint.NET) Wypalanie koloru (Paint.NET)
|
||||
Color Dodge (Paint.NET) Rozjaśnianie koloru (Paint.NET)
|
||||
Stroke Options Opcje obrysu
|
||||
Solid Ciągły
|
||||
Dashed Kreskowany
|
||||
Dotted Kropkowany
|
||||
Caps Zakończenia
|
||||
Butt / Flat Płaskie
|
||||
Miter Ostre
|
||||
Round Okrągłe
|
||||
Bevel Ścięte
|
||||
Miter limit Limit ostrego narożnika
|
||||
Dashed line Linia kreskowana
|
||||
Dash Kreska
|
||||
Gap Odstęp
|
||||
Dash / gap lengths × stroke width Długości kreski / odstępu × szerokość obrysu
|
||||
Add pair Dodaj parę
|
||||
Remove pair Usuń parę
|
||||
Dash offset Przesunięcie kresek
|
||||
Open paths use centered strokes Otwarte ścieżki używają wyśrodkowanych obrysów
|
||||
Use finite lengths and a positive pattern total Użyj skończonych długości i dodatniej sumy wzoru
|
||||
No subject found — try Object Selection or the Lasso Nie znaleziono obiektu — spróbuj Zaznaczania obiektu lub Lassa
|
||||
Quick Selection found nothing there Szybkie zaznaczanie nic tam nie znalazło
|
||||
Object Selection found nothing there — try a tighter rectangle Zaznaczanie obiektu nic tam nie znalazło — spróbuj ciaśniejszego prostokąta
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2804,6 +2804,26 @@ fontWeight Italic Itálico
|
||||
XOR XOR
|
||||
Color Burn (Paint.NET) Superexposição de cores (Paint.NET)
|
||||
Color Dodge (Paint.NET) Subexposição de cores (Paint.NET)
|
||||
Stroke Options Opções de traçado
|
||||
Solid Contínuo
|
||||
Dashed Tracejado
|
||||
Dotted Pontilhado
|
||||
Caps Pontas
|
||||
Butt / Flat Plano
|
||||
Corners Cantos
|
||||
Miter Junção em ângulo
|
||||
Round Redondo
|
||||
Bevel Chanfrado
|
||||
Miter limit Limite da junção
|
||||
Dashed line Linha tracejada
|
||||
Dash Traço
|
||||
Gap Espaço
|
||||
Dash / gap lengths × stroke width Comprimentos de traço / espaço × largura do traçado
|
||||
Add pair Adicionar par
|
||||
Remove pair Remover par
|
||||
Dash offset Deslocamento do tracejado
|
||||
Open paths use centered strokes Caminhos abertos usam traçados centralizados
|
||||
Use finite lengths and a positive pattern total Use comprimentos finitos e um total de padrão positivo
|
||||
No subject found — try Object Selection or the Lasso Nenhum assunto encontrado — tente Seleção de objeto ou o Laço
|
||||
Quick Selection found nothing there A Seleção rápida não encontrou nada ali
|
||||
Object Selection found nothing there — try a tighter rectangle A Seleção de objeto não encontrou nada ali — tente um retângulo mais justo
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2651,6 +2651,26 @@ fontWeight Italic Курсив
|
||||
XOR XOR
|
||||
Color Burn (Paint.NET) Затемнение основы (Paint.NET)
|
||||
Color Dodge (Paint.NET) Осветление основы (Paint.NET)
|
||||
Stroke Options Параметры обводки
|
||||
Solid Сплошная
|
||||
Dashed Штриховая
|
||||
Dotted Пунктирная
|
||||
Caps Концы
|
||||
Butt / Flat Плоские
|
||||
Corners Углы
|
||||
Miter Острые
|
||||
Round Круглые
|
||||
Bevel Скошенные
|
||||
Miter limit Предел острого угла
|
||||
Dashed line Штриховая линия
|
||||
Dash Штрих
|
||||
Gap Пробел
|
||||
Dash / gap lengths × stroke width Длины штриха / пробела × ширина обводки
|
||||
Add pair Добавить пару
|
||||
Remove pair Удалить пару
|
||||
Dash offset Смещение штрихов
|
||||
Open paths use centered strokes Открытые контуры используют обводку по центру
|
||||
Use finite lengths and a positive pattern total Используйте конечные длины и положительную сумму шаблона
|
||||
No subject found — try Object Selection or the Lasso Объект не найден — попробуйте «Выделение объектов» или «Лассо»
|
||||
Quick Selection found nothing there Быстрое выделение ничего здесь не нашло
|
||||
Object Selection found nothing there — try a tighter rectangle Выделение объектов ничего здесь не нашло — попробуйте более узкий прямоугольник
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3485,6 +3485,25 @@ cameraRaw Masking Маскування
|
||||
Color Dodge (Paint.NET) Освітлення основи (Paint.NET)
|
||||
Pro Medium Pro Medium
|
||||
Studio Light Studio Light
|
||||
Stroke Options Параметри обведення
|
||||
Solid Суцільне
|
||||
Dashed Штрихове
|
||||
Dotted Пунктирне
|
||||
Caps Кінці
|
||||
Butt / Flat Пласкі
|
||||
Miter Гострі
|
||||
Round Круглі
|
||||
Bevel Скошені
|
||||
Miter limit Межа гострого кута
|
||||
Dashed line Штрихова лінія
|
||||
Dash Штрих
|
||||
Gap Проміжок
|
||||
Dash / gap lengths × stroke width Довжини штриха / проміжку × ширина обведення
|
||||
Add pair Додати пару
|
||||
Remove pair Видалити пару
|
||||
Dash offset Зсув штрихів
|
||||
Open paths use centered strokes Відкриті контури використовують обведення по центру
|
||||
Use finite lengths and a positive pattern total Використовуйте скінченні довжини й додатну суму візерунка
|
||||
No subject found — try Object Selection or the Lasso Об'єкт не знайдено — спробуйте «Виділення об'єктів» або «Ласо»
|
||||
Quick Selection found nothing there Швидке виділення нічого тут не знайшло
|
||||
Object Selection found nothing there — try a tighter rectangle Виділення об'єктів нічого тут не знайшло — спробуйте вужчий прямокутник
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 1312 and column 18.
|
@@ -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 对象选择在该处未找到任何内容 — 请尝试更紧凑的矩形
|
||||
|
||||
|
@@ -2352,6 +2352,27 @@ fontWeight Italic 斜體
|
||||
XOR XOR
|
||||
Color Burn (Paint.NET) 加深顏色 (Paint.NET)
|
||||
Color Dodge (Paint.NET) 加亮顏色 (Paint.NET)
|
||||
Stroke Options 筆畫選項
|
||||
Solid 實線
|
||||
Dashed 虛線
|
||||
Dotted 點線
|
||||
Alignment 對齊
|
||||
Caps 端點
|
||||
Butt / Flat 平頭
|
||||
Corners 轉角
|
||||
Miter 斜接
|
||||
Round 圓角
|
||||
Bevel 斜角
|
||||
Miter limit 斜接限制
|
||||
Dashed line 虛線
|
||||
Dash 線段
|
||||
Gap 間隔
|
||||
Dash / gap lengths × stroke width 線段 / 間隔長度 × 筆畫寬度
|
||||
Add pair 新增一對
|
||||
Remove pair 移除一對
|
||||
Dash offset 虛線偏移
|
||||
Open paths use centered strokes 開放路徑使用置中筆畫
|
||||
Use finite lengths and a positive pattern total 請使用有限長度和正數圖樣總長度
|
||||
No subject found — try Object Selection or the Lasso 找不到主體 — 請嘗試物件選取工具或套索工具
|
||||
Quick Selection found nothing there 快速選取在該處找不到任何內容
|
||||
Object Selection found nothing there — try a tighter rectangle 物件選取在該處找不到任何內容 — 請嘗試更緊密的矩形
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -134,6 +134,7 @@ pub mod screen_picker;
|
||||
pub mod scrollbars;
|
||||
pub mod served_fonts;
|
||||
pub mod shape_dialog;
|
||||
pub mod shape_stroke_ui;
|
||||
pub mod shortcut_dispatch;
|
||||
pub mod shortcuts;
|
||||
mod sizing;
|
||||
@@ -390,6 +391,7 @@ pub struct PhotocraftApp {
|
||||
pub(crate) blend_preview: Option<blend_preview::BlendPreview>,
|
||||
/// Patch Tool drag: the healed document at the pointer (`patch_preview`).
|
||||
pub(crate) patch_preview: Option<patch_preview::PatchPreview>,
|
||||
pub(crate) shape_stroke_preview: Option<shape_stroke_ui::ShapeStrokePreview>,
|
||||
/// The pixels a Magnetic Lasso border follows (`magnetic_lasso_ui`).
|
||||
pub(crate) magnetic: magnetic_lasso_ui::Runtime,
|
||||
/// The next tool `Down` is a right-button drag that erases (see `paint_mouse`).
|
||||
@@ -584,6 +586,7 @@ impl PhotocraftApp {
|
||||
move_preview: None,
|
||||
blend_preview: None,
|
||||
patch_preview: None,
|
||||
shape_stroke_preview: None,
|
||||
magnetic: Default::default(),
|
||||
secondary_erase: false,
|
||||
defer_live_stroke: false,
|
||||
|
||||
@@ -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]];
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
//! Shape stroke geometry controls shared by the tool defaults and Properties.
|
||||
use egui::{Sense, Stroke, vec2};
|
||||
use photocraft_doc::{LineCap, LineJoin, ShapeStroke, StrokeAlign};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// Colour and width stay in their existing controls. These options never replace a shape's paint.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct StrokeOptions {
|
||||
pub align: StrokeAlign,
|
||||
pub cap: LineCap,
|
||||
pub join: LineJoin,
|
||||
pub miter_limit: f32,
|
||||
pub dashes: Vec<f32>,
|
||||
pub dash_offset: f32,
|
||||
pub opacity: f32,
|
||||
}
|
||||
|
||||
impl Default for StrokeOptions {
|
||||
fn default() -> Self {
|
||||
Self::from(&ShapeStroke::default())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&ShapeStroke> for StrokeOptions {
|
||||
fn from(s: &ShapeStroke) -> Self {
|
||||
Self { align: s.align, cap: s.cap, join: s.join, miter_limit: s.miter_limit, dashes: s.dashes.clone(), dash_offset: s.dash_offset, opacity: s.opacity }
|
||||
}
|
||||
}
|
||||
|
||||
impl StrokeOptions {
|
||||
pub fn params(&self) -> Value {
|
||||
json!({"align": format!("{:?}", self.align).to_ascii_lowercase(),
|
||||
"cap": format!("{:?}", self.cap).to_ascii_lowercase(), "join": format!("{:?}", self.join).to_ascii_lowercase(),
|
||||
"miterLimit": self.miter_limit, "dashes": self.dashes, "dashOffset": self.dash_offset, "opacity": self.opacity * 100.0})
|
||||
}
|
||||
|
||||
pub fn stroke(&self, width: f32, paint: photocraft_doc::Fill) -> ShapeStroke {
|
||||
ShapeStroke {
|
||||
width,
|
||||
paint,
|
||||
align: self.align,
|
||||
cap: self.cap,
|
||||
join: self.join,
|
||||
miter_limit: self.miter_limit,
|
||||
dashes: self.dashes.clone(),
|
||||
dash_offset: self.dash_offset,
|
||||
opacity: self.opacity,
|
||||
}
|
||||
}
|
||||
|
||||
fn label(&self) -> &'static str {
|
||||
if self.dashes.is_empty() {
|
||||
"Solid"
|
||||
} else if self.dashes == [4.0, 2.0] {
|
||||
"Dashed"
|
||||
} else if self.dashes == [0.0, 2.0] && self.cap == LineCap::Round {
|
||||
"Dotted"
|
||||
} else {
|
||||
"Custom"
|
||||
}
|
||||
}
|
||||
|
||||
fn preset(&mut self, name: &str) {
|
||||
self.dash_offset = 0.0;
|
||||
self.dashes = match name {
|
||||
"Dashed" => vec![4.0, 2.0],
|
||||
"Dotted" => {
|
||||
self.cap = LineCap::Round;
|
||||
vec![0.0, 2.0]
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
}
|
||||
|
||||
fn valid(&self) -> bool {
|
||||
self.stroke(3.0, photocraft_doc::Fill::Solid(photocraft_doc::Color::BLACK)).validate().is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct DragCache {
|
||||
shape: photocraft_doc::ShapeLayer,
|
||||
clip: egui::Rect,
|
||||
image_rect: egui::Rect,
|
||||
texture: egui::TextureHandle,
|
||||
}
|
||||
|
||||
/// The drag uses the committed vector rasterizer. Cache by geometry/style/visible region, and
|
||||
/// cap proxy resolution so oversized windows cannot allocate a document-sized preview.
|
||||
pub fn paint_shape_preview(painter: &egui::Painter, shape: &photocraft_doc::ShapeLayer) {
|
||||
let clip = painter.clip_rect();
|
||||
if !clip.is_finite() || clip.is_negative() {
|
||||
return;
|
||||
}
|
||||
let id = egui::Id::new("shape-drag-render");
|
||||
let cached = painter.ctx().data(|d| d.get_temp::<DragCache>(id));
|
||||
let cache = cached.filter(|c| c.shape == *shape && c.clip == clip).or_else(|| {
|
||||
let scale = (2048.0 / clip.width().max(clip.height()).max(1.0)).min(1.0);
|
||||
let mut proxy = shape.clone();
|
||||
let tr = photocraft_geom::Affine { m: [scale as f64, 0.0, 0.0, scale as f64, -(clip.left() * scale) as f64, -(clip.top() * scale) as f64] };
|
||||
proxy.path = proxy.path.transform(&tr);
|
||||
if let Some(stroke) = proxy.stroke.as_mut() {
|
||||
stroke.width *= scale;
|
||||
}
|
||||
let canvas = photocraft_geom::Rect::from_size(photocraft_geom::Size::new((clip.width() * scale).ceil() as u32, (clip.height() * scale).ceil() as u32));
|
||||
let compiled = photocraft_vector::CompiledShape::new(&proxy, photocraft_vector::DEFAULT_TOLERANCE, canvas);
|
||||
let bounds = compiled.bounds()?.intersect(&canvas);
|
||||
if bounds.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let rgba = compiled.render_rgba(bounds);
|
||||
let bytes: Vec<u8> = rgba.iter().flat_map(|p| p.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)).collect();
|
||||
let texture = painter.ctx().load_texture(
|
||||
"shape-drag",
|
||||
egui::ColorImage::from_rgba_unmultiplied([bounds.width() as usize, bounds.height() as usize], &bytes),
|
||||
egui::TextureOptions::LINEAR,
|
||||
);
|
||||
let screen = |x: i32, y: i32| clip.min + vec2(x as f32, y as f32) / scale;
|
||||
Some(DragCache {
|
||||
shape: shape.clone(),
|
||||
clip,
|
||||
image_rect: egui::Rect::from_min_max(screen(bounds.x0, bounds.y0), screen(bounds.x1, bounds.y1)),
|
||||
texture,
|
||||
})
|
||||
});
|
||||
if let Some(cache) = cache {
|
||||
painter.image(cache.texture.id(), cache.image_rect, egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), egui::Color32::WHITE);
|
||||
painter.ctx().data_mut(|d| d.insert_temp(id, cache));
|
||||
}
|
||||
}
|
||||
|
||||
/// Transient editor content is still plain serde state, visible to inspection.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct StrokeEditor {
|
||||
pub key: String,
|
||||
pub draft: StrokeOptions,
|
||||
pub original: StrokeOptions,
|
||||
pub target: Option<(photocraft_doc::DocId, photocraft_doc::LayerId)>,
|
||||
}
|
||||
|
||||
pub(crate) struct ShapeStrokePreview {
|
||||
doc: photocraft_doc::DocId,
|
||||
revision: u64,
|
||||
target: photocraft_doc::LayerId,
|
||||
options: StrokeOptions,
|
||||
shown: std::sync::Arc<photocraft_doc::Document>,
|
||||
key: u64,
|
||||
}
|
||||
|
||||
/// Cache the selected shape preview by revision and draft. Cancel simply drops the snapshot.
|
||||
pub(crate) fn display_doc(app: &mut crate::PhotocraftApp, idx: usize) -> Option<(std::sync::Arc<photocraft_doc::Document>, u64)> {
|
||||
let active = app.session.active_index() == Some(idx);
|
||||
if !active {
|
||||
return None;
|
||||
}
|
||||
let editor = app.ui.stroke_editor.as_ref().filter(|e| active && e.target.is_some() && e.draft != e.original && e.draft.valid());
|
||||
let Some(editor) = editor else {
|
||||
app.shape_stroke_preview = None;
|
||||
return None;
|
||||
};
|
||||
let (doc, target) = editor.target?;
|
||||
let st = app.session.documents().get(idx)?;
|
||||
let current = st.doc.layer(target).and_then(|l| match &l.content {
|
||||
photocraft_doc::LayerContent::Shape(sh) => sh.stroke.as_ref().map(StrokeOptions::from),
|
||||
_ => None,
|
||||
});
|
||||
let locks = st.doc.effective_locks(target);
|
||||
if st.doc.id != doc || st.active_layer != Some(target) || locks.all || locks.pixels || current.as_ref() != Some(&editor.original) {
|
||||
app.shape_stroke_preview = None;
|
||||
app.ui.stroke_editor = None;
|
||||
return None;
|
||||
}
|
||||
let fresh =
|
||||
app.shape_stroke_preview.as_ref().is_some_and(|p| p.doc == st.doc.id && p.revision == st.revision && p.target == target && p.options == editor.draft);
|
||||
if !fresh {
|
||||
let shown = photocraft_engine::vector_cmds::preview_shape_stroke(&st.doc, target, &editor.draft.params()).ok()?;
|
||||
// Hash all cache inputs so returning to an earlier draft is deterministic, and a
|
||||
// reopened editor cannot alias a different document revision's GPU output.
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hash = std::collections::hash_map::DefaultHasher::new();
|
||||
(st.doc.id, st.revision, target, editor.key.as_str(), editor.draft.params().to_string()).hash(&mut hash);
|
||||
let key = (1u64 << 60) | (hash.finish() & ((1u64 << 60) - 1));
|
||||
app.shape_stroke_preview =
|
||||
Some(ShapeStrokePreview { doc: st.doc.id, revision: st.revision, target, options: editor.draft.clone(), shown: std::sync::Arc::new(shown), key });
|
||||
}
|
||||
app.shape_stroke_preview.as_ref().map(|p| (p.shown.clone(), p.key))
|
||||
}
|
||||
|
||||
/// Staged editor: outside click/Escape cancels; Apply returns one complete command patch.
|
||||
pub fn button(
|
||||
ui: &mut egui::Ui,
|
||||
key: &str,
|
||||
current: &StrokeOptions,
|
||||
editor: &mut Option<StrokeEditor>,
|
||||
closed: bool,
|
||||
corners: bool,
|
||||
target: Option<(photocraft_doc::DocId, photocraft_doc::LayerId)>,
|
||||
) -> Option<StrokeOptions> {
|
||||
let response = ui.add_sized(vec2(96.0, 22.0), egui::Button::new(tl!(current.label()))).on_hover_text(tl!("Stroke Options"));
|
||||
let c = response.rect.right_center() - vec2(10.0, 0.0);
|
||||
ui.painter().add(egui::Shape::line(vec![c - vec2(3.0, 1.5), c + vec2(0.0, 1.5), c + vec2(3.0, -1.5)], Stroke::new(1.0, Tokens::get(ui.ctx()).text_dim)));
|
||||
if response.clicked() {
|
||||
let draft = if current.valid() { current.clone() } else { StrokeOptions::default() };
|
||||
*editor = Some(StrokeEditor { key: key.into(), draft, original: current.clone(), target });
|
||||
}
|
||||
let mut result = None;
|
||||
let mut dismissed = false;
|
||||
egui::Popup::from_toggle_button_response(&response).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
|
||||
let Some(e) = editor.as_mut().filter(|e| e.key == key && e.original == *current && e.target == target) else {
|
||||
ui.close();
|
||||
return;
|
||||
};
|
||||
let s = &mut e.draft;
|
||||
ui.set_width(260.0);
|
||||
ui.label(egui::RichText::new(tl!("Stroke Options")).strong());
|
||||
let body_height = (ui.ctx().content_rect().height() - 150.0).clamp(120.0, 520.0);
|
||||
// Keep the footer stable when presets add/remove dash fields or a scroll bar appears.
|
||||
egui::ScrollArea::vertical().max_height(body_height).min_scrolled_height(body_height).auto_shrink([false, false]).show(ui, |ui| {
|
||||
for name in ["Solid", "Dashed", "Dotted"] {
|
||||
let mut sample = s.clone();
|
||||
sample.preset(name);
|
||||
ui.horizontal(|ui| {
|
||||
if ui.selectable_label(s.label() == name, tl!(name)).clicked() {
|
||||
s.preset(name);
|
||||
}
|
||||
preview(ui, &sample, 154.0, false);
|
||||
});
|
||||
}
|
||||
ui.separator();
|
||||
ui.add_enabled_ui(closed, |ui| {
|
||||
choice(ui, "Alignment", &mut s.align, &[(StrokeAlign::Inside, "Inside"), (StrokeAlign::Center, "Center"), (StrokeAlign::Outside, "Outside")]);
|
||||
});
|
||||
if !closed {
|
||||
ui.label(egui::RichText::new(tl!("Open paths use centered strokes")).small());
|
||||
}
|
||||
ui.add_enabled_ui(!closed || !s.dashes.is_empty(), |ui| {
|
||||
choice(ui, "Caps", &mut s.cap, &[(LineCap::Butt, "Butt / Flat"), (LineCap::Round, "Round"), (LineCap::Square, "Square")]);
|
||||
});
|
||||
ui.add_enabled_ui(corners, |ui| {
|
||||
choice(ui, "Corners", &mut s.join, &[(LineJoin::Miter, "Miter"), (LineJoin::Round, "Round"), (LineJoin::Bevel, "Bevel")]);
|
||||
});
|
||||
ui.add_enabled_ui(corners && s.join == LineJoin::Miter, |ui| {
|
||||
number(ui, "Miter limit", &mut s.miter_limit, 1.0..=500.0, "×");
|
||||
});
|
||||
let mut opacity = s.opacity * 100.0;
|
||||
number(ui, "Opacity", &mut opacity, 0.0..=100.0, "%");
|
||||
s.opacity = opacity / 100.0;
|
||||
ui.separator();
|
||||
let mut dashed = !s.dashes.is_empty();
|
||||
if ui.checkbox(&mut dashed, tl!("Dashed line")).changed() {
|
||||
s.preset(if dashed { "Dashed" } else { "Solid" });
|
||||
}
|
||||
if dashed {
|
||||
ui.label(egui::RichText::new(tl!("Dash / gap lengths × stroke width")).small());
|
||||
for (i, pair) in s.dashes.chunks_mut(2).enumerate() {
|
||||
ui.push_id(i, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
for (j, value) in pair.iter_mut().enumerate() {
|
||||
ui.label(tl!(if j == 0 { "Dash" } else { "Gap" }));
|
||||
crate::widgets::value_field(ui, value, 0.0..=10000.0, "×", 68.0);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
if ui.add_enabled(s.dashes.len() < 32, egui::Button::new(tl!("Add pair"))).clicked() {
|
||||
if s.dashes.len() % 2 == 1 {
|
||||
s.dashes.push(2.0);
|
||||
} else {
|
||||
s.dashes.extend([4.0, 2.0]);
|
||||
}
|
||||
}
|
||||
if ui.add_enabled(s.dashes.len() > 2, egui::Button::new(tl!("Remove pair"))).clicked() {
|
||||
s.dashes.truncate(s.dashes.len().saturating_sub(2));
|
||||
}
|
||||
});
|
||||
number(ui, "Dash offset", &mut s.dash_offset, -1_000_000.0..=1_000_000.0, "×");
|
||||
}
|
||||
preview(ui, s, 260.0, true);
|
||||
});
|
||||
let valid = s.valid();
|
||||
if !valid {
|
||||
ui.colored_label(Tokens::get(ui.ctx()).text_dim, tl!("Use finite lengths and a positive pattern total"));
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
if ui.add_enabled(valid, egui::Button::new(tl!("Apply"))).clicked() {
|
||||
result = Some(s.clone());
|
||||
dismissed = true;
|
||||
ui.close();
|
||||
}
|
||||
if ui.button(tl!("Cancel")).clicked() {
|
||||
dismissed = true;
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
if dismissed {
|
||||
egui::Popup::close_id(ui.ctx(), egui::Popup::default_response_id(&response));
|
||||
*editor = None;
|
||||
}
|
||||
if editor.as_ref().is_some_and(|e| e.key == key) && !egui::Popup::is_id_open(ui.ctx(), egui::Popup::default_response_id(&response)) {
|
||||
*editor = None;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn choice<T: Copy + PartialEq>(ui: &mut egui::Ui, label: &str, value: &mut T, choices: &[(T, &str)]) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!(label));
|
||||
egui::ComboBox::from_id_salt(label).width(140.0).selected_text(choices.iter().find(|(v, _)| v == value).map_or("", |(_, n)| tl!(*n))).show_ui(
|
||||
ui,
|
||||
|ui| {
|
||||
for (v, n) in choices {
|
||||
ui.selectable_value(value, *v, tl!(*n));
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn number(ui: &mut egui::Ui, label: &str, value: &mut f32, range: std::ops::RangeInclusive<f32>, unit: &str) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!(label));
|
||||
crate::widgets::value_field(ui, value, range, unit, 78.0);
|
||||
});
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SampleCache {
|
||||
options: StrokeOptions,
|
||||
width: usize,
|
||||
closed: bool,
|
||||
color: egui::Color32,
|
||||
texture: egui::TextureHandle,
|
||||
}
|
||||
|
||||
/// A tiny cached raster sample uses the actual vector renderer, including AA, opacity and joins.
|
||||
fn preview(ui: &mut egui::Ui, options: &StrokeOptions, width: f32, corners: bool) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let height = if corners { 68.0 } else { 22.0 };
|
||||
let (rect, response) = ui.allocate_exact_size(vec2(width, height), Sense::hover());
|
||||
ui.painter().rect_filled(rect, t.radius_sm, t.field);
|
||||
let id = response.id.with("stroke-preview");
|
||||
let w = width as usize;
|
||||
let mut sample = options.clone();
|
||||
if !corners {
|
||||
sample.align = StrokeAlign::Center;
|
||||
}
|
||||
let cached = ui.data(|d| d.get_temp::<SampleCache>(id));
|
||||
let cache = cached.filter(|c| c.options == sample && c.width == w && c.closed == corners && c.color == t.text).unwrap_or_else(|| {
|
||||
let path = if corners {
|
||||
photocraft_vector::shapes::rounded_rect(16.0, 16.0, width as f64 - 32.0, 36.0, [0.0; 4])
|
||||
} else {
|
||||
let mut sp = photocraft_doc::Subpath::polygon(&[(12.0, 11.0), (width as f64 - 12.0, 11.0)]);
|
||||
sp.closed = false;
|
||||
photocraft_doc::Path::new(vec![sp])
|
||||
};
|
||||
let c = t.text;
|
||||
let shape =
|
||||
photocraft_doc::ShapeLayer {
|
||||
path,
|
||||
stroke: Some(sample.stroke(
|
||||
3.0,
|
||||
photocraft_doc::Fill::Solid(photocraft_doc::Color::rgba(c.r() as f32 / 255.0, c.g() as f32 / 255.0, c.b() as f32 / 255.0, 1.0)),
|
||||
)),
|
||||
..Default::default()
|
||||
};
|
||||
let r = photocraft_geom::Rect::from_size(photocraft_geom::Size::new(w as u32, height as u32));
|
||||
let pixels = photocraft_vector::CompiledShape::new(&shape, photocraft_vector::DEFAULT_TOLERANCE, r).render_rgba(r);
|
||||
let bytes: Vec<u8> = pixels.iter().flat_map(|p| p.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)).collect();
|
||||
let texture =
|
||||
ui.ctx().load_texture("stroke-preview", egui::ColorImage::from_rgba_unmultiplied([w, height as usize], &bytes), egui::TextureOptions::LINEAR);
|
||||
SampleCache { options: sample, width: w, closed: corners, color: t.text, texture }
|
||||
});
|
||||
ui.painter().image(cache.texture.id(), rect, egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), egui::Color32::WHITE);
|
||||
ui.painter().rect_stroke(rect, t.radius_sm, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside);
|
||||
ui.data_mut(|d| d.insert_temp(id, cache));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
|
||||
struct State {
|
||||
current: StrokeOptions,
|
||||
editor: Option<StrokeEditor>,
|
||||
applied: usize,
|
||||
}
|
||||
fn harness() -> Harness<'static, State> {
|
||||
Harness::builder().with_size(vec2(500.0, 800.0)).build_ui_state(
|
||||
|ui, s: &mut State| {
|
||||
if let Some(next) = button(ui, "test", &s.current, &mut s.editor, true, true, None) {
|
||||
s.current = next;
|
||||
s.applied += 1;
|
||||
}
|
||||
},
|
||||
State { current: Default::default(), editor: None, applied: 0 },
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preset_preview_is_staged_cancelled_or_applied_once() {
|
||||
let mut h = harness();
|
||||
h.run();
|
||||
h.get_by_label("Solid").click();
|
||||
h.run();
|
||||
h.get_by_label("Dotted").click();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().current.dashes.is_empty());
|
||||
assert_eq!(h.state().editor.as_ref().unwrap().draft.dashes, [0.0, 2.0]);
|
||||
let cancel_rect = h.get_by_label("Cancel").rect();
|
||||
h.get_by_label("Cancel").click();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().editor.is_none(), "Cancel at {cancel_rect:?}; remaining: {:?}", h.query_all_by_label("Cancel").map(|n| n.rect()).collect::<Vec<_>>());
|
||||
assert_eq!(h.state().applied, 0);
|
||||
h.get_by_label("Solid").click();
|
||||
h.run();
|
||||
h.get_by_label("Dotted").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Apply").click();
|
||||
h.run();
|
||||
assert_eq!(h.state().current.cap, LineCap::Round);
|
||||
assert_eq!(h.state().current.dashes, [0.0, 2.0]);
|
||||
assert_eq!(h.state().applied, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_serde_defaults_keep_old_tool_settings_and_full_stroke() {
|
||||
assert_eq!(serde_json::from_value::<StrokeOptions>(json!({})).unwrap(), StrokeOptions::default());
|
||||
let mut style = StrokeOptions::default();
|
||||
style.preset("Dotted");
|
||||
style.dashes = vec![0.0, 2.0, 4.0, 1.0];
|
||||
style.dash_offset = -0.5;
|
||||
let stroke = style.stroke(5.0, photocraft_doc::Fill::Solid(photocraft_doc::Color::BLACK));
|
||||
assert_eq!(StrokeOptions::from(&stroke), style);
|
||||
assert_eq!(serde_json::from_value::<StrokeOptions>(serde_json::to_value(&style).unwrap()).unwrap(), style);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canvas_preview_is_cached_and_matches_commit_at_all_depths() {
|
||||
for depth in [8, 16, 32] {
|
||||
let mut app = crate::PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width":80,"height":60,"depth":depth})).unwrap();
|
||||
let id = app.run("shape.create", json!({"rect":[15,15,45,30],"stroke":{"width":5,"color":"#ff0000"}})).unwrap()["layer"].as_u64().unwrap();
|
||||
let target = photocraft_doc::LayerId(id);
|
||||
let original = StrokeOptions::default();
|
||||
let mut draft = original.clone();
|
||||
draft.preset("Dotted");
|
||||
draft.join = LineJoin::Round;
|
||||
draft.opacity = 0.5;
|
||||
let committed = app.session.active().unwrap().doc.clone();
|
||||
let count = app.session.active().unwrap().history.past_len();
|
||||
app.ui.stroke_editor = Some(StrokeEditor { key: "test".into(), original, draft: draft.clone(), target: Some((committed.id, target)) });
|
||||
let idx = app.session.active_index().unwrap();
|
||||
let first = display_doc(&mut app, idx).unwrap();
|
||||
let next = display_doc(&mut app, idx).unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&first.0, &next.0));
|
||||
assert_eq!(first.1, next.1);
|
||||
assert_eq!(app.session.active().unwrap().doc, committed);
|
||||
assert_eq!(app.session.active().unwrap().history.past_len(), count);
|
||||
app.ui.stroke_editor = None;
|
||||
assert!(display_doc(&mut app, idx).is_none());
|
||||
app.run("shape.edit", json!({"layer":id,"stroke":draft.params()})).unwrap();
|
||||
assert_eq!(app.session.active().unwrap().doc, first.0);
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(app.session.active().unwrap().doc, committed);
|
||||
app.ui.stroke_editor = Some(StrokeEditor { key: "test".into(), original: StrokeOptions::default(), draft, target: Some((committed.id, target)) });
|
||||
app.run("file.new", json!({"width":80,"height":60,"depth":depth})).unwrap();
|
||||
let other = app.session.active_index().unwrap();
|
||||
assert!(display_doc(&mut app, other).is_none());
|
||||
assert!(app.ui.stroke_editor.is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -505,6 +505,8 @@ pub struct ToolOptions {
|
||||
/// radius, polygon sides, line weight.
|
||||
pub shape_fill: bool,
|
||||
pub stroke_width: f32,
|
||||
#[serde(default)]
|
||||
pub shape_stroke: crate::shape_stroke_ui::StrokeOptions,
|
||||
pub corner_radius: f32,
|
||||
pub polygon_sides: u32,
|
||||
pub line_weight: f32,
|
||||
@@ -659,6 +661,7 @@ impl Default for ToolOptions {
|
||||
vector_mode: "path".into(),
|
||||
shape_fill: true,
|
||||
stroke_width: 0.0,
|
||||
shape_stroke: Default::default(),
|
||||
corner_radius: 0.0,
|
||||
polygon_sides: 5,
|
||||
line_weight: 3.0,
|
||||
@@ -897,6 +900,8 @@ pub struct UiState {
|
||||
/// Pen path under construction.
|
||||
#[serde(default)]
|
||||
pub pen: Option<crate::vector_ui::PenPath>,
|
||||
#[serde(default)]
|
||||
pub stroke_editor: Option<crate::shape_stroke_ui::StrokeEditor>,
|
||||
/// Direct Selection tool: selected anchors and the drag in progress (#790).
|
||||
#[serde(default)]
|
||||
pub direct_selection: crate::direct_select::DirectSelection,
|
||||
@@ -1036,6 +1041,7 @@ impl Default for UiState {
|
||||
shell: Default::default(),
|
||||
layer_filter: Vec::new(),
|
||||
pen: None,
|
||||
stroke_editor: None,
|
||||
direct_selection: Default::default(),
|
||||
selected_path: None,
|
||||
panels: Panels::default(),
|
||||
|
||||
+117
-24
@@ -44,7 +44,13 @@ fn hex(c: [f32; 4]) -> String {
|
||||
|
||||
fn stroke_param(app: &PhotocraftApp) -> Value {
|
||||
let o = &app.ui.tool_options;
|
||||
if o.stroke_width > 0.0 { json!({"width": o.stroke_width, "color": hex(app.session.tools.background)}) } else { Value::Null }
|
||||
if o.stroke_width <= 0.0 {
|
||||
return Value::Null;
|
||||
}
|
||||
let mut p = o.shape_stroke.params();
|
||||
p["width"] = json!(o.stroke_width);
|
||||
p["color"] = json!(hex(app.session.tools.background));
|
||||
p
|
||||
}
|
||||
|
||||
/// Geometry of a Shape-tool drag as `shape.create` params, without fill and stroke (None when too
|
||||
@@ -113,19 +119,40 @@ pub fn create_shape(app: &mut PhotocraftApp, mut geometry: Value) -> Result<Valu
|
||||
app.run("shape.create", geometry)
|
||||
}
|
||||
|
||||
/// Shape-tool drag preview: the shape `finish_shape` will create, filled and stroked, under its
|
||||
/// path outline. Custom shapes show their box outline only.
|
||||
/// Shape-tool drag preview using the same path and vector stroke geometry as the commit.
|
||||
pub fn draw_shape_preview(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, tool: Tool, start: [f64; 2], end: [f64; 2], mods: egui::Modifiers) {
|
||||
let o = &app.ui.tool_options;
|
||||
let Some(path) = shape_geometry(o, tool, start, end, mods).and_then(|p| photocraft_engine::vector_cmds::shape_path(&p).ok()) else { return };
|
||||
let custom = tool == Tool::CustomShape;
|
||||
let Some(geometry) = shape_geometry(o, tool, start, end, mods) else { return };
|
||||
let path = if tool == Tool::CustomShape {
|
||||
let Some(rect) = geometry.get("rect").and_then(|r| serde_json::from_value::<[f64; 4]>(r.clone()).ok()) else { return };
|
||||
let groups = photocraft_engine::presets::shapes::all_groups(&app.session);
|
||||
let Some(shape) = groups.iter().flat_map(|g| &g.items).find(|s| s.name == app.ui.presets_ui.shape()) else { return };
|
||||
photocraft_engine::presets::shapes::fit(&shape.path, rect, mods.shift)
|
||||
} else {
|
||||
let Ok(path) = photocraft_engine::vector_cmds::shape_path(&geometry) else { return };
|
||||
path
|
||||
};
|
||||
// ponytail: preview colours skip the canvas's colour management; the commit renders them exactly.
|
||||
let fill = if o.shape_fill && !custom { rgb32(app.session.tools.foreground) } else { Color32::TRANSPARENT };
|
||||
let stroke = if o.stroke_width > 0.0 && !custom { Stroke::new(o.stroke_width * xf.zoom, rgb32(app.session.tools.background)) } else { Stroke::NONE };
|
||||
let fill = if o.shape_fill { rgb32(app.session.tools.foreground) } else { Color32::TRANSPARENT };
|
||||
{
|
||||
let origin = xf.to_screen(0.0, 0.0);
|
||||
let x = xf.to_screen(1.0, 0.0) - origin;
|
||||
let y = xf.to_screen(0.0, 1.0) - origin;
|
||||
let transform = Affine { m: [x.x as f64, x.y as f64, y.x as f64, y.y as f64, origin.x as f64, origin.y as f64] };
|
||||
let c = app.session.tools.background;
|
||||
let shape = photocraft_doc::ShapeLayer {
|
||||
path: path.transform(&transform),
|
||||
fill: (fill != Color32::TRANSPARENT).then(|| {
|
||||
photocraft_doc::Fill::Solid(photocraft_doc::Color::rgba(fill.r() as f32 / 255.0, fill.g() as f32 / 255.0, fill.b() as f32 / 255.0, 1.0))
|
||||
}),
|
||||
stroke: (o.stroke_width > 0.0)
|
||||
.then(|| o.shape_stroke.stroke(o.stroke_width * xf.zoom, photocraft_doc::Fill::Solid(photocraft_doc::Color::rgba(c[0], c[1], c[2], c[3])))),
|
||||
..Default::default()
|
||||
};
|
||||
crate::shape_stroke_ui::paint_shape_preview(painter, &shape);
|
||||
}
|
||||
let accent = Tokens::get(painter.ctx()).accent;
|
||||
for (pts, _) in path_lines(&path, &|q| xf.to_screen(q[0] as f32, q[1] as f32)) {
|
||||
// Every tool's own shape is convex (custom shapes aren't, and draw no fill).
|
||||
painter.add(egui::Shape::convex_polygon(pts.clone(), fill, stroke));
|
||||
// The accent path over a dark halo: visible on any pixels (#172).
|
||||
painter.extend(crate::tool_feedback::contrast_path(pts, true, accent));
|
||||
}
|
||||
@@ -216,8 +243,14 @@ pub fn pen_commit(app: &mut PhotocraftApp, closed: bool) {
|
||||
let path = pen_to_json(&pen, closed);
|
||||
let r = if app.ui.tool_options.vector_mode == "shape" {
|
||||
let fill = if closed && app.ui.tool_options.shape_fill { json!(hex(app.session.tools.foreground)) } else { Value::Null };
|
||||
let stroke =
|
||||
if closed { stroke_param(app) } else { json!({"width": app.ui.tool_options.stroke_width.max(1.0), "color": hex(app.session.tools.foreground)}) };
|
||||
let stroke = if closed {
|
||||
stroke_param(app)
|
||||
} else {
|
||||
let mut stroke = app.ui.tool_options.shape_stroke.params();
|
||||
stroke["width"] = json!(app.ui.tool_options.stroke_width.max(1.0));
|
||||
stroke["color"] = json!(hex(app.session.tools.foreground));
|
||||
stroke
|
||||
};
|
||||
app.run("shape.create", json!({"kind": "path", "path": path, "fill": fill, "stroke": stroke}))
|
||||
} else if let Some((id, existing)) = targeted_vector_mask(app) {
|
||||
// A targeted vector mask takes the new subpath (#196), as in Photoshop.
|
||||
@@ -529,6 +562,12 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo
|
||||
let opts = [("path".to_string(), tl!("Path")), ("shape".to_string(), tl!("Shape"))];
|
||||
crate::widgets::dropdown(ui, "pen-mode", &mut o.vector_mode, &opts, 80.0);
|
||||
crate::widgets::vline(ui, 22.0);
|
||||
if o.vector_mode == "shape" {
|
||||
crate::widgets::value_field(ui, &mut o.stroke_width, 0.0..=288.0, "px", 58.0);
|
||||
if let Some(style) = crate::shape_stroke_ui::button(ui, "pen-stroke", &o.shape_stroke, &mut app.ui.stroke_editor, false, true, None) {
|
||||
o.shape_stroke = style;
|
||||
}
|
||||
}
|
||||
lbl(
|
||||
ui,
|
||||
&crate::i18n::fmt(
|
||||
@@ -547,7 +586,12 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo
|
||||
ui.painter().rect_filled(r, 2.0, rgb32(app.session.tools.foreground));
|
||||
ui.painter().rect_stroke(r, 2.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Outside);
|
||||
lbl(ui, tl!("Stroke:"));
|
||||
let bg = app.session.tools.background;
|
||||
let stroke_color = swatch(ui, Some(&photocraft_doc::Fill::Solid(photocraft_doc::Color::rgba(bg[0], bg[1], bg[2], bg[3]))), tl!("Stroke color"));
|
||||
crate::widgets::value_field(ui, &mut o.stroke_width, 0.0..=288.0, "px", 58.0);
|
||||
if let Some(style) = crate::shape_stroke_ui::button(ui, "tool-stroke", &o.shape_stroke, &mut app.ui.stroke_editor, true, tool != Tool::EllipseShape, None) {
|
||||
o.shape_stroke = style;
|
||||
}
|
||||
match tool {
|
||||
Tool::Rectangle => {
|
||||
crate::widgets::vline(ui, 22.0);
|
||||
@@ -573,6 +617,14 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(color) = stroke_color {
|
||||
if color == "none" {
|
||||
app.ui.tool_options.stroke_width = 0.0;
|
||||
} else if let Err(e) = app.run("tools.setColors", json!({"background": color})) {
|
||||
app.ui.status = e;
|
||||
app.ui.status_error = true;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
@@ -661,6 +713,12 @@ fn corner_radii_patch(radii: [f64; 4], corner: usize, radius: f32) -> Value {
|
||||
|
||||
/// Properties panel for a shape layer: Appearance (fill, stroke) and live shape geometry.
|
||||
pub fn shape_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: photocraft_doc::LayerId) {
|
||||
if app.session.active().is_some_and(|st| {
|
||||
let locks = st.doc.effective_locks(id);
|
||||
locks.all || locks.pixels
|
||||
}) {
|
||||
ui.disable();
|
||||
}
|
||||
let Some(sh) = app.session.active().and_then(|s| s.doc.layer(id)).and_then(|l| match &l.content {
|
||||
LayerContent::Shape(sh) => Some(sh.clone()),
|
||||
_ => None,
|
||||
@@ -669,7 +727,8 @@ pub fn shape_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: photocra
|
||||
};
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let mut edit: Option<Value> = None;
|
||||
let key = |k: &str| format!("shape-{}-{k}", id.0);
|
||||
let doc_id = app.session.active().map(|st| st.doc.id);
|
||||
let key = |k: &str| format!("shape-{doc_id:?}-{}-{k}", id.0);
|
||||
// The shared collapsible section headers (#155).
|
||||
if crate::props_layout::section(ui, "appearance", tl!("Appearance")) {
|
||||
ui.horizontal(|ui| {
|
||||
@@ -690,19 +749,21 @@ pub fn shape_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: photocra
|
||||
if crate::widgets::value_field(ui, &mut w, 0.0..=288.0, "px", 60.0).changed() {
|
||||
edit = Some(if w <= 0.0 { json!({"stroke": null}) } else { json!({"stroke": {"width": w}, "coalesce": key("stroke-w")}) });
|
||||
}
|
||||
if sh.stroke.is_some() {
|
||||
let mut align = match sh.stroke.as_ref().map(|s| s.align) {
|
||||
Some(photocraft_doc::vector::StrokeAlign::Inside) => "inside",
|
||||
Some(photocraft_doc::vector::StrokeAlign::Outside) => "outside",
|
||||
_ => "center",
|
||||
}
|
||||
.to_string();
|
||||
let opts = [("inside".to_string(), "Inside"), ("center".to_string(), "Center"), ("outside".to_string(), "Outside")];
|
||||
if crate::widgets::dropdown(ui, &key("align"), &mut align, &opts, 84.0) {
|
||||
edit = Some(json!({"stroke": {"align": align}}));
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(stroke) = &sh.stroke {
|
||||
let style = crate::shape_stroke_ui::StrokeOptions::from(stroke);
|
||||
if let Some(next) = crate::shape_stroke_ui::button(
|
||||
ui,
|
||||
&key("style"),
|
||||
&style,
|
||||
&mut app.ui.stroke_editor,
|
||||
sh.path.subpaths.iter().all(|s| s.closed),
|
||||
!matches!(sh.live, Some(photocraft_doc::LiveShape::Ellipse { .. })),
|
||||
app.session.active().map(|st| (st.doc.id, id)),
|
||||
) {
|
||||
edit = Some(json!({"stroke": next.params()}));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(live) = &sh.live
|
||||
&& crate::props_layout::section(ui, "liveShape", tl!("Shape"))
|
||||
@@ -1019,6 +1080,38 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn all_shape_tools_and_open_pen_use_the_complete_stroke_defaults() {
|
||||
for tool in [Tool::Rectangle, Tool::EllipseShape, Tool::Triangle, Tool::Polygon, Tool::Line, Tool::CustomShape] {
|
||||
let mut a = app();
|
||||
a.ui.tool_options.stroke_width = 5.0;
|
||||
a.ui.tool_options.shape_stroke = crate::shape_stroke_ui::StrokeOptions {
|
||||
cap: photocraft_doc::LineCap::Square,
|
||||
join: photocraft_doc::LineJoin::Bevel,
|
||||
dashes: vec![4.0, 2.0, 1.0, 3.0],
|
||||
dash_offset: -0.5,
|
||||
opacity: 0.4,
|
||||
..Default::default()
|
||||
};
|
||||
let history = a.session.active().unwrap().history.past_len();
|
||||
finish_shape(&mut a, tool, [20.0, 20.0], [120.0, 100.0], Default::default());
|
||||
let info = a.run("shape.info", json!({})).unwrap();
|
||||
assert_eq!(info["stroke"]["dashes"], json!([4.0, 2.0, 1.0, 3.0]));
|
||||
assert_eq!(info["stroke"]["cap"], "square");
|
||||
assert_eq!(info["stroke"]["width"], 5.0);
|
||||
assert_eq!(a.session.active().unwrap().history.past_len(), history + 1);
|
||||
a.run("edit.undo", json!({})).unwrap();
|
||||
a.run("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(a.run("shape.info", json!({})).unwrap(), info);
|
||||
a.ui.tool_options.vector_mode = "shape".into();
|
||||
a.ui.pen = Some(PenPath { knots: vec![[[30.0, 40.0]; 3], [[80.0, 40.0]; 3]], ..Default::default() });
|
||||
pen_commit(&mut a, false);
|
||||
let open = a.run("shape.info", json!({})).unwrap();
|
||||
assert_eq!(open["stroke"]["dashes"], info["stroke"]["dashes"]);
|
||||
assert_eq!(open["path"]["subpaths"][0]["closed"], false);
|
||||
}
|
||||
}
|
||||
|
||||
fn app() -> PhotocraftApp {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.session.execute("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,45 @@ fn eval(seg: &[Point; 4], t: f64) -> (f64, f64) {
|
||||
(a * p0.x + b * c1.x + c * c2.x + d * p1.x, a * p0.y + b * c1.y + c * c2.y + d * p1.y)
|
||||
}
|
||||
|
||||
/// Validate before interactive rendering: finite coordinates and a bounded tessellation and
|
||||
/// dash workload. Arc length is bounded by the cubic control polygon, without allocating pixels.
|
||||
pub fn validate_shape(path: &Path, stroke: Option<&photocraft_doc::ShapeStroke>) -> Result<(), &'static str> {
|
||||
let knots = path.subpaths.iter().try_fold(0usize, |n, s| n.checked_add(s.knots.len())).ok_or("too many path knots")?;
|
||||
if knots > 4096 || path.subpaths.len() > 4096 {
|
||||
return Err("shape path exceeds 4096 knots/subpaths");
|
||||
}
|
||||
let mut points = 0usize;
|
||||
let mut length = 0.0;
|
||||
for sub in &path.subpaths {
|
||||
for k in &sub.knots {
|
||||
for p in [k.anchor, k.in_ctrl, k.out_ctrl] {
|
||||
if !p.x.is_finite() || !p.y.is_finite() || p.x.abs() > 1_000_000.0 || p.y.abs() > 1_000_000.0 {
|
||||
return Err("shape coordinates must be finite and within ±1000000 px");
|
||||
}
|
||||
}
|
||||
}
|
||||
points = points.saturating_add(1);
|
||||
for seg in sub.segments() {
|
||||
points = points.saturating_add(if is_line(&seg) { 1 } else { pieces(&seg, crate::DEFAULT_TOLERANCE) });
|
||||
length += seg.windows(2).map(|w| (w[1].x - w[0].x).hypot(w[1].y - w[0].y)).sum::<f64>();
|
||||
}
|
||||
}
|
||||
if points > 100_000 {
|
||||
return Err("shape tessellation exceeds 100000 points");
|
||||
}
|
||||
if let Some(s) = stroke {
|
||||
s.validate()?;
|
||||
if s.width > 0.0 && !s.dashes.is_empty() {
|
||||
let period = s.dashes.iter().map(|d| f64::from(*d)).sum::<f64>() * f64::from(s.width);
|
||||
let transitions = (length / period + 2.0) * s.dashes.len() as f64 + points as f64;
|
||||
if !transitions.is_finite() || transitions > 100_000.0 {
|
||||
return Err("dash pattern is too dense for this path (100000 step budget)");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Flattens one subpath. Closed subpaths do not repeat the first point at the end.
|
||||
pub fn flatten_subpath(s: &Subpath, tol: f64) -> Polyline {
|
||||
let mut out = Polyline { closed: s.closed, ..Default::default() };
|
||||
|
||||
@@ -100,11 +100,14 @@ fn circle(c: P, r: f64, tol: f64) -> Vec<P> {
|
||||
/// Stroke outline pieces for `lines`. Fill them with the non-zero rule.
|
||||
pub fn stroke_polygons(lines: &[Polyline], style: &StrokeStyle, tol: f64) -> Vec<Vec<P>> {
|
||||
let mut out = Vec::new();
|
||||
if style.width <= 0.0 || !style.width.is_finite() {
|
||||
if style.width <= 0.0 || !style.width.is_finite() || !style.miter_limit.is_finite() || style.miter_limit < 1.0 {
|
||||
return out;
|
||||
}
|
||||
for pl in lines {
|
||||
if style.dashes.iter().any(|d| *d > 0.0) {
|
||||
if pl.pts.iter().any(|p| !p.0.is_finite() || !p.1.is_finite()) {
|
||||
continue;
|
||||
}
|
||||
if !style.dashes.is_empty() {
|
||||
for dash in dash_polyline(pl, &style.dashes, style.dash_offset) {
|
||||
stroke_one(&dash, style, tol, &mut out);
|
||||
}
|
||||
@@ -210,7 +213,7 @@ fn stroke_one(pl: &Polyline, style: &StrokeStyle, tol: f64, out: &mut Vec<Vec<P>
|
||||
(jn, true) => jn,
|
||||
(_, false) => LineJoin::Miter,
|
||||
};
|
||||
join_piece(v, d1, d2, hw, join, if knot[j] { style.miter_limit } else { f64::INFINITY }, tol, out);
|
||||
join_piece(v, d1, d2, hw, join, if knot[j] { style.miter_limit.min(500.0) } else { f64::INFINITY }, tol, out);
|
||||
}
|
||||
}
|
||||
if !closed {
|
||||
@@ -291,14 +294,24 @@ fn cap_piece(p: P, dir: P, hw: f64, cap: LineCap, tol: f64, out: &mut Vec<Vec<P>
|
||||
|
||||
/// Splits a polyline into dashes (open polylines). `pattern` alternates on/off lengths in px.
|
||||
pub fn dash_polyline(pl: &Polyline, pattern: &[f64], offset: f64) -> Vec<Polyline> {
|
||||
let mut pat: Vec<f64> = pattern.iter().map(|d| d.max(0.0)).collect();
|
||||
if pattern.is_empty() {
|
||||
return vec![pl.clone()];
|
||||
}
|
||||
if pattern.len() > 32
|
||||
|| !offset.is_finite()
|
||||
|| pattern.iter().any(|d| !d.is_finite() || *d < 0.0)
|
||||
|| pl.pts.iter().any(|p| !p.0.is_finite() || !p.1.is_finite())
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
let mut pat = pattern.to_vec();
|
||||
if pat.len() % 2 == 1 {
|
||||
let copy = pat.clone();
|
||||
pat.extend(copy);
|
||||
}
|
||||
let total: f64 = pat.iter().sum();
|
||||
if total <= 1e-9 || pl.pts.len() < 2 {
|
||||
return vec![pl.clone()];
|
||||
if !total.is_finite() || total <= 1e-9 || pl.pts.len() < 2 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut pts = pl.pts.clone();
|
||||
let mut knots = pl.knot.clone();
|
||||
@@ -331,10 +344,15 @@ pub fn dash_polyline(pl: &Polyline, pattern: &[f64], offset: f64) -> Vec<Polylin
|
||||
let mut pos = 0.0;
|
||||
while seg - pos > 1e-12 {
|
||||
guard += 1;
|
||||
if guard > 10_000_000 {
|
||||
break;
|
||||
if guard > 100_000 {
|
||||
// Bound allocations and time, including file-provided patterns. Commands
|
||||
// reject patterns that exceed this budget before document mutation.
|
||||
return Vec::new();
|
||||
}
|
||||
let step = left.min(seg - pos);
|
||||
if step > 0.0 && pos + step == pos {
|
||||
return Vec::new();
|
||||
}
|
||||
pos += step;
|
||||
left -= step;
|
||||
let at_end = seg - pos <= 1e-12;
|
||||
@@ -362,6 +380,25 @@ pub fn dash_polyline(pl: &Polyline, pattern: &[f64], offset: f64) -> Vec<Polylin
|
||||
{
|
||||
out.push(c);
|
||||
}
|
||||
if pl.closed {
|
||||
// A dash that crosses the closing vertex has a join there, not two caps.
|
||||
// Preserve knot flags when stitching the tail onto the first dash.
|
||||
let seam = pl.pts.first().copied();
|
||||
let wraps = out.first().and_then(|d| d.pts.first()).copied() == seam && out.last().and_then(|d| d.pts.last()).copied() == seam;
|
||||
if wraps && out.len() == 1 {
|
||||
if let Some(dash) = out.first_mut() {
|
||||
dash.closed = true;
|
||||
}
|
||||
} else if wraps
|
||||
&& out.len() > 1
|
||||
&& let Some(mut tail) = out.pop()
|
||||
&& let Some(first) = out.first_mut()
|
||||
{
|
||||
tail.pts.extend(first.pts.iter().skip(1).copied());
|
||||
tail.knot.extend(first.knot.iter().skip(1).copied());
|
||||
*first = tail;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -397,4 +434,37 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_dashes_have_a_join_at_the_seam() {
|
||||
let square = line(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)], true);
|
||||
let dashes = dash_polyline(&square, &[8.0, 4.0], 2.0);
|
||||
assert_eq!(dashes.len(), 3);
|
||||
let crossing = &dashes[0];
|
||||
assert_eq!(crossing.pts, [(0.0, 6.0), (0.0, 0.0), (6.0, 0.0)]);
|
||||
assert_eq!(crossing.knot, [true, true, true]);
|
||||
let full = dash_polyline(&square, &[80.0, 2.0], 0.0);
|
||||
assert_eq!(full.len(), 1);
|
||||
assert!(full[0].closed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_and_too_dense_dashes_finish_with_bounded_work() {
|
||||
let pl = line(&[(0.0, 0.0), (100.0, 0.0)], false);
|
||||
for pattern in [vec![0.0, 0.0], vec![f64::INFINITY, 1.0], vec![f64::NAN, 1.0], vec![-1.0, 2.0], vec![1.0; 33], vec![1e-8, 1e-8]] {
|
||||
assert!(dash_polyline(&pl, &pattern, 0.0).is_empty());
|
||||
}
|
||||
assert!(dash_polyline(&pl, &[2.0, 1.0], f64::INFINITY).is_empty());
|
||||
assert!(dash_polyline(&line(&[(f64::NAN, 0.0), (1.0, 0.0)], false), &[2.0, 1.0], 0.0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonuniform_patterns_and_negative_offsets_repeat_by_arc_length() {
|
||||
let pl = line(&[(0.0, 0.0), (30.0, 0.0)], false);
|
||||
let d = dash_polyline(&pl, &[4.0, 2.0, 1.0, 3.0], -10.0);
|
||||
assert_eq!(
|
||||
d.iter().map(|p| (p.pts.first().unwrap().0, p.pts.last().unwrap().0)).collect::<Vec<_>>(),
|
||||
[(0.0, 4.0), (6.0, 7.0), (10.0, 14.0), (16.0, 17.0), (20.0, 24.0), (26.0, 27.0)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)])]);
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Control protocol
|
||||
|
||||
Shape stroke defaults are drivable with `ui.set {shapeStroke: {width, align, cap, join,
|
||||
miterLimit, dashes, dashOffset, opacity}}`; partial fields merge, null disables the stroke.
|
||||
Values use the existing `shape.edit` names and units. Colour uses `tools.setColors`.
|
||||
`ui.inspect` exposes `toolOptions.shape_stroke`, `stroke_width`, and `strokeEditor`.
|
||||
See [Shape stroke options](shape-strokes.md) for limits, preview and persistence behavior.
|
||||
|
||||
The desktop app listens on `127.0.0.1:<port>` (loopback only). Start it with a token file so the credential is not exposed in the process command line:
|
||||
|
||||
```sh
|
||||
@@ -37,7 +43,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i
|
||||
- `engine.execute {command, params}`: run any engine or UI command by id. Engine commands run directly with their default params and never open a dialog. Use `ui.menu.invoke` for menu-click behaviour, which opens a command's dialog when no params are given. `params` must be a JSON object (omit it or pass `null` for none); an array, string, number or boolean is an error naming the command, in every transport (control channel, `serve`, MCP and the CLI)
|
||||
- `engine.commands`: list commands with enablement
|
||||
- `ui.inspect`: full UI state (tool, panels, views, dialogs, windows, window size). The menu tree is not included; use `ui.menu.list`. `brushPicker` is `null` while closed and `{pos: [x, y], list: {collapsed, filter, view, renaming}}` while the Brush Preset picker is open (coordinates in screen points). `view` holds the View/Window/Type preferences: `screen_mode`, `extras`, `show` and `snap_to` flags, `flip_horizontal`, `arrange` (Window › Arrange layout), pixel aspect, font preview size, language options. `perf.timings.gpuInfo` holds the graphics adapter, backend, driver, the backend chosen at launch and why, the canvas renderer (`gpu`/`cpu`) and, after a device loss, `lost` (`help.systemInfo` returns the same as `info`)
|
||||
- `ui.set {tool?, panels?, dockTabs?, dock?, dockWidth?, colorPanel?, maskTarget?, vectorMaskTarget?, selectionMode?, zoom?, center?, rotation?, fit?, theme?, brushSize?, brushSection?, brushTab?, brushesView?, brushPicker?, brushPickerView?, gradientBlendMode?, gradientClassic?}`: change UI state; any other field is an error, checked before anything changes (`zoom` is the canvas zoom where 1 = 100%, one document pixel per physical display pixel, so a scaled display doesn't magnify the image; `theme` is `pro`, `proMedium`, `studio`, `studioLight` or `classic`; `selectionMode` is the selection tools' options-bar mode, 0 New, 1 Add, 2 Subtract, 3 Intersect; `brushSection` indexes the Brush Settings sections, `brushTab` 0 = Brush Settings, 1 = Brushes; `brushesView` and `brushPickerView` are `list` or `grid` for the Brushes panel and the Brush Preset picker; `brushPicker` is `[x, y]` in screen points to open the Brush Preset picker there (as a right-click with a painting tool does) or `null` to close it, and picking a preset in it is `tools.setBrush {"preset": name}`; `dock` is `{order: ["layers", …], heights: {"properties": 180}, collapsed: ["color"]}`, the right-dock groups top to bottom, their heights in points and the groups collapsed to their tab strip; `dockWidth` sets the right dock width in points, clamped to 250..520; `colorPanel` is `{background}`, whether the Color panel edits the background colour; `maskTarget` targets the active layer's mask, which, like Photoshop, swaps in the foreground/background pair kept for masks (white/black until one is picked) and swaps the pixels' pair back when the pixels are targeted again)
|
||||
- `ui.set {tool?, panels?, dockTabs?, dock?, dockWidth?, colorPanel?, maskTarget?, vectorMaskTarget?, selectionMode?, zoom?, center?, rotation?, fit?, theme?, brushSize?, brushSection?, brushTab?, brushesView?, brushPicker?, brushPickerView?, gradientBlendMode?, gradientClassic?, shapeStroke?}`: change UI state; any other field is an error, checked before anything changes (`zoom` is the canvas zoom where 1 = 100%, one document pixel per physical display pixel, so a scaled display doesn't magnify the image; `theme` is `pro`, `proMedium`, `studio`, `studioLight` or `classic`; `selectionMode` is the selection tools' options-bar mode, 0 New, 1 Add, 2 Subtract, 3 Intersect; `brushSection` indexes the Brush Settings sections, `brushTab` 0 = Brush Settings, 1 = Brushes; `brushesView` and `brushPickerView` are `list` or `grid` for the Brushes panel and the Brush Preset picker; `brushPicker` is `[x, y]` in screen points to open the Brush Preset picker there (as a right-click with a painting tool does) or `null` to close it, and picking a preset in it is `tools.setBrush {"preset": name}`; `dock` is `{order: ["layers", …], heights: {"properties": 180}, collapsed: ["color"]}`, the right-dock groups top to bottom, their heights in points and the groups collapsed to their tab strip; `dockWidth` sets the right dock width in points, clamped to 250..520; `colorPanel` is `{background}`, whether the Color panel edits the background colour; `maskTarget` targets the active layer's mask, which, like Photoshop, swaps in the foreground/background pair kept for masks (white/black until one is picked) and swaps the pixels' pair back when the pixels are targeted again)
|
||||
- `typeTransform` is `null` outside a temporary Ctrl/Cmd Type-tool drag. During a drag it reports its source document/revision, original affine, oriented `frame` (layer, local bounds, document-space corners and pivot), and captured `gesture`. Drive it with `ui.pointer` and `command: true` at `down`; `move`/`up` may release the modifier. The document/history remain unchanged during preview, and `up` applies one `type.edit` within `textEdit`'s session. Pressing Escape during the drag cancels only the preview. This pointer state is never restored from saved UI state.
|
||||
- `ui.menu.invoke {id, params?, wait?}` / `ui.menu.list`: activate a menu item by id; list the menu tree. A menu item that starts a background job (a filter without a dialog, such as Blur More) replies with the job's result once it has been applied; with `"wait": false` the reply is `{job, pending: true}` at once
|
||||
- `ui.set` also accepts `gradientBlendMode` (a layer blend mode name, such as `Difference`) and `gradientClassic` (boolean) for the Gradient tool options bar.
|
||||
|
||||
@@ -92,6 +92,12 @@ Confidence: moderate — the next users of 0.2.x will move these numbers either
|
||||
|
||||
### By dimension
|
||||
|
||||
2026-10-10: [Shape stroke controls](shape-strokes.md) expose existing Solid/Dashed/Dotted,
|
||||
custom dash/gap, offset, caps, joins, alignment, miter-limit and opacity capabilities in
|
||||
Shape/Pen options and Properties. Properties stages a cached canvas preview before one
|
||||
undoable edit. Gradient fill controls from #1053 remain open; the Line tool still draws a
|
||||
filled bar. This extends UI reachability and does not claim Photoshop-authored stroke parity.
|
||||
|
||||
| Dimension | Measured / evidence (2026-10-05) | Grade | Notes |
|
||||
|---|---|---|---|
|
||||
| Menu wiring | 626/626 menu items dispatch a command (`parity.md`) | high but shallow | Says nothing about behaviour. |
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user