mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-02 22:20:50 -06:00
wip
This commit is contained in:
@@ -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)),
|
||||
|
||||
Reference in New Issue
Block a user