Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3719b2e79 | ||
|
|
9aed6191d4 | ||
|
|
9b12fff215 | ||
|
|
e428f38767 | ||
|
|
da735914b6 | ||
|
|
2adf4465d6 | ||
|
|
d3c997329d | ||
|
|
1637165600 |
No files matched your search
+1
-1
@@ -127,7 +127,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and DisLine truncated
|
||||
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and DisLine truncated
|
||||
| 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) |
|
||||
| 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day |
|
||||
| 2026-10-08 | v0.5.0–v0.7.0; community contributions: Pen and shape-tool modifiers, Layers, Artboards, Japanese composition, Hebrew/Arabic type, interface languages, saved selections |
|
||||
|
||||
@@ -825,20 +825,9 @@ fn reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.edit("Reshape", |d, _| {
|
||||
let path = path_mut(d, id)?;
|
||||
let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
|
||||
if seg >= sp.segment_count() {
|
||||
if !sp.bend_segment(seg, t, dv) {
|
||||
return Err(EngineError::Other("no such segment".into()));
|
||||
}
|
||||
let n = sp.anchors.len();
|
||||
// Moving both inner control points by v moves B(t) by 3t(1-t)·v. A smooth anchor stays
|
||||
// smooth: its other handle turns with the moved one.
|
||||
let v = dv / (3.0 * t * (1.0 - t));
|
||||
let (i0, i1) = (seg % n, (seg + 1) % n);
|
||||
if let Some(a) = sp.anchors.get_mut(i0) {
|
||||
a.set_handle(true, a.h_out + v, false);
|
||||
}
|
||||
if let Some(b) = sp.anchors.get_mut(i1) {
|
||||
b.set_handle(false, b.h_in + v, false);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
|
||||
@@ -82,7 +82,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Hide Others",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{ids?} hide every top-level layer but those holding the highlighted rows (else the current layer). One undo step → {count}",
|
||||
"{ids?} hide every top-level layer but those holding the highlighted rows (else the current layer), which show. One undo step → {count}",
|
||||
has_doc,
|
||||
|s, p| others(s, p, Flag::Visible, false)
|
||||
),
|
||||
@@ -96,7 +96,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Outline Others",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{ids?} turn Preview off (outline view) for every top-level layer but those holding the highlighted rows (else the current layer) → {count}",
|
||||
"{ids?} turn Preview off (outline view) for every top-level layer but those holding the highlighted rows (else the current layer), which preview → {count}",
|
||||
has_doc,
|
||||
|s, p| others(s, p, Flag::Preview, false)
|
||||
),
|
||||
@@ -114,7 +114,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Lock Others",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{ids?} lock every top-level layer but those holding the highlighted rows (else the current layer) → {count}",
|
||||
"{ids?} lock every top-level layer but those holding the highlighted rows (else the current layer), which unlock → {count}",
|
||||
has_doc,
|
||||
|s, p| others(s, p, Flag::Locked, true)
|
||||
),
|
||||
@@ -176,23 +176,23 @@ impl Flag {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set `flag` to `v` on `ids` (layers that differ) in one undo step labelled `label` → how many
|
||||
/// Set `flag` on each of `changes` (id, value; layers that differ) in one undo step labelled `label` → how many
|
||||
/// changed.
|
||||
fn set_flag(s: &mut Session, ids: Vec<NodeId>, flag: Flag, v: bool, label: &str) -> Result<Value> {
|
||||
fn set_flag(s: &mut Session, changes: Vec<(NodeId, bool)>, flag: Flag, label: &str) -> Result<Value> {
|
||||
let d = &s.doc()?.doc;
|
||||
let ids: Vec<NodeId> = ids.into_iter().filter(|id| d.node(*id).is_some_and(|n| flag.get(n) != v)).collect();
|
||||
if !ids.is_empty() {
|
||||
let changes: Vec<(NodeId, bool)> = changes.into_iter().filter(|(id, v)| d.node(*id).is_some_and(|n| flag.get(n) != *v)).collect();
|
||||
if !changes.is_empty() {
|
||||
s.edit(label, |d, sel| {
|
||||
for id in &ids {
|
||||
for (id, v) in &changes {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
flag.set(n, v);
|
||||
flag.set(n, *v);
|
||||
}
|
||||
}
|
||||
sel.deselect_uneditable(d);
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!({ "count": ids.len() }))
|
||||
Ok(json!({ "count": changes.len() }))
|
||||
}
|
||||
|
||||
/// The top-level layers holding the rows a command acts on.
|
||||
@@ -202,20 +202,22 @@ fn kept_layers(s: &Session, p: &Value) -> Result<HashSet<NodeId>> {
|
||||
Ok(ids.iter().filter_map(|id| d.layer_of(*id)).collect())
|
||||
}
|
||||
|
||||
/// Every top-level layer but the kept ones set to `v`, and the kept ones to the opposite: Alt-click
|
||||
/// on a hidden layer's eye shows just that layer (#1158).
|
||||
fn others(s: &mut Session, p: &Value, flag: Flag, v: bool) -> Result<Value> {
|
||||
let keep = kept_layers(s, p)?;
|
||||
let ids: Vec<NodeId> = s.doc()?.doc.layers.iter().map(|l| l.id).filter(|l| !keep.contains(l)).collect();
|
||||
set_flag(s, ids, flag, v, flag.label(v))
|
||||
let changes = s.doc()?.doc.layers.iter().map(|l| (l.id, !keep.contains(&l.id) == v)).collect();
|
||||
set_flag(s, changes, flag, flag.label(v))
|
||||
}
|
||||
|
||||
fn all(s: &mut Session, flag: Flag, v: bool) -> Result<Value> {
|
||||
let mut ids = vec![];
|
||||
s.doc()?.doc.walk(|n| {
|
||||
if n.is_layer() {
|
||||
ids.push(n.id);
|
||||
ids.push((n.id, v));
|
||||
}
|
||||
});
|
||||
set_flag(s, ids, flag, v, flag.label(v))
|
||||
set_flag(s, ids, flag, flag.label(v))
|
||||
}
|
||||
|
||||
fn template(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -14,7 +14,7 @@ use vectorcraft_doc::live::{
|
||||
PreserveShape, Spine,
|
||||
};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection};
|
||||
use vectorcraft_geom::{Affine, PathData, Point};
|
||||
use vectorcraft_geom::{Affine, PathData, Point, Vec2};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::expand::gradient_sampler;
|
||||
@@ -95,6 +95,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
spine_move_anchor
|
||||
),
|
||||
cmd!(
|
||||
"object.blend.spine.reshapeSegment",
|
||||
"Reshape Spine Segment",
|
||||
[],
|
||||
None,
|
||||
"{id? (default: the selected blend), segment: index, t: 0..1, dx, dy} bend a segment of the blend's spine so its point at t moves by (dx, dy), as path.reshapeSegment bends a path's: a straight spine turns into a curve (#1111)",
|
||||
has_doc,
|
||||
spine_reshape_segment
|
||||
),
|
||||
cmd!(
|
||||
"object.blend.spine.addAnchor",
|
||||
"Add Spine Point",
|
||||
@@ -640,6 +649,18 @@ fn spine_move_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
fn spine_reshape_segment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.blend.spine.reshapeSegment";
|
||||
let seg = index_param(p, "segment", C)?;
|
||||
let t = f64_or(p, "t", 0.5).clamp(0.05, 0.95);
|
||||
let dv = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
|
||||
if !(t.is_finite() && dv.x.is_finite() && dv.y.is_finite() && dv.hypot() < 1e9) {
|
||||
return Err(bad(C, "t, dx and dy must be finite"));
|
||||
}
|
||||
edit_spine(s, p, C, "Reshape Spine", |sp, _, _| if sp.bend_segment(seg, t, dv) { Ok(()) } else { Err(bad(C, "no such spine segment")) })?;
|
||||
ok()
|
||||
}
|
||||
|
||||
fn spine_add_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.blend.spine.addAnchor";
|
||||
let q = finite_point(p, C)?;
|
||||
|
||||
@@ -9,7 +9,7 @@ use vectorcraft_color::{BlendMode, Paint};
|
||||
use vectorcraft_doc::corners::set_corners;
|
||||
use vectorcraft_doc::{Appearance, Document, Knockout, LiveCorners, LiveShape, Node, NodeId, NodeKind, OrientedBox, Scaling};
|
||||
use vectorcraft_geom::shapes::CornerKind;
|
||||
use vectorcraft_geom::{Affine, FillRule, Point, Rect, Vec2};
|
||||
use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect, Vec2};
|
||||
|
||||
use super::edit::{duplicate_in, selected_roots};
|
||||
use super::opacitymask::percent;
|
||||
@@ -106,7 +106,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("object.hide", "Selection", ["Object", "Hide"], Some("Cmd+3"), "{}", has_selection, hide),
|
||||
cmd!("object.showAll", "Show All", ["Object"], Some("Cmd+Alt+3"), "{}", has_doc, show_all),
|
||||
cmd!("object.compoundPath.make", "Make", ["Object", "Compound Path"], Some("Cmd+8"), "{}", has_selection, compound_make),
|
||||
cmd!("object.compoundPath.release", "Release", ["Object", "Compound Path"], Some("Cmd+Alt+Shift+8"), "{}", has_selection, compound_release),
|
||||
cmd!(
|
||||
"object.compoundPath.release",
|
||||
"Release",
|
||||
["Object", "Compound Path"],
|
||||
Some("Cmd+Alt+Shift+8"),
|
||||
"{} release the selected compound paths into their paths, each with the compound path's paint; a path of several subpaths (an SVG `d` with several `M`s) splits into one path per subpath",
|
||||
has_selection,
|
||||
compound_release
|
||||
),
|
||||
cmd!(
|
||||
"object.clippingMask.make",
|
||||
"Make",
|
||||
@@ -658,26 +666,52 @@ fn compound_make(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({ "id": id.0 }))
|
||||
}
|
||||
|
||||
/// Is `n` a compound path to release: a compound path, or a path of several subpaths (an SVG `d`
|
||||
/// with several `M`s, a PDF path), which is one in Illustrator too (#1163)?
|
||||
fn releases(n: &Node) -> bool {
|
||||
match &n.kind {
|
||||
NodeKind::Compound { .. } => true,
|
||||
NodeKind::Path { path, .. } => path.subpaths.len() > 1,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn compound_release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
// With no compound path selected, the document, the selection and the undo history stay as
|
||||
// they are.
|
||||
let d = &s.doc()?.doc;
|
||||
if !ids.iter().any(|id| d.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. }))) {
|
||||
if !ids.iter().any(|id| d.node(*id).is_some_and(releases)) {
|
||||
return ok();
|
||||
}
|
||||
s.edit("Release Compound Path", |d, sel| {
|
||||
let mut out = vec![];
|
||||
for id in &ids {
|
||||
let Some(n) = d.node(*id).cloned() else { continue };
|
||||
let NodeKind::Compound { children, .. } = &n.kind else { continue };
|
||||
let Some(n) = d.node(*id).cloned().filter(releases) else { continue };
|
||||
let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?;
|
||||
d.remove(*id)?;
|
||||
for (k, c) in children.iter().enumerate() {
|
||||
let mut c = (**c).clone();
|
||||
c.appearance = n.appearance.clone();
|
||||
out.push(c.id);
|
||||
d.insert(par, idx + k, c)?;
|
||||
match &n.kind {
|
||||
NodeKind::Compound { children, .. } => {
|
||||
for (k, c) in children.iter().enumerate() {
|
||||
let mut c = (**c).clone();
|
||||
c.appearance = n.appearance.clone();
|
||||
out.push(c.id);
|
||||
d.insert(par, idx + k, c)?;
|
||||
}
|
||||
}
|
||||
// Each subpath becomes a path of its own with the path's paint, in the same
|
||||
// stacking order; the first keeps the path's id.
|
||||
NodeKind::Path { path, .. } => {
|
||||
for (k, sp) in path.subpaths.iter().enumerate() {
|
||||
let mut c = if k == 0 { n.clone() } else { d.reid(&n) };
|
||||
if let NodeKind::Path { path, live, .. } = &mut c.kind {
|
||||
(*path, *live) = (PathData::single(sp.clone()), None);
|
||||
}
|
||||
out.push(c.id);
|
||||
d.insert(par, idx + k, c)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
sel.set(out);
|
||||
|
||||
@@ -381,6 +381,31 @@ fn clipping_and_compound() {
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![b]);
|
||||
}
|
||||
|
||||
/// #1163: a path of several subpaths (an SVG `d` with several `M`s) is a compound path to Release:
|
||||
/// one path per subpath, each with the path's paint, in its stacking order, in one undo step.
|
||||
#[test]
|
||||
fn release_splits_a_path_of_several_subpaths() {
|
||||
let mut s = session();
|
||||
let id = NodeId(
|
||||
s.execute("path.create", &json!({"d": "M0 0 L10 0 L10 10 Z M20 0 L30 0 L30 10 Z M40 0 L50 0 L50 10 Z"})).unwrap()["id"].as_u64().unwrap(),
|
||||
);
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
s.execute("object.compoundPath.release", &json!({})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.history.undo.len(), undo + 1);
|
||||
let sel = st.selection.objects.clone();
|
||||
assert_eq!((sel.len(), sel[0]), (3, id), "the first keeps its id");
|
||||
let layer = st.doc.layers[0].children().unwrap();
|
||||
for (k, n) in sel.iter().map(|i| st.doc.node(*i).unwrap()).enumerate() {
|
||||
let NodeKind::Path { path, .. } = &n.kind else { panic!("a path") };
|
||||
assert_eq!(path.subpaths.len(), 1);
|
||||
assert_eq!(path.bounds().unwrap().x0, 20.0 * k as f64, "in order");
|
||||
assert_eq!(n.appearance.fill_paint().color().unwrap().to_hex(), "#ff0000", "the path's paint");
|
||||
assert_eq!(layer[k].id, n.id, "stacked bottom to top");
|
||||
}
|
||||
}
|
||||
|
||||
/// `object.align` without `horizontal` or `vertical` (an unknown key such as `align` counts as
|
||||
/// neither) returns an error: nothing moves, and the undo and redo stacks stay as they were.
|
||||
#[test]
|
||||
|
||||
@@ -187,3 +187,20 @@ fn key_anchors_round_trip() {
|
||||
let old: BlendSpec = serde_json::from_value(json!({"spine": {"subpaths": []}})).unwrap();
|
||||
assert!(old.key_anchors.is_empty());
|
||||
}
|
||||
|
||||
/// #1111: a straight spine's segment bends into a curve, its middle moving where it's dragged, and
|
||||
/// the steps follow it; junk segments and deltas are refused.
|
||||
#[test]
|
||||
fn a_straight_spine_segment_bends_into_a_curve() {
|
||||
let mut s = session();
|
||||
let (_, _, g) = blend(&mut s);
|
||||
s.execute("object.blend.spine.reshapeSegment", &json!({"segment": 0, "t": 0.5, "dx": 0, "dy": 40})).unwrap();
|
||||
let spine = spec(&s, g).spine.unwrap();
|
||||
let seg = spine.subpaths[0].segment(0);
|
||||
use vectorcraft_geom::kurbo::ParamCurve;
|
||||
assert!(seg.eval(0.5).distance(Point::new(55.0, 45.0)) < 1e-9, "{seg:?}");
|
||||
assert!(center(&steps(&s, g)[1]).y > 20.0, "the step follows the curve");
|
||||
for bad in [json!({"segment": 1, "dy": 10}), json!({"segment": 0, "dx": f64::MAX, "dy": f64::MAX}), json!({"segment": -1})] {
|
||||
assert!(s.execute("object.blend.spine.reshapeSegment", &bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
@@ -543,6 +543,23 @@ fn hide_lock_and_outline_others_and_their_all_counterparts() {
|
||||
assert_eq!(run(&mut s, "layer.previewAll", json!({}))["count"], 0, "nothing to do: no undo step");
|
||||
}
|
||||
|
||||
/// #1158: Alt-click on a hidden (locked) layer's eye (lock) isolates it: it shows (unlocks) as the
|
||||
/// others hide (lock), in one undo step.
|
||||
#[test]
|
||||
fn hide_and_lock_others_show_and_unlock_the_layer_kept() {
|
||||
let mut s = session();
|
||||
let l1 = s.doc().unwrap().doc.layers[0].id;
|
||||
let l2 = id(&run(&mut s, "layer.new", json!({})));
|
||||
let flags = |s: &Session, f: fn(&Node) -> bool| [l1, l2].map(|l| f(&node(s, l)));
|
||||
run(&mut s, "layer.setProps", json!({"ids": [l2.0], "visible": false, "locked": true}));
|
||||
assert_eq!(run(&mut s, "layer.hideOthers", json!({"ids": [l2.0]}))["count"], 2);
|
||||
assert_eq!(flags(&s, |n| n.visible), [false, true]);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(flags(&s, |n| n.visible), [true, false], "one undo step");
|
||||
run(&mut s, "layer.lockOthers", json!({"ids": [l2.0]}));
|
||||
assert_eq!(flags(&s, |n| n.locked), [true, false]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locate_object_highlights_its_row() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -367,6 +367,7 @@ fn structured_junk() {
|
||||
("object.blend.options", json!({"spacing": "distance", "value": -1e308, "orientation": 5})),
|
||||
("object.blend.spine.moveAnchor", json!({"id": 2, "anchor": u64::MAX, "x": 1e308, "y": -1e308, "handle": "sideways"})),
|
||||
("object.blend.spine.addAnchor", json!({"id": u64::MAX, "x": 0, "y": 0})),
|
||||
("object.blend.spine.reshapeSegment", json!({"id": 2, "segment": u64::MAX, "t": f64::NAN, "dx": 1e308, "dy": -1e308})),
|
||||
("object.blend.spine.removeAnchor", json!({"anchor": -1})),
|
||||
("object.puppetWarp", json!({"rest": true, "pins": [], "moved": []})),
|
||||
("object.puppetWarp", json!({"rest": true, "pins": [[1e308, 0]], "moved": [[0, 0]], "expand": -5})),
|
||||
|
||||
@@ -252,6 +252,24 @@ impl SubPath {
|
||||
_ => a,
|
||||
}
|
||||
}
|
||||
/// Bend segment `seg` so its point at `t` moves by `dv`: both inner control points move by
|
||||
/// dv / 3t(1-t), since B(t) moves by 3t(1-t) times that. A smooth anchor stays smooth: its other
|
||||
/// handle turns with the moved one. False when there is no such segment.
|
||||
pub fn bend_segment(&mut self, seg: usize, t: f64, dv: kurbo::Vec2) -> bool {
|
||||
let n = self.anchors.len();
|
||||
if seg >= self.segment_count() || n == 0 {
|
||||
return false;
|
||||
}
|
||||
let v = dv / (3.0 * t * (1.0 - t));
|
||||
if let Some(a) = self.anchors.get_mut(seg % n) {
|
||||
a.set_handle(true, a.h_out + v, false);
|
||||
}
|
||||
if let Some(b) = self.anchors.get_mut((seg + 1) % n) {
|
||||
b.set_handle(false, b.h_in + v, false);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Split segment `seg` at parameter `t`, inserting a new anchor. Returns the new anchor index.
|
||||
pub fn insert_anchor(&mut self, seg: usize, t: f64) -> usize {
|
||||
let n = self.anchors.len();
|
||||
|
||||
@@ -65,6 +65,17 @@ fn conventions_commands_batch_and_bounded_preview() {
|
||||
for max in [json!(0), json!(4097), json!("64")] {
|
||||
assert_eq!(call(&mut s, "render_preview", json!({"max_side":max}))["isError"], true);
|
||||
}
|
||||
// Another artboard, three times wider than tall, previews by its index (#1163).
|
||||
let r = call(&mut s, "run_command", json!({"command":"artboard.new","params":{"x":2000,"y":0,"width":300,"height":100}}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
let r = call(&mut s, "render_preview", json!({"max_side":60,"artboard":1}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
let png = vectorcraft_format::base64_decode(r["content"][0]["data"].as_str().unwrap()).unwrap();
|
||||
let img = image::load_from_memory(&png).unwrap();
|
||||
assert_eq!((img.width(), img.height()), (60, 20));
|
||||
for board in [json!(2), json!(-1), json!("1")] {
|
||||
assert_eq!(call(&mut s, "render_preview", json!({"artboard":board}))["isError"], true, "{board}");
|
||||
}
|
||||
for name in ["ui_inspect", "ui_screenshot"] {
|
||||
assert_eq!(call(&mut s, name, json!({}))["isError"], true);
|
||||
}
|
||||
|
||||
+12
-4
@@ -113,8 +113,11 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"render_preview",
|
||||
"Render a preview",
|
||||
"Render the first artboard as inline PNG without changing the document. max_side defaults to 1024, limited to 1..4096.",
|
||||
obj(json!({"max_side":{"type":"integer","minimum":1,"maximum":4096}}), &[]),
|
||||
"Render an artboard (default the first) as inline PNG without changing the document. max_side defaults to 1024, limited to 1..4096.",
|
||||
obj(
|
||||
json!({"max_side":{"type":"integer","minimum":1,"maximum":4096},"artboard":{"type":"integer","minimum":0,"description":"0-based artboard (default 0)"}}),
|
||||
&[],
|
||||
),
|
||||
true,
|
||||
),
|
||||
tool("ui_inspect", "Inspect the interface", "Inspect the connected desktop interface; returns a tool error in headless mode.", empty(), true),
|
||||
@@ -946,8 +949,13 @@ fn render_preview(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
|
||||
None => 1024,
|
||||
Some(v) => v.as_u64().filter(|v| (1..=4096).contains(v)).ok_or("`max_side` must be an integer in 1..4096")?,
|
||||
};
|
||||
let index = match a.get("artboard") {
|
||||
None => 0,
|
||||
Some(v) => v.as_u64().ok_or("`artboard` must be a 0-based artboard index")?,
|
||||
};
|
||||
let doc = b.call("document.inspect", json!({"depth":0}))?;
|
||||
let board = doc.get("artboards").and_then(Value::as_array).and_then(|v| v.first()).ok_or("no such artboard")?;
|
||||
let board =
|
||||
doc.get("artboards").and_then(Value::as_array).and_then(|v| v.get(usize::try_from(index).ok()?)).ok_or(format!("no artboard {index}"))?;
|
||||
let width = board.get("width").and_then(Value::as_f64).ok_or("artboard has no width")?;
|
||||
let height = board.get("height").and_then(Value::as_f64).ok_or("artboard has no height")?;
|
||||
if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 {
|
||||
@@ -959,7 +967,7 @@ fn render_preview(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
|
||||
if width.max(height) * scale > 4096.0 {
|
||||
return Err("artboard is too large for a bounded preview".into());
|
||||
}
|
||||
let r = b.call("ui.render", json!({"scale":scale}))?;
|
||||
let r = b.call("ui.render", json!({"scale":scale, "artboard": index}))?;
|
||||
let encoded = r.get("pngBase64").and_then(Value::as_str).ok_or("renderer returned no image")?;
|
||||
let png = vectorcraft_format::base64_decode(encoded).ok_or("renderer returned bad base64")?;
|
||||
let img = image::load_from_memory_with_format(&png, image::ImageFormat::Png).map_err(|e| format!("read preview: {e}"))?;
|
||||
|
||||
@@ -90,6 +90,15 @@ enum State {
|
||||
start: Point,
|
||||
began: bool,
|
||||
},
|
||||
/// Dragging a curved segment of a selected blend's spine: it follows the pointer
|
||||
/// (`object.blend.spine.reshapeSegment`, #1111).
|
||||
SpineSegment {
|
||||
id: NodeId,
|
||||
seg: usize,
|
||||
t: f64,
|
||||
start: Point,
|
||||
began: bool,
|
||||
},
|
||||
/// Dragging a handle of the clicked spine point.
|
||||
SpineHandle {
|
||||
id: NodeId,
|
||||
@@ -170,6 +179,15 @@ fn hit_spine_point(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usiz
|
||||
})
|
||||
}
|
||||
|
||||
/// The segment of a selected blend's spine passing within `tol` of `p`: (blend, segment, t, whether
|
||||
/// it is straight).
|
||||
pub(crate) fn hit_spine_segment(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, f64, bool)> {
|
||||
spines(cx).into_iter().filter(|(id, _)| cx.selection.contains(*id)).find_map(|(id, path)| {
|
||||
let (si, seg, t, _, dist) = path.nearest(p)?;
|
||||
(dist <= tol).then(|| (id, seg, t, path.subpaths.get(si).is_some_and(|sp| sp.segment_is_line(seg))))
|
||||
})
|
||||
}
|
||||
|
||||
/// The spine point `sel` and whether `p` is on its outgoing handle's end (`Some(true)`) or its
|
||||
/// incoming one's.
|
||||
fn hit_spine_handle(cx: &ToolContext, sel: (NodeId, usize), p: Point, tol: f64) -> Option<bool> {
|
||||
@@ -402,6 +420,11 @@ impl Tool for DirectSelectionTool {
|
||||
if let Some((id, anchors)) = hit_frame_edge(cx, p, tol) {
|
||||
return self.press_type_area(cx, id, anchors, p, ev.mods.shift);
|
||||
}
|
||||
// A curved segment of a selected blend's spine reshapes, as a path's does.
|
||||
if let Some((id, seg, t, false)) = hit_spine_segment(cx, p, tol) {
|
||||
self.state = State::SpineSegment { id, seg, t, start: p, began: false };
|
||||
return vec![];
|
||||
}
|
||||
if let Some(out) = self.guide.press(cx, ev) {
|
||||
self.state = State::Idle;
|
||||
return out;
|
||||
@@ -546,6 +569,22 @@ impl Tool for DirectSelectionTool {
|
||||
out.push(Action::Preview("object.blend.spine.moveAnchor".into(), json!({"id": id.0, "anchor": anchor, "x": q.x, "y": q.y})));
|
||||
out
|
||||
}
|
||||
(PointerKind::Drag, State::SpineSegment { id, seg, t, start, began }) => {
|
||||
let mut out = vec![];
|
||||
if !began {
|
||||
if p.distance(start) < cx.tol(3.0) {
|
||||
return out;
|
||||
}
|
||||
out.push(Action::Begin("Reshape Spine".into()));
|
||||
self.state = State::SpineSegment { id, seg, t, start, began: true };
|
||||
}
|
||||
let d = p - start;
|
||||
out.push(Action::Preview(
|
||||
"object.blend.spine.reshapeSegment".into(),
|
||||
json!({"id": id.0, "segment": seg, "t": t, "dx": d.x, "dy": d.y}),
|
||||
));
|
||||
out
|
||||
}
|
||||
(PointerKind::Drag, State::SpineHandle { id, anchor, out }) => vec![Action::Preview(
|
||||
"object.blend.spine.moveAnchor".into(),
|
||||
json!({"id": id.0, "anchor": anchor, "handle": if out {"out"} else {"in"}, "x": p.x, "y": p.y, "independent": ev.mods.alt}),
|
||||
@@ -583,6 +622,7 @@ impl Tool for DirectSelectionTool {
|
||||
| State::MoveObject { began, .. }
|
||||
| State::Segment { began, .. }
|
||||
| State::SpinePoint { began, .. }
|
||||
| State::SpineSegment { began, .. }
|
||||
| State::TypeArea { began, .. },
|
||||
) => {
|
||||
self.state = State::Idle;
|
||||
@@ -814,6 +854,45 @@ mod tests {
|
||||
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Anchor { filled: true, .. })), "the clicked point shows");
|
||||
}
|
||||
|
||||
/// #1111: the Anchor Point tool bends a selected blend's straight spine; Direct Selection
|
||||
/// reshapes a curved spine segment and leaves a straight one alone, as it does a path's.
|
||||
#[test]
|
||||
fn spine_segments_bend_with_the_anchor_point_tool_and_direct_selection() {
|
||||
let (mut d, g, _) = blend_doc();
|
||||
let mut s = Selection::default();
|
||||
s.set([g]);
|
||||
let p = paint();
|
||||
let drag = |t: &mut dyn crate::Tool, cx: &ToolContext| {
|
||||
t.pointer(cx, &PointerEvent::new(PointerKind::Down, 160.0, 311.0));
|
||||
let a = t.pointer(cx, &PointerEvent::new(PointerKind::Drag, 160.0, 351.0));
|
||||
t.pointer(cx, &PointerEvent::new(PointerKind::Up, 160.0, 351.0));
|
||||
a
|
||||
};
|
||||
let straight = cx(&d, &s, &p);
|
||||
let a = drag(crate::create("anchorPoint").as_mut(), &straight);
|
||||
assert!(
|
||||
matches!(a.last(), Some(Action::Preview(c, v)) if c == "object.blend.spine.reshapeSegment" && v["id"] == g.0 && v["segment"] == 0 && v["dy"] == 40.0),
|
||||
"{a:?}"
|
||||
);
|
||||
let a = drag(&mut DirectSelectionTool::new(false), &straight);
|
||||
assert!(!a.iter().any(|x| matches!(x, Action::Preview(c, _) if c == "object.blend.spine.reshapeSegment")), "straight: {a:?}");
|
||||
// The spine bent upward: Direct Selection takes it from where it passes.
|
||||
let mut sp = vectorcraft_geom::SubPath::polyline(&[Point::new(110.0, 310.0), Point::new(210.0, 310.0)], false);
|
||||
sp.bend_segment(0, 0.5, vectorcraft_geom::Vec2::new(0.0, 20.0));
|
||||
let Some(NodeKind::Blend { spec, .. }) = d.node_mut(g).map(|n| &mut n.kind) else { panic!("blend") };
|
||||
(spec.spine, spec.key_anchors) = (Some(PathData::single(sp)), vec![0, 1]);
|
||||
let mut t = DirectSelectionTool::new(false);
|
||||
let curved = cx(&d, &s, &p);
|
||||
t.pointer(&curved, &PointerEvent::new(PointerKind::Down, 160.0, 330.0));
|
||||
let a = t.pointer(&curved, &PointerEvent::new(PointerKind::Drag, 160.0, 345.0));
|
||||
assert_eq!(a[0], Action::Begin("Reshape Spine".into()));
|
||||
assert!(
|
||||
matches!(&a[1], Action::Preview(c, v) if c == "object.blend.spine.reshapeSegment" && v["segment"] == 0 && v["dx"] == 0.0 && v["dy"] == 15.0),
|
||||
"{a:?}"
|
||||
);
|
||||
assert_eq!(t.pointer(&curved, &PointerEvent::new(PointerKind::Up, 160.0, 345.0)), vec![Action::Commit]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_and_group_selection_pick_blend_keys() {
|
||||
let (d, g, k1) = blend_doc();
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! Delete: click an anchor to remove it, re-fitting the neighbouring curve (Alt = add).
|
||||
//! Anchor Point: click a smooth anchor → corner; drag an anchor → pull out smooth handles; drag a
|
||||
//! handle → move it independently (Shift: at 45° steps round its anchor); drag a segment → reshape
|
||||
//! the curve.
|
||||
//! the curve, a selected blend's spine's too, straight or curved (#1111).
|
||||
//! Scissors: click a path to split it there.
|
||||
|
||||
use serde_json::json;
|
||||
@@ -19,9 +19,30 @@ use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, Tool
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum State {
|
||||
Idle,
|
||||
Convert { id: NodeId, si: usize, ai: usize, start: Point, began: bool },
|
||||
Handle { id: NodeId, si: usize, ai: usize, out: bool, began: bool },
|
||||
Reshape { id: NodeId, si: usize, seg: usize, t: f64, start: Point, began: bool },
|
||||
Convert {
|
||||
id: NodeId,
|
||||
si: usize,
|
||||
ai: usize,
|
||||
start: Point,
|
||||
began: bool,
|
||||
},
|
||||
Handle {
|
||||
id: NodeId,
|
||||
si: usize,
|
||||
ai: usize,
|
||||
out: bool,
|
||||
began: bool,
|
||||
},
|
||||
/// Bending a segment: a path's, or (`spine`) a selected blend's spine's.
|
||||
Reshape {
|
||||
id: NodeId,
|
||||
si: usize,
|
||||
seg: usize,
|
||||
t: f64,
|
||||
start: Point,
|
||||
began: bool,
|
||||
spine: bool,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct AnchorTool {
|
||||
@@ -94,7 +115,9 @@ impl Tool for AnchorTool {
|
||||
} else if let Some((id, si, ai)) = hit_anchor(cx, p, point) {
|
||||
self.state = State::Convert { id, si, ai, start: p, began: false };
|
||||
} else if let Some((id, si, seg, t)) = hit_segment(cx, p, tol) {
|
||||
self.state = State::Reshape { id, si, seg, t, start: p, began: false };
|
||||
self.state = State::Reshape { id, si, seg, t, start: p, began: false, spine: false };
|
||||
} else if let Some((id, seg, t, _)) = crate::direct::hit_spine_segment(cx, p, tol) {
|
||||
self.state = State::Reshape { id, si: 0, seg, t, start: p, began: false, spine: true };
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
@@ -128,6 +151,10 @@ impl Tool for AnchorTool {
|
||||
json!({"id": id.0, "subpath": si, "anchor": ai, "which": if out { "out" } else { "in" }, "x": q.x, "y": q.y, "independent": true}),
|
||||
)
|
||||
}
|
||||
State::Reshape { id, seg, t, start, spine: true, .. } => Action::Preview(
|
||||
"object.blend.spine.reshapeSegment".into(),
|
||||
json!({"id": id.0, "segment": seg, "t": t, "dx": p.x - start.x, "dy": p.y - start.y}),
|
||||
),
|
||||
State::Reshape { id, si, seg, t, start, .. } => Action::Preview(
|
||||
"path.reshapeSegment".into(),
|
||||
json!({"id": id.0, "subpath": si, "segment": seg, "t": t, "dx": p.x - start.x, "dy": p.y - start.y}),
|
||||
|
||||
@@ -1545,6 +1545,7 @@
|
||||
Move Rows Zeilen verschieben
|
||||
Move Slices Slices verschieben
|
||||
Move Spine Point Achsenpunkt verschieben
|
||||
Reshape Spine Segment Achsensegment umformen
|
||||
Move Swatch Farbfeld verschieben
|
||||
Move Up Nach oben verschieben
|
||||
Move… Verschieben…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1563,6 +1563,7 @@
|
||||
Move Rows Mover filas
|
||||
Move Slices Mover sectores
|
||||
Move Spine Point Mover punto del eje
|
||||
Reshape Spine Segment Remodelar segmento del eje
|
||||
Move Swatch Mover muestra
|
||||
Move Up Subir
|
||||
Move… Mover…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1562,6 +1562,7 @@
|
||||
Move Rows Déplacer les lignes
|
||||
Move Slices Déplacer les tranches
|
||||
Move Spine Point Déplacer le point de la dorsale
|
||||
Reshape Spine Segment Modeler le segment de la dorsale
|
||||
Move Swatch Déplacer la nuance
|
||||
Move Up Monter
|
||||
Move… Déplacer…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1563,6 +1563,7 @@
|
||||
Move Rows Sposta righe
|
||||
Move Slices Sposta sezioni
|
||||
Move Spine Point Sposta punto dorsale
|
||||
Reshape Spine Segment Rimodella segmento della dorsale
|
||||
Move Swatch Sposta campione
|
||||
Move Up Sposta su
|
||||
Move… Sposta…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1697,6 +1697,7 @@
|
||||
Move Rows 行を移動
|
||||
Move Slices スライスを移動
|
||||
Move Spine Point スパインポイントを移動
|
||||
Reshape Spine Segment スパインセグメントをシェイプ変形
|
||||
Move Swatch スウォッチを移動
|
||||
Move Up 上へ移動
|
||||
Move in Perspective 遠近で移動
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1334,6 +1334,7 @@
|
||||
Move Plane to Match Object Mover Plano para Corresponder ao Objeto
|
||||
Move Slices Mover Fatias
|
||||
Move Spine Point Mover Ponto de Coluna
|
||||
Reshape Spine Segment Remodelar Segmento da Coluna
|
||||
Move Swatch Mover Amostra
|
||||
Move Up Mover para Cima
|
||||
Move in Perspective Mover em Perspectiva
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1561,6 +1561,7 @@
|
||||
Move Rows Переместить строки
|
||||
Move Slices Переместить фрагменты
|
||||
Move Spine Point Переместить точку остова
|
||||
Reshape Spine Segment Изменить форму сегмента остова
|
||||
Move Swatch Переместить образец
|
||||
Move Up Переместить вверх
|
||||
Move… Переместить…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1557,6 +1557,7 @@
|
||||
Move Rows Перемістити рядки
|
||||
Move Slices Перемістити фрагменти
|
||||
Move Spine Point Перемістити точку осі
|
||||
Reshape Spine Segment Змінити форму сегмента осі
|
||||
Move Swatch Перемістити зразок
|
||||
Move Up Перемістити вгору
|
||||
Move… Перемістити…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 659 and column 40.
|
@@ -1494,6 +1494,7 @@
|
||||
Move Plane to Match Object 移动平面以符合对象
|
||||
Move Slices 移动切片
|
||||
Move Spine Point 移动脊线点
|
||||
Reshape Spine Segment 整形脊线线段
|
||||
Move Swatch 移动色板
|
||||
Move Up 上移
|
||||
Move… 移动…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1538,6 +1538,7 @@
|
||||
Move Rows 移動列
|
||||
Move Slices 移動切片
|
||||
Move Spine Point 移動脊線點
|
||||
Reshape Spine Segment 改變脊線線段外框
|
||||
Move Swatch 移動色票
|
||||
Move Up 上移
|
||||
Move… 移動…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -692,7 +692,9 @@ new blends start with. The Blend tool (`ui.tool.select {tool: "blend"}`): `ui.po
|
||||
another, blends them; down on an anchor point blends from that point; each further object clicked joins the blend.
|
||||
A selected blend shows its spine. With Direct Selection (`directSelection`) a `ui.pointer` drag from a spine point
|
||||
moves it (`object.blend.spine.moveAnchor`, one undo step) and, once a point is clicked, from one of its handles
|
||||
reshapes the curve; Direct and Group Selection pick a blend's key objects. With the Pen tool a click on a selected
|
||||
reshapes the curve, and a drag on a curved spine segment bends it (`object.blend.spine.reshapeSegment`); the Anchor
|
||||
Point tool (`anchorPoint`) bends any segment of a selected blend's spine, straight ones too. Direct and Group Selection
|
||||
pick a blend's key objects. With the Pen tool a click on a selected
|
||||
blend's spine adds a point (on a point no key sits on, deletes it).
|
||||
|
||||
Perspective grid view options: View → Perspective Grid lists Show/Hide Grid, Show/Hide Rulers, Snap to Grid
|
||||
|
||||
+4
-3
@@ -237,7 +237,7 @@ The older names below remain listed because existing workflows use them.
|
||||
| `command_run` | `id`, optional `params`; runs the command through the existing backend |
|
||||
| `command_batch` | `steps: [{id, params?}]`, optional `stop_on_error` (default true); returns `completed`, `failed`, `results: [{ok, result\|error}]`. Each edit has its own undo step; failures set `isError`. A param string `"$N.path"` or `"$last.path"` takes a value from an earlier step's result (see `run` below) |
|
||||
| `doc_inspect` | Optional `depth`, `childLimit`; same summary as `inspect_document` |
|
||||
| `render_preview` | Optional `max_side` (1–4096, default 1024); inline PNG of the first artboard without editing it. Artboards too large to render within the allocation bound return a tool error |
|
||||
| `render_preview` | Optional `max_side` (1–4096, default 1024) and `artboard` (0-based, default 0); inline PNG of that artboard without editing it. Artboards too large to render within the allocation bound return a tool error |
|
||||
| `ui_inspect`, `ui_screenshot` | Connected desktop state/window capture; tool errors in headless mode |
|
||||
|
||||
Every tool declares read-only, destructive, idempotent and open-world hints. File-writing
|
||||
@@ -3077,8 +3077,9 @@ among the objects takes the others in as more key objects, keeping its options,
|
||||
A blend's spine is the straight lines between its key centres until it is edited. `object.blend.info` lists it
|
||||
(`spine.anchors`, `keyAnchors`: the point each key sits on). `object.blend.spine.addAnchor {x, y}` adds a point where
|
||||
the spine passes nearest, `object.blend.spine.moveAnchor {anchor, x, y}` moves a point (a key on it moves along; with
|
||||
`handle: "in" | "out"` it places that handle) and `object.blend.spine.removeAnchor {anchor}` deletes a point no key
|
||||
sits on; the first edit turns the spine into a path. Moving a key moves its end of the spine. Release
|
||||
`handle: "in" | "out"` it places that handle), `object.blend.spine.reshapeSegment {segment, t, dx, dy}` bends a segment
|
||||
so its point at `t` moves by (dx, dy), turning a straight spine into a curve, and `object.blend.spine.removeAnchor
|
||||
{anchor}` deletes a point no key sits on; the first edit turns the spine into a path. Moving a key moves its end of the spine. Release
|
||||
(`object.blend.release`) leaves the spine as a path that paints nothing (`spines` in the result):
|
||||
|
||||
```json
|
||||
|
||||
Reference in new issue
Block a user