This commit is contained in:
Brandon Thomas 2026-09-30 11:37:31 -04:00
parent 6dd0d2dcff
commit c9f38685b4
33 changed files with 3982 additions and 86 deletions

View File

@ -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 {

View File

@ -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};

View File

@ -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}))
}

View File

@ -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, &params)?;
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})))
}

View File

@ -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
})
}

View File

@ -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

View File

@ -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()
}

View File

@ -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)),

View File

@ -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;

View File

@ -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;
}

View File

@ -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, &params).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());
}

View File

@ -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());
}

View File

@ -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);

View File

@ -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 {

View File

@ -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;

View File

@ -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();

View File

@ -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();

261
crates/render/src/width.rs Normal file
View File

@ -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());
}
}

View File

@ -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]

View File

@ -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]

View File

@ -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)?;

View File

@ -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) {

View File

@ -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();
}
}
}

View File

@ -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());
}
}

View File

@ -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));
}

View File

@ -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}),
})
}

View File

@ -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}));
}
}

View File

@ -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(),

View File

@ -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);
});
}

View File

@ -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));

View File

@ -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;

View File

@ -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"],

View File

@ -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();