mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 20:01:43 +00:00
Merge main into feat/script-console
This commit is contained in:
@@ -263,6 +263,22 @@ fn record_again(s: &mut Session, again: Value) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `d` with each of `ids` that is not in perspective attached to `fallback` as [`persp_edit`] attaches
|
||||
/// it; `d` itself when every one is in perspective already, there is no `fallback`, or one of them
|
||||
/// cannot attach.
|
||||
fn with_fallback<'a>(d: &'a Document, ids: &[NodeId], fallback: Option<Plane>) -> std::borrow::Cow<'a, Document> {
|
||||
let mut g = grid_of(d);
|
||||
let loose: Vec<NodeId> = ids.iter().copied().filter(|id| g.attachment_of(d, *id).is_none()).collect();
|
||||
let Some(plane) = fallback.filter(|_| !loose.is_empty()) else { return std::borrow::Cow::Borrowed(d) };
|
||||
let mut out = d.clone();
|
||||
for id in loose {
|
||||
if attach_in(&mut out, &mut g, id, plane, false, None).is_err() {
|
||||
return std::borrow::Cow::Borrowed(d);
|
||||
}
|
||||
}
|
||||
std::borrow::Cow::Owned(out)
|
||||
}
|
||||
|
||||
fn ids_json(ids: &[NodeId]) -> Value {
|
||||
json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })
|
||||
}
|
||||
@@ -285,9 +301,11 @@ pub(super) fn persp_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let constrain = switch_or(p, "constrain", false, C)?;
|
||||
// `snap_fix` uses Snap to Grid's setting for a `snap` that is not true or false.
|
||||
check_switches(p, &["snap"], C)?;
|
||||
// Shift and Snap to Grid read the objects as they are once those not in perspective are attached.
|
||||
let doc = if perp { std::borrow::Cow::Borrowed(&*s.doc()?.doc) } else { with_fallback(&s.doc()?.doc, &ids, fallback) };
|
||||
// Shift: the constrained end point, read on the first object's plane and depth.
|
||||
let snap_to = if constrain && !perp {
|
||||
let doc = &s.doc()?.doc;
|
||||
let doc = &*doc;
|
||||
let g = grid_of(doc);
|
||||
g.plane_bounds(doc, &ids)
|
||||
.and_then(|(plane, depth, _)| {
|
||||
@@ -305,10 +323,10 @@ pub(super) fn persp_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let fix = if perp {
|
||||
None
|
||||
} else {
|
||||
let doc = &s.doc()?.doc;
|
||||
crate::cmd::perspgrid::snap_fix(doc, &ids, p, (from, snap_to))
|
||||
.and_then(|(plane, depth, correction)| Some((plane, grid_of(doc).plane_delta(plane, depth, from, to)?, correction)))
|
||||
crate::cmd::perspgrid::snap_fix(&doc, &ids, p, (from, snap_to))
|
||||
.and_then(|(plane, depth, correction)| Some((plane, grid_of(&doc).plane_delta(plane, depth, from, to)?, correction)))
|
||||
};
|
||||
drop(doc);
|
||||
let snapped = move |pl: Plane, raw: vectorcraft_geom::Vec2| {
|
||||
let raw = fix.filter(|f| f.0 == pl).map_or(raw, |f| f.1);
|
||||
let (direction, dv) =
|
||||
|
||||
@@ -74,7 +74,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Split Path",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath?, segment, t} or {id, subpath?, anchor}: closed paths open there, open paths become two → {ids}",
|
||||
"{id, subpath?, segment, t} or {id, subpath?, anchor}: closed paths open there, open paths become two; one of the two new end points is left selected → {ids}",
|
||||
has_doc,
|
||||
split
|
||||
),
|
||||
|
||||
@@ -200,9 +200,16 @@ pub(super) fn split(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => return Err(EngineError::Other("no such segment".into())),
|
||||
};
|
||||
let mut pieces = sp.cut_at(&std::collections::BTreeSet::from([k]));
|
||||
// The cut leaves one of its two coincident end points selected, as `path.cutAtAnchors`
|
||||
// does, so a Direct Selection drag pulls the path apart there.
|
||||
let pick = |sel: &mut vectorcraft_doc::Selection, id: NodeId, si: usize| {
|
||||
sel.clear();
|
||||
sel.add(id);
|
||||
sel.anchors.entry(id).or_default().insert((si, 0));
|
||||
};
|
||||
if sp.closed {
|
||||
path.subpaths[si] = pieces.pop().ok_or_else(|| EngineError::Other("no such anchor".into()))?;
|
||||
sel.set([id]);
|
||||
pick(sel, id, si);
|
||||
return Ok(vec![id]);
|
||||
}
|
||||
let [left, right] = <[SubPath; 2]>::try_from(pieces).map_err(|_| EngineError::Other("cannot split an open path at its end point".into()))?;
|
||||
@@ -211,12 +218,12 @@ pub(super) fn split(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let node = sibling_with(d, id, PathData::single(right))?;
|
||||
let (parent, idx, _) = d.position(id).ok_or(EngineError::NoNode(id))?;
|
||||
let nid = d.insert(parent, idx + 1, node)?;
|
||||
sel.set([id, nid]);
|
||||
pick(sel, nid, 0);
|
||||
Ok(vec![id, nid])
|
||||
} else {
|
||||
path.subpaths[si] = left;
|
||||
path.subpaths.insert(si + 1, right);
|
||||
sel.set([id]);
|
||||
pick(sel, id, si + 1);
|
||||
Ok(vec![id])
|
||||
}
|
||||
})?;
|
||||
|
||||
@@ -1018,6 +1018,65 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Source file names that differ only in case (`a.svg` and `sub/A.svg`) get Export for Screens
|
||||
/// folders of their own: names are compared without case, so the second folder is numbered,
|
||||
/// since Windows and macOS would make `a` and `A` one folder.
|
||||
#[test]
|
||||
fn batch_override_export_numbers_folders_that_differ_only_in_case() {
|
||||
let src = tmp("screens-case-src");
|
||||
std::fs::create_dir_all(src.join("sub")).unwrap();
|
||||
write_svg(&src, "a.svg", "#f00");
|
||||
write_svg(&src.join("sub"), "A.svg", "#0f0");
|
||||
let dest = tmp("screens-case-out");
|
||||
let mut s = Session::new();
|
||||
let r = s
|
||||
.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"folder": src.to_string_lossy(),
|
||||
"includeSubdirs": true,
|
||||
"destinationFolder": dest.to_string_lossy(),
|
||||
"commands": [{"command": "document.exportForScreens", "params": {"formats": [{"format": "svg"}]}}]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["failed"], 0, "{r}");
|
||||
assert_eq!(file_names(&dest), ["A-2", "a"], "{r}");
|
||||
for (folder, fill) in [("a", "#ff0000"), ("A-2", "#00ff00")] {
|
||||
assert_eq!(file_names(&dest.join(folder)), ["Artboard-1.svg"], "{r}");
|
||||
let svg = std::fs::read_to_string(dest.join(folder).join("Artboard-1.svg")).unwrap();
|
||||
assert!(svg.contains(fill), "{folder}: {svg}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A source file with nothing left of its name without its extension (` .svg`) gets the folder
|
||||
/// `file`, numbered before a source file named `file.svg` takes it.
|
||||
#[test]
|
||||
fn batch_override_export_names_the_folder_of_an_empty_name_file() {
|
||||
let src = tmp("screens-empty-src");
|
||||
write_svg(&src, " .svg", "#f00");
|
||||
write_svg(&src, "file.svg", "#0f0");
|
||||
let dest = tmp("screens-empty-out");
|
||||
let mut s = Session::new();
|
||||
let r = s
|
||||
.execute(
|
||||
"action.batch",
|
||||
&json!({
|
||||
"folder": src.to_string_lossy(),
|
||||
"destinationFolder": dest.to_string_lossy(),
|
||||
"commands": [{"command": "document.exportForScreens", "params": {"formats": [{"format": "svg"}]}}]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["failed"], 0, "{r}");
|
||||
assert_eq!(file_names(&dest), ["file", "file-2"], "{r}");
|
||||
for (folder, fill) in [("file", "#ff0000"), ("file-2", "#00ff00")] {
|
||||
assert_eq!(file_names(&dest.join(folder)), ["Artboard-1.svg"], "{r}");
|
||||
let svg = std::fs::read_to_string(dest.join(folder).join("Artboard-1.svg")).unwrap();
|
||||
assert!(svg.contains(fill), "{folder}: {svg}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A source file whose name without its extension ends in a period (`a..svg`) gets the folder
|
||||
/// name without it, numbered after the folder of `a.svg`: Windows drops the period from a folder
|
||||
/// name, so `a.` and `a` would be one folder there.
|
||||
|
||||
@@ -221,25 +221,41 @@ pub(crate) fn len_param(p: &Value, key: &str) -> Option<f64> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The number `p` gives `cmd` for `key`, as [`num_param`] reads it, or `None` when `p` leaves it out
|
||||
/// or gives null. Any other value, such as `true` or `"wide"`, is an error: "`{key}` must be a
|
||||
/// number, not {v}".
|
||||
/// The number `p` gives `cmd` for `key`, or `None` when `p` leaves it out or gives null. A string
|
||||
/// counts only when all of it is a number (`"20"`). Any other value, such as `true`, `"wide"` or
|
||||
/// `"20abc"`, is an error: "`{key}` must be a number, not {v}".
|
||||
pub(crate) fn number_given(p: &Value, key: &str, cmd: &str) -> Result<Option<f64>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => num_param(p, key).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be a number, not {v}"))),
|
||||
Some(v) => match v {
|
||||
Value::String(s) => whole_number(s),
|
||||
_ => num_param(p, key),
|
||||
}
|
||||
.map(Some)
|
||||
.ok_or_else(|| bad(cmd, format!("`{key}` must be a number, not {v}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The distance `p` gives `cmd` for `key`, as [`len_param`] reads it (`"5 mm"` included), or `None`
|
||||
/// when `p` leaves it out or gives null. Any other value is an error, as [`number_given`] gives it.
|
||||
/// The distance `p` gives `cmd` for `key`, or `None` when `p` leaves it out or gives null. A string
|
||||
/// counts only when all of it is a length that [`vectorcraft_doc::Unit::parse`] reads (`"5 mm"`) or a
|
||||
/// number. Any other value, such as `"20abc"`, is an error, as [`number_given`] gives it.
|
||||
pub(crate) fn length_given(p: &Value, key: &str, cmd: &str) -> Result<Option<f64>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => len_param(p, key).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be a number, not {v}"))),
|
||||
Some(v) => match v {
|
||||
Value::String(s) => vectorcraft_doc::Unit::Points.parse(s).filter(|v| v.is_finite()).or_else(|| whole_number(s)),
|
||||
_ => num_param(p, key),
|
||||
}
|
||||
.map(Some)
|
||||
.ok_or_else(|| bad(cmd, format!("`{key}` must be a number, not {v}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// `s` as a finite number when all of it, spaces around it aside, is one.
|
||||
fn whole_number(s: &str) -> Option<f64> {
|
||||
s.trim().parse::<f64>().ok().filter(|v| v.is_finite())
|
||||
}
|
||||
|
||||
fn ids_json(ids: &[NodeId]) -> Value {
|
||||
json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })
|
||||
}
|
||||
|
||||
@@ -15,13 +15,13 @@ use serde_json::{Value, json};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::pattern::{self, Overlap, PATTERN_EDIT_LAYER, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType, pattern_paint};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Selection};
|
||||
use vectorcraft_geom::Rect;
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
|
||||
use super::edit::{outermost, selected_roots};
|
||||
use super::pathops::Beside;
|
||||
use super::{
|
||||
CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, f64_or, has_doc, has_selection, ids_param, matrix_param, ok, point_param,
|
||||
str_param, targets,
|
||||
CommandSpec, EngineError, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, finite_numbers, has_doc, has_selection, ids_param,
|
||||
matrix_param, ok, switch_or, targets, text_param,
|
||||
};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -134,7 +134,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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",
|
||||
"{instances?, radius?, reverseOverlap?, startAngle?, endAngle?, center?: [x,y] | hSpacing?, vSpacing?, rows?, cols?, gridType?, brickOffset?, flipRows?, flipCols? | angle?} set the options of the selected repeats",
|
||||
has_repeat,
|
||||
repeat_options
|
||||
),
|
||||
@@ -287,9 +287,12 @@ fn after_edit_mode(s: &mut Session, layer: Option<NodeId>) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The tile type `p` gives `cmd` (`tileType`, `brickOffset`), else `current` with a given brick offset.
|
||||
/// A `tileType` that is not a string, or a `brickOffset` that is not a number, is an error.
|
||||
fn tile_type_param(p: &Value, cmd: &str, current: TileType) -> Result<TileType> {
|
||||
check_numbers(p, &["brickOffset"], cmd)?;
|
||||
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") {
|
||||
match text_param(p, "tileType", cmd)? {
|
||||
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 },
|
||||
@@ -311,12 +314,13 @@ fn pattern_make(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "select the art for the pattern tile"));
|
||||
}
|
||||
let tt = tile_type_param(p, C, TileType::Grid)?;
|
||||
check_numbers(p, &["width", "height"], C)?;
|
||||
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::trim).filter(|n| !n.is_empty()).map(str::to_string);
|
||||
let edit = switch_or(p, "edit", true, C)?;
|
||||
let wanted = text_param(p, "name", C)?.map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
|
||||
let name = s.edit("Make Pattern", |d, sel| {
|
||||
let name = match &wanted {
|
||||
Some(n) => unique_pattern_name(d, n),
|
||||
@@ -343,7 +347,7 @@ fn pattern_make(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn pattern_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.pattern.edit";
|
||||
let name = match str_param(p, "name") {
|
||||
let name = match text_param(p, "name", C)? {
|
||||
Some(n) => n.to_string(),
|
||||
None => {
|
||||
// The selected object's fill (or stroke) pattern.
|
||||
@@ -419,7 +423,7 @@ fn pattern_cancel(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn pattern_save_copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let wanted = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
|
||||
let wanted = text_param(p, "name", "object.pattern.saveCopy")?.map(str::trim).filter(|n| !n.is_empty()).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()))?;
|
||||
@@ -438,19 +442,21 @@ fn pattern_save_copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn pattern_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "pattern.options";
|
||||
let name = match str_param(p, "name") {
|
||||
let name = match text_param(p, "name", C)? {
|
||||
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)?;
|
||||
check_numbers(p, &["width", "height", "hSpacing", "vSpacing", "copies", "dimCopies"], C)?;
|
||||
check_switches(p, &["sizeTileToArt", "showTileEdge", "showSwatchBounds"], C)?;
|
||||
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::trim).filter(|n| !n.is_empty() && *n != name).map(str::to_string);
|
||||
let new_name = text_param(p, "newName", C)?.map(str::trim).filter(|n| !n.is_empty() && *n != name).map(str::to_string);
|
||||
if let Some(n) = &new_name {
|
||||
let d = &s.doc()?.doc;
|
||||
if d.pattern(n).is_some() {
|
||||
@@ -461,11 +467,15 @@ fn pattern_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
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 | Some(Value::Null) => cur.overlap,
|
||||
Some(o) if o.is_object() => Overlap {
|
||||
right_in_front: overlap_side(o, "h", ["left", "right"], cur.overlap.right_in_front, C)?,
|
||||
bottom_in_front: overlap_side(o, "v", ["top", "bottom"], cur.overlap.bottom_in_front, C)?,
|
||||
},
|
||||
None => cur.overlap,
|
||||
Some(got) => {
|
||||
let (path, expected) = ("overlap", "an object");
|
||||
return Err(bad(C, format!("`{path}` must be {expected}, not {got}")));
|
||||
}
|
||||
};
|
||||
let edit_layer = editing(s).filter(|e| e.pattern == name).map(|e| e.layer);
|
||||
let out_name = new_name.clone().unwrap_or(name.clone());
|
||||
@@ -514,6 +524,19 @@ fn pattern_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "name": out_name }))
|
||||
}
|
||||
|
||||
/// Side `side` of the `overlap` object `o` gives `cmd`: true for the second of `names`, false for the
|
||||
/// first, `current` when `o` leaves it out or gives null. Any other value is an error.
|
||||
fn overlap_side(o: &Value, side: &str, names: [&str; 2], current: bool, cmd: &str) -> Result<bool> {
|
||||
match o.get(side) {
|
||||
None | Some(Value::Null) => Ok(current),
|
||||
Some(Value::String(v)) if names.contains(&v.as_str()) => Ok(v == names[1]),
|
||||
Some(_) => {
|
||||
let key = format!("overlap.{side}");
|
||||
Err(bad(cmd, format!("`{key}` must be one of {}", names.join(", "))))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `new` taken for pattern `old` by a swatch or a color group (they share one namespace,
|
||||
/// [`Document::swatch_name_taken`])? It is free when the swatches named `new` are one or more
|
||||
/// swatches of the pattern and none of the pattern's swatches is named `old` (those take the new
|
||||
@@ -571,7 +594,7 @@ fn nodes_using(d: &Document, name: Option<&str>) -> Vec<NodeId> {
|
||||
|
||||
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();
|
||||
let name = text_param(p, "name", C)?.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}`")));
|
||||
}
|
||||
@@ -694,18 +717,25 @@ fn make_repeat(s: &mut Session, p: &Value, cmd: &str, label: &str, make: impl Fn
|
||||
}
|
||||
|
||||
fn repeat_radial(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.repeat.radial";
|
||||
check_numbers(p, &["instances", "radius"], C)?;
|
||||
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"));
|
||||
return Err(bad(C, "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, "object.repeat.radial", "Radial Repeat", |src| RepeatSpec::radial(src, n as u32, radius))
|
||||
let radius = p.get("radius").and_then(Value::as_f64);
|
||||
if radius.is_some_and(|r| r < 0.0) {
|
||||
let key = "radius";
|
||||
return Err(bad(C, format!("`{key}` must be a number not below 0")));
|
||||
}
|
||||
make_repeat(s, p, C, "Radial Repeat", |src| RepeatSpec::radial(src, n as u32, radius))
|
||||
}
|
||||
|
||||
fn repeat_grid(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_numbers(p, &["hSpacing", "vSpacing", "rows", "cols"], "object.repeat.grid")?;
|
||||
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);
|
||||
let rows = p.get("rows").and_then(Value::as_f64).map(|v| (v as u32).clamp(1, 500));
|
||||
let cols = p.get("cols").and_then(Value::as_f64).map(|v| (v as u32).clamp(1, 500));
|
||||
make_repeat(s, p, "object.repeat.grid", "Grid Repeat", |src| {
|
||||
let b = vectorcraft_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));
|
||||
@@ -718,6 +748,7 @@ fn repeat_grid(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn repeat_mirror(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_numbers(p, &["angle", "offset"], "object.repeat.mirror")?;
|
||||
let angle = f64_or(p, "angle", 90.0);
|
||||
let offset = f64_or(p, "offset", 10.0);
|
||||
make_repeat(s, p, "object.repeat.mirror", "Mirror Repeat", |src| RepeatSpec::mirror(src, angle, offset))
|
||||
@@ -803,16 +834,26 @@ pub(crate) fn expand_repeats(d: &mut Document, roots: &[NodeId]) -> Result<usize
|
||||
fn repeat_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.repeat.options";
|
||||
let reps = selected_repeats(s);
|
||||
let numbers = ["instances", "radius", "startAngle", "endAngle", "hSpacing", "vSpacing", "rows", "cols", "brickOffset", "angle"];
|
||||
check_numbers(p, &numbers, C)?;
|
||||
check_switches(p, &["reverseOverlap", "flipRows", "flipCols"], C)?;
|
||||
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") {
|
||||
let grid_type = match text_param(p, "gridType", C)? {
|
||||
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");
|
||||
let center = match p.get("center") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => {
|
||||
let key = "center";
|
||||
let [x, y] = finite_numbers::<2>(v).ok_or_else(|| bad(C, format!("{key} must be [x, y] with finite coordinates")))?;
|
||||
Some(Point::new(x, y))
|
||||
}
|
||||
};
|
||||
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 };
|
||||
@@ -852,6 +893,8 @@ fn repeat_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
if let Some(t) = grid_type {
|
||||
*gt = t;
|
||||
} else if let (Some(v), TileType::BrickByRow { offset } | TileType::BrickByColumn { offset }) = (num("brickOffset"), &mut *gt) {
|
||||
*offset = v;
|
||||
}
|
||||
if let Some(v) = flag("flipRows") {
|
||||
*flip_rows = v;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use super::{CommandSpec, EngineError, Result, Session, always, bad, cmd, str_param};
|
||||
use super::{CommandSpec, EngineError, Result, Session, always, bad, cmd, str_param, switch_or};
|
||||
use crate::Prefs;
|
||||
use crate::units::Measure;
|
||||
use vectorcraft_doc::{GridPrefs, Unit};
|
||||
@@ -763,7 +763,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Set Preference",
|
||||
[],
|
||||
None,
|
||||
"{key, value} or {values: {key: value, …}} set preferences (validated; see prefs.list; lengths in pt or strings with a unit; unitsGeneral also sets the active document's units, one undo step; the eyedropper group takes a partial object, as eyedropper.setOptions; a new recoveryFolder must be a folder that can be listed and written in, and a new pluginsFolder a folder that can be listed: otherwise an error, and nothing changes; an empty value is not checked)",
|
||||
"{key, value} or {values: {key: value, …}, documentUnits?: bool=true} set preferences (validated; see prefs.list; lengths in pt or strings with a unit; unitsGeneral also sets the active document's units, one undo step, unless documentUnits is false; the eyedropper group takes a partial object, as eyedropper.setOptions; a new recoveryFolder must be a folder that can be listed and written in, and a new pluginsFolder a folder that can be listed: otherwise an error, and nothing changes; an empty value is not checked)",
|
||||
always,
|
||||
set
|
||||
),
|
||||
@@ -789,6 +789,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if values.is_empty() {
|
||||
return Err(bad("prefs.set", "give {key, value} or {values}"));
|
||||
}
|
||||
let document_units = switch_or(p, "documentUnits", true, "prefs.set")?;
|
||||
let mut next = s.prefs.clone();
|
||||
next.set_values(&values).map_err(|e| bad("prefs.set", e))?;
|
||||
if let Some(e) = s.prefs.keep_folders(&mut next).into_iter().next() {
|
||||
@@ -797,7 +798,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.prefs.check_folders(&values).map_err(EngineError::Other)?;
|
||||
s.apply_prefs(next);
|
||||
// Units ▸ General is also the open document's units (as Document Setup sets them).
|
||||
if values.contains_key("unitsGeneral") && s.active().is_some() {
|
||||
if document_units && values.contains_key("unitsGeneral") && s.active().is_some() {
|
||||
s.set_document_units(s.default_units())?;
|
||||
}
|
||||
let all = s.prefs.to_json();
|
||||
|
||||
@@ -12,7 +12,7 @@ use super::stroke::StrokeChange;
|
||||
use super::typecmd::refresh_bounds;
|
||||
use super::{
|
||||
CommandSpec, EngineError, Result, Session, bad, bool_or, check_numbers, check_switches, cmd, color_value, has_doc, has_selection, id_param,
|
||||
index, number_or, point_param, str_param, switch_or, targets,
|
||||
index, number_or, point_param, str_param, switch_or, targets, text_param,
|
||||
};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -568,7 +568,8 @@ pub(crate) fn change_font_axes(st: &mut CharStyle, change: &AxisChange) {
|
||||
/// `features: ["dlig", "-liga", …]` → the canonical tag list (differences from the defaults).
|
||||
pub(crate) fn features_param(p: &Value, cmd: &str) -> Result<Option<Vec<String>>> {
|
||||
let Some(v) = p.get("features").filter(|v| !v.is_null()) else { return Ok(None) };
|
||||
let tags: Vec<&str> = v.as_array().ok_or_else(|| bad(cmd, "features must be a list of tags"))?.iter().filter_map(Value::as_str).collect();
|
||||
let not_tags = || bad(cmd, "features must be a list of tags");
|
||||
let tags: Vec<&str> = v.as_array().ok_or_else(not_tags)?.iter().map(|t| t.as_str().ok_or_else(not_tags)).collect::<Result<_>>()?;
|
||||
if let Some(t) = tags.iter().find(|t| !vectorcraft_text::OtFeatures::known_tag(t)) {
|
||||
return Err(bad(
|
||||
cmd,
|
||||
@@ -708,8 +709,8 @@ impl CharChange {
|
||||
stroke_opts.weight = Some(w.clamp(0.0, 1000.0));
|
||||
}
|
||||
let c = Self {
|
||||
font: str_param(p, "font").map(str::to_string),
|
||||
style: str_param(p, "style").map(str::to_string),
|
||||
font: text_param(p, "font", cmd)?.map(str::to_string),
|
||||
style: text_param(p, "style", cmd)?.map(str::to_string),
|
||||
size: num("size"),
|
||||
leading: auto_or_num(p, "leading", cmd)?,
|
||||
tracking: num("tracking"),
|
||||
|
||||
@@ -8,10 +8,10 @@ use vectorcraft_doc::{AreaFit, Document, Justify, Node, NodeId, NodeKind, RubyAl
|
||||
use vectorcraft_geom::{Affine, PathData, Vec2};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::pathops::{len_param, num_param, shape_node};
|
||||
use super::pathops::{len_param, length_given, number_given, shape_node};
|
||||
use super::{
|
||||
CommandSpec, EngineError, Result, Session, bad, check_numbers, cmd, color_value, f64_req, has_doc, has_selection, id_param, index, number_or, ok,
|
||||
str_param, switch_or, targets,
|
||||
str_param, switch_or, targets, text_param,
|
||||
};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -249,16 +249,16 @@ fn ruby(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = text_targets(s, p, C)?;
|
||||
let range = TextRange::parse(p, C)?;
|
||||
let remove = switch_or(p, "remove", false, C)?;
|
||||
let reading = str_param(p, "text").map(str::trim).filter(|t| !t.is_empty()).map(str::to_string);
|
||||
let reading = text_param(p, "text", C)?.map(str::trim).filter(|t| !t.is_empty()).map(str::to_string);
|
||||
if !remove && reading.is_none() {
|
||||
return Err(bad(C, "give `text` (the reading) or `remove`"));
|
||||
}
|
||||
let position = match str_param(p, "position").map(str::to_ascii_lowercase).as_deref() {
|
||||
let position = match text_param(p, "position", C)?.map(str::to_ascii_lowercase).as_deref() {
|
||||
None | Some("above") | Some("right") => RubyPosition::Above,
|
||||
Some("below") | Some("left") => RubyPosition::Below,
|
||||
Some(_) => return Err(bad(C, "`position` is above or below")),
|
||||
};
|
||||
let align = match str_param(p, "align").map(str::to_ascii_lowercase).as_deref() {
|
||||
let align = match text_param(p, "align", C)?.map(str::to_ascii_lowercase).as_deref() {
|
||||
None | Some("center") | Some("centre") => RubyAlign::Center,
|
||||
Some("start") | Some("left") | Some("top") => RubyAlign::Start,
|
||||
Some("end") | Some("right") | Some("bottom") => RubyAlign::End,
|
||||
@@ -812,9 +812,9 @@ pub(crate) fn justify_param(v: &str) -> Option<Justify> {
|
||||
|
||||
fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.setStyle";
|
||||
let font = str_param(p, "font").map(str::to_string);
|
||||
let style = str_param(p, "style").map(str::to_string);
|
||||
let size = len_param(p, "size");
|
||||
let font = text_param(p, "font", C)?.map(str::to_string);
|
||||
let style = text_param(p, "style", C)?.map(str::to_string);
|
||||
let size = length_given(p, "size", C)?;
|
||||
if size.is_some_and(|v| v <= 0.0) {
|
||||
return Err(bad(C, "size must be positive"));
|
||||
}
|
||||
@@ -823,8 +823,8 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(Value::String(a)) if a.eq_ignore_ascii_case("auto") => Some(None),
|
||||
Some(_) => Some(Some(len_param(p, "leading").ok_or_else(|| bad(C, "leading must be a number or \"auto\""))?.clamp(0.1, 5000.0))),
|
||||
};
|
||||
let tracking = num_param(p, "tracking");
|
||||
let justify = match str_param(p, "justify") {
|
||||
let tracking = number_given(p, "tracking", C)?;
|
||||
let justify = match text_param(p, "justify", C)? {
|
||||
Some(j) => Some(justify_param(j).ok_or_else(|| bad(C, "justify must be auto|left|center|right|justifyAll"))?),
|
||||
None => None,
|
||||
};
|
||||
@@ -1282,7 +1282,7 @@ fn set_path_handle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let si = super::draw2::usize_opt(p, "subpath", C)?.unwrap_or(0);
|
||||
let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad(C, "missing anchor"))?;
|
||||
let out = str_param(p, "which") != Some("in");
|
||||
let out = super::object::choice(p, C, "which", &["in", "out"])? != Some("in");
|
||||
let pos = vectorcraft_geom::Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);
|
||||
let independent = switch_or(p, "independent", false, C)?;
|
||||
let node = s.doc()?.doc.node(id);
|
||||
|
||||
Reference in New Issue
Block a user