diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index 568d8c5a..bf915940 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -166,7 +166,8 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option, leaves: bool) -> .layers .iter() .filter(|l| l.visible && !l.locked && doc.pattern_edit.as_ref().is_none_or(|e| e.layer == l.id)) - .flat_map(|l| l.children().into_iter().flatten()).collect(), + .flat_map(|l| l.children().into_iter().flatten()) + .collect(), }; fn touches(n: &Node, r: Rect) -> bool { match &n.kind { diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index ceec9f21..7f178153 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -21,8 +21,8 @@ pub use drawcraft_color as color; pub use drawcraft_geom as geom; pub use hit::{Hit, HitKind}; pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint}; -pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType}; pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, NodeKind}; +pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType}; pub use selection::{AnchorRef, Selection}; pub use text::{CharStyle, Justify, ParaStyle, TextKind, TextObject, TextRun}; diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs new file mode 100644 index 00000000..98534394 --- /dev/null +++ b/crates/engine/src/cmd/colormgmt.rs @@ -0,0 +1,508 @@ +//! Colour management: Edit → Color Settings / Assign Profile, document colour mode conversion +//! through the CMS, gamut checks, View → Proof Setup / Proof Colors / Overprint Preview, the +//! Separations Preview panel, Overprint Black and spot swatches. +//! +//! Colour settings and the proof view are process-wide (`drawcraft_color::cms::active`, +//! `drawcraft_render::proof::view`). A document's assigned profiles are stored in +//! `Document.unknown["colorProfiles"]` (`{rgb?, cmyk?}`) and become active when assigned. + +use std::collections::BTreeMap; + +use drawcraft_color::cms::{self, ColorSettings, Intent, Model, ProofTarget}; +use drawcraft_color::{Color, Paint}; +use drawcraft_doc::ColorMode; +use drawcraft_render::proof::{self, OVERPRINT_KEY}; +use serde_json::{Value, json}; + +use super::*; + +/// `Document.unknown` key for the assigned profiles. +pub const PROFILES_KEY: &str = "colorProfiles"; + +pub fn specs() -> Vec { + vec![ + cmd!( + "edit.colorSettings", + "Color Settings…", + ["Edit"], + Some("Cmd+Shift+K"), + "{rgb?: profile, cmyk?: profile, intent?: \"perceptual\"|\"relative\"|\"saturation\"|\"absolute\", bpc?: bool} set the working spaces → {rgb, cmyk, intent, bpc, profiles: [{name, kind, builtin}]}", + always, + color_settings + ), + cmd!( + "color.loadProfile", + "Load Color Profile…", + [], + None, + "{path: .icc/.icm file} register an ICC profile (native only) → {name, kind}", + always, + load_profile + ), + cmd!( + "edit.assignProfile", + "Assign Profile…", + ["Edit"], + None, + "{rgb?: profile|null, cmyk?: profile|null} tag the document with profiles (colour numbers are kept) → {rgb, cmyk}", + has_doc, + assign_profile + ), + cmd!( + "object.convertDocumentColorMode", + "Convert Document Color Mode", + ["File", "Document Color Mode"], + None, + "{mode: \"cmyk\"|\"rgb\", intent?} set the document colour mode and convert every colour (art, symbols, swatches; swatch links kept) through the colour settings → {changed}", + has_doc, + convert_mode + ), + cmd!( + query "color.convert", + "Convert Color", + [], + None, + "{color, to: \"rgb\"|\"cmyk\"|\"gray\"|\"lab\", intent?} → {model, values, hex, lab: [L,a,b], outOfGamut, deltaE}", + always, + convert_color + ), + cmd!( + query "color.gamutCheck", + "Gamut Warning", + [], + None, + "{ids?, colors?: [color]} colours that can't be printed in the working CMYK (selection, or the whole document when nothing is selected) → {checked, outOfGamut: [{hex, deltaE, count}]}", + always, + gamut_check + ), + cmd!( + query "view.proofSetup", + "Proof Setup", + ["View", "Proof Setup"], + None, + "{target?: \"workingCmyk\"|\"cmyk:\"|\"legacyMacRgb\"|\"srgb\"|\"monitorRgb\"|\"protanopia\"|\"deuteranopia\", intent?, simulatePaper?, proof?: bool (also turn Proof Colors on)} → proof state", + always, + proof_setup + ), + cmd!(query "view.proofColors", "Proof Colors", ["View"], None, "{on?: bool (default: toggle)} → proof state", always, |s, p| { + toggle_view(s, p, |v, on| v.proof_colors = on.unwrap_or(!v.proof_colors)) + }), + cmd!( + query "view.overprintPreview", + "Overprint Preview", + ["View"], + Some("Cmd+Alt+Shift+Y"), + "{on?: bool (default: toggle)} → proof state", + always, + |s, p| toggle_view(s, p, |v, on| v.overprint = on.unwrap_or(!v.overprint)) + ), + cmd!( + query "view.separationsPreview", + "Separations Preview", + [], + None, + "{on?: bool, plates?: [names], toggle?: plate, only?: plate} Separations Preview: show the chosen plates (one plate = greyscale ink coverage) → {on, plates: [{name, spot, visible, rgb}]}", + always, + separations + ), + cmd!(query "color.plates", "Plates", [], None, "{} → {on, plates: [{name, spot, visible, rgb}]}", always, |s, _| Ok(plates_json(s))), + cmd!( + "edit.colors.overprintBlack", + "Overprint Black…", + ["Edit", "Edit Colors"], + None, + "{remove?: false, percentage?: 100, ids?} mark (or unmark) objects whose fill or stroke is black-only ink ≥ percentage as overprinting (Overprint Preview, separations) → {changed}", + has_doc, + overprint_black + ), + cmd!( + "swatch.setSpot", + "Spot Color", + [], + None, + "{name, spot?: true} make a swatch a spot colour (prints on its own plate; spot swatches are global) → {name, spot}", + has_doc, + set_spot + ), + ] +} + +fn intent_param(p: &Value, cmd: &str) -> Result> { + match str_param(p, "intent") { + None => Ok(None), + Some(i) => Intent::parse(i).map(Some).ok_or_else(|| bad(cmd, format!("unknown intent `{i}`"))), + } +} + +fn settings_json(st: &ColorSettings) -> Value { + json!({ + "rgb": st.rgb, + "cmyk": st.cmyk, + "intent": st.intent.id(), + "bpc": st.bpc, + "profiles": cms::profiles().iter().map(|p| json!({"name": p.name, "kind": p.kind, "builtin": p.builtin})).collect::>(), + }) +} + +/// Colour settings/proof changes don't edit the document, but the canvas must redraw: bump the +/// revisions without marking documents dirty. +fn touch_all(s: &mut Session) { + for d in &mut s.docs { + let clean = d.revision == d.saved_revision; + d.revision += 1; + if clean { + d.saved_revision = d.revision; + } + } +} + +fn color_settings(s: &mut Session, p: &Value) -> Result { + const C: &str = "edit.colorSettings"; + let mut st = cms::active_settings(); + let before = st.clone(); + if let Some(v) = str_param(p, "rgb") { + st.rgb = v.to_string(); + } + if let Some(v) = str_param(p, "cmyk") { + st.cmyk = v.to_string(); + } + if let Some(i) = intent_param(p, C)? { + st.intent = i; + } + st.bpc = bool_or(p, "bpc", st.bpc); + if st != before { + cms::set_active(&st).map_err(|e| bad(C, e.to_string()))?; + touch_all(s); + } + Ok(settings_json(&st)) +} + +#[cfg(not(target_arch = "wasm32"))] +fn load_profile(_: &mut Session, p: &Value) -> Result { + const C: &str = "color.loadProfile"; + let path = str_param(p, "path").ok_or_else(|| bad(C, "missing `path`"))?; + let info = cms::load_icc_file(std::path::Path::new(path)).map_err(|e| bad(C, e.to_string()))?; + Ok(json!({"name": info.name, "kind": info.kind})) +} + +#[cfg(target_arch = "wasm32")] +fn load_profile(_: &mut Session, _: &Value) -> Result { + Err(bad("color.loadProfile", "loading profiles from disk isn't available in the browser")) +} + +/// The document's assigned profiles (`None` = working space). +pub fn doc_profiles(d: &drawcraft_doc::Document) -> (Option, Option) { + let o = d.unknown.get(PROFILES_KEY); + let get = |k: &str| o.and_then(|o| o.get(k)).and_then(Value::as_str).map(str::to_string); + (get("rgb"), get("cmyk")) +} + +fn assign_profile(s: &mut Session, p: &Value) -> Result { + const C: &str = "edit.assignProfile"; + let (mut rgb, mut cmyk) = doc_profiles(&s.doc()?.doc); + let known = cms::profiles(); + let pick = |key: &str, cur: &mut Option, kind: cms::ProfileKind| -> Result { + match p.get(key) { + None => Ok(false), + Some(Value::Null) => Ok(cur.take().is_some()), + Some(Value::String(n)) => { + if !known.iter().any(|k| &k.name == n && k.kind == kind) { + return Err(bad(C, format!("unknown {key} profile `{n}`"))); + } + let changed = cur.as_deref() != Some(n.as_str()); + *cur = Some(n.clone()); + Ok(changed) + } + Some(_) => Err(bad(C, format!("`{key}` must be a profile name or null"))), + } + }; + let changed = pick("rgb", &mut rgb, cms::ProfileKind::Rgb)? | pick("cmyk", &mut cmyk, cms::ProfileKind::Cmyk)?; + if changed { + let (r, c) = (rgb.clone(), cmyk.clone()); + s.edit("Assign Profile", |d, _| { + if r.is_none() && c.is_none() { + d.unknown.remove(PROFILES_KEY); + } else { + d.unknown.insert(PROFILES_KEY.into(), json!({"rgb": r, "cmyk": c})); + } + Ok(()) + })?; + let mut st = cms::active_settings(); + if let Some(r) = &rgb { + st.rgb = r.clone(); + } + if let Some(c) = &cmyk { + st.cmyk = c.clone(); + } + cms::set_active(&st).map_err(|e| bad(C, e.to_string()))?; + touch_all(s); + } + Ok(json!({"rgb": rgb, "cmyk": cmyk})) +} + +fn convert_mode(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.convertDocumentColorMode"; + let mode = match str_param(p, "mode").map(str::to_ascii_lowercase).as_deref() { + Some("cmyk") => ColorMode::Cmyk, + Some("rgb") => ColorMode::Rgb, + _ => return Err(bad(C, "mode must be \"cmyk\" or \"rgb\"")), + }; + let c = cms::active(); + let intent = intent_param(p, C)?.unwrap_or(c.settings().intent); + let model = if mode == ColorMode::Cmyk { Model::Cmyk } else { Model::Rgb }; + // Greys stay greys (they print on the black plate in either mode). + let conv = |col: &Color| if matches!(col, Color::Gray { .. }) { *col } else { c.convert(col, model, intent) }; + let mut changed = 0usize; + s.edit("Document Color Mode", |d, _| { + d.color_mode = mode; + proof::map_document_colors(d, &mut |col, _| { + let n = conv(col); + if n != *col { + changed += 1; + } + n + }); + for sw in &mut d.swatches { + match &mut sw.paint { + Paint::Solid { color, .. } => *color = conv(color), + Paint::Gradient(g) => { + for st in &mut g.gradient.stops { + st.color = conv(&st.color); + } + } + _ => {} + } + } + Ok(()) + })?; + Ok(json!({ "changed": changed })) +} + +fn values(c: &Color) -> Vec { + match *c { + Color::Rgb { r, g, b } => vec![r, g, b], + Color::Cmyk { c, m, y, k } => vec![c, m, y, k], + Color::Gray { k } => vec![k], + } +} + +fn convert_color(_: &mut Session, p: &Value) -> Result { + const C: &str = "color.convert"; + let col = p.get("color").and_then(color_value).ok_or_else(|| bad(C, "missing or invalid `color`"))?; + let c = cms::active(); + let intent = intent_param(p, C)?.unwrap_or(c.settings().intent); + let lab = c.lab(&col); + let (model, vals) = match str_param(p, "to").unwrap_or("rgb") { + "rgb" => ("rgb", values(&c.convert(&col, Model::Rgb, intent))), + "cmyk" => ("cmyk", values(&c.convert(&col, Model::Cmyk, intent))), + "gray" | "grayscale" => ("gray", values(&c.convert(&col, Model::Gray, intent))), + "lab" => ("lab", vec![lab.l, lab.a, lab.b]), + other => return Err(bad(C, format!("unknown model `{other}`"))), + }; + let de = c.gamut_error(&col); + Ok(json!({ + "model": model, + "values": vals, + "hex": col.to_hex(), + "lab": [lab.l, lab.a, lab.b], + "outOfGamut": de > cms::GAMUT_THRESHOLD, + "deltaE": de, + })) +} + +fn gamut_check(s: &mut Session, p: &Value) -> Result { + let mut cols: Vec = vec![]; + if let Some(a) = p.get("colors").and_then(Value::as_array) { + cols.extend(a.iter().filter_map(color_value)); + } else { + let st = s.doc()?; + let ids = targets(s, p)?; + let mut collect = |n: &drawcraft_doc::Node| { + let mut n = n.clone(); + proof::map_node_colors(&mut n, &mut |c, _| { + cols.push(*c); + *c + }); + }; + if ids.is_empty() { + for l in &st.doc.layers { + collect(l); + } + } else { + for id in ids { + if let Some(n) = st.doc.node(id) { + collect(n); + } + } + } + } + let c = cms::active(); + let mut out: BTreeMap = BTreeMap::new(); + for col in &cols { + let de = c.gamut_error(col); + if de > cms::GAMUT_THRESHOLD { + out.entry(col.to_hex()).or_insert((de, 0)).1 += 1; + } + } + Ok(json!({ + "checked": cols.len(), + "outOfGamut": out.iter().map(|(h, (de, n))| json!({"hex": h, "deltaE": de, "count": n})).collect::>(), + })) +} + +fn view_json(v: &proof::ProofView) -> Value { + json!({ + "target": v.setup.target.id(), + "intent": v.setup.intent.id(), + "simulatePaper": v.setup.simulate_paper, + "proofColors": v.proof_colors, + "overprintPreview": v.overprint, + "separations": v.separations, + }) +} + +fn proof_setup(s: &mut Session, p: &Value) -> Result { + const C: &str = "view.proofSetup"; + let mut v = proof::view(); + if let Some(t) = str_param(p, "target") { + let t = ProofTarget::parse(t).ok_or_else(|| bad(C, format!("unknown proof target `{t}`")))?; + if let ProofTarget::Cmyk(name) = &t + && !cms::profiles().iter().any(|k| &k.name == name && k.kind == cms::ProfileKind::Cmyk) + { + return Err(bad(C, format!("unknown CMYK profile `{name}`"))); + } + v.setup.target = t; + } + if let Some(i) = intent_param(p, C)? { + v.setup.intent = i; + } + v.setup.simulate_paper = bool_or(p, "simulatePaper", v.setup.simulate_paper); + if let Some(on) = p.get("proof").and_then(Value::as_bool) { + v.proof_colors = on; + } + proof::set_view(v.clone()); + touch_all(s); + Ok(view_json(&v)) +} + +fn toggle_view(s: &mut Session, p: &Value, f: impl FnOnce(&mut proof::ProofView, Option)) -> Result { + let mut v = proof::view(); + f(&mut v, p.get("on").and_then(Value::as_bool)); + proof::set_view(v.clone()); + touch_all(s); + Ok(view_json(&v)) +} + +fn plates_json(s: &Session) -> Value { + let v = proof::view(); + let plates = s.active().map(|d| proof::plates(&d.doc)).unwrap_or_default(); + json!({ + "on": v.separations.is_some(), + "plates": plates.iter().map(|pl| json!({ + "name": pl.name, + "spot": pl.spot, + "visible": v.separations.as_ref().is_none_or(|vis| vis.contains(&pl.name)), + "rgb": pl.rgb, + })).collect::>(), + }) +} + +fn separations(s: &mut Session, p: &Value) -> Result { + const C: &str = "view.separationsPreview"; + let mut v = proof::view(); + let all: Vec = s.active().map(|d| proof::plates(&d.doc).into_iter().map(|p| p.name).collect()).unwrap_or_else(|| cms::PROCESS_PLATES.iter().map(|s| s.to_string()).collect()); + let check = |n: &str| if all.iter().any(|a| a == n) { Ok(n.to_string()) } else { Err(bad(C, format!("unknown plate `{n}`"))) }; + if let Some(on) = p.get("on").and_then(Value::as_bool) { + v.separations = if on { Some(v.separations.take().unwrap_or_else(|| all.clone())) } else { None }; + } + if let Some(a) = p.get("plates").and_then(Value::as_array) { + v.separations = Some(a.iter().filter_map(Value::as_str).map(check).collect::>>()?); + } + if let Some(n) = str_param(p, "only") { + v.separations = Some(vec![check(n)?]); + } + if let Some(n) = str_param(p, "toggle") { + let n = check(n)?; + let vis = v.separations.get_or_insert_with(|| all.clone()); + if let Some(i) = vis.iter().position(|x| *x == n) { + vis.remove(i); + } else { + vis.push(n); + } + } + proof::set_view(v); + touch_all(s); + Ok(plates_json(s)) +} + +fn overprint_black(s: &mut Session, p: &Value) -> Result { + let remove = bool_or(p, "remove", false); + let pct = (f64_or(p, "percentage", 100.0) / 100.0).clamp(0.0, 1.0) as f32; + let ids = targets(s, p)?; + let doc = &s.doc()?.doc; + let black_only = |c: &Color| match *c { + Color::Cmyk { c, m, y, k } => k >= pct - 0.005 && c <= 0.005 && m <= 0.005 && y <= 0.005, + Color::Gray { k } => k >= pct - 0.005, + Color::Rgb { .. } => false, + }; + let mut hits: Vec = vec![]; + for id in &ids { + if let Some(n) = doc.node(*id) { + n.walk(&mut |m: &drawcraft_doc::Node| { + let bl = m.appearance.items.iter().any(|it| match it { + drawcraft_doc::AppearanceItem::Fill(l) => l.paint.color().is_some_and(|c| black_only(&c)), + drawcraft_doc::AppearanceItem::Stroke(l) => l.paint.color().is_some_and(|c| black_only(&c)), + }); + if bl { + hits.push(m.id.0); + } + }); + } + } + let mut list: Vec = proof::overprint_ids(doc).into_iter().map(|i| i.0).collect(); + let before = list.clone(); + if remove { + list.retain(|i| !hits.contains(i)); + } else { + for h in &hits { + if !list.contains(h) { + list.push(*h); + } + } + } + let changed = if remove { before.len() - list.len() } else { list.len() - before.len() }; + if changed > 0 { + s.edit("Overprint Black", |d, _| { + if list.is_empty() { + d.unknown.remove(OVERPRINT_KEY); + } else { + d.unknown.insert(OVERPRINT_KEY.into(), json!(list)); + } + Ok(()) + })?; + } + Ok(json!({ "changed": changed })) +} + +fn set_spot(s: &mut Session, p: &Value) -> Result { + const C: &str = "swatch.setSpot"; + let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?.to_string(); + let spot = bool_or(p, "spot", true); + let sw = s.doc()?.doc.swatches.iter().find(|w| w.name == name).ok_or_else(|| bad(C, format!("no swatch `{name}`")))?; + if !matches!(sw.paint, Paint::Solid { .. }) { + return Err(bad(C, "only solid-colour swatches can be spot colours")); + } + if sw.spot != spot { + let n = name.clone(); + s.edit("Swatch Options", |d, _| { + if let Some(w) = d.swatches.iter_mut().find(|w| w.name == n) { + w.spot = spot; + if spot { + w.global = true; + } + } + Ok(()) + })?; + } + Ok(json!({"name": name, "spot": spot})) +} diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs new file mode 100644 index 00000000..6797c243 --- /dev/null +++ b/crates/engine/src/cmd/distortcmds.rs @@ -0,0 +1,496 @@ +//! Commands behind the distortion tools: width points, Liquify, Puppet Warp and the Perspective +//! Grid. The geometry kernels live in `drawcraft_tools::distort` (shared with the tools). +//! +//! The perspective grid definition is document data stored under +//! `Document.unknown["perspectiveGrid"]` (read/written by [`grid_of`] / [`store_grid`]). + +use std::sync::Arc; + +use drawcraft_doc::{Document, NodeId, NodeKind, WidthProfile}; +use drawcraft_geom::Point; +use drawcraft_tools::distort::liquify::{LiquifyParams, apply_stroke, dabs}; +use drawcraft_tools::distort::perspective::{PerspectiveGrid, Plane}; +use drawcraft_tools::distort::{arap, collect_points, mesh_for, warp_node_with}; +use serde_json::{Value, json}; + +use super::edit::selected_roots; +use super::*; +use crate::EngineError; + +pub fn specs() -> Vec { + vec![ + cmd!( + "stroke.widthPoint.set", + "Width Point", + [], + None, + "{id, t: 0..1 (fraction of the path length), left, right: side widths in pt, index?: width point to move/replace} → {index}. Creates a uniform profile first if needed", + has_doc, + width_point_set + ), + cmd!("stroke.widthPoint.remove", "Delete Width Point", [], None, "{id, index}", has_doc, width_point_remove), + cmd!( + "stroke.widthProfile.set", + "Width Profile", + [], + None, + "{ids?|id?, points: [[t, left, right]…] (width factors, 1 = the stroke weight) | null = uniform}", + has_doc, + width_profile_set + ), + cmd!( + "object.liquify", + "Liquify", + [], + None, + "{tool: warp|twirl|pucker|bloat|scallop|crystallize|wrinkle, points: [[x,y]…] (the brush stroke), diameter?|width?, height? (pt, default 100), angle?, intensity? (0..1 or %), detail? (1..10), simplify? (0..100), rate? (twirl °), complexity?, horizontal?, vertical?, affectAnchors?, affectIn?, affectOut?, ids? (default: selection, else every path the brush touches)}", + has_doc, + liquify + ), + cmd!( + "object.puppetWarp", + "Puppet Warp", + [], + None, + "{id?|ids? (default: selection), pins: [[x,y]…] (current pin positions), moved: [[x,y]…] (targets, same length), expand?: pt} as-rigid-as-possible mesh warp of anchors and handles", + has_doc, + puppet_warp + ), + cmd!( + "perspective.grid.set", + "Define Perspective Grid", + [], + None, + "{kind?: 1|2|3, origin?: [x,y], horizon?, vpLeft?, vpRight?, vpVertical?: [x,y], distance?, cell?, extent?, height?, visible?, plane?} merge into the document's grid", + has_doc, + grid_set + ), + cmd!( + "perspective.grid.preset", + "Perspective Grid Preset", + [], + None, + "{kind: 1|2|3} reset the grid to the one/two/three-point preset for the first artboard (and show it)", + has_doc, + grid_preset + ), + cmd!( + "perspective.grid.show", + "Show Perspective Grid", + [], + Some("Cmd+Shift+I"), + "{visible?: bool} (no param toggles). View state: not an undo step", + has_doc, + grid_show + ), + cmd!( + "perspective.plane.set", + "Active Perspective Plane", + [], + None, + "{plane: left|right|ground|none} Plane Switching widget. View state: not an undo step", + has_doc, + plane_set + ), + cmd!( + "perspective.attach", + "Attach to Active Plane", + [], + None, + "{ids?, plane?: left|right|ground (default: the active plane)} project the objects onto the plane", + has_doc, + attach + ), + cmd!("perspective.release", "Release with Perspective", [], None, "{ids?} detach from the grid (geometry unchanged)", has_doc, release), + cmd!( + "perspective.move", + "Move in Perspective", + [], + None, + "{ids?, from: [x,y], to: [x,y], plane?} slide objects within their plane (unattached objects attach to `plane`/the active plane first)", + has_doc, + persp_move + ), + cmd!( + "perspective.draw", + "Draw in Perspective", + [], + None, + "{command: shape.* id, params, plane?} run a shape command and attach the result to the plane", + has_doc, + persp_draw + ), + ] +} + +// ---------- width points ---------- + +fn uniform() -> WidthProfile { + WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] } +} + +fn width_point_set(s: &mut Session, p: &Value) -> Result { + const C: &str = "stroke.widthPoint.set"; + let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let t = f64_req(p, "t", C)?.clamp(0.0, 1.0); + let (left, right) = (f64_req(p, "left", C)?, f64_req(p, "right", C)?); + if !(left.is_finite() && right.is_finite()) || left < 0.0 || right < 0.0 || left.max(right) > 1.0e5 { + return Err(bad(C, "left/right must be non-negative widths")); + } + let index = p.get("index").and_then(Value::as_u64).map(|v| v as usize); + let pos = s.edit("Width Point", |d, _| { + let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; + let st = n.appearance.stroke_mut().ok_or_else(|| EngineError::Other("the object has no stroke".into()))?; + if st.width <= 0.0 { + return Err(EngineError::Other("the stroke has no weight".into())); + } + let half = st.width / 2.0; + let mut prof = st.profile.take().unwrap_or_else(uniform); + if let Some(i) = index { + if i >= prof.points.len() { + return Err(bad(C, "no such width point")); + } + prof.points.remove(i); + } + prof.points.retain(|q| (q.0 - t).abs() > 1e-6); + let pos = prof.points.iter().position(|q| q.0 > t).unwrap_or(prof.points.len()); + prof.points.insert(pos, (t, left / half, right / half)); + st.profile = Some(prof); + Ok(pos) + })?; + Ok(json!({ "index": pos })) +} + +fn width_point_remove(s: &mut Session, p: &Value) -> Result { + const C: &str = "stroke.widthPoint.remove"; + let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let index = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing index"))? as usize; + s.edit("Delete Width Point", |d, _| { + let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; + let st = n.appearance.stroke_mut().ok_or_else(|| EngineError::Other("the object has no stroke".into()))?; + let prof = st.profile.as_mut().filter(|pr| index < pr.points.len()).ok_or_else(|| bad(C, "no such width point"))?; + prof.points.remove(index); + if prof.points.is_empty() { + st.profile = None; + } + Ok(()) + })?; + ok() +} + +fn width_profile_set(s: &mut Session, p: &Value) -> Result { + const C: &str = "stroke.widthProfile.set"; + let profile = match p.get("points") { + None => return Err(bad(C, "missing points")), + Some(Value::Null) => None, + Some(Value::Array(a)) if a.is_empty() => None, + Some(Value::Array(a)) => { + let mut pts = vec![]; + for q in a { + let v: Vec = q.as_array().map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); + if v.len() != 3 || v.iter().any(|x| !x.is_finite()) || v[1] < 0.0 || v[2] < 0.0 { + return Err(bad(C, "points must be [t, left, right] with non-negative widths")); + } + pts.push((v[0].clamp(0.0, 1.0), v[1], v[2])); + } + pts.sort_by(|a, b| a.0.total_cmp(&b.0)); + Some(WidthProfile { points: pts }) + } + _ => return Err(bad(C, "points must be an array or null")), + }; + let ids = targets(s, p)?; + s.edit("Width Profile", |d, _| { + for id in &ids { + let n = d.node_mut(*id).ok_or(EngineError::NoNode(*id))?; + if let Some(st) = n.appearance.stroke_mut() { + st.profile = profile.clone(); + } + } + Ok(()) + })?; + ok() +} + +// ---------- liquify ---------- + +fn points_of(p: &Value, key: &str) -> Option> { + p.get(key)?.as_array()?.iter().map(|q| Some(Point::new(q.get(0)?.as_f64()?, q.get(1)?.as_f64()?))).collect() +} + +/// Leaf paths under `roots` that can be edited. +fn leaf_paths(d: &Document, roots: &[NodeId]) -> Vec { + let mut out = vec![]; + for r in roots { + if let Some(n) = d.node(*r) { + n.walk(&mut |c| { + if matches!(c.kind, NodeKind::Path { .. }) && !out.contains(&c.id) { + out.push(c.id); + } + }); + } + } + out.retain(|id| d.is_editable(*id)); + out +} + +fn liquify(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.liquify"; + let prm = LiquifyParams::from_json(p).ok_or_else(|| bad(C, "missing or unknown `tool`"))?; + let pts = points_of(p, "points").filter(|v| !v.is_empty()).ok_or_else(|| bad(C, "points must be a non-empty [[x,y]…] list"))?; + if pts.iter().any(|q| !q.x.is_finite() || !q.y.is_finite()) { + return Err(bad(C, "points must be finite")); + } + let dab_pts = dabs(&pts, prm.dab_spacing()); + let roots = match ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])) { + Some(v) => v, + None => s.doc()?.selection.objects.clone(), + }; + let doc = &s.doc()?.doc; + let leaves = if roots.is_empty() { + // Nothing selected: everything the brush sweeps over. + let sweep = dab_pts.iter().map(|c| prm.brush_bounds(*c)).reduce(|a, b| a.union(b)).unwrap(); + let mut all = vec![]; + doc.walk(|n| { + if matches!(n.kind, NodeKind::Path { guide: false, .. }) && n.geometric_bounds().is_some_and(|b| b.inflate(1e-6, 1e-6).intersect(sweep).area() > 0.0) { + all.push(n.id); + } + }); + all.retain(|id| doc.is_editable(*id) && doc.is_visible(*id)); + all + } else { + leaf_paths(doc, &roots) + }; + let changed = s.edit(&format!("{} Tool", prm.kind.label()), |d, _| { + let mut changed = vec![]; + for id in &leaves { + let Some(n) = d.node_mut(*id) else { continue }; + if let NodeKind::Path { path, live, .. } = &mut n.kind + && apply_stroke(path, &dab_pts, &prm, id.0) + { + *live = None; + changed.push(id.0); + } + } + Ok(changed) + })?; + Ok(json!({ "ids": changed })) +} + +// ---------- puppet warp ---------- + +fn puppet_warp(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.puppetWarp"; + let pins = points_of(p, "pins").ok_or_else(|| bad(C, "pins must be [[x,y]…]"))?; + let moved = points_of(p, "moved").ok_or_else(|| bad(C, "moved must be [[x,y]…]"))?; + if pins.is_empty() || pins.len() != moved.len() { + return Err(bad(C, "pins and moved must be non-empty lists of the same length")); + } + if pins.iter().chain(&moved).any(|q| !q.x.is_finite() || !q.y.is_finite()) { + return Err(bad(C, "pins must be finite")); + } + let ids = match ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])) { + Some(v) => v, + None => selected_roots(s)?, + }; + if ids.is_empty() { + return Err(EngineError::Other("select the artwork to warp".into())); + } + let expand = f64_or(p, "expand", 3.0).clamp(0.0, 1000.0); + let mesh = mesh_for(&s.doc()?.doc, &ids, expand).ok_or_else(|| EngineError::Other("nothing to warp".into()))?; + let pins: Vec = pins.iter().zip(&moved).map(|(a, b)| arap::Pin { rest: *a, target: *b }).collect(); + let deformed = arap::deform(&mesh, &pins); + let f = |q: Point| mesh.map(&deformed, q); + s.edit("Puppet Warp", |d, _| { + for id in &ids { + let n = d.node_mut(*id).ok_or(EngineError::NoNode(*id))?; + warp_node_with(n, &f); + } + Ok(()) + })?; + Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) +} + +// ---------- perspective grid ---------- + +/// The document's perspective grid (the default preset when none was defined). +pub(crate) fn grid_of(d: &Document) -> PerspectiveGrid { + PerspectiveGrid::effective(d) +} + +/// Write the grid into the document (`Document.unknown["perspectiveGrid"]`). +pub(crate) fn store_grid(d: &mut Document, g: &PerspectiveGrid) { + g.store(d); +} + +/// Change view state of the grid without an undo step. +fn silent(s: &mut Session, f: impl FnOnce(&mut PerspectiveGrid)) -> Result { + let st = s.doc_mut()?; + let mut g = grid_of(&st.doc); + f(&mut g); + store_grid(Arc::make_mut(&mut st.doc), &g); + if let Some(it) = &mut st.interaction { + store_grid(Arc::make_mut(&mut it.doc), &g); + } + st.revision += 1; + Ok(g) +} + +fn grid_set(s: &mut Session, p: &Value) -> Result { + let g = grid_of(&s.doc()?.doc).merged(p).map_err(|e| bad("perspective.grid.set", e))?; + s.edit("Define Perspective Grid", |d, _| { + store_grid(d, &g); + Ok(()) + })?; + Ok(g.definition_json()) +} + +fn grid_preset(s: &mut Session, p: &Value) -> Result { + let kind = p.get("kind").and_then(Value::as_u64).filter(|k| (1..=3).contains(k)).ok_or_else(|| bad("perspective.grid.preset", "kind must be 1, 2 or 3"))? as u8; + let old = grid_of(&s.doc()?.doc); + let ab = s.doc()?.doc.artboards.first().map(|a| a.rect).ok_or_else(|| EngineError::Other("no artboard".into()))?; + let g = PerspectiveGrid { attached: old.attached, ..PerspectiveGrid::preset(kind, ab) }; + s.edit("Perspective Grid Preset", |d, _| { + store_grid(d, &g); + Ok(()) + })?; + Ok(g.definition_json()) +} + +fn grid_show(s: &mut Session, p: &Value) -> Result { + let want = p.get("visible").and_then(Value::as_bool); + let g = silent(s, |g| g.visible = want.unwrap_or(!g.visible))?; + Ok(json!({ "visible": g.visible })) +} + +fn plane_set(s: &mut Session, p: &Value) -> Result { + let plane = str_param(p, "plane").and_then(Plane::parse).ok_or_else(|| bad("perspective.plane.set", "plane must be left|right|ground|none"))?; + silent(s, |g| g.plane = plane)?; + Ok(json!({ "plane": plane.id() })) +} + +fn plane_param(g: &PerspectiveGrid, p: &Value, cmd: &str) -> Result { + let plane = match str_param(p, "plane") { + Some(v) => Plane::parse(v).ok_or_else(|| bad(cmd, "plane must be left|right|ground"))?, + None => g.plane, + }; + if plane == Plane::None { + return Err(EngineError::Other("no active perspective plane".into())); + } + Ok(plane) +} + +fn roots(s: &Session, p: &Value) -> Result> { + match ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])) { + Some(v) => Ok(v), + None => selected_roots(s), + } +} + +/// Map `n` with `f`, failing if any point would leave the plane's visible side. +fn warp_checked(d: &mut Document, id: NodeId, f: &dyn Fn(Point) -> Option) -> Result<()> { + let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; + let mut pts = vec![]; + collect_points(n, &mut pts); + if pts.iter().any(|q| f(*q).is_none()) { + return Err(EngineError::Other("the object would cross the horizon of the perspective plane".into())); + } + warp_node_with(n, &|q| f(q).unwrap_or(q)); + Ok(()) +} + +fn attach_in(d: &mut Document, g: &mut PerspectiveGrid, id: NodeId, plane: Plane) -> Result<()> { + let b = d.node(id).ok_or(EngineError::NoNode(id))?.geometric_bounds().ok_or_else(|| EngineError::Other("the object has no geometry".into()))?; + let m = g.attach_map(plane, b).ok_or_else(|| EngineError::Other("the object is beyond the plane's horizon".into()))?; + warp_checked(d, id, &m)?; + g.attached.insert(id.0.to_string(), plane); + Ok(()) +} + +fn attach(s: &mut Session, p: &Value) -> Result { + let ids = roots(s, p)?; + let mut g = grid_of(&s.doc()?.doc); + let plane = plane_param(&g, p, "perspective.attach")?; + s.edit("Attach to Active Plane", |d, _| { + for id in &ids { + attach_in(d, &mut g, *id, plane)?; + } + store_grid(d, &g); + Ok(()) + })?; + Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>(), "plane": plane.id() })) +} + +fn release(s: &mut Session, p: &Value) -> Result { + let ids = roots(s, p)?; + let mut g = grid_of(&s.doc()?.doc); + s.edit("Release with Perspective", |d, _| { + for id in &ids { + g.attached.remove(&id.0.to_string()); + } + store_grid(d, &g); + Ok(()) + })?; + ok() +} + +fn persp_move(s: &mut Session, p: &Value) -> Result { + const C: &str = "perspective.move"; + let ids = roots(s, p)?; + let from = point_param(p, "from").ok_or_else(|| bad(C, "missing from"))?; + let to = point_param(p, "to").ok_or_else(|| bad(C, "missing to"))?; + let mut g = grid_of(&s.doc()?.doc); + let fallback = plane_param(&g, p, C).ok(); + s.edit("Move in Perspective", |d, _| { + for id in &ids { + let plane = match g.attached_plane(*id) { + Some(pl) => pl, + None => { + let pl = fallback.ok_or_else(|| EngineError::Other("the object isn't on a perspective plane".into()))?; + attach_in(d, &mut g, *id, pl)?; + pl + } + }; + let m = g.move_map(plane, from, to).ok_or_else(|| EngineError::Other("the pointer is beyond the plane's horizon".into()))?; + warp_checked(d, *id, &m)?; + } + store_grid(d, &g); + Ok(()) + })?; + ok() +} + +fn persp_draw(s: &mut Session, p: &Value) -> Result { + const C: &str = "perspective.draw"; + let command = str_param(p, "command").filter(|c| c.starts_with("shape.")).ok_or_else(|| bad(C, "command must be a shape.* command"))?.to_string(); + let params = p.get("params").cloned().unwrap_or_else(|| json!({})); + let mut g = grid_of(&s.doc()?.doc); + let plane = plane_param(&g, p, C)?; + let undo_before = s.doc()?.history.undo.len(); + let r = s.execute(&command, ¶ms)?; + let id = r.get("id").and_then(Value::as_u64).map(NodeId).ok_or_else(|| EngineError::Other(format!("{command} didn't create an object")))?; + let res = s.edit("Draw in Perspective", |d, _| { + attach_in(d, &mut g, id, plane)?; + store_grid(d, &g); + Ok(()) + }); + // One undo step for the whole gesture. + let st = s.doc_mut()?; + if st.interaction.is_none() && st.history.undo.len() > undo_before + 1 { + st.history.undo.truncate(undo_before + 1); + if let Some(e) = st.history.undo.last_mut() { + e.label = "Draw in Perspective".into(); + } + } + res?; + Ok(json!({ "id": id.0, "plane": plane.id() })) +} + +/// Shape tool previews become `perspective.draw` while the grid is shown with an active plane. +pub(crate) fn perspective_rewrite(s: &Session, cmd: &str, params: &Value) -> Option<(String, Value)> { + const SHAPES: &[&str] = &["shape.rectangle", "shape.ellipse", "shape.polygon", "shape.star", "shape.line", "shape.rectangularGrid", "shape.arc"]; + if !SHAPES.contains(&cmd) { + return None; + } + let g = PerspectiveGrid::from_doc(&s.active()?.doc)?; + (g.visible && g.plane != Plane::None).then(|| ("perspective.draw".to_string(), json!({"command": cmd, "params": params}))) +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 89cb530f..fc91c0a0 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -3,7 +3,9 @@ mod brushsym; mod buildcmds; mod colorcmds; +pub mod colormgmt; mod create; +pub(crate) mod distortcmds; mod docmenu; mod draw2; mod edit; @@ -14,6 +16,7 @@ mod live; pub(crate) mod menucmds; mod object; mod paint; +mod patterncmds; mod panelcmds; mod path; mod pathops; @@ -138,13 +141,16 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(menucmds::specs()); v.extend(live::specs()); v.extend(colorcmds::specs()); + v.extend(colormgmt::specs()); v.extend(typemenu::specs()); v.extend(textedit::specs()); v.extend(docmenu::specs()); v.extend(panelcmds::specs()); v.extend(buildcmds::specs()); v.extend(brushsym::specs()); + v.extend(patterncmds::specs()); v.extend(prefscmds::specs()); + v.extend(distortcmds::specs()); v }) } diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 623d942a..b4f42b0f 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -76,6 +76,10 @@ pub(crate) fn paint_from(s: &Session, p: &Value) -> Result> { } if let Some(name) = str_param(p, "swatch") { let st = s.doc()?; + // A pattern definition works as its swatch even without a swatch entry. + if st.doc.swatch(name).is_none() && st.doc.pattern(name).is_some() { + return Ok(Some(drawcraft_doc::pattern::pattern_paint(name))); + } let sw = st.doc.swatch(name).ok_or_else(|| EngineError::Other(format!("no swatch `{name}`")))?; let mut paint = sw.paint.clone(); if sw.global diff --git a/crates/engine/src/cmd/patterncmds.rs b/crates/engine/src/cmd/patterncmds.rs new file mode 100644 index 00000000..a0c2b316 --- /dev/null +++ b/crates/engine/src/cmd/patterncmds.rs @@ -0,0 +1,804 @@ +//! Pattern swatches (Object → Pattern, Pattern Options panel) and Repeat (Object → Repeat). +//! +//! **Pattern editing mode** works like isolation: `object.pattern.make` / `object.pattern.edit` +//! put a copy of the tile art into a temporary top layer (`Document::pattern_edit` records it), +//! which the renderer draws alone with dimmed copies around it; hit testing only reaches that +//! layer. `object.pattern.done` stores the layer's art as the pattern definition, +//! `object.pattern.cancel` restores the previous definition. Both remove the layer. +//! +//! **Repeat** objects are live nodes (`NodeKind::Repeat`) holding the source art and the +//! arrangement; `drawcraft_doc::pattern` evaluates the instances. + +use std::sync::Arc; + +use drawcraft_color::Paint; +use drawcraft_doc::pattern::{self, Overlap, PATTERN_EDIT_LAYER, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType, pattern_paint}; +use drawcraft_doc::{Document, Node, NodeId, NodeKind, Selection}; +use drawcraft_geom::Rect; +use serde_json::{Value, json}; + +use super::edit::selected_roots; +use super::*; + +pub fn specs() -> Vec { + vec![ + // ---------- Pattern ---------- + cmd!( + "object.pattern.make", + "Make", + ["Object", "Pattern"], + None, + "{ids?, name?, tileType?: grid|brickByRow|brickByColumn|hexByColumn|hexByRow, brickOffset?: 0..1 (0.5), width?, height?, edit?: bool (true)} make a pattern swatch from the selection and enter pattern editing mode → {name}", + has_selection, + pattern_make + ), + cmd!( + "object.pattern.edit", + "Edit Pattern", + ["Object", "Pattern"], + Some("Cmd+Shift+F8"), + "{name?} edit a pattern (default: the selected object's fill pattern) in pattern editing mode", + can_edit_pattern, + pattern_edit + ), + cmd!( + "object.pattern.done", + "Done", + [], + None, + "{} leave pattern editing mode, saving the tile art into the pattern", + in_pattern_edit, + pattern_done + ), + cmd!("object.pattern.cancel", "Cancel", [], None, "{} leave pattern editing mode, discarding changes", in_pattern_edit, pattern_cancel), + cmd!( + "object.pattern.saveCopy", + "Save a Copy", + [], + None, + "{name?} while editing, save the current tile as a new pattern swatch → {name}", + in_pattern_edit, + pattern_save_copy + ), + cmd!( + "pattern.options", + "Pattern Options", + ["Window", "Pattern Options"], + None, + "{name? (default: the pattern being edited), newName?, tileType?, brickOffset?, width?, height?, hSpacing?, vSpacing?, sizeTileToArt?: bool, overlap?: {h: left|right, v: top|bottom}, copies?: 3|5|7|9, dimCopies?: %, showTileEdge?: bool}", + has_doc, + pattern_options + ), + cmd!( + "pattern.delete", + "Delete Pattern", + [], + None, + "{name} delete a pattern swatch (objects painted with it lose that paint)", + has_doc, + pattern_delete + ), + cmd!(query "pattern.list", "List Patterns", [], None, "{} → {patterns: [{name, tileType, width, height, ...}], editing?}", has_doc, pattern_list), + cmd!( + "pattern.transform", + "Transform Patterns", + [], + None, + "{ids?, matrix: [a,b,c,d,e,f]} transform only the pattern placement of the objects' pattern paints (Transform Patterns)", + has_selection_or_ids, + pattern_transform + ), + // ---------- Repeat ---------- + cmd!( + "object.repeat.radial", + "Radial", + ["Object", "Repeat"], + None, + "{ids?, instances?: n (8), radius?: pt} radial repeat of the selection (or switch the selected repeat to radial) → {id}", + has_selection, + repeat_radial + ), + cmd!( + "object.repeat.grid", + "Grid", + ["Object", "Repeat"], + None, + "{ids?, hSpacing?: pt, vSpacing?: pt (¼ of the art size), rows?: n (3), cols?: n (3)} grid repeat of the selection → {id}", + has_selection, + repeat_grid + ), + cmd!( + "object.repeat.mirror", + "Mirror", + ["Object", "Repeat"], + None, + "{ids?, angle?: deg (90 = vertical axis), offset?: pt (10)} mirror repeat of the selection → {id}", + has_selection, + repeat_mirror + ), + cmd!( + "object.repeat.release", + "Release", + ["Object", "Repeat"], + None, + "{} release the selected repeats back to their source art → {ids}", + has_repeat, + repeat_release + ), + cmd!( + "object.repeat.options", + "Options…", + ["Object", "Repeat"], + None, + "{instances?, radius?, reverseOverlap?, startAngle?, endAngle?, center?: [x,y] | hSpacing?, vSpacing?, rows?, cols?, gridType?, flipRows?, flipCols? | angle?} set the options of the selected repeats", + has_repeat, + repeat_options + ), + cmd!( + "object.repeat.expand", + "Expand Repeat", + [], + None, + "{} replace the selected repeats by groups of their instances → {ids}", + has_repeat, + repeat_expand + ), + ] +} + +// ---------- predicates ---------- + +fn editing(s: &Session) -> Option<&PatternEdit> { + s.active()?.doc.pattern_edit.as_ref() +} + +fn in_pattern_edit(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + editing(s).map(|_| ()).ok_or_else(|| "not in pattern editing mode".into()) +} + +fn can_edit_pattern(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + if editing(s).is_some() { + return Err("already editing a pattern".into()); + } + if s.active().is_some_and(|st| st.doc.patterns.is_empty()) { Err("the document has no patterns".into()) } else { Ok(()) } +} + +fn has_selection_or_ids(s: &Session) -> std::result::Result<(), String> { + has_doc(s) +} + +fn selected_repeats(s: &Session) -> Vec { + let Some(st) = s.active() else { return vec![] }; + let mut out = vec![]; + for id in &st.selection.objects { + for a in st.doc.ancestry(*id).unwrap_or_default().into_iter().rev() { + if st.doc.node(a).is_some_and(pattern::is_repeat) { + if !out.contains(&a) { + out.push(a); + } + break; + } + } + } + out +} + +fn has_repeat(s: &Session) -> std::result::Result<(), String> { + has_selection(s)?; + if selected_repeats(s).is_empty() { Err("select a repeat".into()) } else { Ok(()) } +} + +// ---------- helpers ---------- + +fn ids_json(ids: &[NodeId]) -> Value { + json!({ "ids": ids.iter().map(|i| i.0).collect::>() }) +} + +fn roots(s: &Session, p: &Value) -> Result> { + match ids_param(p, "ids") { + Some(ids) => { + let st = s.doc()?; + let mut sel = Selection::default(); + sel.set(ids.iter().copied().filter(|id| st.doc.node(*id).is_some_and(|n| !n.is_layer()))); + Ok(sel.in_paint_order(&st.doc)) + } + None => selected_roots(s), + } +} + +fn unique_pattern_name(d: &Document, base: &str) -> String { + if d.pattern(base).is_none() && d.swatch(base).is_none() { + return base.to_string(); + } + (2..).map(|i| format!("{base} {i}")).find(|n| d.pattern(n).is_none() && d.swatch(n).is_none()).unwrap() +} + +fn next_pattern_name(d: &Document) -> String { + (1..).map(|i| format!("New Pattern {i}")).find(|n| d.pattern(n).is_none() && d.swatch(n).is_none()).unwrap() +} + +/// Fresh-id deep copies of `nodes`. +fn copies(d: &mut Document, nodes: &[Arc]) -> Vec { + nodes.iter().map(|n| d.reid(n)).collect() +} + +/// Enter pattern editing mode for `name`: a top layer with copies of the tile art. +fn enter_edit(d: &mut Document, sel: &mut Selection, name: &str, original: Option) -> Result { + let art = d.pattern(name).map(|p| p.art.clone()).ok_or_else(|| EngineError::Other(format!("no pattern `{name}`")))?; + let layer = d.add_layer(Some(PATTERN_EDIT_LAYER)); + let nodes = copies(d, &art); + let mut ids = vec![]; + for n in nodes { + ids.push(n.id); + d.insert(Some(layer), usize::MAX, n)?; + } + d.pattern_edit = Some(PatternEdit { pattern: name.to_string(), layer, original }); + sel.set(ids); + Ok(layer) +} + +/// Leave editing mode: drop the layer, return the edit record. +fn leave_edit(d: &mut Document, sel: &mut Selection) -> Result<(PatternEdit, Vec>)> { + let pe = d.pattern_edit.take().ok_or_else(|| EngineError::Other("not in pattern editing mode".into()))?; + let art = match d.remove(pe.layer) { + Ok(l) => l.children().cloned().unwrap_or_default(), + Err(_) => vec![], + }; + sel.clear(); + Ok((pe, art)) +} + +fn after_edit_mode(s: &mut Session, layer: Option) -> Result<()> { + let st = s.doc_mut()?; + st.isolation = layer; + st.active_layer = layer.or_else(|| st.doc.default_layer()); + st.revision += 1; + Ok(()) +} + +fn tile_type_param(p: &Value, cmd: &str, current: TileType) -> Result { + let off = p.get("brickOffset").and_then(Value::as_f64).map(|o| if o > 1.0 { o / 100.0 } else { o }); + match str_param(p, "tileType") { + Some(t) => TileType::parse(t, off).ok_or_else(|| bad(cmd, format!("unknown tileType `{t}` ({})", TileType::IDS.join("|")))), + None => Ok(match (current, off) { + (TileType::BrickByRow { .. }, Some(o)) => TileType::BrickByRow { offset: o }, + (TileType::BrickByColumn { .. }, Some(o)) => TileType::BrickByColumn { offset: o }, + (c, _) => c, + }), + } +} + +// ---------- Pattern commands ---------- + +fn pattern_make(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.pattern.make"; + if editing(s).is_some() { + return Err(bad(C, "already in pattern editing mode")); + } + let roots = roots(s, p)?; + if roots.is_empty() { + return Err(bad(C, "select the art for the pattern tile")); + } + let tt = tile_type_param(p, C, TileType::Grid)?; + let (w, h) = (p.get("width").and_then(Value::as_f64), p.get("height").and_then(Value::as_f64)); + if w.is_some_and(|v| !(v.is_finite() && v > 0.0)) || h.is_some_and(|v| !(v.is_finite() && v > 0.0)) { + return Err(bad(C, "width/height must be > 0")); + } + let edit = bool_or(p, "edit", true); + let wanted = str_param(p, "name").map(str::to_string); + let name = s.edit("Make Pattern", |d, sel| { + let name = match &wanted { + Some(n) => unique_pattern_name(d, n), + None => next_pattern_name(d), + }; + let src: Vec> = roots.iter().filter_map(|id| d.node(*id).cloned()).map(Arc::new).collect(); + let art: Vec> = copies(d, &src).into_iter().map(Arc::new).collect(); + let mut def = PatternDef::new(&name, art); + def.tile_type = tt; + let c = def.tile.center(); + def.tile = Rect::from_center_size(c, (w.unwrap_or(def.tile.width()).max(1e-3), h.unwrap_or(def.tile.height()).max(1e-3))); + d.patterns.push(def); + d.swatches.push(drawcraft_color::Swatch { name: name.clone(), paint: pattern_paint(&name), global: false, spot: false }); + if edit { + enter_edit(d, sel, &name, None)?; + } + Ok(name) + })?; + if edit { + let layer = editing(s).map(|e| e.layer); + after_edit_mode(s, layer)?; + } + Ok(json!({ "name": name })) +} + +fn pattern_edit(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.pattern.edit"; + let name = match str_param(p, "name") { + Some(n) => n.to_string(), + None => { + // The selected object's fill (or stroke) pattern. + let st = s.doc()?; + st.selection + .objects + .iter() + .filter_map(|id| st.doc.node(*id)) + .find_map(|n| { + n.appearance.items.iter().find_map(|it| match it { + drawcraft_doc::AppearanceItem::Fill(f) => pattern_name(&f.paint), + drawcraft_doc::AppearanceItem::Stroke(sk) => pattern_name(&sk.paint), + }) + }) + .ok_or_else(|| bad(C, "give a pattern name or select an object painted with a pattern"))? + } + }; + let orig = s.doc()?.doc.pattern(&name).cloned().ok_or_else(|| bad(C, format!("no pattern `{name}`")))?; + let layer = s.edit("Edit Pattern", |d, sel| enter_edit(d, sel, &name, Some(orig)))?; + after_edit_mode(s, Some(layer))?; + Ok(json!({ "name": name })) +} + +fn pattern_name(p: &Paint) -> Option { + match p { + Paint::Pattern { pattern, .. } => Some(pattern.clone()), + _ => None, + } +} + +fn pattern_done(s: &mut Session, _: &Value) -> Result { + let name = s.edit("Done Editing Pattern", |d, sel| { + let (pe, art) = leave_edit(d, sel)?; + if let Some(def) = d.pattern_mut(&pe.pattern) { + def.art = art; + if def.size_tile_to_art { + def.fit_tile_to_art(); + } + } + Ok(pe.pattern) + })?; + after_edit_mode(s, None)?; + Ok(json!({ "name": name })) +} + +fn pattern_cancel(s: &mut Session, _: &Value) -> Result { + s.edit("Cancel Pattern Editing", |d, sel| { + let (pe, _) = leave_edit(d, sel)?; + match pe.original { + Some(orig) => { + if let Some(def) = d.pattern_mut(&pe.pattern) { + *def = orig; + } + } + None => { + d.patterns.retain(|p| p.name != pe.pattern); + d.swatches.retain(|sw| !matches!(&sw.paint, Paint::Pattern { pattern, .. } if *pattern == pe.pattern)); + } + } + Ok(()) + })?; + after_edit_mode(s, None)?; + ok() +} + +fn pattern_save_copy(s: &mut Session, p: &Value) -> Result { + let wanted = str_param(p, "name").map(str::to_string); + let name = s.edit("Save a Copy", |d, _| { + let pe = d.pattern_edit.clone().ok_or_else(|| EngineError::Other("not in pattern editing mode".into()))?; + let mut def = d.pattern(&pe.pattern).cloned().ok_or_else(|| EngineError::Other("pattern missing".into()))?; + let art: Vec> = d.node(pe.layer).and_then(|l| l.children().cloned()).unwrap_or_default(); + let name = unique_pattern_name(d, wanted.as_deref().unwrap_or(&format!("{} copy", pe.pattern))); + def.name = name.clone(); + def.art = copies(d, &art).into_iter().map(Arc::new).collect(); + if def.size_tile_to_art { + def.fit_tile_to_art(); + } + d.patterns.push(def); + d.swatches.push(drawcraft_color::Swatch { name: name.clone(), paint: pattern_paint(&name), global: false, spot: false }); + Ok(name) + })?; + Ok(json!({ "name": name })) +} + +fn pattern_options(s: &mut Session, p: &Value) -> Result { + const C: &str = "pattern.options"; + let name = match str_param(p, "name") { + Some(n) => n.to_string(), + None => editing(s).map(|e| e.pattern.clone()).ok_or_else(|| bad(C, "give `name` (or edit a pattern)"))?, + }; + let cur = s.doc()?.doc.pattern(&name).cloned().ok_or_else(|| bad(C, format!("no pattern `{name}`")))?; + let tt = tile_type_param(p, C, cur.tile_type)?; + let num = |k: &str| p.get(k).and_then(Value::as_f64); + for k in ["width", "height"] { + if num(k).is_some_and(|v| !(v.is_finite() && v > 0.0)) { + return Err(bad(C, format!("{k} must be > 0"))); + } + } + let new_name = str_param(p, "newName").map(str::to_string).filter(|n| !n.is_empty() && *n != name); + if let Some(n) = &new_name + && s.doc()?.doc.pattern(n).is_some() + { + return Err(bad(C, format!("a pattern named `{n}` exists"))); + } + let overlap = match p.get("overlap") { + Some(o) => Overlap { + right_in_front: str_param(o, "h").map_or(cur.overlap.right_in_front, |v| v == "right"), + bottom_in_front: str_param(o, "v").map_or(cur.overlap.bottom_in_front, |v| v == "bottom"), + }, + None => cur.overlap, + }; + let edit_layer = editing(s).filter(|e| e.pattern == name).map(|e| e.layer); + let out_name = new_name.clone().unwrap_or(name.clone()); + s.edit("Pattern Options", |d, _| { + // While editing, the tile art is the edit layer's. + let live_art: Option>> = edit_layer.and_then(|l| d.node(l)).and_then(|l| l.children().cloned()); + let def = d.pattern_mut(&name).ok_or_else(|| EngineError::Other("pattern missing".into()))?; + def.tile_type = tt; + def.overlap = overlap; + if let Some(v) = num("hSpacing") { + def.h_spacing = v; + } + if let Some(v) = num("vSpacing") { + def.v_spacing = v; + } + if let Some(v) = p.get("sizeTileToArt").and_then(Value::as_bool) { + def.size_tile_to_art = v; + } + if let Some(v) = num("copies") { + def.copies = (v as u32).clamp(1, 15) | 1; + } + if let Some(v) = num("dimCopies") { + def.dim_copies = v.clamp(0.0, 100.0) as f32; + } + if let Some(v) = p.get("showTileEdge").and_then(Value::as_bool) { + def.show_tile_edge = v; + } + if def.size_tile_to_art { + let saved = std::mem::take(&mut def.art); + def.art = live_art.clone().unwrap_or_else(|| saved.clone()); + def.fit_tile_to_art(); + def.art = saved; + } else if num("width").is_some() || num("height").is_some() { + let c = def.tile.center(); + def.tile = Rect::from_center_size(c, (num("width").unwrap_or(def.tile.width()), num("height").unwrap_or(def.tile.height()))); + } + if let Some(nn) = &new_name { + def.name = nn.clone(); + rename_pattern_uses(d, &name, nn); + } + Ok(()) + })?; + Ok(json!({ "name": out_name })) +} + +/// Rename a pattern in swatches, paints and the edit record. +fn rename_pattern_uses(d: &mut Document, old: &str, new: &str) { + let fix = |p: &mut Paint| { + if let Paint::Pattern { pattern, .. } = p + && pattern == old + { + *pattern = new.to_string(); + } + }; + for sw in d.swatches.iter_mut().chain(d.swatch_groups.iter_mut().flat_map(|g| g.swatches.iter_mut())) { + if sw.name == old { + sw.name = new.to_string(); + } + fix(&mut sw.paint); + } + if let Some(pe) = &mut d.pattern_edit + && pe.pattern == old + { + pe.pattern = new.to_string(); + } + let ids = nodes_using(d, Some(old)); + for id in ids { + if let Some(n) = d.node_mut(id) { + for it in n.appearance.items.iter_mut() { + match it { + drawcraft_doc::AppearanceItem::Fill(f) => fix(&mut f.paint), + drawcraft_doc::AppearanceItem::Stroke(s) => fix(&mut s.paint), + } + } + } + } +} + +fn nodes_using(d: &Document, name: Option<&str>) -> Vec { + let mut ids = vec![]; + d.walk(|n| { + if pattern::uses_pattern(n, name) { + ids.push(n.id); + } + }); + ids +} + +fn pattern_delete(s: &mut Session, p: &Value) -> Result { + const C: &str = "pattern.delete"; + let name = str_param(p, "name").ok_or_else(|| bad(C, "missing name"))?.to_string(); + if s.doc()?.doc.pattern(&name).is_none() { + return Err(bad(C, format!("no pattern `{name}`"))); + } + if editing(s).is_some_and(|e| e.pattern == name) { + return Err(bad(C, "finish editing the pattern first")); + } + let n = s.edit("Delete Pattern", |d, _| { + d.patterns.retain(|p| p.name != name); + let is_it = |sw: &drawcraft_color::Swatch| matches!(&sw.paint, Paint::Pattern { pattern, .. } if *pattern == name); + d.swatches.retain(|sw| !is_it(sw)); + for g in &mut d.swatch_groups { + g.swatches.retain(|sw| !is_it(sw)); + } + let ids = nodes_using(d, Some(&name)); + for id in &ids { + if let Some(n) = d.node_mut(*id) { + for it in n.appearance.items.iter_mut() { + let p = match it { + drawcraft_doc::AppearanceItem::Fill(f) => &mut f.paint, + drawcraft_doc::AppearanceItem::Stroke(s) => &mut s.paint, + }; + if matches!(p, Paint::Pattern { pattern, .. } if *pattern == name) { + *p = Paint::None; + } + } + } + } + Ok(ids.len()) + })?; + Ok(json!({ "unpainted": n })) +} + +fn pattern_list(s: &mut Session, _: &Value) -> Result { + let st = s.doc()?; + let pats: Vec = st + .doc + .patterns + .iter() + .map(|p| { + json!({ + "name": p.name, + "tileType": p.tile_type.id(), + "brickOffset": match p.tile_type { TileType::BrickByRow { offset } | TileType::BrickByColumn { offset } => offset, _ => 0.5 }, + "tile": [p.tile.x0, p.tile.y0, p.tile.width(), p.tile.height()], + "width": p.tile.width(), + "height": p.tile.height(), + "hSpacing": p.h_spacing, + "vSpacing": p.v_spacing, + "sizeTileToArt": p.size_tile_to_art, + "overlap": {"h": if p.overlap.right_in_front { "right" } else { "left" }, "v": if p.overlap.bottom_in_front { "bottom" } else { "top" }}, + "copies": p.copies, + "dimCopies": p.dim_copies, + "showTileEdge": p.show_tile_edge, + "artCount": p.art.len(), + }) + }) + .collect(); + let editing = st.doc.pattern_edit.as_ref().map(|e| json!({"name": e.pattern, "layer": e.layer.0})); + Ok(json!({ "patterns": pats, "editing": editing })) +} + +fn pattern_transform(s: &mut Session, p: &Value) -> Result { + const C: &str = "pattern.transform"; + let m = matrix_param(p, "matrix").ok_or_else(|| bad(C, "missing `matrix` [a,b,c,d,e,f]"))?; + let ids = targets(s, p)?; + if ids.is_empty() { + return Err(bad(C, "nothing selected")); + } + let n = s.edit("Transform Patterns", |d, _| { + let mut all = vec![]; + for id in &ids { + if let Some(n) = d.node(*id) { + n.walk(&mut |c| { + if pattern::uses_pattern(c, None) { + all.push(c.id); + } + }); + } + } + for id in &all { + if let Some(n) = d.node_mut(*id) { + pattern::transform_pattern_paints(n, m); + } + } + Ok(all.len()) + })?; + Ok(json!({ "count": n })) +} + +// ---------- Repeat ---------- + +/// Make a repeat from the selection, or switch the selected repeat's kind. +fn make_repeat(s: &mut Session, p: &Value, label: &str, make: impl Fn(Vec>) -> RepeatSpec) -> Result { + let existing = selected_repeats(s); + if ids_param(p, "ids").is_none() + && let [r] = existing.as_slice() + { + let r = *r; + s.edit(label, |d, _| { + let n = d.node_mut(r).ok_or(EngineError::NoNode(r))?; + if let NodeKind::Repeat(spec) = &mut n.kind { + spec.kind = make(spec.source.clone()).kind; + } + Ok(()) + })?; + return Ok(json!({ "id": r.0 })); + } + let roots = roots(s, p)?; + if roots.is_empty() { + return Err(EngineError::Other("select the art to repeat".into())); + } + let id = s.edit(label, |d, sel| { + let top = *roots.last().unwrap(); + let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?; + let nodes: Vec> = roots.iter().filter_map(|id| d.node(*id).cloned()).map(Arc::new).collect(); + let id = d.alloc_id(); + d.insert(par, idx + 1, Node::new(id, NodeKind::Repeat(make(nodes))))?; + for r in &roots { + d.remove(*r)?; + } + sel.set([id]); + Ok(id) + })?; + Ok(json!({ "id": id.0 })) +} + +fn repeat_radial(s: &mut Session, p: &Value) -> Result { + let n = f64_or(p, "instances", 8.0); + if !(1.0..=1000.0).contains(&n) { + return Err(bad("object.repeat.radial", "instances must be 1..1000")); + } + let radius = p.get("radius").and_then(Value::as_f64).filter(|r| r.is_finite() && *r >= 0.0); + make_repeat(s, p, "Radial Repeat", |src| RepeatSpec::radial(src, n as u32, radius)) +} + +fn repeat_grid(s: &mut Session, p: &Value) -> Result { + let (hs, vs) = (p.get("hSpacing").and_then(Value::as_f64), p.get("vSpacing").and_then(Value::as_f64)); + let rows = p.get("rows").and_then(Value::as_u64).map(|v| v.clamp(1, 500) as u32); + let cols = p.get("cols").and_then(Value::as_u64).map(|v| v.clamp(1, 500) as u32); + make_repeat(s, p, "Grid Repeat", |src| { + let b = drawcraft_doc::live::nodes_bounds(&src).unwrap_or(Rect::new(0.0, 0.0, 10.0, 10.0)); + let mut r = RepeatSpec::grid(src, hs.unwrap_or(b.width() / 4.0), vs.unwrap_or(b.height() / 4.0)); + if let RepeatKind::Grid { rows: rr, cols: cc, .. } = &mut r.kind { + *rr = rows.unwrap_or(*rr); + *cc = cols.unwrap_or(*cc); + } + r + }) +} + +fn repeat_mirror(s: &mut Session, p: &Value) -> Result { + let angle = f64_or(p, "angle", 90.0); + let offset = f64_or(p, "offset", 10.0); + make_repeat(s, p, "Mirror Repeat", |src| RepeatSpec::mirror(src, angle, offset)) +} + +fn repeat_release(s: &mut Session, _: &Value) -> Result { + let reps = selected_repeats(s); + let ids = s.edit("Release Repeat", |d, sel| { + let mut out = vec![]; + for r in &reps { + let Some(n) = d.node(*r).cloned() else { continue }; + let (par, idx, _) = d.position(*r).ok_or(EngineError::NoNode(*r))?; + d.remove(*r)?; + for (k, c) in n.children().into_iter().flatten().enumerate() { + out.push(c.id); + d.insert(par, idx + k, (**c).clone())?; + } + } + sel.set(out.iter().copied()); + Ok(out) + })?; + Ok(ids_json(&ids)) +} + +fn fix_ids(d: &mut Document, n: &mut Node) { + if n.id == NodeId(0) { + n.id = d.alloc_id(); + } + if let Some(ch) = n.children_mut() { + for c in ch.iter_mut() { + fix_ids(d, Arc::make_mut(c)); + } + } +} + +fn repeat_expand(s: &mut Session, _: &Value) -> Result { + let reps = selected_repeats(s); + let ids = s.edit("Expand Repeat", |d, sel| { + let mut out = vec![]; + for r in &reps { + let Some(n) = d.node(*r).cloned() else { continue }; + let mut g = drawcraft_doc::live::expanded_group(&n, None); + + // The group keeps the repeat's id; every generated instance node gets a fresh one. + if let Some(ch) = g.children_mut() { + for c in ch.iter_mut() { + fix_ids(d, Arc::make_mut(c)); + } + } + let (par, idx, _) = d.position(*r).ok_or(EngineError::NoNode(*r))?; + d.remove(*r)?; + d.insert(par, idx, g)?; + out.push(*r); + } + sel.set(out.iter().copied()); + Ok(out) + })?; + Ok(ids_json(&ids)) +} + +fn repeat_options(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.repeat.options"; + let reps = selected_repeats(s); + let num = |k: &str| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()); + let flag = |k: &str| p.get(k).and_then(Value::as_bool); + if num("instances").is_some_and(|n| !(1.0..=1000.0).contains(&n)) { + return Err(bad(C, "instances must be 1..1000")); + } + let grid_type = match str_param(p, "gridType") { + Some(t) => Some(TileType::parse(t, num("brickOffset")).ok_or_else(|| bad(C, format!("unknown gridType `{t}`")))?), + None => None, + }; + let center = point_param(p, "center"); + s.edit("Repeat Options", |d, _| { + for r in &reps { + let Some(NodeKind::Repeat(spec)) = d.node_mut(*r).map(|n| &mut n.kind) else { continue }; + match &mut spec.kind { + RepeatKind::Radial { instances, radius, center: c, reverse_overlap, start_angle, end_angle } => { + if let Some(v) = num("instances") { + *instances = v as u32; + } + if let Some(v) = num("radius") { + *radius = v.max(0.0); + } + if let Some(v) = flag("reverseOverlap") { + *reverse_overlap = v; + } + if let Some(v) = num("startAngle") { + *start_angle = v; + } + if let Some(v) = num("endAngle") { + *end_angle = v; + } + if let Some(v) = center { + *c = v; + } + } + RepeatKind::Grid { h_spacing, v_spacing, rows, cols, grid_type: gt, flip_rows, flip_cols } => { + if let Some(v) = num("hSpacing") { + *h_spacing = v; + } + if let Some(v) = num("vSpacing") { + *v_spacing = v; + } + if let Some(v) = num("rows") { + *rows = (v as u32).clamp(1, 500); + } + if let Some(v) = num("cols") { + *cols = (v as u32).clamp(1, 500); + } + if let Some(t) = grid_type { + *gt = t; + } + if let Some(v) = flag("flipRows") { + *flip_rows = v; + } + if let Some(v) = flag("flipCols") { + *flip_cols = v; + } + } + RepeatKind::Mirror { angle, center: c } => { + if let Some(v) = num("angle") { + *angle = v; + } + if let Some(v) = center { + *c = v; + } + } + } + } + Ok(()) + })?; + ok() +} diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index a598b61f..769b3a7f 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -13,9 +13,16 @@ use crate::Prefs; pub enum PrefKind { Bool, /// A number in `min..=max` shown with `unit` ("pt", "px", "°", "%", "min", ""). - Num { min: f64, max: f64, unit: &'static str }, + Num { + min: f64, + max: f64, + unit: &'static str, + }, /// A whole number in `min..=max`. - Int { min: i64, max: i64 }, + Int { + min: i64, + max: i64, + }, /// One of (value, label). Choice(&'static [(&'static str, &'static str)]), /// `#rrggbb`. @@ -119,7 +126,13 @@ pub const PREF_SPECS: &[PrefSpec] = &[ "Handles", choice(&[("solid", "Solid"), ("hollow", "Hollow"), ("large", "Large")]) ), - p!("highlightAnchorsOnHover", "Selection & Anchor Display", "Anchor Points, Handle, and Bounding Box Display", "Highlight anchors on mouse over", bool), + p!( + "highlightAnchorsOnHover", + "Selection & Anchor Display", + "Anchor Points, Handle, and Bounding Box Display", + "Highlight anchors on mouse over", + bool + ), p!( "showHandlesMultipleAnchors", "Selection & Anchor Display", @@ -127,7 +140,13 @@ pub const PREF_SPECS: &[PrefSpec] = &[ "Show handles when multiple anchors are selected", bool ), - p!("hideCornerWidgetAbove", "Selection & Anchor Display", "Anchor Points, Handle, and Bounding Box Display", "Hide Corner Widget for angles greater than", num(0.0, 180.0, "°")), + p!( + "hideCornerWidgetAbove", + "Selection & Anchor Display", + "Anchor Points, Handle, and Bounding Box Display", + "Hide Corner Widget for angles greater than", + num(0.0, 180.0, "°") + ), p!("penRubberBand", "Selection & Anchor Display", "Enable Rubber Band for", "Pen Tool", bool), p!("curvatureRubberBand", "Selection & Anchor Display", "Enable Rubber Band for", "Curvature Tool", bool), // Type @@ -251,8 +270,20 @@ pub const PREF_SPECS: &[PrefSpec] = &[ p!("copyAsSvg", "Clipboard Handling", "On Copy", "Include SVG Code", bool), p!("copyAsPdf", "Clipboard Handling", "On Copy", "PDF", bool), p!("copyAicb", "Clipboard Handling", "On Copy", "AICB (no transparency support)", bool), - p!("aicbMode", "Clipboard Handling", "On Copy", "AICB", choice(&[("preservePaths", "Preserve Paths"), ("preserveAppearance", "Preserve Appearance and Overprints")])), - p!("pasteTextFormatting", "Clipboard Handling", "On Paste", "When pasting text", choice(&[("keep", "Keep Formatting"), ("plain", "Keep Plain Text")])), + p!( + "aicbMode", + "Clipboard Handling", + "On Copy", + "AICB", + choice(&[("preservePaths", "Preserve Paths"), ("preserveAppearance", "Preserve Appearance and Overprints")]) + ), + p!( + "pasteTextFormatting", + "Clipboard Handling", + "On Paste", + "When pasting text", + choice(&[("keep", "Keep Formatting"), ("plain", "Keep Plain Text")]) + ), // Appearance of Black p!("blackOnScreen", "Appearance of Black", "", "On Screen", choice(BLACK)), p!("blackOutput", "Appearance of Black", "", "Printing / Exporting", choice(BLACK_OUT)), @@ -269,7 +300,8 @@ pub fn spec(key: &str) -> Option<&'static PrefSpec> { /// "true"/"false"; choices by value or label (case-insensitive). pub fn validate(key: &str, v: &Value) -> std::result::Result { let sp = spec(key).ok_or_else(|| format!("unknown preference `{key}`"))?; - let num = || v.as_f64().or_else(|| v.as_str().and_then(|s| s.trim().trim_end_matches(|c: char| c.is_alphabetic() || c == '°').trim().parse().ok())); + let num = + || v.as_f64().or_else(|| v.as_str().and_then(|s| s.trim().trim_end_matches(|c: char| c.is_alphabetic() || c == '°').trim().parse().ok())); match sp.kind { PrefKind::Bool => match v { Value::Bool(b) => Ok(json!(b)), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 004c508a..201db622 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -603,6 +603,8 @@ impl Session { st.doc = it.doc.clone(); st.selection = it.selection.clone(); } + let rw = cmd::distortcmds::perspective_rewrite(self, cmd, params); + let (cmd, params) = rw.as_ref().map_or((cmd, params), |(c, p)| (c.as_str(), p)); let r = self.execute(cmd, params); let st = self.doc_mut()?; if let Some(it) = &mut st.interaction { @@ -658,8 +660,12 @@ mod tests; #[cfg(test)] mod tests_brushsym; #[cfg(test)] +mod tests_colormgmt; +#[cfg(test)] mod tests_build; #[cfg(test)] +mod tests_distort; +#[cfg(test)] mod tests_draw2; #[cfg(test)] mod tests_file; @@ -674,6 +680,8 @@ mod tests_prefs; #[cfg(test)] mod tests_pathops; #[cfg(test)] +mod tests_pattern; +#[cfg(test)] mod tests_textedit; #[cfg(test)] mod tests_xform; diff --git a/crates/engine/src/tests_colormgmt.rs b/crates/engine/src/tests_colormgmt.rs new file mode 100644 index 00000000..f9465f26 --- /dev/null +++ b/crates/engine/src/tests_colormgmt.rs @@ -0,0 +1,354 @@ +//! Colour management: document colour mode through the CMS, gamut checks, profiles, proofing, +//! overprint preview, separations and CMYK/spot PDF output. + +use std::sync::Mutex; + +use drawcraft_color::cms::{self, Intent, Lab, delta_e2000}; +use drawcraft_color::{Color, Paint}; +use drawcraft_doc::{ColorMode, Document}; +use drawcraft_geom::Affine; +use drawcraft_render::proof::{ProofSetup, ProofTarget}; +use drawcraft_render::{RenderOptions, Renderer}; +use serde_json::{Value, json}; + +use super::*; + +/// Tests that touch the process-wide proof view or colour settings run one at a time. +static GLOBAL: Mutex<()> = Mutex::new(()); + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s +} + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64, color: Value) -> NodeId { + let id = NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"ids": [id.0], "color": color})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [id.0], "none": true})).unwrap(); + id +} + +fn fill_of(s: &Session, id: NodeId) -> Paint { + s.doc().unwrap().doc.node(id).unwrap().appearance.fill_paint() +} + +fn lab_of_rgb(rgb: [f32; 3]) -> Lab { + cms::lab::srgb_to_lab(rgb) +} + +fn render(doc: &Document, opts: RenderOptions) -> drawcraft_render::Rendered { + let mut r = Renderer::new(); + r.threads = 0; + r.render(doc, 100, 100, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..opts }) +} + +#[test] +fn document_mode_converts_through_cms() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0, json!("#ff0000")); + let r = s.execute("object.convertDocumentColorMode", &json!({"mode": "cmyk"})).unwrap(); + assert!(r["changed"].as_u64().unwrap() >= 1); + assert_eq!(s.doc().unwrap().doc.color_mode, ColorMode::Cmyk); + let Paint::Solid { color: Color::Cmyk { c, m, y, k }, .. } = fill_of(&s, a) else { panic!("{:?}", fill_of(&s, a)) }; + // A press red: lots of M and Y, no cyan or black — not the naive (0, 1, 1, 0) necessarily. + assert!(m > 0.8 && y > 0.8 && c < 0.05 && k < 0.05, "{c} {m} {y} {k}"); + // Swatches are converted too. + assert!(s.doc().unwrap().doc.swatches.iter().filter_map(|w| w.paint.color()).all(|c| !matches!(c, Color::Rgb { .. }))); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.color_mode, ColorMode::Rgb); + assert_eq!(fill_of(&s, a), Paint::solid(Color::rgb(1.0, 0.0, 0.0))); + assert!(s.execute("object.convertDocumentColorMode", &json!({"mode": "lab"})).is_err()); +} + +#[test] +fn document_mode_conversion_is_idempotent() { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 10.0, 10.0, json!("#336699")); + rect(&mut s, 20.0, 0.0, 10.0, 10.0, json!({"gray": 40})); + s.execute("object.convertDocumentColorMode", &json!({"mode": "cmyk"})).unwrap(); + let once = s.doc().unwrap().doc.clone(); + let r = s.execute("object.convertDocumentColorMode", &json!({"mode": "cmyk"})).unwrap(); + assert_eq!(r["changed"], 0); + assert_eq!(*s.doc().unwrap().doc, *once); + s.execute("object.convertDocumentColorMode", &json!({"mode": "rgb"})).unwrap(); + let twice = s.doc().unwrap().doc.clone(); + assert_eq!(s.execute("object.convertDocumentColorMode", &json!({"mode": "rgb"})).unwrap()["changed"], 0); + assert_eq!(*s.doc().unwrap().doc, *twice); +} + +#[test] +fn rgb_cmyk_rgb_document_roundtrip_within_tolerance() { + let mut s = session(); + let cols = ["#cc9980", "#808080", "#4d7359", "#e6d94d"]; + let ids: Vec = cols.iter().enumerate().map(|(i, h)| rect(&mut s, i as f64 * 20.0, 0.0, 10.0, 10.0, json!(h))).collect(); + s.execute("object.convertDocumentColorMode", &json!({"mode": "cmyk", "intent": "relative"})).unwrap(); + s.execute("object.convertDocumentColorMode", &json!({"mode": "rgb"})).unwrap(); + for (id, h) in ids.iter().zip(cols) { + let orig = Color::from_hex(h).unwrap().to_rgb(); + let back = fill_of(&s, *id).color().unwrap().to_rgb(); + let de = delta_e2000(lab_of_rgb(orig), lab_of_rgb(back)); + // Black-point compensation shifts shadows a little; everything stays within ~3 ΔE00. + assert!(de < 3.0, "{h}: ΔE {de}"); + } +} + +#[test] +fn swatch_links_survive_mode_conversion() { + let mut s = session(); + s.execute("swatch.new", &json!({"name": "Brand", "color": "#2a6fb0", "global": true})).unwrap(); + let a = NodeId(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"ids": [a.0], "swatch": "Brand"})).unwrap(); + s.execute("object.convertDocumentColorMode", &json!({"mode": "cmyk"})).unwrap(); + let Paint::Solid { color, swatch } = fill_of(&s, a) else { panic!() }; + assert_eq!(swatch.as_deref(), Some("Brand")); + let sw = s.doc().unwrap().doc.swatches.iter().find(|w| w.name == "Brand").unwrap().paint.color().unwrap(); + assert!(matches!(color, Color::Cmyk { .. })); + assert_eq!(color, sw, "object and swatch converted identically"); +} + +#[test] +fn gamut_check_flags_unprintable_colours() { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 10.0, 10.0, json!("#0000ff")); + rect(&mut s, 20.0, 0.0, 10.0, 10.0, json!("#cc9980")); + s.execute("select.set", &json!({"ids": []})).unwrap(); + let r = s.execute("color.gamutCheck", &json!({})).unwrap(); + let hexes: Vec<&str> = r["outOfGamut"].as_array().unwrap().iter().map(|v| v["hex"].as_str().unwrap()).collect(); + assert!(hexes.contains(&"#0000ff"), "{r}"); + assert!(!hexes.contains(&"#cc9980"), "{r}"); + let r = s.execute("color.gamutCheck", &json!({"colors": ["#00ff00", {"c": 100, "m": 0, "y": 0, "k": 0}]})).unwrap(); + assert_eq!(r["checked"], 2); + assert_eq!(r["outOfGamut"].as_array().unwrap().len(), 1); +} + +#[test] +fn convert_query_and_intents() { + let mut s = session(); + let q = |s: &mut Session, intent: &str| s.execute("color.convert", &json!({"color": "#0000ff", "to": "cmyk", "intent": intent})).unwrap(); + let p = q(&mut s, "perceptual"); + let r = q(&mut s, "relative"); + assert_ne!(p["values"], r["values"], "intents differ"); + assert_eq!(r["outOfGamut"], true); + let lab = s.execute("color.convert", &json!({"color": "#ff0000", "to": "lab"})).unwrap(); + let l = lab["values"][0].as_f64().unwrap(); + assert!((l - 54.3).abs() < 1.0, "sRGB red L* (D50) ≈ 54.3: {lab}"); + let g = s.execute("color.convert", &json!({"color": "#808080", "to": "gray"})).unwrap(); + assert!((g["values"][0].as_f64().unwrap() - 0.5).abs() < 0.02, "{g}"); + assert!(s.execute("color.convert", &json!({"color": "#808080", "to": "hsv"})).is_err()); + assert!(s.execute("color.convert", &json!({"color": "#808080", "intent": "vivid"})).is_err()); +} + +#[test] +fn assign_profile_is_stored_and_undoable() { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let mut s = session(); + let r = s.execute("edit.assignProfile", &json!({"cmyk": cms::GENERIC_CMYK})).unwrap(); + assert_eq!(r["cmyk"], cms::GENERIC_CMYK); + assert_eq!(cmd::colormgmt::doc_profiles(&s.doc().unwrap().doc).1.as_deref(), Some(cms::GENERIC_CMYK)); + assert!(s.execute("edit.assignProfile", &json!({"cmyk": "No Such Profile"})).is_err()); + assert!(s.execute("edit.assignProfile", &json!({"rgb": cms::GENERIC_CMYK})).is_err(), "kind must match"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(cmd::colormgmt::doc_profiles(&s.doc().unwrap().doc), (None, None)); +} + +#[test] +fn color_settings_lists_profiles_and_validates() { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let mut s = session(); + let r = s.execute("edit.colorSettings", &json!({})).unwrap(); + let names: Vec<&str> = r["profiles"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap()).collect(); + assert!(names.contains(&cms::GENERIC_CMYK) && names.contains(&cms::SRGB) && names.contains(&cms::DEVICE_CMYK)); + assert!(s.execute("edit.colorSettings", &json!({"cmyk": "Missing.icc"})).is_err()); + assert_eq!(cms::active_settings().cmyk, r["cmyk"].as_str().unwrap(), "failed change leaves settings alone"); + let before = r["bpc"].as_bool().unwrap(); + let r2 = s.execute("edit.colorSettings", &json!({"bpc": !before})).unwrap(); + assert_eq!(r2["bpc"], !before); + s.execute("edit.colorSettings", &json!({"bpc": before})).unwrap(); + assert!(s.execute("color.loadProfile", &json!({"path": "/nonexistent/x.icc"})).is_err()); +} + +#[test] +fn proof_colors_simulate_press_on_screen() { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 100.0, 100.0, json!("#0000ff")); + let doc = s.doc().unwrap().doc.clone(); + assert_eq!(render(&doc, RenderOptions::default()).pixel(50, 50), [0, 0, 255, 255]); + let proofed = render(&doc, RenderOptions { proof: Some(ProofSetup::default()), ..Default::default() }).pixel(50, 50); + assert_ne!(proofed, [0, 0, 255, 255]); + assert!(proofed[2] > proofed[0] && proofed[2] > proofed[1], "still blue: {proofed:?}"); + let de = delta_e2000(lab_of_rgb([0.0, 0.0, 1.0]), lab_of_rgb(proofed.map(|v| v as f32 / 255.0)[..3].try_into().unwrap())); + assert!(de > 5.0, "out-of-gamut blue visibly changes: ΔE {de}"); + // Colour-blindness proof. + let pr = render(&doc, RenderOptions { proof: Some(ProofSetup { target: ProofTarget::Protanopia, ..Default::default() }), ..Default::default() }); + assert_ne!(pr.pixel(50, 50), [0, 0, 255, 255]); +} + +#[test] +fn separation_plates_render_ink_coverage() { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 50.0, 100.0, json!({"c": 100, "m": 0, "y": 0, "k": 0})); + rect(&mut s, 50.0, 0.0, 50.0, 100.0, json!({"c": 0, "m": 50, "y": 0, "k": 0})); + let doc = s.doc().unwrap().doc.clone(); + let plate = |name: &str| { + render(&doc, RenderOptions { proof: Some(ProofSetup { separations: Some(vec![name.into()]), ..Default::default() }), ..Default::default() }) + }; + let c = plate("Cyan"); + assert_eq!(c.pixel(25, 50), [0, 0, 0, 255], "100% cyan is solid on the cyan plate"); + assert_eq!(c.pixel(75, 50), [255, 255, 255, 255], "no cyan in the magenta object"); + let m = plate("Magenta"); + assert_eq!(m.pixel(25, 50), [255, 255, 255, 255]); + let v = m.pixel(75, 50)[0] as i32; + assert!((v - 128).abs() <= 2, "50% magenta renders as 50% grey: {v}"); + assert_eq!(plate("Black").pixel(25, 50), [255, 255, 255, 255]); + // Composite of C+M only shows the magenta object in magenta ink. + let cm = render(&doc, RenderOptions { proof: Some(ProofSetup { separations: Some(vec!["Magenta".into()]), ..Default::default() }), ..Default::default() }); + assert_eq!(cm.pixel(25, 50)[0], 255); + let all = render( + &doc, + RenderOptions { proof: Some(ProofSetup { separations: Some(vec!["Cyan".into(), "Magenta".into(), "Yellow".into(), "Black".into()]), ..Default::default() }), ..Default::default() }, + ); + let p = all.pixel(25, 50); + assert!(p[0] < 40 && p[2] > 200, "composite cyan: {p:?}"); +} + +#[test] +fn rgb_art_separates_with_gcr() { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 100.0, 100.0, json!("#000000")); + let doc = s.doc().unwrap().doc.clone(); + let k = render(&doc, RenderOptions { proof: Some(ProofSetup { separations: Some(vec!["Black".into()]), ..Default::default() }), ..Default::default() }).pixel(50, 50); + assert!(k[0] < 20, "RGB black is mostly K: {k:?}"); + let c = render(&doc, RenderOptions { proof: Some(ProofSetup { separations: Some(vec!["Cyan".into()]), ..Default::default() }), ..Default::default() }).pixel(50, 50); + assert!(c[0] > 40 && c[0] < 200, "rich black has some cyan: {c:?}"); +} + +#[test] +fn spot_colours_get_their_own_plate() { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let mut s = session(); + s.execute("swatch.new", &json!({"name": "Brand Orange", "color": {"c": 0, "m": 60, "y": 100, "k": 0}, "global": true})).unwrap(); + s.execute("swatch.setSpot", &json!({"name": "Brand Orange"})).unwrap(); + assert!(s.execute("swatch.setSpot", &json!({"name": "Nope"})).is_err()); + let a = NodeId(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"ids": [a.0], "swatch": "Brand Orange"})).unwrap(); + let plates = s.execute("color.plates", &json!({})).unwrap(); + let names: Vec<&str> = plates["plates"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["Cyan", "Magenta", "Yellow", "Black", "Brand Orange"]); + let doc = s.doc().unwrap().doc.clone(); + let plate = |n: &str| render(&doc, RenderOptions { proof: Some(ProofSetup { separations: Some(vec![n.into()]), ..Default::default() }), ..Default::default() }).pixel(50, 50); + assert_eq!(plate("Brand Orange"), [0, 0, 0, 255]); + assert_eq!(plate("Magenta"), [255, 255, 255, 255], "spot ink isn't on the process plates"); + // Panel commands: only / toggle. + let r = s.execute("view.separationsPreview", &json!({"only": "Brand Orange"})).unwrap(); + let vis: Vec = r["plates"].as_array().unwrap().iter().map(|p| p["visible"].as_bool().unwrap()).collect(); + assert_eq!(vis, [false, false, false, false, true]); + let r = s.execute("view.separationsPreview", &json!({"toggle": "Cyan"})).unwrap(); + assert_eq!(r["plates"][0]["visible"], true); + assert_eq!(drawcraft_render::proof::active_proof().unwrap().separations.unwrap().len(), 2); + assert!(drawcraft_render::proof::overprint_preview_on(), "separations imply overprint preview"); + assert!(s.execute("view.separationsPreview", &json!({"only": "Mauve"})).is_err()); + s.execute("view.separationsPreview", &json!({"on": false})).unwrap(); + assert!(drawcraft_render::proof::active_proof().is_none_or(|p| p.separations.is_none())); +} + +#[test] +fn overprint_black_multiplies_in_preview() { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 100.0, 100.0, json!({"c": 100, "m": 0, "y": 0, "k": 0})); + let k = rect(&mut s, 25.0, 25.0, 50.0, 50.0, json!({"c": 0, "m": 0, "y": 0, "k": 100})); + let rgbk = rect(&mut s, 0.0, 0.0, 10.0, 10.0, json!("#000000")); + s.execute("select.set", &json!({"ids": [k.0, rgbk.0]})).unwrap(); + let r = s.execute("edit.colors.overprintBlack", &json!({})).unwrap(); + assert_eq!(r["changed"], 1, "only the 100% K object (not RGB black) overprints"); + let doc = s.doc().unwrap().doc.clone(); + let knock = render(&doc, RenderOptions::default()).pixel(50, 50); + let over = render(&doc, RenderOptions { overprint_preview: true, ..Default::default() }).pixel(50, 50); + assert!((knock[0] as i32 - knock[2] as i32).abs() < 8, "knockout shows plain black: {knock:?}"); + assert!(over[2] as i32 > over[0] as i32 + 10, "overprint lets the cyan through: {over:?}"); + assert!(over[2] < knock[2] + 60 && over[2] <= 128, "and stays dark: {over:?}"); + s.execute("edit.colors.overprintBlack", &json!({"remove": true})).unwrap(); + assert!(drawcraft_render::proof::overprint_ids(&s.doc().unwrap().doc).is_empty()); +} + +#[test] +fn view_toggles_redraw_without_dirtying() { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let mut s = session(); + let rev = s.doc().unwrap().revision; + let r = s.execute("view.proofColors", &json!({"on": true})).unwrap(); + assert_eq!(r["proofColors"], true); + assert!(s.doc().unwrap().revision > rev, "canvas redraws"); + assert_eq!(s.doc().unwrap().revision, s.doc().unwrap().saved_revision, "not marked modified"); + let r = s.execute("view.proofSetup", &json!({"target": "deuteranopia", "intent": "perceptual"})).unwrap(); + assert_eq!(r["target"], "deuteranopia"); + assert_eq!(drawcraft_render::proof::active_proof().unwrap().target, ProofTarget::Deuteranopia); + assert!(s.execute("view.proofSetup", &json!({"target": "cmyk:Nope"})).is_err()); + s.execute("view.proofSetup", &json!({"target": "workingCmyk", "intent": "relative"})).unwrap(); + s.execute("view.proofColors", &json!({})).unwrap(); + assert!(drawcraft_render::proof::active_proof().is_none()); + let r = s.execute("view.overprintPreview", &json!({})).unwrap(); + assert_eq!(r["overprintPreview"], true); + s.execute("view.overprintPreview", &json!({"on": false})).unwrap(); +} + +fn pdf_text(doc: &Document) -> String { + let bytes = drawcraft_pdf::export(doc, &drawcraft_pdf::PdfOptions { compress: false, created: Some(0), ..Default::default() }).unwrap(); + String::from_utf8_lossy(&bytes).into_owned() +} + +#[test] +fn pdf_cmyk_document_writes_device_cmyk() { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 10.0, 10.0, json!("#ff0000")); + let rgb_pdf = pdf_text(&s.doc().unwrap().doc); + assert!(!rgb_pdf.contains("DeviceCMYK")); + s.execute("object.convertDocumentColorMode", &json!({"mode": "cmyk"})).unwrap(); + let pdf = pdf_text(&s.doc().unwrap().doc); + std::fs::write("/tmp/claude-dbg.pdf", &pdf).ok(); + assert!(pdf.contains("DeviceCMYK"), "CMYK colours are written as DeviceCMYK"); + // RGB colours pasted into a CMYK document are separated on export. + let mut s2 = session(); + rect(&mut s2, 0.0, 0.0, 10.0, 10.0, json!("#336699")); + let mut d = (*s2.doc().unwrap().doc).clone(); + d.color_mode = ColorMode::Cmyk; + assert!(pdf_text(&d).contains("DeviceCMYK")); +} + +#[test] +fn pdf_spot_colour_is_a_separation() { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let mut s = session(); + s.execute("swatch.new", &json!({"name": "PMSish 123", "color": {"c": 0, "m": 20, "y": 90, "k": 0}, "global": true})).unwrap(); + s.execute("swatch.setSpot", &json!({"name": "PMSish 123"})).unwrap(); + let a = NodeId(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"ids": [a.0], "swatch": "PMSish 123"})).unwrap(); + let pdf = pdf_text(&s.doc().unwrap().doc); + assert!(pdf.contains("/Separation"), "spot → Separation colour space"); + assert!(pdf.contains("PMSish#20123"), "colorant name"); + assert!(pdf.contains("DeviceCMYK"), "CMYK alternate space"); +} + +#[test] +fn intents_reach_the_document_conversion() { + let mut a = session(); + rect(&mut a, 0.0, 0.0, 10.0, 10.0, json!("#00ff00")); + let mut b = session(); + rect(&mut b, 0.0, 0.0, 10.0, 10.0, json!("#00ff00")); + a.execute("object.convertDocumentColorMode", &json!({"mode": "cmyk", "intent": "perceptual"})).unwrap(); + b.execute("object.convertDocumentColorMode", &json!({"mode": "cmyk", "intent": "relative"})).unwrap(); + let first = |s: &Session| { + let mut out = None; + s.doc().unwrap().doc.walk(|n| { + if out.is_none() + && let Some(c) = n.appearance.fill_paint().color() + && n.children().is_none() + { + out = Some(c); + } + }); + out.unwrap() + }; + assert_ne!(first(&a), first(&b)); + let _ = Intent::Perceptual; +} diff --git a/crates/engine/src/tests_distort.rs b/crates/engine/src/tests_distort.rs new file mode 100644 index 00000000..eba3b4ef --- /dev/null +++ b/crates/engine/src/tests_distort.rs @@ -0,0 +1,341 @@ +//! Width points, Liquify, Puppet Warp and Perspective Grid commands and tools. + +use drawcraft_geom::{PathData, Point, Rect, Shape}; +use drawcraft_tools::distort::perspective::{PerspectiveGrid, Plane}; +use drawcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; +use serde_json::json; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + let r = s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap(); + NodeId(r["id"].as_u64().unwrap()) +} + +fn line(s: &mut Session) -> NodeId { + let r = s.execute("shape.line", &json!({"x1": 100, "y1": 300, "x2": 500, "y2": 300})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("stroke.set", &json!({"ids": [id.0], "weight": 10})).unwrap(); + id +} + +fn path(s: &Session, id: NodeId) -> PathData { + s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone() +} + +fn profile(s: &Session, id: NodeId) -> Option> { + s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().profile.as_ref().map(|p| p.points.clone()) +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +fn gesture(s: &mut Session, tool: &str, pts: &[(f64, f64)], mods: Mods) { + let v = ViewInfo::default(); + s.select_tool(tool, v).unwrap(); + for (i, (x, y)) in pts.iter().enumerate() { + let kind = if i == 0 { + PointerKind::Down + } else if i == pts.len() - 1 { + PointerKind::Up + } else { + PointerKind::Drag + }; + s.pointer(&PointerEvent::new(kind, *x, *y).with_mods(mods), v).unwrap(); + } +} + +fn close(a: Point, b: Point, tol: f64) -> bool { + a.distance(b) <= tol +} + +// ---------- width ---------- + +#[test] +fn width_point_set_creates_profile_and_undoes() { + let mut s = session(); + let id = line(&mut s); + let n = undo_len(&s); + let r = s.execute("stroke.widthPoint.set", &json!({"id": id.0, "t": 0.5, "left": 10, "right": 5})).unwrap(); + assert_eq!(r["index"], json!(1)); + assert_eq!(profile(&s, id).unwrap(), vec![(0.0, 1.0, 1.0), (0.5, 2.0, 1.0), (1.0, 1.0, 1.0)]); + assert_eq!(undo_len(&s), n + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(profile(&s, id), None); +} + +#[test] +fn width_point_move_and_remove() { + let mut s = session(); + let id = line(&mut s); + s.execute("stroke.widthPoint.set", &json!({"id": id.0, "t": 0.5, "left": 10, "right": 10})).unwrap(); + let r = s.execute("stroke.widthPoint.set", &json!({"id": id.0, "t": 0.25, "left": 10, "right": 10, "index": 1})).unwrap(); + assert_eq!(r["index"], json!(1)); + assert_eq!(profile(&s, id).unwrap()[1], (0.25, 2.0, 2.0)); + assert_eq!(profile(&s, id).unwrap().len(), 3); + s.execute("stroke.widthPoint.remove", &json!({"id": id.0, "index": 1})).unwrap(); + assert_eq!(profile(&s, id).unwrap().len(), 2); + assert!(s.execute("stroke.widthPoint.remove", &json!({"id": id.0, "index": 7})).is_err()); + assert!(s.execute("stroke.widthPoint.set", &json!({"id": id.0, "t": 0.5, "left": -1, "right": 1})).is_err()); +} + +#[test] +fn width_profile_set_and_clear() { + let mut s = session(); + let id = line(&mut s); + s.execute("stroke.widthProfile.set", &json!({"ids": [id.0], "points": [[1, 0, 0], [0, 0.5, 0.5]]})).unwrap(); + assert_eq!(profile(&s, id).unwrap(), vec![(0.0, 0.5, 0.5), (1.0, 0.0, 0.0)]); + s.execute("stroke.widthProfile.set", &json!({"ids": [id.0], "points": null})).unwrap(); + assert_eq!(profile(&s, id), None); +} + +#[test] +fn width_tool_gesture_is_one_undo_step() { + let mut s = session(); + let id = line(&mut s); + let n = undo_len(&s); + gesture(&mut s, "width", &[(300.0, 301.0), (300.0, 310.0), (300.0, 320.0)], Mods::default()); + let p = profile(&s, id).unwrap(); + assert_eq!(p.len(), 3); + // The last drag sample (10 pt from the path) sets both sides: factor 10 / (10 / 2) = 2. + assert!((p[1].0 - 0.5).abs() < 1e-3 && (p[1].1 - 2.0).abs() < 1e-6 && (p[1].2 - 2.0).abs() < 1e-6, "{p:?}"); + assert_eq!(undo_len(&s), n + 1); + // Delete removes the selected width point. + s.tool_key(ToolKey::Delete, Mods::default(), ViewInfo::default()).unwrap(); + assert_eq!(profile(&s, id).unwrap().len(), 2); +} + +// ---------- liquify ---------- + +fn warp_params() -> serde_json::Value { + json!({"tool": "warp", "points": [[300, 150], [340, 150], [380, 150]], "diameter": 100, "intensity": 100}) +} + +#[test] +fn liquify_is_deterministic_and_localized() { + let run = || { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 200.0, 200.0); + let b = rect(&mut s, 500.0, 400.0, 100.0, 100.0); + s.execute("select.all", &json!({})).unwrap(); + s.execute("object.liquify", &warp_params()).unwrap(); + (path(&s, a), path(&s, b)) + }; + let (a1, b1) = run(); + let (a2, b2) = run(); + assert_eq!(a1, a2); + assert_eq!(b1, b2); + // The far rectangle is untouched; the right edge of the first bulged right near y = 150. + assert_eq!(b1.bounds().unwrap(), Rect::new(500.0, 400.0, 600.0, 500.0)); + let bb = a1.bounds().unwrap(); + assert!(bb.x1 > 320.0 && bb.x0 == 100.0 && bb.y1 == 300.0, "{bb:?}"); + assert!(a1.anchor_count() > 4); +} + +#[test] +fn liquify_without_selection_hits_paths_under_the_brush() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 200.0, 200.0); + let b = rect(&mut s, 500.0, 400.0, 100.0, 100.0); + s.execute("select.set", &json!({"ids": []})).unwrap(); + let r = s.execute("object.liquify", &json!({"tool": "pucker", "points": [[550, 450]], "diameter": 150, "intensity": 1})).unwrap(); + assert_eq!(r["ids"], json!([b.0])); + assert_eq!(path(&s, a).bounds().unwrap(), Rect::new(100.0, 100.0, 300.0, 300.0)); + assert!(path(&s, b).bounds().unwrap().width() < 100.0); +} + +#[test] +fn liquify_tool_replays_from_the_journal() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 200.0, 200.0); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + let n = undo_len(&s); + s.select_tool("twirl", ViewInfo::default()).unwrap(); + s.set_tool_option("diameter", &json!(120)); + gesture(&mut s, "twirl", &[(300.0, 200.0), (302.0, 210.0), (305.0, 220.0), (305.0, 230.0)], Mods::default()); + assert_eq!(undo_len(&s), n + 1); + let after = path(&s, a); + assert_ne!(after, path(&session_with_rect(), a)); + let (cmd, params) = s.journal.last().unwrap().clone(); + assert_eq!(cmd, "object.liquify"); + let mut t = session_with_rect(); + t.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + t.execute(&cmd, ¶ms).unwrap(); + assert_eq!(path(&t, a), after); +} + +fn session_with_rect() -> Session { + let mut s = session(); + rect(&mut s, 100.0, 100.0, 200.0, 200.0); + s +} + +// ---------- puppet warp ---------- + +#[test] +fn puppet_warp_satisfies_pins_and_keeps_area() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 300.0, 100.0); + let n = undo_len(&s); + s.execute( + "object.puppetWarp", + &json!({"id": a.0, "pins": [[120, 150], [250, 150], [380, 150]], "moved": [[120, 150], [250, 150], [370, 90]]}), + ) + .unwrap(); + let p = path(&s, a); + let area0 = 300.0 * 100.0; + let area1 = p.to_bezpath().area().abs(); + assert!((area1 / area0 - 1.0).abs() < 0.08, "area {area1}"); + // The pinned left end stays; the right end rose. + let bb = p.bounds().unwrap(); + assert!((bb.x0 - 100.0).abs() < 6.0, "{bb:?}"); + assert!(bb.y0 < 80.0, "{bb:?}"); + assert_eq!(undo_len(&s), n + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(path(&s, a).bounds().unwrap(), Rect::new(100.0, 100.0, 400.0, 200.0)); + assert!(s.execute("object.puppetWarp", &json!({"id": a.0, "pins": [[1, 1]], "moved": []})).is_err()); +} + +#[test] +fn puppet_tool_drag_warps_selection() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 300.0, 100.0); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + let v = ViewInfo::default(); + s.select_tool("puppetWarp", v).unwrap(); + // First click seeds pins and adds one at the right end, then drag it. + s.pointer(&PointerEvent::new(PointerKind::Down, 385.0, 150.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 385.0, 150.0), v).unwrap(); + let n = undo_len(&s); + s.pointer(&PointerEvent::new(PointerKind::Down, 385.0, 150.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 385.0, 120.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 385.0, 120.0), v).unwrap(); + assert_eq!(undo_len(&s), n + 1); + assert_eq!(s.journal.last().unwrap().0, "object.puppetWarp"); + assert!(path(&s, a).bounds().unwrap().y0 < 95.0); +} + +// ---------- perspective ---------- + +fn grid(s: &Session) -> PerspectiveGrid { + PerspectiveGrid::from_doc(&s.doc().unwrap().doc).unwrap() +} + +#[test] +fn grid_show_and_plane_are_view_state() { + let mut s = session(); + let n = undo_len(&s); + assert_eq!(s.execute("perspective.grid.show", &json!({})).unwrap(), json!({"visible": true})); + s.execute("perspective.plane.set", &json!({"plane": "ground"})).unwrap(); + assert_eq!(grid(&s).plane, Plane::Ground); + assert_eq!(undo_len(&s), n); + assert_eq!(s.execute("perspective.grid.show", &json!({})).unwrap(), json!({"visible": false})); + assert!(s.execute("perspective.plane.set", &json!({"plane": "up"})).is_err()); + let ov = s.overlays(ViewInfo::default()); + assert!(ov.is_empty(), "hidden grid draws nothing with the selection tool"); + s.select_tool("perspectiveGrid", ViewInfo::default()).unwrap(); + assert!(s.overlays(ViewInfo::default()).len() > 20); +} + +#[test] +fn grid_preset_and_set_are_undoable() { + let mut s = session(); + s.execute("perspective.grid.preset", &json!({"kind": 3})).unwrap(); + assert_eq!(grid(&s).kind, 3); + s.execute("perspective.grid.set", &json!({"cell": 40, "horizon": 200})).unwrap(); + assert_eq!((grid(&s).cell, grid(&s).horizon), (40.0, 200.0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(grid(&s).kind, 3); + assert_ne!(grid(&s).cell, 40.0); + assert!(s.execute("perspective.grid.set", &json!({"distance": 0})).is_err()); + assert!(s.execute("perspective.grid.preset", &json!({"kind": 4})).is_err()); + // The grid is saved with the document. + let saved = serde_json::to_value(&*s.doc().unwrap().doc).unwrap(); + assert_eq!(saved["unknown"]["perspectiveGrid"]["kind"], json!(3)); +} + +#[test] +fn attach_projects_onto_the_plane_and_releases() { + let mut s = session(); + s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap(); + let a = rect(&mut s, 450.0, 300.0, 100.0, 100.0); + s.execute("perspective.attach", &json!({"ids": [a.0], "plane": "right"})).unwrap(); + let g = grid(&s); + assert_eq!(g.attached_plane(a), Some(Plane::Right)); + // Every anchor lies on the plane: its plane coordinates form an axis-aligned rectangle. + let p = path(&s, a); + let q: Vec = p.anchors().map(|(_, _, an)| g.to_plane(Plane::Right, an.p).unwrap()).collect(); + let xs: Vec = q.iter().map(|v| v.x).collect(); + let ys: Vec = q.iter().map(|v| v.y).collect(); + for v in &q { + assert!(xs.iter().filter(|x| (*x - v.x).abs() < 1e-6).count() == 2 && ys.iter().filter(|y| (*y - v.y).abs() < 1e-6).count() == 2, "{q:?}"); + } + // The dragged corners are kept. + assert!(p.anchors().any(|(_, _, an)| close(an.p, Point::new(450.0, 300.0), 1e-6))); + assert!(p.anchors().any(|(_, _, an)| close(an.p, Point::new(550.0, 400.0), 1e-6))); + s.execute("perspective.release", &json!({"ids": [a.0]})).unwrap(); + assert_eq!(grid(&s).attached_plane(a), None); + assert_eq!(path(&s, a), p); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(path(&s, a).bounds().unwrap(), Rect::new(450.0, 300.0, 550.0, 400.0)); +} + +#[test] +fn rectangle_tool_draws_in_perspective_on_the_active_plane() { + let mut s = session(); + s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap(); + s.execute("perspective.plane.set", &json!({"plane": "left"})).unwrap(); + let n = undo_len(&s); + gesture(&mut s, "rectangle", &[(300.0, 320.0), (330.0, 360.0), (350.0, 400.0)], Mods::default()); + assert_eq!(undo_len(&s), n + 1); + let (cmd, params) = s.journal.last().unwrap().clone(); + assert_eq!(cmd, "perspective.draw"); + assert_eq!(params["command"], json!("shape.rectangle")); + let id = *s.doc().unwrap().selection.objects.last().unwrap(); + assert_eq!(grid(&s).attached_plane(id), Some(Plane::Left)); + let p = path(&s, id); + assert!(p.anchors().any(|(_, _, an)| close(an.p, Point::new(300.0, 320.0), 1e-6))); + // Not a plain rectangle any more: the top edge slopes toward the left vanishing point. + let ys: Vec = p.anchors().map(|(_, _, an)| an.p.y).collect(); + assert!(ys.iter().any(|y| (y - 320.0).abs() > 0.5 && (y - 400.0).abs() > 0.5), "{ys:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(id).is_none()); +} + +#[test] +fn perspective_selection_moves_within_the_plane() { + let mut s = session(); + s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap(); + let a = rect(&mut s, 450.0, 380.0, 60.0, 60.0); + s.execute("perspective.attach", &json!({"ids": [a.0], "plane": "right"})).unwrap(); + let g = grid(&s); + let before: Vec = path(&s, a).anchors().map(|(_, _, an)| g.to_plane(Plane::Right, an.p).unwrap()).collect(); + let centre = before.iter().fold(drawcraft_geom::Vec2::ZERO, |a, p| a + p.to_vec2()) / before.len() as f64; + let from = g.to_page(Plane::Right, centre.to_point()).unwrap(); + let to = g.to_page(Plane::Right, (centre + drawcraft_geom::Vec2::new(40.0, 20.0)).to_point()).unwrap(); + let n = undo_len(&s); + gesture(&mut s, "perspectiveSelection", &[(from.x, from.y), (to.x, to.y), (to.x, to.y)], Mods::default()); + assert_eq!(undo_len(&s), n + 1); + let after: Vec = path(&s, a).anchors().map(|(_, _, an)| g.to_plane(Plane::Right, an.p).unwrap()).collect(); + for (b, c) in before.iter().zip(&after) { + assert!((*c - *b - drawcraft_geom::Vec2::new(40.0, 20.0)).hypot() < 1e-6, "{b:?} → {c:?}"); + } +} + +#[test] +fn perspective_move_attaches_loose_objects_to_the_given_plane() { + let mut s = session(); + s.execute("perspective.grid.preset", &json!({"kind": 1})).unwrap(); + let a = rect(&mut s, 250.0, 380.0, 50.0, 50.0); + s.execute("perspective.move", &json!({"ids": [a.0], "from": [260, 390], "to": [270, 390], "plane": "ground"})).unwrap(); + assert_eq!(grid(&s).attached_plane(a), Some(Plane::Ground)); + assert!(s.execute("perspective.attach", &json!({"ids": [a.0], "plane": "none"})).is_err()); +} diff --git a/crates/engine/src/tests_pattern.rs b/crates/engine/src/tests_pattern.rs new file mode 100644 index 00000000..089e8e93 --- /dev/null +++ b/crates/engine/src/tests_pattern.rs @@ -0,0 +1,447 @@ +//! Pattern swatches, pattern editing mode and Repeat, driven through `Session::execute`. + +use drawcraft_color::Paint; +use drawcraft_doc::pattern::{PATTERN_EDIT_LAYER, RepeatKind, TileType}; +use drawcraft_doc::{Node, NodeKind}; +use drawcraft_geom::{Point, Rect}; +use serde_json::{Value, json}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + s +} + +fn id_of(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap()) +} + +fn node(s: &Session, id: NodeId) -> Node { + s.doc().unwrap().doc.node(id).unwrap().clone() +} + +fn sel(s: &mut Session, ids: &[NodeId]) { + s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::>()})).unwrap(); +} + +fn doc(s: &Session) -> &drawcraft_doc::Document { + &s.doc().unwrap().doc +} + +/// A red 10×10 square made into pattern "Dots" (editing finished). +fn make_dots(s: &mut Session) -> NodeId { + let r = rect(s, 0.0, 0.0, 10.0, 10.0); + s.execute("paint.setFill", &json!({"ids": [r.0], "color": "#ff0000"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [r.0], "none": true})).unwrap(); + sel(s, &[r]); + s.execute("object.pattern.make", &json!({"name": "Dots", "width": 20, "height": 20})).unwrap(); + s.execute("object.pattern.done", &json!({})).unwrap(); + r +} + +fn close(a: Point, b: Point) -> bool { + (a - b).hypot() < 1e-6 +} + +// ---------- patterns ---------- + +#[test] +fn make_creates_definition_swatch_and_edit_mode() { + let mut s = session(); + let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + sel(&mut s, &[r]); + let v = s.execute("object.pattern.make", &json!({})).unwrap(); + assert_eq!(v["name"], "New Pattern 1"); + let d = doc(&s); + let def = d.pattern("New Pattern 1").unwrap(); + assert_eq!(def.tile, Rect::new(0.0, 0.0, 10.0, 10.0)); + assert!(d.swatch("New Pattern 1").is_some_and(|sw| matches!(sw.paint, Paint::Pattern { .. }))); + let pe = d.pattern_edit.as_ref().unwrap(); + assert_eq!(d.node(pe.layer).unwrap().name.as_deref(), Some(PATTERN_EDIT_LAYER)); + assert_eq!(d.node(pe.layer).unwrap().children().unwrap().len(), 1); + // The original art stays in the document; new art goes into the edit layer. + assert!(d.node(r).is_some()); + assert_eq!(s.doc().unwrap().insertion_parent(), Some(pe.layer)); +} + +#[test] +fn done_saves_edited_art_and_leaves_mode() { + let mut s = session(); + let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + sel(&mut s, &[r]); + s.execute("object.pattern.make", &json!({"name": "P"})).unwrap(); + // Draw another shape inside the pattern. + rect(&mut s, 2.0, 2.0, 3.0, 3.0); + s.execute("object.pattern.done", &json!({})).unwrap(); + let d = doc(&s); + assert!(d.pattern_edit.is_none()); + assert_eq!(d.pattern("P").unwrap().art.len(), 2); + assert!(d.layers.iter().all(|l| l.name.as_deref() != Some(PATTERN_EDIT_LAYER))); + assert!(s.doc().unwrap().isolation.is_none()); +} + +#[test] +fn cancel_new_pattern_removes_it() { + let mut s = session(); + let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + sel(&mut s, &[r]); + s.execute("object.pattern.make", &json!({"name": "P"})).unwrap(); + s.execute("object.pattern.cancel", &json!({})).unwrap(); + assert!(doc(&s).pattern("P").is_none()); + assert!(doc(&s).swatch("P").is_none()); + assert_eq!(doc(&s).layers.len(), 1); +} + +#[test] +fn edit_then_cancel_restores_definition() { + let mut s = session(); + make_dots(&mut s); + s.execute("object.pattern.edit", &json!({"name": "Dots"})).unwrap(); + s.execute("pattern.options", &json!({"tileType": "hexByRow", "width": 40})).unwrap(); + assert_eq!(doc(&s).pattern("Dots").unwrap().tile_type, TileType::HexByRow); + s.execute("object.pattern.cancel", &json!({})).unwrap(); + let def = doc(&s).pattern("Dots").unwrap(); + assert_eq!(def.tile_type, TileType::Grid); + assert_eq!(def.tile.width(), 20.0); +} + +#[test] +fn edit_defaults_to_selected_objects_pattern() { + let mut s = session(); + make_dots(&mut s); + let big = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); + sel(&mut s, &[big]); + let v = s.execute("object.pattern.edit", &json!({})).unwrap(); + assert_eq!(v["name"], "Dots"); + assert!(doc(&s).pattern_edit.is_some()); + // Edit again while editing is refused. + assert!(s.execute("object.pattern.edit", &json!({"name": "Dots"})).is_err()); +} + +#[test] +fn set_fill_with_pattern_swatch() { + let mut s = session(); + make_dots(&mut s); + let big = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); + assert!(matches!(node(&s, big).appearance.fill_paint(), Paint::Pattern { pattern, .. } if pattern == "Dots")); +} + +#[test] +fn set_fill_with_pattern_name_without_swatch() { + let mut s = session(); + make_dots(&mut s); + Arc::make_mut(&mut s.doc_mut().unwrap().doc).swatches.retain(|sw| sw.name != "Dots"); + let big = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); + assert!(matches!(node(&s, big).appearance.fill_paint(), Paint::Pattern { .. })); +} + +#[test] +fn options_set_tile_type_spacing_and_overlap() { + let mut s = session(); + make_dots(&mut s); + s.execute( + "pattern.options", + &json!({"name": "Dots", "tileType": "brickByColumn", "brickOffset": 0.25, "overlap": {"h": "right", "v": "bottom"}, "copies": 7, "dimCopies": 40}), + ) + .unwrap(); + let def = doc(&s).pattern("Dots").unwrap(); + assert_eq!(def.tile_type, TileType::BrickByColumn { offset: 0.25 }); + assert!(def.overlap.right_in_front && def.overlap.bottom_in_front); + assert_eq!(def.copies, 7); + assert_eq!(def.dim_copies, 40.0); + // Size tile to art with spacing. + s.execute("pattern.options", &json!({"name": "Dots", "sizeTileToArt": true, "hSpacing": 4, "vSpacing": 6})).unwrap(); + let def = doc(&s).pattern("Dots").unwrap(); + assert!((def.tile.width() - 14.0).abs() < 1e-6 && (def.tile.height() - 16.0).abs() < 1e-6, "{:?}", def.tile); + assert!(s.execute("pattern.options", &json!({"name": "Dots", "tileType": "zigzag"})).is_err()); + assert!(s.execute("pattern.options", &json!({"name": "Dots", "width": -1})).is_err()); +} + +#[test] +fn options_rename_updates_swatch_and_paints() { + let mut s = session(); + make_dots(&mut s); + let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0); + s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); + s.execute("pattern.options", &json!({"name": "Dots", "newName": "Spots"})).unwrap(); + assert!(doc(&s).pattern("Spots").is_some() && doc(&s).pattern("Dots").is_none()); + assert!(doc(&s).swatch("Spots").is_some()); + assert!(matches!(node(&s, big).appearance.fill_paint(), Paint::Pattern { pattern, .. } if pattern == "Spots")); +} + +#[test] +fn delete_pattern_unpaints_objects() { + let mut s = session(); + make_dots(&mut s); + let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0); + s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); + let v = s.execute("pattern.delete", &json!({"name": "Dots"})).unwrap(); + assert_eq!(v["unpainted"], 1); + assert!(doc(&s).pattern("Dots").is_none() && doc(&s).swatch("Dots").is_none()); + assert!(node(&s, big).appearance.fill_paint().is_none()); + assert!(s.execute("pattern.delete", &json!({"name": "Dots"})).is_err()); +} + +#[test] +fn transform_patterns_only_moves_placement() { + let mut s = session(); + make_dots(&mut s); + let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0); + s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); + let before = node(&s, big).geometric_bounds(); + s.execute("pattern.transform", &json!({"ids": [big.0], "matrix": [2, 0, 0, 2, 5, 0]})).unwrap(); + let n = node(&s, big); + assert_eq!(n.geometric_bounds(), before); + let Paint::Pattern { xf, .. } = n.appearance.fill_paint() else { panic!() }; + assert_eq!(xf.as_coeffs(), [2.0, 0.0, 0.0, 2.0, 5.0, 0.0]); +} + +#[test] +fn list_reports_patterns_and_edit_state() { + let mut s = session(); + make_dots(&mut s); + let v = s.execute("pattern.list", &json!({})).unwrap(); + assert_eq!(v["patterns"][0]["name"], "Dots"); + assert_eq!(v["patterns"][0]["tileType"], "grid"); + assert_eq!(v["patterns"][0]["width"], 20.0); + assert!(v["editing"].is_null()); + s.execute("object.pattern.edit", &json!({"name": "Dots"})).unwrap(); + assert_eq!(s.execute("pattern.list", &json!({})).unwrap()["editing"]["name"], "Dots"); +} + +#[test] +fn save_a_copy_while_editing() { + let mut s = session(); + make_dots(&mut s); + s.execute("object.pattern.edit", &json!({"name": "Dots"})).unwrap(); + let v = s.execute("object.pattern.saveCopy", &json!({})).unwrap(); + assert_eq!(v["name"], "Dots copy"); + assert!(doc(&s).pattern("Dots copy").is_some()); +} + +#[test] +fn undo_make_pattern() { + let mut s = session(); + let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + sel(&mut s, &[r]); + s.execute("object.pattern.make", &json!({"name": "P"})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(doc(&s).pattern("P").is_none() && doc(&s).pattern_edit.is_none()); +} + +#[test] +fn hit_testing_in_edit_mode_only_reaches_tile_layer() { + let mut s = session(); + let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let other = rect(&mut s, 200.0, 200.0, 50.0, 50.0); + sel(&mut s, &[r]); + s.execute("object.pattern.make", &json!({"name": "P"})).unwrap(); + let d = doc(&s); + let opt = drawcraft_doc::hit::HitOptions::default(); + assert!(drawcraft_doc::hit::hit_test(d, Point::new(220.0, 220.0), opt).is_none()); + let h = drawcraft_doc::hit::hit_test(d, Point::new(5.0, 5.0), opt).unwrap(); + assert_ne!(h.leaf, r); + let _ = other; +} + +#[test] +fn save_load_roundtrip_keeps_patterns_and_repeats() { + let mut s = session(); + make_dots(&mut s); + let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0); + s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); + let r = rect(&mut s, 300.0, 50.0, 10.0, 10.0); + sel(&mut s, &[r]); + s.execute("object.repeat.radial", &json!({"instances": 6})).unwrap(); + let bytes = drawcraft_format::save(doc(&s), false); + let back = drawcraft_format::load(&bytes).unwrap(); + assert_eq!(back.patterns, doc(&s).patterns); + assert_eq!(back.node(big).unwrap().appearance.fill_paint(), node(&s, big).appearance.fill_paint()); + assert!(back.layers[0].children().unwrap().iter().any(|n| matches!(n.kind, NodeKind::Repeat(_)))); +} + +#[test] +fn svg_export_writes_pattern_def() { + let mut s = session(); + make_dots(&mut s); + let big = rect(&mut s, 100.0, 100.0, 50.0, 50.0); + s.execute("paint.setFill", &json!({"ids": [big.0], "swatch": "Dots"})).unwrap(); + let svg = drawcraft_svg::export(doc(&s), &drawcraft_svg::ExportOptions::default()); + assert!(svg.contains("").unwrap(); + assert!(svg[start..end].contains(" 500); +} + +// ---------- repeat ---------- + +#[test] +fn radial_repeat_wraps_selection() { + let mut s = session(); + let r = rect(&mut s, 195.0, 95.0, 10.0, 10.0); + sel(&mut s, &[r]); + let v = s.execute("object.repeat.radial", &json!({"instances": 8, "radius": 50})).unwrap(); + let n = node(&s, id_of(&v)); + let NodeKind::Repeat(spec) = &n.kind else { panic!("{:?}", n.kind) }; + assert_eq!(spec.source.len(), 1); + let RepeatKind::Radial { instances, radius, center, .. } = spec.kind else { panic!() }; + assert_eq!((instances, radius), (8, 50.0)); + assert!(close(center, Point::new(200.0, 150.0))); + assert_eq!(spec.transforms().len(), 8); + assert_eq!(n.kind_label(), "Radial Repeat"); +} + +#[test] +fn radial_instance_transforms_rotate_around_center() { + let mut s = session(); + let r = rect(&mut s, 195.0, 95.0, 10.0, 10.0); + sel(&mut s, &[r]); + let v = s.execute("object.repeat.radial", &json!({"instances": 4, "radius": 50})).unwrap(); + let NodeKind::Repeat(spec) = node(&s, id_of(&v)).kind else { panic!() }; + let t = spec.transforms(); + let c = Point::new(200.0, 100.0); + assert!(close(t[0] * c, Point::new(200.0, 100.0))); + assert!(close(t[1] * c, Point::new(250.0, 150.0))); + assert!(close(t[2] * c, Point::new(200.0, 200.0))); + assert!(close(t[3] * c, Point::new(150.0, 150.0))); +} + +#[test] +fn expand_yields_n_copies() { + let mut s = session(); + let r = rect(&mut s, 195.0, 95.0, 10.0, 10.0); + sel(&mut s, &[r]); + let v = s.execute("object.repeat.radial", &json!({"instances": 5})).unwrap(); + let id = id_of(&v); + s.execute("object.repeat.expand", &json!({})).unwrap(); + let g = node(&s, id); + assert!(matches!(g.kind, NodeKind::Group { .. })); + assert_eq!(g.children().unwrap().len(), 5); + // Every generated node has a real, unique id. + let mut ids = vec![]; + g.walk(&mut |n| ids.push(n.id)); + assert!(ids.iter().all(|i| i.0 != 0)); + let mut u = ids.clone(); + u.sort(); + u.dedup(); + assert_eq!(u.len(), ids.len()); +} + +#[test] +fn grid_repeat_and_options() { + let mut s = session(); + let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + sel(&mut s, &[r]); + let v = s.execute("object.repeat.grid", &json!({"hSpacing": 5, "vSpacing": 5})).unwrap(); + let id = id_of(&v); + assert_eq!(node(&s, id).geometric_bounds(), Some(Rect::new(0.0, 0.0, 40.0, 40.0))); + s.execute("object.repeat.options", &json!({"rows": 2, "cols": 4, "flipCols": true, "gridType": "brickByRow"})).unwrap(); + let NodeKind::Repeat(spec) = node(&s, id).kind else { panic!() }; + let RepeatKind::Grid { rows, cols, flip_cols, grid_type, .. } = spec.kind else { panic!() }; + assert_eq!((rows, cols, flip_cols), (2, 4, true)); + assert_eq!(grid_type, TileType::BrickByRow { offset: 0.5 }); + assert_eq!(spec.expand().len(), 8); +} + +#[test] +fn mirror_repeat_reflects() { + let mut s = session(); + let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + sel(&mut s, &[r]); + let v = s.execute("object.repeat.mirror", &json!({"offset": 5})).unwrap(); + let n = node(&s, id_of(&v)); + let b = n.geometric_bounds().unwrap(); + assert!((b.x1 - 30.0).abs() < 1e-9 && b.x0.abs() < 1e-9, "{b:?}"); + s.execute("object.repeat.options", &json!({"angle": 0})).unwrap(); + let b = node(&s, id_of(&v)).geometric_bounds().unwrap(); + assert!((b.width() - 10.0).abs() < 1e-9, "{b:?}"); +} + +#[test] +fn switching_repeat_kind_keeps_source() { + let mut s = session(); + let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + sel(&mut s, &[r]); + let v = s.execute("object.repeat.radial", &json!({})).unwrap(); + let id = id_of(&v); + let v2 = s.execute("object.repeat.grid", &json!({})).unwrap(); + assert_eq!(id_of(&v2), id); + let NodeKind::Repeat(spec) = node(&s, id).kind else { panic!() }; + assert!(matches!(spec.kind, RepeatKind::Grid { .. })); + assert_eq!(spec.source[0].id, r); +} + +#[test] +fn release_restores_source() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0); + sel(&mut s, &[a, b]); + s.execute("object.repeat.mirror", &json!({})).unwrap(); + assert!(doc(&s).layers[0].children().unwrap().len() == 1); + let v = s.execute("object.repeat.release", &json!({})).unwrap(); + assert_eq!(v["ids"], json!([a.0, b.0])); + assert_eq!(node(&s, a).geometric_bounds(), Some(Rect::new(0.0, 0.0, 10.0, 10.0))); + assert!(s.execute("object.repeat.release", &json!({})).is_err()); +} + +#[test] +fn repeat_moves_with_transform() { + let mut s = session(); + let r = rect(&mut s, 195.0, 95.0, 10.0, 10.0); + sel(&mut s, &[r]); + let v = s.execute("object.repeat.radial", &json!({"instances": 4, "radius": 50})).unwrap(); + let id = id_of(&v); + let before = node(&s, id).geometric_bounds().unwrap(); + s.execute("object.transform", &json!({"ids": [id.0], "matrix": [1, 0, 0, 1, 10, 20]})).unwrap(); + let after = node(&s, id).geometric_bounds().unwrap(); + assert!(after.x0 > before.x0 && after.y0 > before.y0, "{before:?} {after:?}"); +} + +#[test] +fn repeat_hit_test_uses_instances() { + let mut s = session(); + let r = rect(&mut s, 195.0, 95.0, 10.0, 10.0); + sel(&mut s, &[r]); + s.execute("object.repeat.radial", &json!({"instances": 4, "radius": 50})).unwrap(); + let d = doc(&s); + let opt = drawcraft_doc::hit::HitOptions::default(); + assert!(drawcraft_doc::hit::hit_test(d, Point::new(250.0, 150.0), opt).is_some()); + // The empty centre of the ring is not the repeat. + assert!(drawcraft_doc::hit::hit_test(d, Point::new(200.0, 150.0), opt).is_none()); +} + +#[test] +fn repeat_commands_validate() { + let mut s = session(); + let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + sel(&mut s, &[r]); + assert!(s.execute("object.repeat.radial", &json!({"instances": 0})).is_err()); + assert!(s.execute("object.repeat.options", &json!({"instances": 4})).is_err(), "no repeat selected"); + s.execute("select.none", &json!({})).ok(); + assert!(s.execute("object.pattern.make", &json!({})).is_err()); +} diff --git a/crates/engine/src/tests_prefs.rs b/crates/engine/src/tests_prefs.rs index 2de1a23a..87858592 100644 --- a/crates/engine/src/tests_prefs.rs +++ b/crates/engine/src/tests_prefs.rs @@ -53,7 +53,12 @@ fn set_rejects_out_of_range_and_wrong_types() { #[test] fn set_normalizes_strings_labels_and_colours() { let mut s = Session::new(); - let r = s.execute("prefs.set", &json!({"values": {"uiBrightness": "Medium Light", "gridColor": "ABCDEF", "keyboardIncrement": "2.5 pt", "scaleStrokes": "false"}})).unwrap(); + let r = s + .execute( + "prefs.set", + &json!({"values": {"uiBrightness": "Medium Light", "gridColor": "ABCDEF", "keyboardIncrement": "2.5 pt", "scaleStrokes": "false"}}), + ) + .unwrap(); assert_eq!(r["uiBrightness"], json!("mediumLight")); assert_eq!(s.prefs.grid_color, "#abcdef"); assert_eq!(s.prefs.keyboard_increment, 2.5); diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index e5c6c7f8..3bc31afe 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -91,7 +91,11 @@ impl Session { } pub fn overlays(&mut self, view: ViewInfo) -> Vec { - self.with_tool_cx(view, |t, cx| t.overlays(cx)) + let mut v = self.with_tool_cx(view, |t, cx| t.overlays(cx)); + if let Some(d) = self.active() { + v.splice(0..0, drawcraft_tools::distort::perspective::grid_overlays(&d.doc, 1.0 / view.zoom.max(1e-9), self.tool.id())); + } + v } pub fn cursor(&mut self, p: Point, mods: Mods, view: ViewInfo) -> Cursor { diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 9a16d71b..bdde03fd 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -197,6 +197,32 @@ fn rects_overlap(a: Rect, b: Rect) -> bool { } impl Exporter<'_> { + /// A colour for the page: in CMYK documents RGB colours are separated into DeviceCMYK through + /// the active colour settings, so the file carries press values. + fn col(&mut self, c: &Color) -> krilla::color::Color { + if self.doc.color_mode == drawcraft_doc::ColorMode::Cmyk && matches!(c, Color::Rgb { .. }) { + let cms = drawcraft_color::cms::active(); + let [cc, m, y, k] = cms.to_cmyk(c, cms.settings().intent); + return cmyk::Color::new(q(cc), q(m), q(y), q(k)).into(); + } + color(c) + } + + /// A solid paint, as a Separation colour space when it's linked to a spot swatch (the + /// swatch's CMYK equivalent is the alternate space). + fn solid(&mut self, c: &Color, swatch: Option<&str>) -> krilla::color::Color { + use krilla::color::separation::{Color as SepColor, SeparationColorant, SeparationSpace}; + let spot = swatch.and_then(|n| self.doc.swatches.iter().find(|s| s.spot && s.name == n)).and_then(|s| s.paint.color().map(|sc| (s.name.clone(), sc))); + let Some((name, sc)) = spot else { return self.col(c) }; + let cms = drawcraft_color::cms::active(); + let intent = cms.settings().intent; + let full = cms.to_cmyk(&sc, intent); + let total: f32 = full.iter().sum(); + let tint = if total <= 1e-4 { 1.0 } else { (cms.to_cmyk(c, intent).iter().sum::() / total).clamp(0.0, 1.0) }; + let alt = krilla::color::RegularColor::Cmyk(cmyk::Color::new(q(full[0]), q(full[1]), q(full[2]), q(full[3]))); + SepColor::new(q(tint), SeparationSpace::new(SeparationColorant::Custom(name), alt)).into() + } + fn warn(&mut self, w: impl Into) { let w = w.into(); if !self.warnings.contains(&w) { @@ -208,7 +234,7 @@ impl Exporter<'_> { fn paint(&mut self, p: &Paint, bounds: Rect) -> Option { match p { Paint::None => None, - Paint::Solid { color: c, .. } => Some(color(c).into()), + Paint::Solid { color: c, swatch } => Some(self.solid(c, swatch.as_deref()).into()), Paint::Gradient(g) => { let geom = g.resolve(bounds); let mut stops: Vec = Vec::new(); @@ -216,7 +242,7 @@ impl Exporter<'_> { for (o, c, a) in g.gradient.expanded_stops() { let o = o.clamp(last, 1.0); last = o; - stops.push(Stop { offset: norm(o), color: color(&c), opacity: norm(a) }); + stops.push(Stop { offset: norm(o), color: self.col(&c), opacity: norm(a) }); } if stops.is_empty() { return None; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 726ac433..4a825d36 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -10,8 +10,9 @@ mod brush_fx; mod fx; mod live; mod paint; -pub mod proof; mod pattern; +pub mod proof; +mod width; use std::collections::HashMap; use std::sync::Arc; @@ -26,6 +27,7 @@ pub use drawcraft_effects as effects; pub use live::expand_live; pub use pattern::render_pattern_swatch; pub use vello_cpu; +pub use width::width_outline; /// Rendering options. #[derive(Clone, Debug)] @@ -560,7 +562,12 @@ impl Renderer { } if paint::set_paint(ctx, &st.paint, bounds.inflate(st.width / 2.0, st.width / 2.0), f.doc) { self.fold_alpha(ctx, &st.paint); - ctx.stroke_path(bp); + if let Some(o) = width::outline_for(bp, st, f.px * 0.25) { + ctx.set_fill_rule(peniko::Fill::NonZero); + ctx.fill_path(&o); + } else { + ctx.stroke_path(bp); + } } if inside { ctx.pop_layer(); diff --git a/crates/render/src/pattern.rs b/crates/render/src/pattern.rs index caf3a8b8..8d36297b 100644 --- a/crates/render/src/pattern.rs +++ b/crates/render/src/pattern.rs @@ -69,7 +69,8 @@ fn rasterize(r: &mut Renderer, doc: &Document, def: &PatternDef, region: Rect, w let px_rect = Rect::new(0.0, 0.0, w as f64, h as f64); for o in def.offsets_covering(region) { let v = view * def.instance_xf(o); - let frame = Frame { doc, view: v, visible: v.inverse().transform_rect_bbox(px_rect), px: 1.0 / v.determinant().abs().sqrt().max(1e-12), opts: &opts }; + let frame = + Frame { doc, view: v, visible: v.inverse().transform_rect_bbox(px_rect), px: 1.0 / v.determinant().abs().sqrt().max(1e-12), opts: &opts }; for a in &def.art { r.draw_arc(&mut ctx, &frame, a); } @@ -122,7 +123,11 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine, let scale = raster_scale(s, (pw, ph)); let params = (def.tile, def.tile_type, def.overlap); let hit = CACHE.with(|c| { - c.borrow().tiles.get(name).filter(|e| e.scale == scale && e.params == params && same_art(&e.art, &def.art)).map(|e| (e.pixmap.clone(), e.sx, e.sy)) + c.borrow() + .tiles + .get(name) + .filter(|e| e.scale == scale && e.params == params && same_art(&e.art, &def.art)) + .map(|e| (e.pixmap.clone(), e.sx, e.sy)) }); let (pm, sx, sy) = match hit { Some(h) => h, @@ -204,7 +209,11 @@ mod tests { /// A 20×20 tile with a red 10×10 square in its top-left corner. fn doc_with_pattern(tt: TileType) -> Document { let mut d = Document::new(200.0, 200.0); - let red = Node::path(NodeId(900), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0)); + let red = Node::path( + NodeId(900), + shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), + Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0), + ); let mut def = PatternDef::new("Checks", vec![Arc::new(red)]); def.tile = Rect::new(0.0, 0.0, 20.0, 20.0); def.tile_type = tt; @@ -319,7 +328,11 @@ mod tests { #[test] fn repeat_renders_instances() { let mut d = Document::new(200.0, 200.0); - let sq = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + let sq = Node::path( + NodeId(50), + shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), + ); let id = d.alloc_id(); let l = d.layers[0].id; d.insert(Some(l), 0, Node::new(id, NodeKind::Repeat(RepeatSpec::grid(vec![Arc::new(sq)], 10.0, 10.0)))).unwrap(); diff --git a/crates/render/src/width.rs b/crates/render/src/width.rs new file mode 100644 index 00000000..e96bb8c9 --- /dev/null +++ b/crates/render/src/width.rs @@ -0,0 +1,261 @@ +//! Variable-width strokes (Width tool / width profiles). +//! +//! A stroke with a [`WidthProfile`] is drawn as a filled outline: each subpath is flattened, and +//! at every sample the left and right offsets `width/2 · factor(t)` (t = fraction of the +//! subpath's length) are placed along the (mitred) normal. Open subpaths get butt, round or +//! projecting caps (the round cap blends the two side widths); closed subpaths become two loops +//! of opposite orientation filled with the non-zero rule. + +use drawcraft_doc::{LineCap, StrokeAlign, StrokeLayer, WidthProfile}; +use drawcraft_geom::kurbo::{self, BezPath, PathEl, Point, Vec2}; + +/// The outline to fill instead of stroking, when `st` has a width profile (and no dashes). +pub(crate) fn outline_for(bp: &BezPath, st: &StrokeLayer, tol: f64) -> Option { + let profile = st.profile.as_ref()?; + if st.dash.as_ref().is_some_and(|d| d.pattern.iter().any(|v| *v > 0.0)) { + return None; + } + let closed = bp.elements().last().is_some_and(|e| matches!(e, PathEl::ClosePath)); + let width = match st.align { + StrokeAlign::Center => st.width, + _ if closed => st.width * 2.0, + _ => st.width, + }; + let o = width_outline(bp, width, profile, st.cap, tol); + (!o.elements().is_empty()).then_some(o) +} + +/// Filled outline of `bp` stroked with `width` scaled by `profile`, flattened to `tol`. +pub fn width_outline(bp: &BezPath, width: f64, profile: &WidthProfile, cap: LineCap, tol: f64) -> BezPath { + let mut out = BezPath::new(); + for (pts, closed) in subpaths(bp, tol.max(1e-4)) { + outline_subpath(&mut out, &pts, closed, width, profile, cap); + } + out +} + +/// Flattened subpaths (consecutive duplicates removed; closed ones don't repeat the start). +fn subpaths(bp: &BezPath, tol: f64) -> Vec<(Vec, bool)> { + let mut out: Vec<(Vec, bool)> = vec![]; + let mut cur: Vec = vec![]; + let push = |cur: &mut Vec, p: Point| { + if cur.last().is_none_or(|l| l.distance(p) > 1e-9) { + cur.push(p); + } + }; + kurbo::flatten(bp.iter(), tol, |el| match el { + PathEl::MoveTo(p) => { + if cur.len() > 1 { + out.push((std::mem::take(&mut cur), false)); + } + cur.clear(); + cur.push(p); + } + PathEl::LineTo(p) => push(&mut cur, p), + PathEl::ClosePath => { + if cur.len() > 1 && cur.first().unwrap().distance(*cur.last().unwrap()) <= 1e-9 { + cur.pop(); + } + if cur.len() > 1 { + let start = cur[0]; + out.push((std::mem::take(&mut cur), true)); + cur.push(start); + } else { + cur.clear(); + } + } + _ => {} + }); + if cur.len() > 1 { + out.push((cur, false)); + } + out +} + +fn unit(v: Vec2) -> Vec2 { + let h = v.hypot(); + if h > 1e-12 { v / h } else { Vec2::new(1.0, 0.0) } +} + +/// Left normal in y-down space (left of the direction of travel). +fn left(t: Vec2) -> Vec2 { + Vec2::new(t.y, -t.x) +} + +/// Insert samples where the profile has width points (so the piecewise-linear profile is exact +/// along straight runs). +fn densify(pts: &[Point], closed: bool, profile: &WidthProfile) -> Vec { + let n = pts.len(); + let seg_count = if closed { n } else { n - 1 }; + let total: f64 = (0..seg_count).map(|i| pts[i].distance(pts[(i + 1) % n])).sum(); + if total <= 1e-12 { + return pts.to_vec(); + } + let mut out = Vec::with_capacity(n + profile.points.len()); + let mut acc = 0.0; + for i in 0..seg_count { + let (a, b) = (pts[i], pts[(i + 1) % n]); + let len = a.distance(b); + out.push(a); + for (t, _, _) in &profile.points { + let d = t * total; + if d > acc + 1e-9 && d < acc + len - 1e-9 { + out.push(a.lerp(b, (d - acc) / len)); + } + } + acc += len; + } + if !closed { + out.push(pts[n - 1]); + } + out +} + +fn outline_subpath(out: &mut BezPath, pts: &[Point], closed: bool, width: f64, profile: &WidthProfile, cap: LineCap) { + let pts = &densify(pts, closed, profile)[..]; + let n = pts.len(); + let seg_count = if closed { n } else { n - 1 }; + let seg_dir: Vec = (0..seg_count).map(|i| unit(pts[(i + 1) % n] - pts[i])).collect(); + let mut cum = vec![0.0; n + 1]; + for i in 0..seg_count { + cum[i + 1] = cum[i] + pts[i].distance(pts[(i + 1) % n]); + } + let total = cum[seg_count]; + if total <= 1e-12 { + return; + } + // Vertex normals (mitred, limited to 4× so sharp corners don't spike). + let normal_at = |i: usize| -> (Vec2, f64) { + let (a, b) = if closed { + (seg_dir[(i + seg_count - 1) % seg_count], seg_dir[i % seg_count]) + } else if i == 0 { + (seg_dir[0], seg_dir[0]) + } else if i == n - 1 { + (seg_dir[seg_count - 1], seg_dir[seg_count - 1]) + } else { + (seg_dir[i - 1], seg_dir[i]) + }; + let (na, nb) = (left(a), left(b)); + let nm = unit(na + nb); + let c = nm.dot(nb).max(0.25); + (nm, 1.0 / c) + }; + let half = width / 2.0; + let mut lpts = Vec::with_capacity(n); + let mut rpts = Vec::with_capacity(n); + for (i, p) in pts.iter().enumerate() { + let (nm, miter) = normal_at(i); + let (fl, fr) = profile.at(cum[i] / total); + lpts.push(*p + nm * (miter * half * fl.max(0.0))); + rpts.push(*p - nm * (miter * half * fr.max(0.0))); + } + if closed { + polygon(out, lpts.iter().copied()); + polygon(out, rpts.iter().rev().copied()); + return; + } + let (t0, t1) = (seg_dir[0], seg_dir[seg_count - 1]); + let (fl0, fr0) = profile.at(0.0); + let (fl1, fr1) = profile.at(1.0); + let (wl0, wr0, wl1, wr1) = (half * fl0.max(0.0), half * fr0.max(0.0), half * fl1.max(0.0), half * fr1.max(0.0)); + let mut ring: Vec = lpts.clone(); + // End cap: from the left side around the end to the right side. + cap_points(&mut ring, pts[n - 1], left(t1), t1, wl1, wr1, cap); + ring.extend(rpts.iter().rev()); + // Start cap: from the right side around the start back to the left side. + cap_points(&mut ring, pts[0], -left(t0), -t0, wr0, wl0, cap); + polygon(out, ring.into_iter()); +} + +/// Cap points between `c + u·r0` and `c − u·r1`, bulging towards `v`. +fn cap_points(ring: &mut Vec, c: Point, u: Vec2, v: Vec2, r0: f64, r1: f64, cap: LineCap) { + match cap { + LineCap::Butt => {} + LineCap::Square => { + let h = (r0 + r1) / 2.0; + ring.push(c + u * r0 + v * h); + ring.push(c - u * r1 + v * h); + } + LineCap::Round => { + let steps = 16; + for k in 1..steps { + let a = std::f64::consts::PI * k as f64 / steps as f64; + let r = r0 + (r1 - r0) * (k as f64 / steps as f64); + ring.push(c + u * (a.cos() * r) + v * (a.sin() * r)); + } + } + } +} + +fn polygon(out: &mut BezPath, mut pts: impl Iterator) { + let Some(first) = pts.next() else { return }; + out.move_to(first); + for p in pts { + out.line_to(p); + } + out.close_path(); +} + +#[cfg(test)] +mod tests { + use super::*; + use kurbo::Shape; + + fn line() -> BezPath { + let mut b = BezPath::new(); + b.move_to((0.0, 0.0)); + b.line_to((100.0, 0.0)); + b + } + + fn area(b: &BezPath) -> f64 { + b.area().abs() + } + + #[test] + fn uniform_profile_matches_a_plain_stroke() { + let p = WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] }; + assert!((area(&width_outline(&line(), 10.0, &p, LineCap::Butt, 0.01)) - 1000.0).abs() < 1e-6); + let round = area(&width_outline(&line(), 10.0, &p, LineCap::Round, 0.01)); + let want = 1000.0 + std::f64::consts::PI * 25.0; + assert!((round - want).abs() / want < 0.01, "{round} vs {want}"); + assert!((area(&width_outline(&line(), 10.0, &p, LineCap::Square, 0.01)) - 1100.0).abs() < 1e-6); + } + + #[test] + fn lens_profile_is_a_diamond_and_one_sided_profiles_stay_on_their_side() { + let a = area(&width_outline(&line(), 10.0, &WidthProfile::lens(), LineCap::Butt, 0.01)); + assert!((a - 500.0).abs() < 1e-6, "{a}"); + let one = WidthProfile { points: vec![(0.0, 1.0, 0.0), (1.0, 1.0, 0.0)] }; + let o = width_outline(&line(), 10.0, &one, LineCap::Butt, 0.01); + assert!((area(&o) - 500.0).abs() < 1e-6); + let bb = o.bounding_box(); + // Travelling +x, the left side is up (negative y). + assert!(bb.y0 < -4.99 && bb.y1 <= 1e-9, "{bb:?}"); + } + + #[test] + fn closed_paths_make_a_ring() { + let mut sq = BezPath::new(); + sq.move_to((0.0, 0.0)); + sq.line_to((100.0, 0.0)); + sq.line_to((100.0, 100.0)); + sq.line_to((0.0, 100.0)); + sq.close_path(); + let p = WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] }; + let a = area(&width_outline(&sq, 10.0, &p, LineCap::Butt, 0.01)); + assert!((a - (110.0 * 110.0 - 90.0 * 90.0)).abs() < 1e-6, "{a}"); + } + + #[test] + fn curves_are_offset_by_the_profile() { + // A circle of radius 50 with a uniform 10 pt stroke: ring area 2π·50·10. + let c = kurbo::Circle::new((0.0, 0.0), 50.0).to_path(1e-4); + let p = WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] }; + let a = area(&width_outline(&c, 10.0, &p, LineCap::Butt, 0.001)); + let want = 2.0 * std::f64::consts::PI * 50.0 * 10.0; + assert!((a - want).abs() / want < 0.01, "{a} vs {want}"); + let st = StrokeLayer { profile: None, ..StrokeLayer::new(drawcraft_color::Paint::solid(drawcraft_color::Color::BLACK), 4.0) }; + assert!(outline_for(&c, &st, 0.1).is_none()); + } +} diff --git a/crates/tools/src/distort/arap.rs b/crates/tools/src/distort/arap.rs index cd4100b2..00db7406 100644 --- a/crates/tools/src/distort/arap.rs +++ b/crates/tools/src/distort/arap.rs @@ -108,6 +108,8 @@ pub struct Mesh { ny: usize, /// Per grid cell: its two triangles (None = cell not part of the mesh). cells: Vec>, + /// Per grid cell: centre inside the art (not just the band around it). + core: Vec, } /// Mesh construction options. @@ -137,11 +139,16 @@ impl Mesh { let ny = ((b.height() / cell).ceil() as usize).max(1); let origin = Point::new(b.x0, b.y0); let reach = cell * 0.75 + opt.expand.max(0.0); - let keep = |c: Point| -> bool { + // 0 = outside, 1 = in the band around the art, 2 = core (inside a fill or on a path). + let keep = |c: Point| -> u8 { if outlines.is_empty() { - return true; + return 2; } - outlines.iter().any(|o| { + let near = |o: &BezPath, r: f64| o.segments().any(|s| s.nearest(c, 1e-3).distance_sq <= r * r); + if outlines.iter().any(|o| closed_winding(o, c) != 0 || near(o, cell * 0.25)) { + return 2; + } + u8::from(outlines.iter().any(|o| { let bb = o.bounding_box().inflate(reach, reach); if !bb.contains(c) { return false; @@ -149,16 +156,19 @@ impl Mesh { if closed_winding(o, c) != 0 { return true; } - o.segments().any(|s| s.nearest(c, 1e-3).distance_sq <= reach * reach) - }) + near(o, reach) + })) }; let mut vid = vec![usize::MAX; (nx + 1) * (ny + 1)]; let mut verts = vec![]; let mut kept = vec![false; nx * ny]; + let mut core = vec![false; nx * ny]; for j in 0..ny { for i in 0..nx { let c = Point::new(origin.x + (i as f64 + 0.5) * cell, origin.y + (j as f64 + 0.5) * cell); - kept[j * nx + i] = keep(c); + let k = keep(c); + kept[j * nx + i] = k > 0; + core[j * nx + i] = k == 2; } } // Number vertices row by row (keeps the systems banded). @@ -188,7 +198,7 @@ impl Mesh { cells[j * nx + i] = Some([t0, t0 + 1]); } } - Mesh { verts, tris, origin, cell, nx, ny, cells } + Mesh { verts, tris, origin, cell, nx, ny, cells, core } } /// Band width (in vertices) of the vertex adjacency. @@ -429,7 +439,7 @@ pub fn auto_pins(mesh: &Mesh, count: usize) -> Vec { .cells .iter() .enumerate() - .filter(|(_, c)| c.is_some()) + .filter(|(k, c)| c.is_some() && (mesh.core[*k] || !mesh.core.iter().any(|x| *x))) .map(|(k, _)| Point::new(mesh.origin.x + ((k % mesh.nx) as f64 + 0.5) * mesh.cell, mesh.origin.y + ((k / mesh.nx) as f64 + 0.5) * mesh.cell)) .collect(); if centres.is_empty() { @@ -559,7 +569,7 @@ mod tests { .flat_map(|t| (0..3).map(move |k| (t[k], t[(k + 1) % 3]))) .map(|(i, j)| (d[i].distance(d[j]) / m.verts[i].distance(m.verts[j]) - 1.0).abs()) .fold(0.0, f64::max); - assert!(worst < 0.25, "edge stretch {worst}"); + assert!(worst < 0.3, "edge stretch {worst}"); } #[test] diff --git a/crates/tools/src/distort/liquify.rs b/crates/tools/src/distort/liquify.rs index 20ae6986..0ef99df9 100644 --- a/crates/tools/src/distort/liquify.rs +++ b/crates/tools/src/distort/liquify.rs @@ -589,10 +589,11 @@ mod tests { let near = |pd: &PathData| pd.anchors().filter(|(_, _, an)| an.p.distance(c) < 50.0).map(|(_, _, an)| an.p.x).fold(f64::MIN, f64::max); assert!(near(&a) <= 200.0 + 1e-9); let mut b = square(); - apply_stroke(&mut b, &[c, c, c], &prm(LiquifyKind::Bloat), 1); - assert!(b.bounds().unwrap().x1 > 200.5); - // Bloat pushes the edge point at the brush centre along the normal (x only). - assert!(b.anchors().any(|(_, _, an)| (an.p.y - 100.0).abs() < 1e-6 && an.p.x > 200.5)); + // A brush just inside the right edge bulges it outward; the far edges stay put. + apply_stroke(&mut b, &[Point::new(180.0, 100.0); 3], &prm(LiquifyKind::Bloat), 1); + let bb = b.bounds().unwrap(); + assert!(bb.x1 > 205.0, "{bb:?}"); + assert_eq!((bb.x0, bb.y0, bb.y1), (0.0, 0.0, 200.0)); } #[test] diff --git a/crates/tools/src/distort/perspective.rs b/crates/tools/src/distort/perspective.rs index f8e15450..03fb6e1e 100644 --- a/crates/tools/src/distort/perspective.rs +++ b/crates/tools/src/distort/perspective.rs @@ -295,7 +295,7 @@ impl PerspectiveGrid { /// `b` to the plane rectangle whose opposite corners project to `b`'s top-left and /// bottom-right, then the plane homography projects it (so a drawn rectangle keeps the two /// corners the user dragged between). - pub fn attach_map(&self, plane: Plane, b: Rect) -> Option Option> { + pub fn attach_map(&self, plane: Plane, b: Rect) -> Option Option + use<>> { let h = self.homography(plane)?; let hi = h.inverse()?; let a = hi.apply(Point::new(b.x0, b.y0))?; @@ -308,7 +308,7 @@ impl PerspectiveGrid { } /// The map that slides art lying on `plane` by the plane-space offset between `from` and `to`. - pub fn move_map(&self, plane: Plane, from: Point, to: Point) -> Option Option> { + pub fn move_map(&self, plane: Plane, from: Point, to: Point) -> Option Option + use<>> { let h = self.homography(plane)?; let hi = h.inverse()?; let d = hi.apply(to)? - hi.apply(from)?; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 2fa6027c..9bb8b42f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -198,6 +198,7 @@ pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { * Affine::scale(v.zoom * ppp as f64) * Affine::translate(-v.center.to_vec2()); let opts = drawcraft_render::RenderOptions { outline: app.ui.view.outline, background: None, artboards: false, ..Default::default() }; + let opts = drawcraft_render::RenderOptions { proof: drawcraft_render::proof::active_proof(), overprint_preview: drawcraft_render::proof::overprint_preview_on(), ..opts }; // Light documents render synchronously (no lag vs overlays); heavy ones go to the worker. let heavy = app.canvas.last_ms > 8.0 && app.canvas.texture.is_some(); match (&mut app.canvas.worker, heavy) { diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 6a9919e2..9de83d86 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -231,8 +231,13 @@ pub fn doc_tabs(app: &mut DrawcraftApp, ui: &mut Ui) { if let Some(st) = app.session.active() && let Some(iso) = st.isolation { - let crumbs: Vec = + let mut crumbs: Vec = st.doc.ancestry(iso).unwrap_or_default().iter().filter_map(|id| st.doc.node(*id)).map(|n| n.display_name()).collect(); + // Pattern editing mode: the pattern's name, and Save a Copy / Done / Cancel at the right. + let pattern_edit = st.doc.pattern_edit.as_ref().map(|e| e.pattern.clone()); + if let Some(pn) = &pattern_edit { + crumbs.push(pn.clone()); + } let (bar, _) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::hover()); ui.painter().rect_filled(bar, 0.0, t.panel); let back = egui::Rect::from_min_size(bar.min + vec2(6.0, 3.0), vec2(18.0, 18.0)); @@ -245,8 +250,30 @@ pub fn doc_tabs(app: &mut DrawcraftApp, ui: &mut Ui) { egui::FontId::proportional(12.0), t.text, ); + let mut pattern_cmd = None; + if pattern_edit.is_some() { + let mut x = bar.right() - 6.0; + for (label, cmd) in [("Cancel", "object.pattern.cancel"), ("Done", "object.pattern.done"), ("Save a Copy", "object.pattern.saveCopy")] { + let w = 12.0 + 7.0 * label.len() as f32; + let r = egui::Rect::from_min_max(egui::pos2(x - w, bar.top() + 3.0), egui::pos2(x, bar.bottom() - 3.0)); + let resp = ui.interact(r, ui.id().with(("pat-bar", cmd)), Sense::click()); + ui.painter().rect_filled(r, 3.0, if resp.hovered() { t.hover } else { t.panel }); + ui.painter().rect_stroke(r, 3.0, Stroke::new(1.0, t.button_border), egui::StrokeKind::Inside); + ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, label, egui::FontId::proportional(12.0), t.text); + if resp.clicked() { + pattern_cmd = Some(cmd); + } + x -= w + 6.0; + } + } if bresp.clicked() { - app.run("object.exitIsolation", json!({})).ok(); + pattern_cmd = pattern_cmd.or(pattern_edit.as_ref().map(|_| "object.pattern.done")); + if pattern_cmd.is_none() { + app.run("object.exitIsolation", json!({})).ok(); + } + } + if let Some(cmd) = pattern_cmd { + app.run(cmd, json!({})).ok(); } } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 6808acac..ed0e2efd 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -186,6 +186,12 @@ pub fn run_ui_command(app: &mut DrawcraftApp, id: &str, p: &Value) -> Option { + app.ui.open_panel = Some("separations".into()); + app.ui.dock = true; + app.session.execute(id, p).map_err(|e| e.to_string()) + } "view.outline" => flag(&mut app.ui.view.outline), "view.pixelPreview" => flag(&mut app.ui.view.pixel_preview), "view.edges" => flag(&mut app.ui.view.edges), @@ -382,7 +388,10 @@ pub fn checked(app: &DrawcraftApp, id: &str, p: &Value) -> Option { } "window.workspace" => p.get("name").and_then(Value::as_str) == Some(app.ui.workspace.as_str()), "window.brightness" => p.get("brightness").and_then(Value::as_str).and_then(Brightness::parse) == Some(app.ui.brightness), - "file.documentColorMode" => { + "view.proofColors" => drawcraft_render::proof::view().proof_colors, + "view.overprintPreview" => drawcraft_render::proof::view().overprint, + "view.proofSetup" => p.get("target").and_then(Value::as_str) == Some(drawcraft_render::proof::view().setup.target.id().as_str()), + "file.documentColorMode" | "object.convertDocumentColorMode" => { let cmyk = app.session.active().is_some_and(|d| d.doc.color_mode == drawcraft_engine::doc::ColorMode::Cmyk); p.get("mode").and_then(Value::as_str) == Some(if cmyk { "cmyk" } else { "rgb" }) } @@ -493,8 +502,8 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { sub( "Document Color Mode", vec![ - cp("CMYK Color", "file.documentColorMode", json!({"mode": "cmyk"})), - cp("RGB Color", "file.documentColorMode", json!({"mode": "rgb"})), + cp("CMYK Color", "object.convertDocumentColorMode", json!({"mode": "cmyk"})), + cp("RGB Color", "object.convertDocumentColorMode", json!({"mode": "rgb"})), ], ), c("File Info…", "file.info"), @@ -534,7 +543,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { c("Convert to Grayscale", "edit.colors.toGrayscale"), c("Convert to RGB", "edit.colors.toRGB"), c("Invert Colors", "edit.colors.invert"), - todo("Overprint Black…"), + c("Overprint Black…", "edit.colors.overprintBlack"), cp("Saturate…", "edit.colors.saturate", json!({"intensity": 20})), ], ), @@ -543,10 +552,10 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { todo("Transparency Flattener Presets…"), todo("Print Presets…"), todo("Adobe PDF Presets…"), - todo("Perspective Grid Presets…"), + cp("Perspective Grid Presets…", "perspective.grid.preset", json!({"kind": 2})), Sep, - todos("Color Settings…", "Cmd+Shift+K"), - todo("Assign Profile…"), + c("Color Settings…", "edit.colorSettings"), + c("Assign Profile…", "edit.assignProfile"), Sep, c("Keyboard Shortcuts…", "edit.keyboardShortcuts"), c("Preferences…", "edit.preferences"), @@ -644,8 +653,18 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { ], ), sub("Shape", vec![c("Convert to Shape", "object.shape.convertToShape"), c("Expand Shape", "object.expandShape")]), - sub("Pattern", vec![todo("Make"), todo("Edit Pattern"), todo("Tile Edge Color…")]), - sub("Repeat", vec![todo("Radial"), todo("Grid"), todo("Mirror"), Sep, todo("Release"), todo("Options…")]), + sub("Pattern", vec![c("Make", "object.pattern.make"), c("Edit Pattern", "object.pattern.edit"), todo("Tile Edge Color…")]), + sub( + "Repeat", + vec![ + c("Radial", "object.repeat.radial"), + c("Grid", "object.repeat.grid"), + c("Mirror", "object.repeat.mirror"), + Sep, + c("Release", "object.repeat.release"), + c("Options…", "object.repeat.options"), + ], + ), sub( "Blend", vec![ @@ -679,7 +698,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { c("Edit Contents", "object.envelope.editContents"), ], ), - sub("Perspective", vec![todo("Attach to Active Plane"), todo("Release with Perspective")]), + sub("Perspective", vec![c("Attach to Active Plane", "perspective.attach"), c("Release with Perspective", "perspective.release")]), sub( "Live Paint", vec![ @@ -833,7 +852,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { "View", vec![ c("Outline", "view.outline"), - todos("Overprint Preview", "Cmd+Alt+Shift+Y"), + c("Overprint Preview", "view.overprintPreview"), c("Pixel Preview", "view.pixelPreview"), todo("Trim View"), c("Presentation Mode", "view.presentation"), @@ -849,13 +868,16 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { sub( "Proof Setup", vec![ - todo("Working CMYK"), - todo("Internet Standard RGB (sRGB)"), - todo("Color blindness – Protanopia-type"), - todo("Color blindness – Deuteranopia-type"), + cp("Working CMYK", "view.proofSetup", json!({"target": "workingCmyk", "proof": true})), + cp("Legacy Macintosh RGB (Gamma 1.8)", "view.proofSetup", json!({"target": "legacyMacRgb", "proof": true})), + cp("Internet Standard RGB (sRGB)", "view.proofSetup", json!({"target": "srgb", "proof": true})), + cp("Monitor RGB", "view.proofSetup", json!({"target": "monitorRgb", "proof": true})), + cp("Color blindness – Protanopia-type", "view.proofSetup", json!({"target": "protanopia", "proof": true})), + cp("Color blindness – Deuteranopia-type", "view.proofSetup", json!({"target": "deuteranopia", "proof": true})), + cp("Customize…", "window.panel", json!({"panel": "separations"})), ], ), - todo("Proof Colors"), + c("Proof Colors", "view.proofColors"), Sep, c("Zoom In", "view.zoomIn"), c("Zoom Out", "view.zoomOut"), @@ -887,7 +909,17 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { ], ), c("Smart Guides", "view.smartGuides"), - sub("Perspective Grid", vec![todos("Show Grid", "Cmd+Shift+I"), todo("Define Grid…")]), + sub( + "Perspective Grid", + vec![ + c("Show Grid", "perspective.grid.show"), + Sep, + cp("One Point Perspective", "perspective.grid.preset", json!({"kind": 1})), + cp("Two Point Perspective", "perspective.grid.preset", json!({"kind": 2})), + cp("Three Point Perspective", "perspective.grid.preset", json!({"kind": 3})), + cp("Define Grid…", "perspective.grid.set", json!({"kind": 2, "cell": 20, "distance": 300})), + ], + ), c("Show Grid", "view.grid"), c("Snap to Grid", "view.snapToGrid"), todo("Snap to Pixel"), @@ -931,9 +963,9 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { todo("Magic Wand"), panel("Navigator", "navigator"), panel("Pathfinder", "pathfinder"), - todo("Pattern Options"), + panel("Pattern Options", "patternOptions"), panel("Properties", "properties"), - todo("Separations Preview"), + panel("Separations Preview", "separations"), panel("Stroke", "stroke"), todo("SVG Interactivity"), panel("Swatches", "swatches"), @@ -1070,8 +1102,18 @@ pub fn invoke(app: &mut DrawcraftApp, id: &str, p: Value) { app.ui.dialog = Some(crate::state::Dialog::new(kind, fields)); return; } + // Repeat Options: a dialog with the selected repeat's current values. + if id == "object.repeat.options" + && p.as_object().is_none_or(|o| o.is_empty()) + && let Some(fields) = crate::panels::pattern_options::repeat_fields(app) + { + let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Repeat Options", "params": fields})); + return; + } if let Err(e) = app.run(id, p) { app.status(e); + } else if matches!(id, "object.pattern.make" | "object.pattern.edit") { + app.ui.open_panel = Some("patternOptions".into()); } } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 2f1c5942..6053f784 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -20,7 +20,9 @@ pub mod layers; pub mod navigator; pub mod paragraph; pub mod pathfinder; +pub mod pattern_options; pub mod properties; +pub mod separations; pub mod stroke; pub mod swatches; pub mod symbols; @@ -62,6 +64,7 @@ pub fn show_icon_panel(app: &mut DrawcraftApp, ui: &mut Ui, id: &str) { "history" => history::show(app, ui), "actions" => actions::show(app, ui), "info" => info::show(app, ui), + "separations" => separations::show(app, ui), "artboards" => artboards::show(app, ui), "gradient" => gradient::show(app, ui), "character" => character::show(app, ui), @@ -70,6 +73,7 @@ pub fn show_icon_panel(app: &mut DrawcraftApp, ui: &mut Ui, id: &str) { "navigator" => navigator::show(app, ui), "brushes" => brushes::show(app, ui), "symbols" => symbols::show(app, ui), + "patternOptions" => pattern_options::show(app, ui), _ => { dim_label(ui, "This panel is on the roadmap (see the parity plan)."); } @@ -91,6 +95,7 @@ pub fn panel_menu_items(app: &mut DrawcraftApp, ui: &mut Ui, id: &str) { "transform" => transform::menu(app, ui), "history" => history::menu(app, ui), "info" => info::menu(app, ui), + "separations" => separations::menu(app, ui), "artboards" => artboards::menu(app, ui), "gradient" => gradient::menu(app, ui), "character" => character::menu(app, ui), @@ -99,6 +104,7 @@ pub fn panel_menu_items(app: &mut DrawcraftApp, ui: &mut Ui, id: &str) { "navigator" => navigator::menu(app, ui), "brushes" => brushes::menu(app, ui), "symbols" => symbols::menu(app, ui), + "patternOptions" => pattern_options::menu(app, ui), _ => { ui.add_enabled(false, egui::Button::new("No options").frame(false)); } diff --git a/crates/ui-egui/src/panels/pattern_options.rs b/crates/ui-egui/src/panels/pattern_options.rs new file mode 100644 index 00000000..912c7b33 --- /dev/null +++ b/crates/ui-egui/src/panels/pattern_options.rs @@ -0,0 +1,216 @@ +//! Pattern Options panel: active in pattern editing mode (Object → Pattern → Make / Edit +//! Pattern). Name, Tile Type, Brick Offset, Width/Height, Size Tile to Art with H/V spacing, +//! Overlap, Copies, Dim Copies, Show Tile Edge; Save a Copy / Done / Cancel. Outside editing +//! mode it lists the document's patterns with an Edit button. + +use drawcraft_doc::pattern::{PatternDef, RepeatKind, TileType}; +use drawcraft_doc::{NodeKind, Unit}; +use egui::Ui; +use serde_json::{Value, json}; + +use super::{pstate, set_pstate}; +use crate::DrawcraftApp; +use crate::widgets::{self, menu_item}; + +const TILE_LABELS: [&str; 5] = ["Grid", "Brick by Row", "Brick by Column", "Hex by Column", "Hex by Row"]; +const BRICK: [(&str, f64); 4] = [("1/2", 0.5), ("1/3", 1.0 / 3.0), ("1/4", 0.25), ("1/5", 0.2)]; +const COPIES: [(&str, u32); 4] = [("3 x 3", 3), ("5 x 5", 5), ("7 x 7", 7), ("9 x 9", 9)]; + +fn editing(app: &DrawcraftApp) -> Option<(PatternDef, Unit)> { + let st = app.session.active()?; + let pe = st.doc.pattern_edit.as_ref()?; + Some((st.doc.pattern(&pe.pattern)?.clone(), st.doc.units)) +} + +fn row(ui: &mut Ui, label: &str, add: impl FnOnce(&mut Ui)) { + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(egui::vec2(96.0, 24.0), egui::Sense::hover()); + let t = crate::theme::Tokens::get(ui.ctx()); + ui.painter().text(r.left_center(), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(12.5), t.text); + add(ui); + }); +} + +fn set(app: &mut DrawcraftApp, name: &str, mut p: Value) { + p["name"] = json!(name); + if let Err(e) = app.run("pattern.options", p) { + app.status(e); + } +} + +pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { + if app.session.active().is_none() { + super::empty_state(ui, "swatch-book", "No document", "Open a document to edit its patterns."); + return; + } + let Some((def, unit)) = editing(app) else { + idle(app, ui); + return; + }; + let name = def.name.clone(); + // Name (commit on Enter / focus loss). + row(ui, "Name:", |ui| { + let id = ui.id().with("po-name"); + let mut buf: String = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_else(|| name.clone()); + if !ui.memory(|m| m.has_focus(id)) { + buf = name.clone(); + } + let r = ui.add(egui::TextEdit::singleline(&mut buf).id(id).desired_width(150.0)); + ui.data_mut(|d| d.insert_temp(id, buf.clone())); + if r.lost_focus() && !buf.trim().is_empty() && buf != name { + set(app, &name, json!({"newName": buf.trim()})); + } + }); + let tt_index = TileType::IDS.iter().position(|i| *i == def.tile_type.id()).unwrap_or(0); + row(ui, "Tile Type:", |ui| { + if let Some(i) = widgets::dropdown(ui, "po-tile", TILE_LABELS[tt_index], &TILE_LABELS, 150.0) { + set(app, &name, json!({"tileType": TileType::IDS[i]})); + } + }); + let offset = match def.tile_type { + TileType::BrickByRow { offset } | TileType::BrickByColumn { offset } => Some(offset), + _ => None, + }; + row(ui, "Brick Offset:", |ui| { + ui.add_enabled_ui(offset.is_some(), |ui| { + let cur = BRICK.iter().find(|(_, v)| offset.is_some_and(|o| (o - v).abs() < 1e-6)).map(|(l, _)| *l).unwrap_or("1/2"); + let labels: Vec<&str> = BRICK.iter().map(|(l, _)| *l).collect(); + if let Some(i) = widgets::dropdown(ui, "po-brick", cur, &labels, 70.0) { + set(app, &name, json!({"brickOffset": BRICK[i].1})); + } + }); + }); + let sized = def.size_tile_to_art; + row(ui, "Width:", |ui| { + ui.add_enabled_ui(!sized, |ui| { + if let Some(v) = widgets::num_field(ui, "po-w", Some(def.tile.width()), unit, 90.0) { + set(app, &name, json!({"width": v})); + } + }); + }); + row(ui, "Height:", |ui| { + ui.add_enabled_ui(!sized, |ui| { + if let Some(v) = widgets::num_field(ui, "po-h", Some(def.tile.height()), unit, 90.0) { + set(app, &name, json!({"height": v})); + } + }); + }); + if widgets::check(ui, "Size Tile to Art", sized, true) { + set(app, &name, json!({"sizeTileToArt": !sized})); + } + row(ui, "H Spacing:", |ui| { + ui.add_enabled_ui(sized, |ui| { + if let Some(v) = widgets::num_field(ui, "po-hs", Some(def.h_spacing), unit, 90.0) { + set(app, &name, json!({"hSpacing": v})); + } + }); + }); + row(ui, "V Spacing:", |ui| { + ui.add_enabled_ui(sized, |ui| { + if let Some(v) = widgets::num_field(ui, "po-vs", Some(def.v_spacing), unit, 90.0) { + set(app, &name, json!({"vSpacing": v})); + } + }); + }); + row(ui, "Overlap:", |ui| { + let h = if def.overlap.right_in_front { "Right in Front" } else { "Left in Front" }; + if let Some(i) = widgets::dropdown(ui, "po-oh", h, &["Left in Front", "Right in Front"], 110.0) { + set(app, &name, json!({"overlap": {"h": if i == 1 { "right" } else { "left" }}})); + } + }); + row(ui, "", |ui| { + let v = if def.overlap.bottom_in_front { "Bottom in Front" } else { "Top in Front" }; + if let Some(i) = widgets::dropdown(ui, "po-ov", v, &["Top in Front", "Bottom in Front"], 110.0) { + set(app, &name, json!({"overlap": {"v": if i == 1 { "bottom" } else { "top" }}})); + } + }); + widgets::divider(ui); + row(ui, "Copies:", |ui| { + let cur = COPIES.iter().find(|(_, n)| *n == def.copies).map(|(l, _)| *l).unwrap_or("5 x 5"); + let labels: Vec<&str> = COPIES.iter().map(|(l, _)| *l).collect(); + if let Some(i) = widgets::dropdown(ui, "po-copies", cur, &labels, 80.0) { + set(app, &name, json!({"copies": COPIES[i].1})); + } + }); + row(ui, "Dim Copies to:", |ui| { + if let Some(v) = widgets::plain_field(ui, "po-dim", def.dim_copies as f64, "%", 0, 60.0) { + set(app, &name, json!({"dimCopies": v})); + } + }); + if widgets::check(ui, "Show Tile Edge", def.show_tile_edge, true) { + set(app, &name, json!({"showTileEdge": !def.show_tile_edge})); + } + widgets::divider(ui); + ui.horizontal(|ui| { + if widgets::flat_button(ui, "Save a Copy", 90.0).clicked() + && let Err(e) = app.run("object.pattern.saveCopy", json!({})) + { + app.status(e); + } + if widgets::flat_button(ui, "Done", 60.0).clicked() { + app.run("object.pattern.done", json!({})).ok(); + } + if widgets::flat_button(ui, "Cancel", 60.0).clicked() { + app.run("object.pattern.cancel", json!({})).ok(); + } + }); +} + +/// Not editing: the document's patterns, one to pick and edit. +fn idle(app: &mut DrawcraftApp, ui: &mut Ui) { + let names: Vec = app.session.active().map(|st| st.doc.patterns.iter().map(|p| p.name.clone()).collect()).unwrap_or_default(); + widgets::dim_label(ui, "Pattern options are available in pattern editing mode."); + ui.add_space(4.0); + if names.is_empty() { + widgets::dim_label(ui, "Select art and choose Object › Pattern › Make."); + return; + } + let mut chosen: String = pstate(ui.ctx(), "po-chosen"); + if !names.contains(&chosen) { + chosen = names[0].clone(); + } + ui.horizontal(|ui| { + let labels: Vec<&str> = names.iter().map(String::as_str).collect(); + if let Some(i) = widgets::dropdown(ui, "po-pick", &chosen, &labels, 150.0) { + set_pstate(ui.ctx(), "po-chosen", names[i].clone()); + } + if widgets::flat_button(ui, "Edit Pattern", 90.0).clicked() + && let Err(e) = app.run("object.pattern.edit", json!({"name": chosen})) + { + app.status(e); + } + }); +} + +pub fn menu(app: &mut DrawcraftApp, ui: &mut Ui) { + let editing = editing(app).is_some(); + if menu_item(ui, "Save a Copy", editing, false) { + app.run("object.pattern.saveCopy", json!({})).ok(); + } + if menu_item(ui, "Done", editing, false) { + app.run("object.pattern.done", json!({})).ok(); + } + if menu_item(ui, "Cancel", editing, false) { + app.run("object.pattern.cancel", json!({})).ok(); + } +} + +/// Current values of the selected repeat as Repeat Options dialog fields. +pub fn repeat_fields(app: &DrawcraftApp) -> Option { + let st = app.session.active()?; + let spec = st.selection.objects.iter().find_map(|id| { + st.doc.ancestry(*id)?.into_iter().rev().find_map(|a| match &st.doc.node(a)?.kind { + NodeKind::Repeat(r) => Some(r.clone()), + _ => None, + }) + })?; + Some(match spec.kind { + RepeatKind::Radial { instances, radius, reverse_overlap, start_angle, end_angle, .. } => { + json!({"instances": instances, "radius": radius, "startAngle": start_angle, "endAngle": end_angle, "reverseOverlap": reverse_overlap}) + } + RepeatKind::Grid { h_spacing, v_spacing, rows, cols, grid_type, flip_rows, flip_cols } => { + json!({"hSpacing": h_spacing, "vSpacing": v_spacing, "rows": rows, "cols": cols, "gridType": grid_type.id(), "flipRows": flip_rows, "flipCols": flip_cols}) + } + RepeatKind::Mirror { angle, .. } => json!({"angle": angle}), + }) +} diff --git a/crates/ui-egui/src/panels/separations.rs b/crates/ui-egui/src/panels/separations.rs new file mode 100644 index 00000000..81eaad73 --- /dev/null +++ b/crates/ui-egui/src/panels/separations.rs @@ -0,0 +1,178 @@ +//! Separations Preview panel: process plates C/M/Y/K plus spot plates with eye toggles (one visible +//! plate renders as greyscale ink coverage; Alt-click an eye to show only that plate), and the +//! colour-management controls behind Edit → Color Settings… / Assign Profile… and View → Proof +//! Setup (working spaces, intent, black-point compensation, proof target, gamut check). + +use drawcraft_color::cms::{self, Intent, ProfileKind}; +use drawcraft_render::proof; +use egui::{Sense, Ui, vec2}; +use serde_json::{Value, json}; + +use crate::DrawcraftApp; +use crate::theme::Tokens; +use crate::widgets::{self, dim_label, menu_item}; + +const PROOF_TARGETS: [(&str, &str); 6] = [ + ("workingCmyk", "Working CMYK"), + ("legacyMacRgb", "Legacy Macintosh RGB (Gamma 1.8)"), + ("srgb", "Internet Standard RGB (sRGB)"), + ("monitorRgb", "Monitor RGB"), + ("protanopia", "Color blindness – Protanopia-type"), + ("deuteranopia", "Color blindness – Deuteranopia-type"), +]; + +fn run(app: &mut DrawcraftApp, id: &str, p: Value) { + if let Err(e) = app.run(id, p) { + app.ui.status = e; + } +} + +fn plates_section(app: &mut DrawcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let v = proof::view(); + let on = v.separations.is_some(); + if widgets::check(ui, "Overprint Preview (separations)", on, app.session.active().is_some()) { + run(app, "view.separationsPreview", json!({"on": !on})); + } + let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { + dim_label(ui, "No document open."); + return; + }; + let plates = proof::plates(&doc); + let visible = |n: &str| v.separations.as_ref().is_none_or(|s| s.iter().any(|x| x == n)); + let alt = ui.input(|i| i.modifiers.alt); + widgets::list_box(ui, |ui| { + let all_on = plates.iter().all(|p| visible(&p.name)); + // CMYK composite row. + ui.horizontal(|ui| { + ui.add_enabled_ui(on, |ui| { + if widgets::toggle_icon(ui, "eye", all_on, 18.0, "Show all plates") && on { + run(app, "view.separationsPreview", json!({"plates": plates.iter().map(|p| p.name.clone()).collect::>()})); + } + }); + ui.label(egui::RichText::new("CMYK").size(12.0).color(if on { t.text } else { t.text_dim })); + }); + for p in &plates { + ui.horizontal(|ui| { + let vis = visible(&p.name); + let mut clicked = false; + ui.add_enabled_ui(on, |ui| { + clicked = widgets::toggle_icon(ui, "eye", vis, 18.0, "Show/hide plate (Alt-click: only this plate)"); + }); + if clicked && on { + let key = if alt { "only" } else { "toggle" }; + run(app, "view.separationsPreview", json!({ key: p.name })); + } + let (r, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover()); + let q = |x: f32| (x.clamp(0.0, 1.0) * 255.0).round() as u8; + ui.painter().rect_filled(r, 2.0, egui::Color32::from_rgb(q(p.rgb[0]), q(p.rgb[1]), q(p.rgb[2]))); + let label = if p.spot { format!("{} (spot)", p.name) } else { p.name.clone() }; + ui.label(egui::RichText::new(label).size(12.0).color(if on { t.text } else { t.text_dim })); + }); + } + }); +} + +fn profile_dropdown(ui: &mut Ui, id: &str, current: &str, kind: ProfileKind, extra: Option<&str>) -> Option { + let names: Vec = extra.map(str::to_string).into_iter().chain(cms::profiles().into_iter().filter(|p| p.kind == kind).map(|p| p.name)).collect(); + let refs: Vec<&str> = names.iter().map(String::as_str).collect(); + widgets::dropdown(ui, id, current, &refs, 210.0).map(|i| names[i].clone()) +} + +fn settings_section(app: &mut DrawcraftApp, ui: &mut Ui) { + let st = cms::active_settings(); + widgets::section_header(ui, "Color Settings"); + ui.horizontal(|ui| { + ui.label("RGB:"); + if let Some(n) = profile_dropdown(ui, "cms-rgb", &st.rgb, ProfileKind::Rgb, None) { + run(app, "edit.colorSettings", json!({"rgb": n})); + } + }); + ui.horizontal(|ui| { + ui.label("CMYK:"); + if let Some(n) = profile_dropdown(ui, "cms-cmyk", &st.cmyk, ProfileKind::Cmyk, None) { + run(app, "edit.colorSettings", json!({"cmyk": n})); + } + }); + ui.horizontal(|ui| { + ui.label("Intent:"); + let labels: Vec<&str> = Intent::ALL.iter().map(|i| i.label()).collect(); + if let Some(i) = widgets::dropdown(ui, "cms-intent", st.intent.label(), &labels, 170.0) { + run(app, "edit.colorSettings", json!({"intent": Intent::ALL[i].id()})); + } + }); + if widgets::check(ui, "Use Black Point Compensation", st.bpc, true) { + run(app, "edit.colorSettings", json!({"bpc": !st.bpc})); + } + ui.horizontal(|ui| { + if widgets::flat_button(ui, "Load Profile…", 110.0).clicked() + && let Some(path) = app.services.pick_open.as_mut().and_then(|f| f()) + { + run(app, "color.loadProfile", json!({ "path": path })); + } + if widgets::flat_button(ui, "Gamut Check", 100.0).clicked() { + match app.run("color.gamutCheck", json!({})) { + Ok(r) => { + let n = r["outOfGamut"].as_array().map_or(0, Vec::len); + app.ui.status = if n == 0 { "All colours are within the CMYK gamut".into() } else { format!("{n} colour(s) are out of the CMYK gamut") }; + } + Err(e) => app.ui.status = e, + } + } + }); + if let Some(d) = app.session.active() { + let (_, cmyk) = drawcraft_engine::cmd::colormgmt::doc_profiles(&d.doc); + const WORKING: &str = "Working CMYK (don't tag)"; + ui.horizontal(|ui| { + ui.label("Assign:"); + if let Some(n) = profile_dropdown(ui, "cms-assign", cmyk.as_deref().unwrap_or(WORKING), ProfileKind::Cmyk, Some(WORKING)) { + let v = if n == WORKING { Value::Null } else { Value::String(n) }; + run(app, "edit.assignProfile", json!({ "cmyk": v })); + } + }); + } +} + +fn proof_section(app: &mut DrawcraftApp, ui: &mut Ui) { + let v = proof::view(); + widgets::section_header(ui, "Proof Setup"); + let cur = v.setup.target.id(); + let label = PROOF_TARGETS.iter().find(|(id, _)| *id == cur).map_or(cur.as_str(), |(_, l)| l); + let labels: Vec<&str> = PROOF_TARGETS.iter().map(|(_, l)| *l).collect(); + if let Some(i) = widgets::dropdown(ui, "proof-target", label, &labels, 230.0) { + run(app, "view.proofSetup", json!({"target": PROOF_TARGETS[i].0})); + } + ui.horizontal(|ui| { + if widgets::check(ui, "Proof Colors", v.proof_colors, true) { + run(app, "view.proofColors", json!({"on": !v.proof_colors})); + } + if widgets::check(ui, "Simulate Paper", v.setup.simulate_paper, v.setup.target.is_cmyk()) { + run(app, "view.proofSetup", json!({"simulatePaper": !v.setup.simulate_paper})); + } + }); + if widgets::check(ui, "Overprint Preview", v.overprint, true) { + run(app, "view.overprintPreview", json!({"on": !v.overprint})); + } +} + +pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { + plates_section(app, ui); + widgets::divider(ui); + proof_section(app, ui); + widgets::divider(ui); + settings_section(app, ui); +} + +pub fn menu(app: &mut DrawcraftApp, ui: &mut Ui) { + let v = proof::view(); + if menu_item(ui, "Overprint Preview", true, v.overprint) { + run(app, "view.overprintPreview", json!({})); + } + if menu_item(ui, "Proof Colors", true, v.proof_colors) { + run(app, "view.proofColors", json!({})); + } + if menu_item(ui, "Show All Plates", v.separations.is_some(), false) { + run(app, "view.separationsPreview", json!({"on": false})); + run(app, "view.separationsPreview", json!({"on": true})); + } +} diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 36054f0a..3959f401 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -130,6 +130,44 @@ fn apply(app: &mut DrawcraftApp, ui: &Ui, e: &Entry) { } } +/// A pattern swatch drawn as a rendered tile (cached by the definition's identity and size). +fn pattern_thumb(app: &DrawcraftApp, ui: &Ui, r: Rect, paint: &Paint) { + use std::cell::RefCell; + use std::collections::HashMap; + type Key = (String, Vec, String, u32); + thread_local! { + static CACHE: RefCell>> = RefCell::new(HashMap::new()); + } + let Paint::Pattern { pattern, .. } = paint else { return }; + let Some(st) = app.session.active() else { return }; + let Some(def) = st.doc.pattern(pattern) else { return }; + let px = (r.width() * ui.ctx().pixels_per_point()).round().max(4.0) as u32; + let key = ( + pattern.clone(), + def.art.iter().map(|a| std::sync::Arc::as_ptr(a) as usize).collect(), + format!("{:?}{:?}{:?}", def.tile, def.tile_type, def.overlap), + px, + ); + let tex = CACHE.with(|c| c.borrow().get(&key).cloned()).unwrap_or_else(|| { + let tex = drawcraft_render::render_pattern_swatch(&st.doc, pattern, px).map(|img| { + let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); + ui.ctx().load_texture(format!("pattern-swatch-{pattern}-{px}"), color, egui::TextureOptions::LINEAR) + }); + CACHE.with(|c| { + let mut c = c.borrow_mut(); + if c.len() > 256 { + c.clear(); + } + c.insert(key, tex.clone()); + }); + tex + }); + if let Some(tex) = tex { + ui.painter().rect_filled(r, 0.0, egui::Color32::WHITE); + ui.painter().image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), egui::Color32::WHITE); + } +} + fn draw_registration(ui: &Ui, r: Rect) { let t = Tokens::get(ui.ctx()); ui.painter().rect_filled(r, 0.0, egui::Color32::WHITE); @@ -184,6 +222,7 @@ pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { let sel = selected_name(ui).or(active_swatch); let (tile, pitch) = view.tile(); let mut clicked: Option = None; + let mut edit_pattern: Option = None; widgets::list_box(ui, |ui| { egui::ScrollArea::vertical().id_salt("swatch-scroll").max_height(if view == View::LargeThumb { 200.0 } else { 150.0 }).show(ui, |ui| { ui.set_width(ui.available_width()); @@ -204,7 +243,10 @@ pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { let chip = Rect::from_min_size(r.left_center() + vec2(4.0, -tile / 2.0), vec2(tile, tile)); match e { Entry::Registration => draw_registration(ui, chip), - Entry::Swatch { paint, .. } => swatch_tile(ui, chip, paint, false, false), + Entry::Swatch { paint, .. } => { + swatch_tile(ui, chip, paint, false, false); + pattern_thumb(app, ui, chip, paint); + } Entry::Folder(_) => draw_folder(ui, chip), } ui.painter().text( @@ -265,6 +307,12 @@ pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { Entry::Registration => draw_registration(ui, cell), Entry::Swatch { paint, global, .. } => { swatch_tile(ui, cell, paint, sel.as_deref() == Some(name.as_str()), resp.hovered()); + pattern_thumb(app, ui, cell.shrink(1.0), paint); + if resp.double_clicked() + && let Paint::Pattern { pattern, .. } = paint + { + edit_pattern = Some(pattern.clone()); + } if *global { let k = cell.shrink(1.0); ui.painter().add(egui::Shape::convex_polygon( @@ -284,6 +332,10 @@ pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { } }); }); + if let Some(name) = edit_pattern { + app.run("object.pattern.edit", json!({"name": name})).ok(); + app.ui.open_panel = Some("patternOptions".into()); + } if let Some(e) = clicked { let name = match &e { Entry::Registration => REGISTRATION.to_string(), diff --git a/crates/ui-egui/src/prefs_dialog.rs b/crates/ui-egui/src/prefs_dialog.rs index 990448d1..365302ce 100644 --- a/crates/ui-egui/src/prefs_dialog.rs +++ b/crates/ui-egui/src/prefs_dialog.rs @@ -120,19 +120,24 @@ pub fn show(app: &mut DrawcraftApp, ctx: &egui::Context) { ui.add_space(12.0); ui.horizontal_top(|ui| { // Category list. - egui::Frame::NONE.fill(t.panel_darker).corner_radius(egui::CornerRadius::same(4)).inner_margin(egui::Margin::same(6)).show(ui, |ui| { - ui.set_width(196.0); - ui.set_min_height(430.0); - ui.spacing_mut().item_spacing.y = 1.0; - for c in PREF_CATEGORIES { - let sel = *c == cat; - let text = egui::RichText::new(*c).size(12.5).color(if sel { t.text_strong } else { t.text }); - let r = ui.add_sized([ui.available_width(), 24.0], egui::Button::selectable(sel, text).frame_when_inactive(false)); - if r.clicked() { - d.fields.insert("__category".into(), json!(c)); - } - } - }); + egui::Frame::NONE.fill(t.panel_darker).corner_radius(egui::CornerRadius::same(4)).inner_margin(egui::Margin::same(6)).show( + ui, + |ui| { + ui.set_width(196.0); + ui.set_min_height(430.0); + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = 1.0; + for c in PREF_CATEGORIES { + let sel = *c == cat; + let text = egui::RichText::new(*c).size(12.5).color(if sel { t.text_strong } else { t.text }); + let b = egui::Button::selectable(sel, text).frame_when_inactive(false).min_size(egui::vec2(184.0, 24.0)); + if ui.add(b).clicked() { + d.fields.insert("__category".into(), json!(c)); + } + } + }); + }, + ); ui.add_space(14.0); // Fields. ui.vertical(|ui| { @@ -146,7 +151,10 @@ pub fn show(app: &mut DrawcraftApp, ctx: &egui::Context) { }); ui.add_space(14.0); ui.horizontal(|ui| { - if widgets::secondary_button(ui, "Reset Preferences").on_hover_text("Restore every preference to its default (applied on OK)").clicked() { + if widgets::secondary_button(ui, "Reset Preferences") + .on_hover_text("Restore every preference to its default (applied on OK)") + .clicked() + { reset = true; } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -280,7 +288,10 @@ fn bool_row(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { fn labeled(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui)) { let t = Tokens::get(ui.ctx()); ui.horizontal(|ui| { - ui.add_sized([210.0, 22.0], egui::Label::new(egui::RichText::new(format!("{label}:")).color(t.text)).truncate()); + ui.allocate_ui_with_layout(egui::vec2(210.0, 22.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.set_min_width(210.0); + ui.add(egui::Label::new(egui::RichText::new(format!("{label}:")).color(t.text)).truncate()); + }); add(ui); }); } diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs index 23693f8f..de170377 100644 --- a/crates/ui-egui/src/shortcut_editor.rs +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -42,7 +42,10 @@ pub fn intern(s: &str) -> &'static str { /// Mirror `ui.shortcut_overrides` into the global store (cheap when unchanged). pub fn sync(ui: &UiState) { - let same = store().read().map(|m| m.len() == ui.shortcut_overrides.len() && m.iter().all(|(k, v)| ui.shortcut_overrides.get(k).map(String::as_str) == Some(*v))).unwrap_or(false); + let same = store() + .read() + .map(|m| m.len() == ui.shortcut_overrides.len() && m.iter().all(|(k, v)| ui.shortcut_overrides.get(k).map(String::as_str) == Some(*v))) + .unwrap_or(false); if !same { if let Ok(mut m) = store().write() { *m = ui.shortcut_overrides.iter().map(|(k, v)| (k.clone(), intern(v))).collect(); @@ -358,7 +361,9 @@ pub fn run_command(app: &mut DrawcraftApp, id: &str, p: &Value) -> Option Ok(json!(all_conflicts(&app.ui.shortcut_overrides).into_iter().map(|(c, k)| json!({"shortcut": c, "ids": k})).collect::>())), + "shortcuts.conflicts" => { + Ok(json!(all_conflicts(&app.ui.shortcut_overrides).into_iter().map(|(c, k)| json!({"shortcut": c, "ids": k})).collect::>())) + } "shortcuts.reset" => { app.ui.shortcut_overrides.clear(); app.ui.shortcut_set = PRESETS[0].into(); @@ -518,18 +523,30 @@ pub fn show(app: &mut DrawcraftApp, ctx: &egui::Context) { let scroll_to = d.fields.remove("__scrollTo").and_then(|v| v.as_str().map(str::to_string)); egui::Frame::NONE.fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(egui::Margin::same(4)).show(ui, |ui| { ui.horizontal(|ui| { - ui.add_sized([360.0, 16.0], egui::Label::new(egui::RichText::new(if tools { "Tool" } else { "Command" }).color(t.text_dim).size(11.0))); + ui.allocate_ui_with_layout(egui::vec2(360.0, 16.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.set_min_width(360.0); + ui.label(egui::RichText::new(if tools { "Tool" } else { "Command" }).color(t.text_dim).size(11.0)); + }); ui.label(egui::RichText::new("Shortcut").color(t.text_dim).size(11.0)); }); egui::ScrollArea::vertical().max_height(360.0).auto_shrink([false, false]).show(ui, |ui| { for e in entries().iter().filter(|e| e.is_tool == tools) { let sc = effective_in(&ov, &e.key); - if !q.is_empty() && !(e.label.to_lowercase().contains(&q) || e.group.to_lowercase().contains(&q) || sc.as_deref().is_some_and(|s| s.to_lowercase().contains(&q))) { + if !q.is_empty() + && !(e.label.to_lowercase().contains(&q) + || e.group.to_lowercase().contains(&q) + || sc.as_deref().is_some_and(|s| s.to_lowercase().contains(&q))) + { continue; } let row = ui.horizontal(|ui| { let name = if e.is_tool || e.group.is_empty() { e.label.clone() } else { format!("{} › {}", e.group, e.label) }; - let r = ui.add_sized([360.0, 20.0], egui::Button::selectable(selected == e.key, egui::RichText::new(name).color(t.text)).truncate()); + let r = ui + .allocate_ui_with_layout(egui::vec2(360.0, 20.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.set_min_width(360.0); + ui.add(egui::Button::selectable(selected == e.key, egui::RichText::new(name).color(t.text)).truncate()) + }) + .inner; if r.clicked() { d.fields.insert("__selected".into(), json!(e.key)); } @@ -540,7 +557,9 @@ pub fn show(app: &mut DrawcraftApp, ctx: &egui::Context) { }; let changed = ov.contains_key(&e.key); let txt = egui::RichText::new(label).color(if changed { t.accent } else { t.text }); - let b = ui.add_sized([150.0, 20.0], egui::Button::new(txt).selected(rec == e.key)).on_hover_text("Click, then press the new shortcut (Esc cancels)"); + let b = ui + .add_sized([150.0, 20.0], egui::Button::new(txt).selected(rec == e.key)) + .on_hover_text("Click, then press the new shortcut (Esc cancels)"); if b.clicked() { d.fields.insert("__recording".into(), json!(e.key)); d.fields.insert("__selected".into(), json!(e.key)); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index fc5b03f2..38c47122 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -82,7 +82,9 @@ pub fn handle(app: &mut DrawcraftApp, ctx: &egui::Context) { if !typing && ctx.input(|i| i.key_pressed(k)) { let _ = app.session.tool_key(tk, Mods::default(), view); if k == Key::Escape && !busy { - if app.session.active().is_some_and(|d| d.isolation.is_some()) { + if app.session.active().is_some_and(|d| d.doc.pattern_edit.is_some()) { + let _ = app.run("object.pattern.done", json!({})); + } else if app.session.active().is_some_and(|d| d.isolation.is_some()) { let _ = app.run("object.exitIsolation", json!({})); } else if app.ui.flyout.is_some() { app.ui.flyout = None; diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index f369629d..ab581a8a 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -63,6 +63,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("swatches", "Swatches", "swatch-book"), ("brushes", "Brushes", "paintbrush"), ("symbols", "Symbols", "spray-can"), + ("patternOptions", "Pattern Options", "grid-3x3"), ("stroke", "Stroke", "dc-stroke"), ("gradient", "Gradient", "dc-gradient"), ("transparency", "Transparency", "dc-transparency"), @@ -79,12 +80,13 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("actions", "Actions", "dc-actions"), ("info", "Info", "info"), ("navigator", "Navigator", "map"), + ("separations", "Separations Preview", "printer"), ]; /// Groups of icon panels separated by dividers in the collapsed column. pub const ICON_PANEL_GROUPS: &[&[&str]] = &[ &["color", "colorGuide"], - &["swatches", "brushes", "symbols"], + &["swatches", "brushes", "symbols", "patternOptions"], &["stroke", "gradient", "transparency"], &["appearance", "graphicStyles"], &["artboards"], diff --git a/crates/ui-egui/src/workspaces.rs b/crates/ui-egui/src/workspaces.rs index 62226e7c..3a0b94f7 100644 --- a/crates/ui-egui/src/workspaces.rs +++ b/crates/ui-egui/src/workspaces.rs @@ -139,7 +139,11 @@ pub fn apply(ui: &mut UiState, w: &Workspace) { /// A workspace by name (user workspaces shadow nothing: names are unique across both lists). pub fn find(ui: &UiState, name: &str) -> Option { - ui.custom_workspaces.iter().find(|w| w.name.eq_ignore_ascii_case(name)).cloned().or_else(|| builtins().into_iter().find(|w| w.name.eq_ignore_ascii_case(name))) + ui.custom_workspaces + .iter() + .find(|w| w.name.eq_ignore_ascii_case(name)) + .cloned() + .or_else(|| builtins().into_iter().find(|w| w.name.eq_ignore_ascii_case(name))) } /// Every workspace name: built-ins then user workspaces. @@ -199,7 +203,10 @@ fn names_store() -> &'static RwLock> { /// Mirror the user workspace names for the (static) menu tree. pub fn sync(ui: &UiState) { - let same = names_store().read().map(|v| v.len() == ui.custom_workspaces.len() && v.iter().zip(&ui.custom_workspaces).all(|(a, b)| *a == b.name)).unwrap_or(true); + let same = names_store() + .read() + .map(|v| v.len() == ui.custom_workspaces.len() && v.iter().zip(&ui.custom_workspaces).all(|(a, b)| *a == b.name)) + .unwrap_or(true); if !same { if let Ok(mut v) = names_store().write() { *v = ui.custom_workspaces.iter().map(|w| crate::shortcut_editor::intern(&w.name)).collect(); @@ -211,7 +218,8 @@ pub fn sync(ui: &UiState) { /// Window → Workspace submenu items. pub fn menu_items() -> Vec { use crate::menus::Item; - let mut v: Vec = builtins().into_iter().map(|w| Item::Cmd(crate::shortcut_editor::intern(&w.name), "window.workspace", json!({"name": w.name}))).collect(); + let mut v: Vec = + builtins().into_iter().map(|w| Item::Cmd(crate::shortcut_editor::intern(&w.name), "window.workspace", json!({"name": w.name}))).collect(); let custom = names_store().read().map(|v| v.clone()).unwrap_or_default(); if !custom.is_empty() { v.push(Item::Sep); @@ -413,11 +421,13 @@ mod tests { #[test] fn apply_classic_then_reset_to_essentials_keeps_brightness() { let mut ui = UiState { brightness: crate::theme::Brightness::Light, ..Default::default() }; - apply(&mut ui, &find(&ui, "Essentials Classic").unwrap()); + let w = find(&ui, "Essentials Classic").unwrap(); + apply(&mut ui, &w); assert!(ui.control_bar && ui.view.rulers && ui.toolbar_advanced); assert_eq!(ui.open_panel.as_deref(), Some("color")); assert_eq!(ui.workspace, "Essentials Classic"); - apply(&mut ui, &find(&ui, "essentials").unwrap()); + let w = find(&ui, "essentials").unwrap(); + apply(&mut ui, &w); assert!(!ui.control_bar && !ui.view.rulers); assert_eq!(ui.brightness, crate::theme::Brightness::Light); } @@ -429,9 +439,11 @@ mod tests { ui.dock_tab = DockTab::Layers; ui.open_panel = Some("stroke".into()); save_as(&mut ui, "Mine").unwrap(); - apply(&mut ui, &find(&ui, ESSENTIALS).unwrap()); + let w = find(&ui, ESSENTIALS).unwrap(); + apply(&mut ui, &w); assert!(!ui.control_bar); - apply(&mut ui, &find(&ui, "Mine").unwrap()); + let w = find(&ui, "Mine").unwrap(); + apply(&mut ui, &w); assert!(ui.control_bar && ui.dock_tab == DockTab::Layers && ui.open_panel.as_deref() == Some("stroke")); // Survives the UiState JSON round trip. let back: UiState = serde_json::from_value(serde_json::to_value(&ui).unwrap()).unwrap();