wip
This commit is contained in:
parent
6dd0d2dcff
commit
c9f38685b4
|
|
@ -166,7 +166,8 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, 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 {
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
||||
|
|
|
|||
|
|
@ -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<CommandSpec> {
|
||||
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:<profile>\"|\"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<Option<Intent>> {
|
||||
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::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<String>, Option<String>) {
|
||||
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<Value> {
|
||||
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<String>, kind: cms::ProfileKind| -> Result<bool> {
|
||||
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<Value> {
|
||||
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<f32> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
let mut cols: Vec<Color> = 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<String, (f32, usize)> = 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::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
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<Value> {
|
||||
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<bool>)) -> Result<Value> {
|
||||
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::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
fn separations(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "view.separationsPreview";
|
||||
let mut v = proof::view();
|
||||
let all: Vec<String> = 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::<Result<Vec<_>>>()?);
|
||||
}
|
||||
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<Value> {
|
||||
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<u64> = 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<u64> = 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<Value> {
|
||||
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}))
|
||||
}
|
||||
|
|
@ -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<CommandSpec> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<f64> = 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<Vec<Point>> {
|
||||
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<NodeId> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<arap::Pin> = 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::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
// ---------- 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<PerspectiveGrid> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Plane> {
|
||||
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<Vec<NodeId>> {
|
||||
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<Point>) -> 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<Value> {
|
||||
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::<Vec<_>>(), "plane": plane.id() }))
|
||||
}
|
||||
|
||||
fn release(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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})))
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,6 +76,10 @@ pub(crate) fn paint_from(s: &Session, p: &Value) -> Result<Option<Paint>> {
|
|||
}
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<CommandSpec> {
|
||||
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<NodeId> {
|
||||
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::<Vec<_>>() })
|
||||
}
|
||||
|
||||
fn roots(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
||||
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<Node>]) -> Vec<Node> {
|
||||
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<PatternDef>) -> Result<NodeId> {
|
||||
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<Arc<Node>>)> {
|
||||
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<NodeId>) -> 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<TileType> {
|
||||
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<Value> {
|
||||
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<Arc<Node>> = roots.iter().filter_map(|id| d.node(*id).cloned()).map(Arc::new).collect();
|
||||
let art: Vec<Arc<Node>> = 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<Value> {
|
||||
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<String> {
|
||||
match p {
|
||||
Paint::Pattern { pattern, .. } => Some(pattern.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn pattern_done(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Arc<Node>> = 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<Value> {
|
||||
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<Vec<Arc<Node>>> = 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<NodeId> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
let st = s.doc()?;
|
||||
let pats: Vec<Value> = 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<Value> {
|
||||
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<Arc<Node>>) -> RepeatSpec) -> Result<Value> {
|
||||
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<Arc<Node>> = 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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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()
|
||||
}
|
||||
|
|
@ -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<Value, String> {
|
||||
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)),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<NodeId> = 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<bool> = 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;
|
||||
}
|
||||
|
|
@ -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<Vec<(f64, f64, f64)>> {
|
||||
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<Point> = p.anchors().map(|(_, _, an)| g.to_plane(Plane::Right, an.p).unwrap()).collect();
|
||||
let xs: Vec<f64> = q.iter().map(|v| v.x).collect();
|
||||
let ys: Vec<f64> = 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<f64> = 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<Point> = 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<Point> = 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());
|
||||
}
|
||||
|
|
@ -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::<Vec<_>>()})).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("<pattern id=\"pattern-1\""), "{svg}");
|
||||
assert!(svg.contains("patternUnits=\"userSpaceOnUse\""));
|
||||
assert!(svg.contains("width=\"20\" height=\"20\""));
|
||||
assert!(svg.contains("fill=\"url(#pattern-1)\"") || svg.contains("fill:url(#pattern-1)"), "{svg}");
|
||||
// The tile art is inside the pattern.
|
||||
let start = svg.find("<pattern").unwrap();
|
||||
let end = svg.find("</pattern>").unwrap();
|
||||
assert!(svg[start..end].contains("<path"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_export_expands_pattern_fills() {
|
||||
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 rep = drawcraft_pdf::export_with_report(doc(&s), &Default::default()).unwrap();
|
||||
assert!(rep.warnings.iter().all(|w| !w.contains("pattern")), "{:?}", rep.warnings);
|
||||
assert!(rep.bytes.len() > 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());
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -91,7 +91,11 @@ impl Session {
|
|||
}
|
||||
|
||||
pub fn overlays(&mut self, view: ViewInfo) -> Vec<Overlay> {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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::<f32>() / 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<String>) {
|
||||
let w = w.into();
|
||||
if !self.warnings.contains(&w) {
|
||||
|
|
@ -208,7 +234,7 @@ impl Exporter<'_> {
|
|||
fn paint(&mut self, p: &Paint, bounds: Rect) -> Option<krilla::paint::Paint> {
|
||||
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<Stop> = 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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<BezPath> {
|
||||
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<Point>, bool)> {
|
||||
let mut out: Vec<(Vec<Point>, bool)> = vec![];
|
||||
let mut cur: Vec<Point> = vec![];
|
||||
let push = |cur: &mut Vec<Point>, 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<Point> {
|
||||
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<Vec2> = (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<Point> = 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<Point>, 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<Item = Point>) {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
@ -108,6 +108,8 @@ pub struct Mesh {
|
|||
ny: usize,
|
||||
/// Per grid cell: its two triangles (None = cell not part of the mesh).
|
||||
cells: Vec<Option<[usize; 2]>>,
|
||||
/// Per grid cell: centre inside the art (not just the band around it).
|
||||
core: Vec<bool>,
|
||||
}
|
||||
|
||||
/// 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<Point> {
|
|||
.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]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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<impl Fn(Point) -> Option<Point>> {
|
||||
pub fn attach_map(&self, plane: Plane, b: Rect) -> Option<impl Fn(Point) -> Option<Point> + 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<impl Fn(Point) -> Option<Point>> {
|
||||
pub fn move_map(&self, plane: Plane, from: Point, to: Point) -> Option<impl Fn(Point) -> Option<Point> + use<>> {
|
||||
let h = self.homography(plane)?;
|
||||
let hi = h.inverse()?;
|
||||
let d = hi.apply(to)? - hi.apply(from)?;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<String> =
|
||||
let mut crumbs: Vec<String> =
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -186,6 +186,12 @@ pub fn run_ui_command(app: &mut DrawcraftApp, id: &str, p: &Value) -> Option<Res
|
|||
crate::prefs_dialog::open(app, s("category").as_deref());
|
||||
Ok(Value::Null)
|
||||
}
|
||||
// Edit → Color Settings… / Assign Profile… (no params): show the colour-management panel.
|
||||
"edit.colorSettings" | "edit.assignProfile" if p.as_object().is_none_or(|o| o.is_empty()) => {
|
||||
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<bool> {
|
|||
}
|
||||
"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<Item>)> {
|
|||
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<Item>)> {
|
|||
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<Item>)> {
|
|||
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<Item>)> {
|
|||
],
|
||||
),
|
||||
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<Item>)> {
|
|||
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<Item>)> {
|
|||
"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<Item>)> {
|
|||
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<Item>)> {
|
|||
],
|
||||
),
|
||||
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<Item>)> {
|
|||
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());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::<String>(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<String> = 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<Value> {
|
||||
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}),
|
||||
})
|
||||
}
|
||||
|
|
@ -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::<Vec<_>>()}));
|
||||
}
|
||||
});
|
||||
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<String> {
|
||||
let names: Vec<String> = 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}));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<usize>, String, u32);
|
||||
thread_local! {
|
||||
static CACHE: RefCell<HashMap<Key, Option<egui::TextureHandle>>> = 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<Entry> = None;
|
||||
let mut edit_pattern: Option<String> = 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(),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Result
|
|||
.collect(),
|
||||
))
|
||||
}
|
||||
"shortcuts.conflicts" => Ok(json!(all_conflicts(&app.ui.shortcut_overrides).into_iter().map(|(c, k)| json!({"shortcut": c, "ids": k})).collect::<Vec<_>>())),
|
||||
"shortcuts.conflicts" => {
|
||||
Ok(json!(all_conflicts(&app.ui.shortcut_overrides).into_iter().map(|(c, k)| json!({"shortcut": c, "ids": k})).collect::<Vec<_>>()))
|
||||
}
|
||||
"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));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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<Workspace> {
|
||||
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<Vec<&'static str>> {
|
|||
|
||||
/// 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<crate::menus::Item> {
|
||||
use crate::menus::Item;
|
||||
let mut v: Vec<Item> = builtins().into_iter().map(|w| Item::Cmd(crate::shortcut_editor::intern(&w.name), "window.workspace", json!({"name": w.name}))).collect();
|
||||
let mut v: Vec<Item> =
|
||||
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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue