Compare commits

...
Author SHA1 Message Date
bflatastic 0497c9e46d #1007: Reverse Gradient mirrors off-centre midpoints
Reversal read the midpoints after Gradient::reverse had reordered the stops, so each stop
took the wrong segment's midpoint: red-to-blue with its midpoint at 20% reversed to a 50%
midpoint instead of 80%, moving the transition.

Gradient::reverse now mirrors each segment's midpoint with it, so a reversed gradient is the
same ramp the other way round; paint.editGradient's own (wrong) pass is gone, and the SVG
importer's reflect spread, which had a correct copy of the same mirroring, uses it too.

Tests: two- and three-stop gradients with off-centre midpoints sample the same reversed (t vs
1 - t) and come back after two reversals; paint.editGradient reverse turns a 0.2 midpoint into
0.8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	ROADMAP.md
2026-10-11 03:51:31 +07:00
bflatastic cacc9e4f36 #1006: Select > All on Active Artboard selects on the active artboard
The menu item sends no artboard, so the engine fell back to the first one even with another
active in the Artboards panel.

The frontend fills in the active artboard (the view's, as the Artboards panel and navigator
show it) for select.allOnArtboard, as it already did for Paste in Place / in Front / in Back
(#693); the two now share one block, and the index is kept within the artboards. An explicit
artboard still wins; the engine is unchanged.

The test makes artboard 2 active, then 1, and invokes the menu item each time, then passes
artboard 1 explicitly with artboard 1 active.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	ROADMAP.md
2026-10-11 03:51:27 +07:00
bflatastic 33b6a6323d #1005: vectorcraft-cli run ends quietly when its reader closes early
`run` reported "Broken pipe" (on Windows "The pipe is being closed") and exited 1 when the
reader of its stdout went away, unlike `commands` and the other subcommands, which end quietly.

Its step printer now treats a closed pipe as the end of output, not an error: the remaining
steps still run (an export is still written), unprinted, and the run exits 0. Other write
errors and failing steps still fail.

The test runs document.json, a rectangle and an SVG export with stdout closed: status 0, empty
stderr, the SVG written; `run --cmd not-a-command` still fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	ROADMAP.md
2026-10-11 03:51:23 +07:00
bflatastic 148ef1870a #1004: a connected app's screenshot renders the artboard asked for
The desktop app's ui.render (MCP `screenshot` when connected) always rendered the first
artboard and ignored `artboard`, which the headless backend honours.

It now renders the 0-based `artboard` (the first without one, an error for one that doesn't
exist), as headless does. control-protocol.md and the control.rs list show the parameter.

The test drives ui.render through the control channel on a 64 x 64 and a 96 x 48 artboard:
no index and 0 give 64 x 64, 1 gives 96 x 48 (48 x 24 at scale 0.5), 2 is an error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	ROADMAP.md
2026-10-11 03:51:19 +07:00
bflatastic 7f17d980d4 #1003: MCP add_text colours type made on or in a path
add_text passed `color` to text.create only, and text.createInPath built its own character
style without a colour, so type on a path and path-based area type took the current fill.

text.createInPath now uses the same new-type style as text.create (size, font, style, color,
else the current fill), and add_text sends `color` on both routes.

The MCP test draws a blue rectangle (the current fill), turns it into on-path and area type
with color #ff0000, and reads red back through inspect_document.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	ROADMAP.md
2026-10-11 03:51:15 +07:00
bflatastic eeb213e5a8 #1002: new art is refused when no layer is shown and unlocked
With every layer hidden or locked, the insertion layer fell back to the top layer anyway, so
drawing a shape, typing, pasting, making a graph, a flare, a grid, trim marks or a symbol put
the art on a hidden or locked layer: invisible, or unselectable by Select All. Illustrator
refuses to draw on a locked or hidden target layer (public forum answers, e.g.
https://community.adobe.com/t5/illustrator/can-t-draw-a-new-object-in-my-document/m-p/11107182).

DocState::target_parent is insertion_parent for new art, failing with "the target layer is
locked or hidden" when it would be a layer that isn't editable; those creation paths use it.
Another layer that is shown and unlocked still takes the art, as before. The message is in the
de, es, fr, it, ja, ru and uk catalogs. File > Place is left as it is (another branch reworks
it).

The test hides, then locks, the only layer: an ellipse, a polar grid, text and a paste each
fail with the message and leave the document unchanged; a new layer then takes a rectangle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	ROADMAP.md
2026-10-11 03:51:11 +07:00
bflatastic e77d15c70d #1001: named text styles take leading, kerning, mojikumi and other attributes left out at their defaults
Character and paragraph styles checked a new attribute against the keys its base style
serializes, and the JSON leaves out Auto leading and kerning, no mojikumi and other defaults.
So charStyle.new / setAttrs refused leading or kerning, paraStyle.new refused mojikumi, and a
style captured from text with explicit leading failed to apply to Auto-leading text, to
duplicate or to come back out of a library.

The check now also accepts every field name the style type deserializes (read from serde's
derive), still refusing unknown keys and the file-only justify_auto.

The test creates and edits styles with kerning, leading, small caps, mojikumi and direction,
applies a captured leading style to fresh text with and without Clear Overrides, duplicates it,
uses it from a library, and checks `nope` and `justify_auto` are still refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	ROADMAP.md
2026-10-11 03:51:06 +07:00
BFlat 3416bf49cd Merge pull request #1037 from storytold/fix/983-supersampled-export
#983: supersample Art Optimized edges in exports
2026-10-11 03:48:46 +07:00
bflatasticandClaude Opus 5.5 178f99d60e #983: supersample Art Optimized edges in exports
Asset Export, File › Export, Save for Web and Object › Rasterize drew
each shape with its own smoothed edge, so where two shapes met edge to
edge (Divide, Shape Builder, abutting art) both half-covered the pixels
on the line and the background showed through as a thin ghost border.

With Art Optimized (the default) these now supersample, as the option's
name in Illustrator says: the art is drawn with hard edges at four times
the size (three or two times for large images, within a 40-megapixel
budget and the renderer's 65,535-pixel side) and each block averaged
into one pixel. Pixels shared by two shapes are fully owned by one or
the other at the finer size, so no seam is left, and the outer edges
are still smooth. The canvas keeps shape-by-shape smoothing for speed;
None and Type Optimized are unchanged.

RenderOptions::supersample turns it on (exports and Rasterize set it).
A render test checks two triangles sharing a diagonal: a seam with
shape-by-shape smoothing, none supersampled, and the factor's limits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 03:48:03 +07:00
BFlat c403e57d2a Merge pull request #1013 from Ni-zav/fix/991-shift-stepper-s20261010T1758Z-a74b
fix(widgets): apply Shift and Ctrl modifiers to numeric stepper buttons
2026-10-11 03:45:43 +07:00
BFlat e606c56064 Merge pull request #1012 from Ni-zav/fix/1000-object-mosaic-input-s20261010T1758Z-a74b
fix(dialogs): preserve integer counts in command parameter fields
2026-10-11 03:45:27 +07:00
BFlat d15bc61818 Merge pull request #988 from Ni-zav/fix/noissue-crop-box-option-s20261010T1630Z-c8d2e1
fix(crop): reject malformed crop rectangles without shifting coordinates
2026-10-11 03:45:10 +07:00
BFlat 428894df22 Merge pull request #999 from Ni-zav/fix/noissue-distort-geometry-validation-s20261010T1654Z-8d74ec
fix(distort): reject malformed warp geometry and explicit targets
2026-10-11 03:44:54 +07:00
BFlat 0cb1beaf21 Merge pull request #998 from Ni-zav/fix/noissue-path-anchor-validation-s20261010T1654Z-8d74ec
Fix/noissue path anchor validation s20261010 t1654 z 8d74ec
2026-10-11 03:44:37 +07:00
BFlat c05ad3985c Merge pull request #997 from Ni-zav/fix/noissue-anchor-selection-input-s20261010T1654Z-8d74ec
fix(selection): reject malformed anchor references without changing selection
2026-10-11 03:44:20 +07:00
BFlat 05bb1ac97f Merge pull request #996 from Ni-zav/fix/noissue-assets-explicit-targets-s20261010T1654Z-8d74ec
fix(assets): reject invalid object IDs before collecting export assets
2026-10-11 03:44:04 +07:00
BFlat 7915bfc0e1 Merge pull request #987 from Ni-zav/fix/noissue-stroke-input-atomic-s20261010T1630Z-c8d2e1
fix(stroke): reject invalid dash entries and keep defaults atomic
2026-10-11 03:43:47 +07:00
BFlat 219b9da346 Merge pull request #986 from Ni-zav/fix/noissue-rect-cut-input-s20261010T1630Z-c8d2e1
fix(cut): reject malformed rectangle cut bounds
2026-10-11 03:43:30 +07:00
BFlat 683f6439ca Merge pull request #985 from Ni-zav/fix/noissue-place-coordinates-s20261010T1630Z-c8d2e1
fix(place): reject malformed rectangle and position parameters
2026-10-11 03:43:14 +07:00
BFlat 67a0ffca4d Merge pull request #984 from Ni-zav/fix/noissue-paint-proxy-atomic-s20261010T1630Z-c8d2e1
fix(paint): preserve fill and stroke defaults when edits fail
2026-10-11 03:42:57 +07:00
bflatasticandClaude Opus 5.5 e0d494b2eb ROADMAP: the validation batch, Object Mosaic counts and stepper modifiers
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:24:31 +07:00
bflatasticandClaude Opus 5.5 9e09d53fd9 Review #1013: the stepper test from #1015 compiles and hits the spinner
The headless test taken from #1015 set `RawInput::modifiers`, which egui
0.36 doesn't have, and clicked left of the spinner (the right-aligned
label's text ends before its box). Modifiers now come as
`ModifiersChanged` events, as the Appearance panel tests send them, and
the click is placed from the field's text. It also checks Ctrl/Cmd steps
a tenth. It fails with the spinner ignoring modifiers (15 pt for 24).
rustfmt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:14:00 +07:00
Nigel Ihza Firdausy 6d5fdc8a2a test(ui): Shift-click on the Stroke weight stepper steps 10 pt (#991)
The headless panel test of #1015, kept on top of #1013's fix (#1015's
own change to the spinner is superseded by it).
2026-10-11 02:06:41 +07:00
Nigel Ihza Firdausy 057c2ee2c1 fix(widgets): honor Shift on numeric spinner buttons (#991) 2026-10-11 02:06:20 +07:00
bflatasticandClaude Opus 5.5 5a9c6e2e06 Review #1012: whole numbers typed in command dialogs are integers
The PR kept a field's integer kind only while its value had been an
integer: typing 2.5 in Columns and then 3 sent 3.0, and the mosaic fell
back to 10 × 10 again. A whole number typed in any command dialog is now
an integer (`typed_number`) whatever the field held before; commands
that take any number read integers as well, and a fraction stays one.

Test: Object › Create Object Mosaic… opened from its menu item, 3 typed
over Columns and Enter makes a 3 × 10 mosaic (it made 10 × 10 before,
#1000).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:06:19 +07:00
Nigel Ihza Firdausy 130bacde12 fix(dialogs): preserve integral command parameters in numeric fields (#1000) 2026-10-11 01:59:54 +07:00
bflatasticandClaude Opus 5.5 311d75fc97 Review #988: rustfmt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:58:43 +07:00
Nigel Ihza Firdausy c845c7f9e0 fix(crop): reject malformed crop rectangles without shifting coordinates 2026-10-11 01:58:31 +07:00
bflatasticandClaude Opus 5.5 2ff5688511 Review #999: corners and from through finite_numbers; rustfmt
Same checks in a third of the code; the corners destructure into the
four points instead of being indexed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:51:12 +07:00
Nigel Ihza Firdausy 0d697ed79d fix(distort): validate the full corner quad, source bounds and targets 2026-10-11 01:50:31 +07:00
bflatasticandClaude Opus 5.5 57050b0987 Review #998: one strict anchor parser; build fix for path.appendAnchor
The PR added `checked_anchor_from_json` next to the lenient
`anchor_from_json` and dropped path.rs's import of the latter, which
`path.appendAnchor` still called, so the engine didn't compile.
`anchor_from_json` is now the strict parser itself (handles through
`finite_numbers`), used by path.create, path.setAnchors and
path.appendAnchor alike: the Pen's appended anchor with a malformed
handle fails too instead of losing the handle. Tests: a non-boolean
`closed` and a malformed appended handle change nothing. rustfmt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:50:30 +07:00
Nigel Ihza Firdausy 743fe2521e fix(path): remove unused legacy anchor parser import 2026-10-11 01:49:22 +07:00
Nigel Ihza Firdausy 6bf2a42e22 fix(path): reject malformed anchors before creating or replacing geometry 2026-10-11 01:49:22 +07:00
bflatasticandClaude Opus 5.5 93cadc8994 Review #997: one anchor-reference parser, for Reshape area type too
`text.reshapeArea` read its anchors with the lenient `parse_refs`, so a
malformed reference among good ones moved only the good ones. It now
uses `checked_refs` as well, and `parse_refs` (no other user) goes.
Test: a mixed list fails without moving anything.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:48:17 +07:00
Nigel Ihza Firdausy 2f984f2e7a fix(selection): reject malformed anchor references before selection changes 2026-10-11 01:47:03 +07:00
bflatasticandClaude Opus 5.5 2f458842ae Review #996: rustfmt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:46:59 +07:00
Nigel Ihza Firdausy 3b742b6c0c fix(assets): validate all explicit export object ids before collecting 2026-10-11 01:46:24 +07:00
bflatasticandClaude Opus 5.5 de17a00eb2 Review #987: test the next-object stroke with nothing selected; rustfmt
A rejected Stroke panel edit with nothing selected leaves the next
object's stroke as it was; an accepted one sets its weight and dashes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:46:22 +07:00
Nigel Ihza Firdausy 059bd82676 fix(stroke): reject malformed dashes and keep defaults atomic 2026-10-11 01:45:19 +07:00
bflatasticandClaude Opus 5.5 6eeabcd742 Review #986: the rect through finite_numbers; rustfmt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:45:18 +07:00
Nigel Ihza Firdausy bfbe9e1eb6 fix(cut): reject malformed rectangle cut bounds 2026-10-11 01:44:43 +07:00
bflatasticandClaude Opus 5.5 ac6d72b44a Review #985: one parser for exact number lists; null at is no position
- `finite_numbers::<N>` (cmd/mod.rs) reads an array of exactly N finite
  numbers, nothing dropped or padded; `rect` uses it, and the rect cut,
  Free Distort and anchor handles reuse it in the PRs stacked on this one.
- `at` is parsed once (`at_param`) instead of point_param plus a second
  shape check that indexed the params; `at: null` means no position, as
  null does for the other optional params, and gets a test.
- rustfmt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:44:11 +07:00
Nigel Ihza Firdausy 004d015cfe fix(place): reject malformed rectangle and position parameters 2026-10-11 01:42:58 +07:00
bflatasticandClaude Opus 5.5 e860b01ed8 Review #984: rustfmt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:36:54 +07:00
Nigel Ihza Firdausy 4c011cf42e fix(paint): preserve new-art defaults when paint edits are rejected 2026-10-11 01:36:17 +07:00
BFlat 6f7f32c64d Merge pull request #1017 from storytold/feat/brush-strokes-effects
Part of gap 2: Effect › Brush Strokes (Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e)
2026-10-11 01:24:49 +07:00
56 changed files with 914 additions and 150 deletions

No files matched your search

+1 -1
View File
@@ -123,7 +123,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) |
| 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). |
| 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 |
+15 -1
View File
@@ -292,7 +292,21 @@ fn run(args: &[String]) -> Result<(), String> {
let mut h = Headless::new();
let mut out = std::io::stdout().lock();
let mut emit = |v: Value| writeln!(out, "{v}").map_err(|e| e.to_string());
// A reader that stopped early (a closed pipe) isn't an error, and the batch goes on: its later
// steps (an export…) still run, unprinted.
let mut closed = false;
let mut emit = |v: Value| {
if closed {
return Ok(());
}
match writeln!(out, "{v}") {
Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => {
closed = true;
Ok(())
}
r => r.map_err(|e| e.to_string()),
}
};
if let Some(path) = &input {
let r = h.call("app.open", json!({"path": path})).map_err(|e| format!("open {path}: {e}"))?;
emit(json!({"step": "open", "path": path, "result": r}))?;
+18
View File
@@ -297,3 +297,21 @@ fn closed_stdout_ends_quietly() {
let err = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success() && !err.contains("panicked"), "{:?}: {err}", out.status);
}
/// #1005: `run` whose reader has gone (`vectorcraft-cli run … | true`) ends quietly with status 0,
/// and its later steps still run; a failing step still fails.
#[test]
fn run_with_a_closed_stdout_ends_quietly_and_finishes_the_batch() {
let svg = tmp("closed-run.svg");
let _ = std::fs::remove_file(&svg);
let rect = r#"{"x":1,"y":1,"width":5,"height":5}"#;
let args = ["run", "--cmd", "document.json", "--cmd", "shape.rectangle", "--params", rect, "--export", svg.to_str().unwrap()];
let mut child = Command::new(BIN).args(args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
drop(child.stdout.take());
let out = child.wait_with_output().unwrap();
let err = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success() && err.is_empty(), "{:?}: {err}", out.status);
assert!(svg.is_file(), "the export after the closed reader still ran");
let out = Command::new(BIN).args(["run", "--cmd", "not-a-command"]).stdout(Stdio::null()).output().unwrap();
assert!(!out.status.success());
}
+36 -2
View File
@@ -131,10 +131,18 @@ impl Gradient {
std::iter::once((s.offset, s.color, s.opacity, None)).chain(mid)
})
}
/// The same ramp the other way round: the stops in reverse order at mirrored offsets, each
/// segment's midpoint mirrored with it.
pub fn reverse(&mut self) {
self.stops.reverse();
for s in &mut self.stops {
s.offset = 1.0 - s.offset;
for i in 0..self.stops.len() {
// A midpoint belongs to the segment after its stop: reversed, stop i's segment is the
// one the next stop (not yet mirrored) had, measured from the other end.
let mid = self.stops.get(i + 1).map_or(0.5, |n| 1.0 - n.midpoint);
if let Some(s) = self.stops.get_mut(i) {
s.offset = 1.0 - s.offset;
s.midpoint = mid;
}
}
}
pub fn sort(&mut self) {
@@ -569,6 +577,32 @@ mod tests {
assert_eq!(g.stops[0].offset, 0.0);
}
/// #1007: reversing mirrors off-centre midpoints with their segments, so the ramp is the same
/// the other way round.
#[test]
fn reverse_mirrors_midpoints() {
let stop = |offset, hex, midpoint| GradientStop { midpoint, ..GradientStop::new(offset, Color::from_hex(hex).unwrap()) };
let two = Gradient { kind: GradientKind::Linear, stops: vec![stop(0.0, "#ff0000", 0.2), stop(1.0, "#0000ff", 0.5)] };
let three =
Gradient { kind: GradientKind::Linear, stops: vec![stop(0.0, "#ff0000", 0.2), stop(0.3, "#00ff00", 0.8), stop(1.0, "#0000ff", 0.5)] };
for g in [two, three] {
let mut r = g.clone();
r.reverse();
let mids: Vec<f32> = r.stops.iter().map(|s| s.midpoint).collect();
let want: Vec<f32> = g.stops.iter().rev().skip(1).map(|s| 1.0 - s.midpoint).chain([0.5]).collect();
assert!(mids.iter().zip(&want).all(|(a, b)| (a - b).abs() < 1e-6), "{mids:?} {want:?}");
for i in 0..=20 {
let t = i as f32 / 20.0;
let ((a, ao), (b, bo)) = (g.sample(t), r.sample(1.0 - t));
let same = a.to_rgba8(1.0).iter().zip(b.to_rgba8(1.0)).all(|(x, y)| x.abs_diff(y) <= 1);
assert!(same && (ao - bo).abs() < 1e-6, "t {t}: {a:?} vs {b:?}");
}
r.reverse();
let back = r.stops.iter().zip(&g.stops).all(|(a, b)| (a.offset - b.offset).abs() < 1e-6 && (a.midpoint - b.midpoint).abs() < 1e-6);
assert!(back, "reversed twice, it is as it was: {:?}", r.stops);
}
}
fn close(a: Point, b: Point) -> bool {
a.distance(b) < 1e-9
}
+7 -6
View File
@@ -80,12 +80,13 @@ fn clean_name(name: &str) -> Option<String> {
fn add(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "assets.add";
let ids = match ids_param(p, "ids") {
Some(ids) => {
let d = &s.doc()?.doc;
roots_of(d, d.paint_order(ids))
}
None => selected_roots(s)?,
let ids = if p.get("ids").is_some_and(|v| !v.is_null()) {
// An explicit list is authoritative: never silently drop malformed or stale ids.
let ids = checked_ids_param(s, p, "ids", C)?;
let d = &s.doc()?.doc;
roots_of(d, d.paint_order(ids))
} else {
selected_roots(s)?
};
if ids.is_empty() {
return Err(bad(C, "select the art to collect (or give its ids)"));
+3 -2
View File
@@ -603,7 +603,7 @@ fn symbol_place(s: &mut Session, p: &Value) -> Result<Value> {
let centre = d.artboards.first().map(|a| a.rect.center()).unwrap_or(Point::ZERO);
let c = Point::new(f64_or(p, "x", centre.x), f64_or(p, "y", centre.y));
let size = natural_size(d, &name);
let parent = s.doc()?.insertion_parent();
let parent = s.doc()?.target_parent()?;
let id = s.edit("Place Symbol Instance", |d, sel| {
let id = d.alloc_id();
d.insert(parent, usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: name.clone(), xf: place_xf(c, size) }))?;
@@ -843,7 +843,8 @@ fn symbol_spray(s: &mut Session, p: &Value) -> Result<Value> {
let alt = bool_or(p, "alt", false);
let size = natural_size(&st.doc, &name);
let set = st.selection.objects.first().copied().filter(|id| st.selection.len() == 1 && st.doc.node(*id).is_some_and(is_symbol_set));
let parent = st.insertion_parent();
// Only a new Symbol Set needs a layer that takes new art.
let parent = if alt || set.is_some() { st.insertion_parent() } else { st.target_parent()? };
if alt {
// Remove instances of this symbol under the brush (within the set, or anywhere).
let d = &st.doc;
+22 -13
View File
@@ -79,7 +79,7 @@ pub(crate) fn add_node(s: &mut Session, label: &str, kind: NodeKind, appearance:
/// Insert a new object looking like `look` (drawing modes apply); select it.
pub(crate) fn add_look(s: &mut Session, label: &str, kind: NodeKind, look: NewArt, name: Option<String>) -> Result<Value> {
let parent = s.doc()?.insertion_parent();
let parent = s.doc()?.target_parent()?;
let mode = s.draw_mode;
let inside = s.draw_inside;
let behind_of = s.doc()?.selection.in_paint_order(&s.doc()?.doc).first().copied();
@@ -298,7 +298,7 @@ fn flare(s: &mut Session, p: &Value) -> Result<Value> {
};
parts.push(("Ring", circle(at, rr), radial(at, rr, stops), 1.0));
}
let parent = s.doc()?.insertion_parent();
let parent = s.doc()?.target_parent()?;
let id = s.edit("Flare", |d, sel| {
let children = parts
.into_iter()
@@ -352,7 +352,7 @@ fn arc(s: &mut Session, p: &Value) -> Result<Value> {
}
fn grid_group(s: &mut Session, label: &str, paths: Vec<PathData>) -> Result<Value> {
let parent = s.doc()?.insertion_parent();
let parent = s.doc()?.target_parent()?;
let look = s.new_art_look(vectorcraft_color::Paint::None, s.paint.stroke.clone(), s.paint.stroke_width);
let id = s.edit(label, |d, sel| {
let children = paths
@@ -385,15 +385,24 @@ fn polar_grid(s: &mut Session, p: &Value) -> Result<Value> {
grid_group(s, "Polar Grid", shapes::polar_grid(r, c, rad))
}
pub(crate) fn anchor_from_json(v: &Value) -> Option<Anchor> {
let p = Point::new(v.get("x")?.as_f64()?, v.get("y")?.as_f64()?);
let h_in = point_param(v, "in").unwrap_or(p);
let h_out = point_param(v, "out").unwrap_or(p);
let mut a = Anchor::with_handles(p, h_in, h_out);
if v.get("smooth").and_then(Value::as_bool) == Some(true) {
a.kind = AnchorKind::Smooth;
/// An anchor `{x, y, in?, out?, smooth?}` given to `cmd` (handles `[x, y]`, at the anchor when
/// absent or null): anything malformed fails rather than being left out or replaced by a default,
/// which would change the path's shape.
pub(crate) fn anchor_from_json(v: &Value, cmd: &str) -> Result<Anchor> {
let coord =
|key: &str| v.get(key).and_then(Value::as_f64).filter(|n| n.is_finite()).ok_or_else(|| bad(cmd, format!("anchor `{key}` must be a number")));
let p = Point::new(coord("x")?, coord("y")?);
let handle = |key: &str| match v.get(key).filter(|h| !h.is_null()) {
None => Ok(p),
Some(h) => finite_numbers(h).map(|[x, y]| Point::new(x, y)).ok_or_else(|| bad(cmd, format!("anchor `{key}` must be [x, y]"))),
};
let mut anchor = Anchor::with_handles(p, handle("in")?, handle("out")?);
match v.get("smooth").filter(|b| !b.is_null()) {
None | Some(Value::Bool(false)) => {}
Some(Value::Bool(true)) => anchor.kind = AnchorKind::Smooth,
Some(_) => return Err(bad(cmd, "anchor `smooth` must be true or false")),
}
Some(a)
Ok(anchor)
}
fn path_create(s: &mut Session, p: &Value) -> Result<Value> {
@@ -406,8 +415,8 @@ fn path_create(s: &mut Session, p: &Value) -> Result<Value> {
.and_then(Value::as_array)
.ok_or_else(|| bad("path.create", "missing anchors"))?
.iter()
.filter_map(anchor_from_json)
.collect();
.map(|a| anchor_from_json(a, "path.create"))
.collect::<Result<_>>()?;
if anchors.is_empty() {
return Err(bad("path.create", "need at least one anchor"));
}
+2 -2
View File
@@ -221,8 +221,8 @@ fn rect_cut(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.rectCut";
let rect = match p.get("rect") {
Some(v) => {
let a: Vec<f64> = v.as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
let [x, y, w, h] = a[..] else { return Err(bad(C, "rect must be [x, y, width, height]")) };
// All four or nothing: a malformed entry dropped would cut a different region.
let [x, y, w, h] = finite_numbers(v).ok_or_else(|| bad(C, "rect must be [x, y, width, height]"))?;
Rect::new(x, y, x + w, y + h).abs()
}
None => Rect::from_points(point_req(p, "from", C)?, point_req(p, "to", C)?),
+1 -1
View File
@@ -346,7 +346,7 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
let off = s.prefs.paste_offset;
let st = s.doc()?;
let same_doc = clip.source_doc == Some(st.uid);
let parent = st.insertion_parent();
let parent = st.target_parent()?;
// A copied artboard comes back with its art: right of the last artboard, or where it was
// (Paste in Place, in Front, in Back). Paste on All Artboards pastes only the art.
let board = clip
+1 -6
View File
@@ -270,13 +270,8 @@ pub(crate) fn apply_gradient_edit_in(
gp.swatch = None;
}
if bool_or(p, "reverse", false) {
// Midpoints mirrored with their segments.
gp.gradient.reverse();
// Midpoints belong to the segment to the right; mirror them.
let n = gp.gradient.stops.len();
let mids: Vec<f32> = gp.gradient.stops.iter().map(|s| s.midpoint).collect();
for i in 0..n {
gp.gradient.stops[i].midpoint = if i + 1 < n { 1.0 - mids[n - 2 - i] } else { 0.5 };
}
}
// Angle and aspect edits keep the focal point in its place in the extent ellipse.
let before = gp.geom;
+1 -1
View File
@@ -831,7 +831,7 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
spec.categories = vec!["A".into(), "B".into(), "C".into(), "D".into()];
spec.rows = vec![vec![3.0, 2.0], vec![5.0, 4.0], vec![4.0, 6.0], vec![7.0, 5.0]];
}
let parent = s.doc()?.insertion_parent();
let parent = s.doc()?.target_parent()?;
let id = s.edit("Graph", |d, sel| {
let gid = d.alloc_id();
let mut g = Node::group(gid, vec![]);
+1 -1
View File
@@ -702,7 +702,7 @@ fn trim_marks(s: &mut Session, p: &Value) -> Result<Value> {
if rects.is_empty() {
return Err(bad(C, "nothing to mark"));
}
let parent = s.doc()?.insertion_parent();
let parent = s.doc()?.target_parent()?;
let ids = s.edit("Create Trim Marks", |d, sel| {
let mut ids = vec![];
for r in &rects {
+11
View File
@@ -324,6 +324,17 @@ pub(crate) fn checked_id(s: &Session, value: &Value, command: &str) -> Result<No
Ok(id)
}
/// `v` as exactly `N` finite numbers (`[x, y]`, `[x, y, width, height]`…): `None` when it isn't an
/// array of that length or an entry isn't a number, so nothing malformed is left out or padded.
pub(crate) fn finite_numbers<const N: usize>(v: &Value) -> Option<[f64; N]> {
let a = v.as_array().filter(|a| a.len() == N)?;
let mut out = [0.0; N];
for (o, n) in out.iter_mut().zip(a) {
*o = n.as_f64().filter(|n| n.is_finite())?;
}
Some(out)
}
pub(crate) fn point_param(p: &Value, key: &str) -> Option<Point> {
let a = p.get(key)?.as_array()?;
Some(Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))
+13 -11
View File
@@ -375,15 +375,7 @@ fn apply_to_proxy(s: &mut Session, p: &Value, paint: Paint) -> Result<Value> {
/// remember the paint.
fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<Value> {
let cmd = if fill { "paint.setFill" } else { "paint.setStroke" };
// New art gets the paint fitted to itself, not placed where this one is.
if fill {
s.paint.fill = unplaced(&paint);
} else {
s.paint.stroke = unplaced(&paint);
}
if bool_or(p, "focus", true) {
s.fill_active = fill;
}
// Resolve the requested targets before mutating defaults or the active proxy.
let item = item_target(s, p, cmd)?.of_kind(s, fill);
let selected = item.targets(s, p)?;
let ids = super::graph::paint_targets(&s.doc()?.doc, &selected);
@@ -423,6 +415,16 @@ fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<V
Ok(())
},
)?;
// Rejected edits leave the next shape's appearance and active proxy untouched.
// New art takes an unplaced gradient so that it fits the new object's own bounds.
if fill {
s.paint.fill = unplaced(&paint);
} else {
s.paint.stroke = unplaced(&paint);
}
if bool_or(p, "focus", true) {
s.fill_active = fill;
}
s.remember_paint(&paint);
ok()
}
@@ -436,7 +438,6 @@ fn set_run_stroke(style: &mut CharStyle, paint: Paint) {
}
fn swap(s: &mut Session, p: &Value) -> Result<Value> {
std::mem::swap(&mut s.paint.fill, &mut s.paint.stroke);
let ids = paint_targets(s, p)?;
if !ids.is_empty() {
s.edit("Swap Fill and Stroke", |d, _| {
@@ -458,11 +459,11 @@ fn swap(s: &mut Session, p: &Value) -> Result<Value> {
Ok(())
})?;
}
std::mem::swap(&mut s.paint.fill, &mut s.paint.stroke);
ok()
}
fn default_paint(s: &mut Session, p: &Value) -> Result<Value> {
super::newart::reset(s);
let ids = paint_targets(s, p)?;
if !ids.is_empty() {
s.edit("Default Fill and Stroke", |d, _| {
@@ -481,6 +482,7 @@ fn default_paint(s: &mut Session, p: &Value) -> Result<Value> {
Ok(())
})?;
}
super::newart::reset(s);
ok()
}
+15 -6
View File
@@ -129,7 +129,7 @@ fn path_mut(d: &mut vectorcraft_doc::Document, id: NodeId) -> Result<&mut PathDa
fn append_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.appendAnchor", "missing id"))?;
let a = anchor_from_json(p).ok_or_else(|| bad("path.appendAnchor", "missing x/y"))?;
let a = anchor_from_json(p, "path.appendAnchor")?;
s.edit("Pen", |d, sel| {
let path = path_mut(d, id)?;
match path.subpaths.last_mut() {
@@ -217,12 +217,21 @@ fn set_anchors(s: &mut Session, p: &Value) -> Result<Value> {
let data = PathData::new(
subs.iter()
.map(|sp| {
SubPath::new(
sp.get("anchors").and_then(Value::as_array).map(|a| a.iter().filter_map(anchor_from_json).collect()).unwrap_or_default(),
bool_or(sp, "closed", false),
)
let anchors = sp
.get("anchors")
.and_then(Value::as_array)
.ok_or_else(|| bad("path.setAnchors", "each subpath needs an anchors array"))?
.iter()
.map(|a| anchor_from_json(a, "path.setAnchors"))
.collect::<Result<Vec<_>>>()?;
let closed = match sp.get("closed") {
None | Some(Value::Null) => false,
Some(Value::Bool(v)) => *v,
Some(_) => return Err(bad("path.setAnchors", "closed must be a boolean")),
};
Ok(SubPath::new(anchors, closed))
})
.collect(),
.collect::<Result<Vec<_>>>()?,
);
s.edit("Reshape", |d, _| {
*path_mut(d, id)? = data;
+13 -4
View File
@@ -309,13 +309,22 @@ fn transformed(mut node: Node, m: Affine) -> Node {
/// `[x, y, width, height]` with a positive size.
fn rect_param(p: &Value) -> Result<Option<Rect>> {
let Some(v) = p.get("rect") else { return Ok(None) };
let n: Vec<f64> = v.as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
match n[..] {
[x, y, w, h] if w > 0.0 && h > 0.0 => Ok(Some(Rect::new(x, y, x + w, y + h))),
// All four or nothing: a malformed entry dropped would shift the others ([0, "bad", 10, 40, 50]).
match finite_numbers(v) {
Some([x, y, w, h]) if w > 0.0 && h > 0.0 => Ok(Some(Rect::new(x, y, x + w, y + h))),
_ => Err(bad(PLACE, "rect must be [x, y, width, height] with a positive width and height")),
}
}
/// `at`, the `[x, y]` the art is centred on (absent or null: none); anything else fails rather than
/// placing the art as if no point were given.
fn at_param(p: &Value) -> Result<Option<Point>> {
p.get("at")
.filter(|v| !v.is_null())
.map(|v| finite_numbers(v).map(|[x, y]| Point::new(x, y)).ok_or_else(|| bad(PLACE, "at must be [x, y]")))
.transpose()
}
/// The transform that puts new art with the natural box `natural` where `old` is: an image's scale
/// and rotation relative to its 100% size (its resolution's) and its centre are kept (with
/// `image_xf`); other objects keep their centre.
@@ -342,7 +351,7 @@ fn fit(natural: Rect, r: Rect) -> Affine {
fn place(s: &mut Session, p: &Value) -> Result<Value> {
let replace = bool_or(p, "replace", false);
let template = bool_or(p, "template", false);
let at = point_param(p, "at");
let at = at_param(p)?;
let rect = rect_param(p)?;
if replace && (template || at.is_some() || rect.is_some()) {
return Err(bad(PLACE, "replace keeps the replaced object's place and transform: drop template, at and rect"));
+2 -1
View File
@@ -152,7 +152,8 @@ fn render(doc: &Document, nodes: Vec<Node>, region: Rect, scale: f64, anti_alias
tmp.layers = vec![Arc::new(layer)];
let mut r = vectorcraft_render::Renderer::new();
r.threads = 0;
r.render_region_with(&tmp, region, scale, &RenderOptions { skip_templates: true, anti_alias, ..Default::default() })
// Art Optimized is supersampled, as in exports: shapes meeting edge to edge leave no seam.
r.render_region_with(&tmp, region, scale, &RenderOptions { skip_templates: true, anti_alias, supersample: true, ..Default::default() })
}
/// Pixels per point of raster effects rendered as images (the document's raster effects
+36 -13
View File
@@ -294,17 +294,35 @@ fn key(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
/// `[[subpath, anchor]…]` anchor references (malformed entries are left out).
pub(crate) fn parse_refs(v: Option<&Value>) -> Vec<(usize, usize)> {
v.and_then(Value::as_array)
.map(|a| a.iter().filter_map(|x| Some((x.get(0)?.as_u64()? as usize, x.get(1)?.as_u64()? as usize))).collect())
.unwrap_or_default()
/// `[[subpath, anchor]…]` anchor references of `cmd`: a malformed entry fails rather than being
/// left out, which would act on other anchors than the ones given.
pub(crate) fn checked_refs(v: Option<&Value>, cmd: &str) -> Result<Vec<(usize, usize)>> {
let refs = v.and_then(Value::as_array).ok_or_else(|| bad(cmd, "`anchors` must be an array of [subpath, anchor] pairs"))?;
refs.iter()
.map(|v| {
let Some([subpath, anchor]) = v.as_array().map(Vec::as_slice) else {
return Err(bad(cmd, format!("invalid anchor reference {v}: expected [subpath, anchor]")));
};
let coordinate = |v: &Value| {
v.as_u64()
.and_then(|n| usize::try_from(n).ok())
.ok_or_else(|| bad(cmd, format!("invalid anchor index {v}: expected a non-negative integer")))
};
Ok((coordinate(subpath)?, coordinate(anchor)?))
})
.collect()
}
fn anchors(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("select.anchors", "missing id"))?;
let refs = parse_refs(p.get("anchors"));
let mode = str_param(p, "mode").unwrap_or("set").to_string();
const C: &str = "select.anchors";
let id = checked_id(s, p.get("id").ok_or_else(|| bad(C, "missing id"))?, C)?;
let refs = checked_refs(p.get("anchors"), C)?;
let mode = match str_param(p, "mode") {
None | Some("set") => "set",
Some("add") => "add",
Some("toggle") => "toggle",
Some(m) => return Err(bad(C, format!("unknown mode {m:?}"))),
};
s.select(|_, sel| {
if mode == "set" {
sel.clear();
@@ -323,11 +341,16 @@ fn anchors(s: &mut Session, p: &Value) -> Result<Value> {
}
fn anchors_many(s: &mut Session, p: &Value) -> Result<Value> {
let items: Vec<(NodeId, BTreeSet<(usize, usize)>)> = p
.get("items")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(|it| Some((NodeId(it.get("id")?.as_u64()?), parse_refs(it.get("anchors")).into_iter().collect()))).collect())
.unwrap_or_default();
const C: &str = "select.anchorsMany";
let raw = p.get("items").and_then(Value::as_array).ok_or_else(|| bad(C, "`items` must be an array of {id, anchors}"))?;
let items: Vec<(NodeId, BTreeSet<(usize, usize)>)> = raw
.iter()
.map(|it| {
let id = checked_id(s, it.get("id").ok_or_else(|| bad(C, "each item needs an id"))?, C)?;
let refs = checked_refs(it.get("anchors"), C)?;
Ok((id, refs.into_iter().collect()))
})
.collect::<Result<_>>()?;
// `add: true` is the older spelling of mode add.
let mode = match str_param(p, "mode") {
Some(m @ ("set" | "add" | "toggle" | "subtract")) => m,
+14 -8
View File
@@ -100,7 +100,12 @@ impl StrokeChange {
let dash = match p.get("dash") {
None => None,
Some(Value::Null) => Some(None),
Some(Value::Array(a)) => Some(Some(a.iter().filter_map(Value::as_f64).collect::<Vec<_>>()).filter(|d| !d.is_empty())),
Some(Value::Array(a)) => {
// Do not silently omit malformed segments and change the user's dash pattern.
let values: Vec<f64> =
a.iter().map(|v| v.as_f64().ok_or_else(|| bad(cmd, format!("dash lengths must be numbers, got {v}")))).collect::<Result<_>>()?;
Some(Some(values).filter(|d| !d.is_empty()))
}
Some(v) => return Err(bad(cmd, format!("dash must be a list of lengths or null, got {v}"))),
};
Ok(Self {
@@ -167,9 +172,6 @@ fn no_targets(ids: &[vectorcraft_doc::NodeId], p: &Value) -> bool {
fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "stroke.set";
let change = StrokeChange::parse(p, C)?;
if let Some(w) = change.weight {
s.paint.stroke_width = w;
}
let item = item_target(s, p, C)?.of_kind(s, false);
let mut ids = item.targets(s, p)?;
let new_art = no_targets(&ids, p);
@@ -216,10 +218,6 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
st.profile = pr.clone();
}
};
// With nothing selected the Stroke panel sets up the next object drawn.
if new_art && let Some(st) = s.new_art_stroke_mut() {
set(st);
}
edit_items(s, &ids, item, C, "Stroke", false, |n, index| {
if index.is_none()
&& let NodeKind::Text(t) = &mut n.kind
@@ -235,6 +233,14 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(())
})?;
// Commit the new-art template only after the complete request is validated
// and the selected artwork edit has succeeded.
if let Some(w) = change.weight {
s.paint.stroke_width = w;
}
if new_art && let Some(st) = s.new_art_stroke_mut() {
set(st);
}
ok()
}
+2 -11
View File
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Area / Path Type",
[],
None,
"{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, fit?: none|autoHeight|shrinkText, fitMinPercent? (area: as text.areaOptions; default autoHeight when the autoSizeAreaType preference is on), placeholder?: bool (placeholder text instead, as text.create), leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id}",
"{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, style?, color? (as text.create), fit?: none|autoHeight|shrinkText, fitMinPercent? (area: as text.areaOptions; default autoHeight when the autoSizeAreaType preference is on), placeholder?: bool (placeholder text instead, as text.create), leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id}",
has_doc,
create_in_path
),
@@ -485,16 +485,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
if !on_path && !path.is_closed() && path.bounds().is_none_or(|b| b.width() < 1.0 || b.height() < 1.0) {
return Err(bad(C, "area type needs a path that encloses an area"));
}
let mut style = CharStyle::default();
if let Some(v) = p.get("size").and_then(Value::as_f64) {
style.size = v.clamp(0.1, 1296.0);
}
if let Some(f) = str_param(p, "font") {
style.font_family = f.to_string();
}
if !s.paint.fill.is_none() && s.paint.fill != Paint::solid(vectorcraft_color::Color::WHITE) {
style.fill = s.paint.fill.clone();
}
let style = super::create::new_type_style(s, p);
let text = str_param(p, "text").unwrap_or("").to_string();
let start = match point_param(p, "at") {
Some(at) if on_path => vectorcraft_geom::ArcPath::new(path).fraction_at(at).unwrap_or(0.0),
+69 -1
View File
@@ -133,14 +133,44 @@ fn to_attrs<T: Serialize>(v: &T) -> Map<String, Value> {
m
}
/// Every attribute name `T` reads, including those its JSON leaves out at their defaults (Auto
/// leading and kerning, no mojikumi…): the field names serde's derive hands `deserialize_struct`.
/// Not `justify_auto`, which [`to_map`] turns into `"justify": "Auto"`.
fn attr_names<T: DeserializeOwned>() -> Vec<&'static str> {
use serde::de::{Error as _, Visitor, value::Error};
struct Names<'a>(&'a mut &'static [&'static str]);
impl<'de> serde::Deserializer<'de> for Names<'_> {
type Error = Error;
fn deserialize_any<V: Visitor<'de>>(self, _: V) -> std::result::Result<V::Value, Error> {
Err(Error::custom("not a struct"))
}
fn deserialize_struct<V: Visitor<'de>>(self, _: &'static str, fields: &'static [&'static str], _: V) -> std::result::Result<V::Value, Error> {
*self.0 = fields;
Err(Error::custom("field names only"))
}
serde::forward_to_deserialize_any! {
bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char str string bytes byte_buf option unit
unit_struct newtype_struct seq tuple tuple_struct map enum identifier ignored_any
}
}
let mut fields: &'static [&'static str] = &[];
// Always an error: the probe only records the names.
let _ = T::deserialize(Names(&mut fields));
fields.iter().copied().filter(|&f| f != "justify_auto").collect()
}
/// `base` with `attrs` written over it (unknown or ill-typed attributes are an error).
fn with_attrs<T: Serialize + DeserializeOwned>(base: &T, attrs: &Map<String, Value>, cmd: &str) -> Result<T> {
let mut m = to_attrs(base);
if let Some(full) = to_map(base) {
m.insert("style_name".into(), full.get("style_name").cloned().unwrap_or(Value::Null));
}
let names = attr_names::<T>();
for (k, v) in attrs {
if !m.contains_key(k) {
// Not only the keys `base` writes: those at their defaults (Auto leading…) are left out.
if !m.contains_key(k) && !names.contains(&k.as_str()) {
return Err(bad(cmd, format!("unknown attribute `{k}`")));
}
m.insert(k.clone(), v.clone());
@@ -575,6 +605,44 @@ mod tests {
assert_eq!(s.doc().unwrap().doc.char_styles.iter().find(|d| d.name == "Pink").unwrap().attrs["fill"], fill);
}
/// #1001: attributes their JSON leaves out at the defaults (Auto leading and kerning, no
/// mojikumi) are still attributes a style can set, and a style captured with one applies to
/// text at the default, is duplicated and comes back out of a library.
#[test]
fn styles_take_attributes_left_out_at_their_defaults() {
let (mut s, id) = session_with_text("A");
s.execute("charStyle.new", &json!({"name": "Kerned", "attrs": {"kerning": 40, "leading": 30}})).unwrap();
s.execute("charStyle.setAttrs", &json!({"name": "Kerned", "attrs": {"kerning": 20, "small_caps": 70}})).unwrap();
s.execute("paraStyle.new", &json!({"name": "Punctuation", "attrs": {"mojikumi": "lineEndHalf"}})).unwrap();
s.execute("paraStyle.setAttrs", &json!({"name": "Punctuation", "attrs": {"direction": "rightToLeft"}})).unwrap();
for bad in [json!({"nope": 1}), json!({"justify_auto": true})] {
assert!(s.execute("paraStyle.new", &json!({"name": "Bad", "attrs": bad})).is_err(), "{bad}");
}
// Captured from text with explicit leading, applied to fresh Auto-leading text.
s.execute("text.setStyle", &json!({"leading": 30})).unwrap();
s.execute("charStyle.new", &json!({"name": "Spaced"})).unwrap();
let fresh = s.execute("text.create", &json!({"x": 10, "y": 90, "text": "B"})).unwrap()["id"].as_u64().unwrap();
assert_eq!(runs(&s, fresh)[0].style.leading, None);
for clear in [false, true] {
s.execute("charStyle.apply", &json!({"name": "Spaced", "id": fresh, "clearOverrides": clear})).unwrap();
assert_eq!(runs(&s, fresh)[0].style.leading, Some(30.0));
}
let copy = s.execute("charStyle.duplicate", &json!({"name": "Spaced"})).unwrap()["name"].as_str().unwrap().to_string();
assert_eq!(s.doc().unwrap().doc.char_styles.iter().find(|d| d.name == copy).unwrap().attrs["leading"], 30.0);
s.execute("charStyle.apply", &json!({"name": "Kerned", "id": fresh})).unwrap();
let st = runs(&s, fresh)[0].style.clone();
assert_eq!((st.kerning, st.small_caps), (Some(20.0), Some(70.0)));
s.execute("paraStyle.apply", &json!({"name": "Punctuation", "id": id})).unwrap();
let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind else { panic!() };
assert_eq!((t.para.mojikumi, t.para.direction), (vectorcraft_doc::Mojikumi::LineEndHalf, Some(vectorcraft_doc::ParaDirection::RightToLeft)));
// A library keeps the captured attributes and makes the style again where it's used.
s.execute("select.set", &json!({"ids": [id]})).unwrap();
let lib = s.execute("library.add", &json!({"kind": "charStyle", "name": "Lib Spaced"})).unwrap();
let other = s.execute("text.create", &json!({"x": 10, "y": 150, "text": "C"})).unwrap()["id"].as_u64().unwrap();
s.execute("library.use", &json!({"library": lib["library"], "kind": "charStyle", "item": lib["name"]})).unwrap();
assert_eq!(runs(&s, other)[0].style.leading, Some(30.0));
}
#[test]
fn paragraph_styles_and_survive_save() {
let (mut s, id) = session_with_text("Body");
+1 -1
View File
@@ -628,7 +628,7 @@ fn area_options(s: &mut Session, p: &Value) -> Result<Value> {
fn reshape_area(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.reshapeArea";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let mut refs = super::select::parse_refs(p.get("anchors"));
let mut refs = super::select::checked_refs(p.get("anchors"), C)?;
refs.sort_unstable();
refs.dedup();
if refs.is_empty() {
+8 -2
View File
@@ -582,8 +582,14 @@ pub fn render(doc: &Document, s: &WebSettings) -> std::result::Result<WebRender,
let doc = doc.without_edit_modes();
let (region, scale) = region(&doc, s)?;
let page = (doc.setup.background == vectorcraft_doc::Background::White).then_some([255; 4]);
let opts =
vectorcraft_render::RenderOptions { background: page, skip_templates: true, anti_alias: s.anti_alias, precise: true, ..Default::default() };
let opts = vectorcraft_render::RenderOptions {
background: page,
skip_templates: true,
anti_alias: s.anti_alias,
precise: true,
supersample: true,
..Default::default()
};
let img = vectorcraft_render::Renderer::new().render_region_with(&doc, region, scale, &opts);
Ok(WebRender { rgba: img.to_straight(), width: img.width, height: img.height, region, scale })
}
+17 -20
View File
@@ -132,32 +132,29 @@ fn warp_node(n: &mut Node, pr: &Projective) {
}
fn distort(s: &mut Session, p: &Value) -> Result<Value> {
let corners: Vec<Point> = p
const C: &str = "object.distort";
// All four corners or nothing, each exactly [x, y]: nothing malformed is left out.
let corners: Option<Vec<Point>> = p
.get("corners")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(|c| Some(Point::new(c.get(0)?.as_f64()?, c.get(1)?.as_f64()?))).collect())
.unwrap_or_default();
if corners.len() != 4 {
return Err(bad("object.distort", "corners must be 4 [x,y] points (TL, TR, BR, BL)"));
.filter(|a| a.len() == 4)
.and_then(|a| a.iter().map(|c| finite_numbers(c).map(|[x, y]| Point::new(x, y))).collect());
let Some(&[tl, tr, br, bl]) = corners.as_deref() else { return Err(bad(C, "corners must be 4 [x, y] points (TL, TR, BR, BL)")) };
let ids = if p.get("ids").is_some_and(|v| !v.is_null()) { checked_ids_param(s, p, "ids", C)? } else { selected_roots(s)? };
if ids.is_empty() {
return Err(bad(C, "nothing to distort"));
}
let ids = match ids_param(p, "ids") {
Some(v) => v,
None => selected_roots(s)?,
};
let src = match p.get("from").and_then(Value::as_array) {
Some(a) if a.len() == 4 => {
let f: Vec<f64> = a.iter().filter_map(Value::as_f64).collect();
if f.len() != 4 {
return Err(bad("object.distort", "from must be [x0,y0,x1,y1]"));
}
Rect::new(f[0], f[1], f[2], f[3])
let src = match p.get("from").filter(|v| !v.is_null()) {
None => s.doc()?.doc.bounds_of(&ids, false).ok_or_else(|| bad(C, "nothing to distort"))?,
Some(v) => {
let [x0, y0, x1, y1] = finite_numbers(v).ok_or_else(|| bad(C, "from must be [x0, y0, x1, y1]"))?;
Rect::new(x0, y0, x1, y1)
}
_ => s.doc()?.doc.bounds_of(&ids, false).ok_or_else(|| EngineError::Other("nothing to distort".into()))?,
};
if src.width().abs() < 1e-9 || src.height().abs() < 1e-9 {
return Err(EngineError::Other("cannot distort a zero-size bounding box".into()));
if !src.width().is_finite() || !src.height().is_finite() || src.width().abs() < 1e-9 || src.height().abs() < 1e-9 {
return Err(bad(C, "cannot distort a zero-size or non-finite bounding box"));
}
let pr = Projective::from_rect(src, [corners[0], corners[1], corners[2], corners[3]]);
let pr = Projective::from_rect(src, [tl, tr, br, bl]);
s.edit("Free Distort", |d, _| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
+8
View File
@@ -346,6 +346,14 @@ impl DocState {
// A sublayer takes new art only while it and the layers around it are shown and unlocked.
self.active_layer.filter(|l| self.doc.node(*l).is_some_and(|n| n.is_layer()) && self.doc.is_editable(*l)).or_else(|| self.doc.default_layer())
}
/// [`Self::insertion_parent`] for new art, which a locked or hidden layer never takes: an error
/// when no layer is shown and unlocked, so the art is refused rather than hidden or locked away.
pub fn target_parent(&self) -> Result<Option<NodeId>> {
match self.insertion_parent() {
Some(p) if !self.doc.is_editable(p) => Err(EngineError::Other("the target layer is locked or hidden".into())),
parent => Ok(parent),
}
}
/// The highlighted Layers panel rows that still exist (ids are reused after undo, so a
/// remembered row must still be in the document).
pub fn highlighted_rows(&self) -> Vec<NodeId> {
+131
View File
@@ -134,6 +134,74 @@ fn fill_and_stroke_commands() {
s.execute("paint.setFill", &json!({"gradient": {"kind": "radial"}})).unwrap();
}
#[test]
fn stroke_rejects_invalid_dash_components_and_preserves_defaults_on_error() {
let mut s = session();
let id = rect(&mut s, 0.0, 0.0, 40.0, 30.0);
s.execute("stroke.set", &json!({"weight": 3, "dash": [6, 3]})).unwrap();
let previous_weight = s.paint.stroke_width;
let previous_stroke = s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().cloned();
for params in [
json!({"weight": 10, "dash": [6, "not a number", 3]}),
json!({"weight": 10, "ids": "not an array"}),
json!({"weight": 10, "item": "invalid"}),
json!({"weight": 10, "arrowAlign": "unknown"}),
] {
assert!(s.execute("stroke.set", &params).is_err(), "{params} must fail");
assert_eq!(s.paint.stroke_width, previous_weight, "failed stroke edit changed new-art defaults");
assert_eq!(s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().cloned(), previous_stroke);
}
// A valid dash list still updates the selected stroke and new-art default width.
s.execute("stroke.set", &json!({"weight": 7, "dash": [4, 2]})).unwrap();
assert_eq!(s.paint.stroke_width, 7.0);
assert_eq!(s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().width, 7.0);
// With nothing selected the panel sets up the next object drawn: a rejected edit doesn't.
s.execute("select.none", &json!({})).unwrap();
let next = s.new_art();
assert!(s.execute("stroke.set", &json!({"weight": 12, "dash": [2, 2], "arrowAlign": "unknown"})).is_err());
assert_eq!(s.new_art(), next);
s.execute("stroke.set", &json!({"weight": 12, "dash": [2, 2]})).unwrap();
let next = s.new_art();
let stroke = next.stroke().unwrap();
assert_eq!((stroke.width, stroke.dash.as_ref().map(|d| d.pattern.clone())), (12.0, Some(vec![2.0, 2.0])));
}
#[test]
fn rejected_paint_edits_leave_new_art_defaults_and_focus_unchanged() {
let mut s = session();
let id = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap();
s.execute("paint.setStroke", &json!({"color": "#ff0000"})).unwrap();
let defaults = s.paint.clone();
let focused = s.fill_active;
let original_fill = s.doc().unwrap().doc.node(id).unwrap().appearance.fill_paint();
let original_stroke = s.doc().unwrap().doc.node(id).unwrap().appearance.stroke_paint();
// Both shortcut commands used to change defaults before checking invalid ids.
for (command, params) in [
("paint.swap", json!({"ids": "not an array"})),
("paint.default", json!({"ids": ["not an object id"]})),
("paint.setFill", json!({"color": "#123456", "ids": [false]})),
("paint.setStroke", json!({"color": "#123456", "ids": "not an array"})),
("paint.setFill", json!({"color": "#123456", "item": "invalid"})),
] {
assert!(s.execute(command, &params).is_err(), "{command} should reject {params}");
assert_eq!(s.paint, defaults, "{command} changed new-art defaults on failure");
assert_eq!(s.fill_active, focused, "{command} changed the active proxy on failure");
let appearance = &s.doc().unwrap().doc.node(id).unwrap().appearance;
assert_eq!(appearance.fill_paint(), original_fill);
assert_eq!(appearance.stroke_paint(), original_stroke);
}
// Successful operations still update both the selected object and new-art defaults.
s.execute("paint.swap", &json!({})).unwrap();
assert_eq!(s.paint.fill, defaults.stroke);
assert_eq!(s.paint.stroke, defaults.fill);
}
#[test]
fn select_same_fill() {
let mut s = session();
@@ -892,3 +960,66 @@ fn given_targets_are_used_as_given_and_range_style_takes_the_selected_type() {
assert!(s.execute("text.setRangeStyle", &json!({"start": 0, "end": 3, "size": 40})).is_err(), "two type objects: which one?");
assert!(s.execute("text.setRangeStyle", &json!({"id": "two", "size": 40})).is_err());
}
#[test]
fn malformed_anchor_selection_never_replaces_or_partially_changes_selection() {
let mut s = session();
let a = rect(&mut s, 10.0, 10.0, 30.0, 20.0);
let b = rect(&mut s, 50.0, 10.0, 30.0, 20.0);
let expected = s.doc().unwrap().selection.objects.clone();
let expected_anchors = s.doc().unwrap().selection.anchors.clone();
for p in [
json!({"id": a.0, "anchors": [[0, 0], ["bad", 2]]}),
json!({"id": a.0, "anchors": [[0, 0, 1]]}),
json!({"id": a.0, "anchors": "not an array"}),
json!({"id": u64::MAX, "anchors": []}),
json!({"id": a.0, "anchors": [], "mode": "missing"}),
] {
assert!(s.execute("select.anchors", &p).is_err(), "{p}");
assert_eq!(s.doc().unwrap().selection.objects, expected);
assert_eq!(s.doc().unwrap().selection.anchors, expected_anchors);
}
for p in [
json!({"items": [{"id": a.0, "anchors": [[0, 0]]}, {"id": b.0, "anchors": [[0, "bad"]]}]}),
json!({"items": [{"id": a.0, "anchors": [[0, 0]]}, {"id": "bad", "anchors": [[0, 0]]}]}),
json!({"items": [{"id": u64::MAX, "anchors": []}]}),
json!({"items": [{"id": a.0}]}),
] {
assert!(s.execute("select.anchorsMany", &p).is_err(), "{p}");
assert_eq!(s.doc().unwrap().selection.objects, expected);
assert_eq!(s.doc().unwrap().selection.anchors, expected_anchors);
}
s.execute("select.anchorsMany", &json!({"items": [{"id": a.0, "anchors": [[0, 0]]}]})).unwrap();
assert!(s.doc().unwrap().selection.contains(a));
assert!(!s.doc().unwrap().selection.contains(b));
}
#[test]
fn path_commands_reject_bad_anchors_before_modifying_geometry() {
let mut s = session();
let a = rect(&mut s, 10.0, 20.0, 50.0, 30.0);
let original_points: Vec<_> = s.doc().unwrap().doc.node(a).unwrap().path_data().unwrap().anchors().map(|(_, _, an)| an.p).collect();
let history = s.doc().unwrap().history.undo.len();
let valid = json!({"x": 10, "y": 20});
for invalid in [
json!({"x": "bad", "y": 40}),
json!({"x": 50}),
json!({"x": 50, "y": 40, "in": [0]}),
json!({"x": 50, "y": 40, "out": [1, "bad"]}),
json!({"x": 50, "y": 40, "smooth": "yes"}),
] {
let anchors = json!([valid.clone(), invalid]);
assert!(s.execute("path.create", &json!({"anchors": anchors})).is_err(), "{anchors}");
assert!(s.execute("path.setAnchors", &json!({"id": a.0, "subpaths": [{"anchors": anchors}]})).is_err(), "{anchors}");
let points: Vec<_> = s.doc().unwrap().doc.node(a).unwrap().path_data().unwrap().anchors().map(|(_, _, an)| an.p).collect();
assert_eq!(points, original_points);
assert_eq!(s.doc().unwrap().history.undo.len(), history);
}
assert!(s.execute("path.setAnchors", &json!({"id": a.0, "subpaths": [{"anchors": []}, {"closed": true}]})).is_err());
assert!(s.execute("path.setAnchors", &json!({"id": a.0, "subpaths": [{"anchors": [valid.clone()], "closed": "yes"}]})).is_err());
// The Pen's append reads its anchor the same way.
assert!(s.execute("path.appendAnchor", &json!({"id": a.0, "x": 5, "y": 5, "out": [1]})).is_err());
assert_eq!(s.doc().unwrap().history.undo.len(), history);
s.execute("path.setAnchors", &json!({"id": a.0, "subpaths": [{"anchors": [{"x": 0, "y": 0}, {"x": 30, "y": 40}], "closed": false}]})).unwrap();
assert_eq!(s.doc().unwrap().doc.node(a).unwrap().path_data().unwrap().anchor_count(), 2);
}
+18
View File
@@ -265,3 +265,21 @@ fn assets_export_into_a_folder() {
assert!(dir.join("2x/Logo@2x.png").is_file() && dir.join("PDF/Logo.pdf").is_file());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn collecting_explicit_assets_rejects_invalid_ids_without_adding_anything() {
let mut s = session();
let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0);
let b = rect(&mut s, 50.0, 10.0, 20.0, 20.0);
let original_selection = s.doc().unwrap().selection.objects.clone();
let before = undo_len(&s);
for ids in [json!([a, "not an object"]), json!([a, 3.5]), json!([a, u64::MAX]), json!({"id": a})] {
assert!(s.execute("assets.add", &json!({"ids": ids})).is_err(), "invalid ids: {ids}");
assert!(assets(&mut s).is_empty(), "invalid ids must not collect only the valid subset");
assert_eq!(undo_len(&s), before);
assert_eq!(s.doc().unwrap().selection.objects, original_selection);
}
// Valid ids still collect art in document paint order.
assert_eq!(run(&mut s, "assets.add", json!({"ids": [b, a]}))["added"], 2);
assert_eq!(assets(&mut s).len(), 2);
}
+18
View File
@@ -118,6 +118,24 @@ fn line_cut_that_misses_changes_nothing() {
assert!(s.execute("path.lineCut", &json!({"from": [5, 5]})).is_err());
}
#[test]
fn rect_cut_rejects_non_numeric_rectangle_elements_without_modifying_art() {
let mut s = session();
let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
select(&mut s, &[id]);
let original = bounds(&s, id);
let history = undo_depth(&s);
// The old parser dropped the string and used the four remaining values.
for params in [json!([0, "ignored", 10, 50, 50]), json!([0, 10, 50, null, 50]), json!([0, 10, 50, 50, 5])] {
assert!(s.execute("path.rectCut", &json!({"rect": params})).is_err());
assert!(near(bounds(&s, id), original), "failed cut must not change geometry");
assert_eq!(undo_depth(&s), history, "failed cut must not add undo history");
}
// Proper rectangular cuts still work.
s.execute("path.rectCut", &json!({"rect": [10, 10, 70, 70]})).unwrap();
assert!(near(bounds(&s, id), Rect::new(10.0, 10.0, 80.0, 80.0)));
}
#[test]
fn rect_cut_crops_to_real_geometry() {
let mut s = session();
+30
View File
@@ -78,6 +78,36 @@ fn art_in_a_sublayer_is_selected_as_objects_not_the_sublayer() {
assert!(s.doc().unwrap().doc.node(a).is_none());
}
/// #1002: with no layer shown and unlocked, new art is refused (the document unchanged) rather
/// than put on a hidden or locked layer; another layer that takes art still gets it.
#[test]
fn new_art_is_refused_when_no_layer_is_shown_and_unlocked() {
for prop in ["visible", "locked"] {
let mut s = session();
let layer = s.doc().unwrap().doc.layers[0].id;
let copied = rect(&mut s, 0.0);
run(&mut s, "select.set", json!({"ids": [copied.0]}));
run(&mut s, "edit.copy", json!({}));
run(&mut s, "select.none", json!({}));
run(&mut s, "layer.setProps", json!({"ids": [layer.0], prop: prop == "locked"}));
let before = s.doc().unwrap().doc.clone();
for (cmd, p) in [
("shape.ellipse", json!({"x": 10, "y": 10, "width": 20, "height": 20})),
("shape.polarGrid", json!({"x": 10, "y": 10, "width": 20, "height": 20})),
("text.create", json!({"x": 10, "y": 40, "text": "A"})),
("edit.paste", json!({})),
] {
let e = s.execute(cmd, &p).unwrap_err();
assert_eq!(e.to_string(), "the target layer is locked or hidden", "{prop} {cmd}");
}
assert_eq!(s.doc().unwrap().doc.layers, before.layers, "{prop}: nothing was added");
let other = id(&run(&mut s, "layer.new", json!({})));
run(&mut s, "layer.setCurrent", json!({"id": layer.0}));
let n = rect(&mut s, 50.0);
assert_eq!(parent(&s, n), Some(other), "{prop}");
}
}
#[test]
fn clicking_rows_at_any_depth_highlights_them_and_sets_the_current_layer() {
let mut s = session();
+7
View File
@@ -56,6 +56,13 @@ fn edit_gradient_angle_reverse_and_errors() {
s.execute("paint.editGradient", &json!({"stroke": false, "reverse": true})).unwrap();
let Paint::Gradient(g) = node(&s, id).appearance.fill_paint() else { panic!() };
assert_eq!(g.gradient.stops[0].color.to_hex(), "#000000");
// #1007: an off-centre midpoint is mirrored, not reset.
let stops = json!([{"offset": 0, "color": "#ff0000", "midpoint": 0.2}, {"offset": 1, "color": "#0000ff"}]);
s.execute("paint.editGradient", &json!({"stroke": false, "stops": stops})).unwrap();
s.execute("paint.editGradient", &json!({"stroke": false, "reverse": true})).unwrap();
let Paint::Gradient(g) = node(&s, id).appearance.fill_paint() else { panic!() };
assert_eq!(g.gradient.stops[0].color.to_hex(), "#0000ff");
assert!((g.gradient.stops[0].midpoint - 0.8).abs() < 1e-6, "{:?}", g.gradient.stops);
assert!(s.execute("paint.editGradient", &json!({"stops": [{"offset": 0, "color": "#fff"}]})).is_err());
assert!(s.execute("paint.editGradient", &json!({"kind": "conic"})).is_err());
}
+31
View File
@@ -57,6 +57,37 @@ fn serialize(s: &mut Session, format: &str) -> Vec<u8> {
vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()
}
#[test]
fn malformed_place_coordinates_are_rejected_instead_of_silently_changed() {
let mut s = session();
let png = png(4, 2, [20, 40, 60, 255], None);
let original = s.doc().unwrap().doc.layers.clone();
// Previously filter_map dropped the string from a five-element rectangle and
// treated the remaining four values as a different, valid placement.
for rect in [json!([0, "ignored", 10, 40, 50]), json!([10, 20, 40, null, 50]), json!([10, 20, 40, 50, 60])] {
let mut params = file("tiny.png", &png);
params["rect"] = rect;
assert!(s.execute("file.place", &params).is_err(), "{params}");
assert_eq!(s.doc().unwrap().doc.layers, original, "failed Place must not add art");
}
// An explicitly supplied invalid position formerly behaved as though no
// position were supplied and centred the image on the artboard.
for at in [json!(["not-a-number", 20]), json!([10, null]), json!([10, 20, 30]), json!("invalid")] {
let mut params = file("tiny.png", &png);
params["at"] = at;
assert!(s.execute("file.place", &params).is_err(), "{params}");
assert_eq!(s.doc().unwrap().doc.layers, original);
}
let mut params = file("tiny.png", &png);
params["rect"] = json!([10, 20, 80, 60]);
let placed = s.execute("file.place", &params).unwrap();
assert_eq!(placed["ids"].as_array().unwrap().len(), 1);
// A null position is no position, as for the other optional params.
let mut params = file("tiny.png", &png);
params["at"] = Value::Null;
assert_eq!(s.execute("file.place", &params).unwrap()["ids"].as_array().unwrap().len(), 1);
}
#[test]
fn a_300_px_png_at_300_ppi_places_as_72_pt_centred_selected_and_undoable() {
let mut s = session();
+3
View File
@@ -236,6 +236,9 @@ fn reshape_area_takes_untrusted_params() {
json!({"id": id.0, "dx": 1, "dy": 1}),
json!({"id": id.0, "anchors": [], "dx": 1, "dy": 1}),
json!({"id": id.0, "anchors": [["a", 1], [0], "x"], "dx": 1, "dy": 1}),
// A malformed reference among good ones fails rather than moving only the good ones.
json!({"id": id.0, "anchors": [[0, 2], [0, "3"]], "dx": 1, "dy": 1}),
json!({"id": id.0, "anchors": [[0, 2], [0, 3, 1]], "dx": 1, "dy": 1}),
json!({"id": id.0, "anchors": [[0, 9]], "dx": 1, "dy": 1}),
json!({"id": id.0, "anchors": [[0, 1], [7, 0]], "dx": 1, "dy": 1}),
json!({"id": id.0, "anchors": [[0, 2]], "dy": 1}),
+24
View File
@@ -572,3 +572,27 @@ fn copy_from_requires_source() {
assert!(s.execute("appearance.copyFrom", &json!({})).is_err());
assert!(s.execute("appearance.copyFrom", &json!({"source": 9999})).is_err());
}
#[test]
fn distort_rejects_malformed_coordinates_and_ids_without_editing_art() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
let original = bounds(&s, a);
let history = undo_len(&s);
let corners = json!([[10, 0], [90, 0], [100, 100], [0, 100]]);
for p in [
json!({"corners": [[10, 0, 50], [90, 0], [100, 100], [0, 100]]}),
json!({"corners": [[10, 0], [90, 0], ["bad", 100], [0, 100]]}),
json!({"corners": [[10, 0], [90, 0], [100, 100], [0, 100], ["bad"]]}),
json!({"corners": corners, "from": [0, 0, 100]}),
json!({"corners": corners, "from": "not bounds"}),
json!({"corners": corners, "ids": [a.0, "bad"]}),
json!({"corners": corners, "ids": [a.0, u64::MAX]}),
] {
assert!(s.execute("object.distort", &p).is_err(), "invalid input: {p}");
assert!(close(bounds(&s, a), original));
assert_eq!(undo_len(&s), history);
}
s.execute("object.distort", &json!({"corners": corners, "from": [0, 0, 100, 100], "ids": [a.0]})).unwrap();
assert_eq!(undo_len(&s), history + 1);
}
+16
View File
@@ -312,6 +312,22 @@ fn inspect_document_reports_the_paint_of_type() {
assert_eq!((t["fill"].as_str(), t["stroke"].as_str(), t["strokeWidth"].as_f64()), (Some("#0000ff"), Some("#00ff00"), Some(3.0)), "{t}");
}
/// #1003: type made on or in a path takes add_text's `color` too, over the current fill.
#[test]
fn add_text_on_or_in_a_path_takes_its_color() {
for mode in ["onPath", "area"] {
let mut s = server();
let r = call(&mut s, 2, "draw_shape", json!({"shape": "rectangle", "x": 40, "y": 40, "width": 180, "height": 60, "fill": "#0000ff"}));
let path = serde_json::from_str::<Value>(&text_of(&r)).unwrap()["id"].clone();
let r = call(&mut s, 3, "add_text", json!({"text": "Hello", "path": path, "mode": mode, "color": "#ff0000"}));
assert_eq!(r["isError"], false, "{r}");
let id = serde_json::from_str::<Value>(&text_of(&r)).unwrap()["id"].clone();
let v: Value = serde_json::from_str(&text_of(&call(&mut s, 4, "inspect_document", json!({})))).unwrap();
let t = v["layers"][0]["children"].as_array().unwrap().iter().find(|c| c["id"] == id).cloned().unwrap();
assert_eq!(t["fill"].as_str(), Some("#ff0000"), "{mode}: {t}");
}
}
/// save_file says it saves in the document's own format or the one the path's extension picks.
#[test]
fn save_file_describes_the_formats_it_writes() {
+1 -4
View File
@@ -574,7 +574,7 @@ fn select_ids(b: &mut dyn Backend, a: &Args) -> Result<(), String> {
fn add_text(b: &mut dyn Backend, a: &Args) -> Result<Value, String> {
let text = a.get("text").and_then(Value::as_str).ok_or("missing string argument `text`")?;
let mut style = json!({});
for k in ["size", "font"] {
for k in ["size", "font", "color"] {
if let Some(v) = a.get(k) {
style[k] = v.clone();
}
@@ -592,9 +592,6 @@ fn add_text(b: &mut dyn Backend, a: &Args) -> Result<Value, String> {
for (k, v) in style.as_object().into_iter().flatten() {
p[k.as_str()] = v.clone();
}
if let Some(c) = a.get("color") {
p["color"] = c.clone();
}
if let (Some(w), Some(h)) = (a.get("width"), a.get("height")) {
p["area"] = json!({"width": w, "height": h});
}
+2
View File
@@ -97,6 +97,8 @@ impl RasterExportOptions {
anti_alias: self.anti_alias,
// Opaque art stays exactly opaque where translucent edges cross it (#787).
precise: true,
// Shapes meeting edge to edge leave no seam (#983).
supersample: true,
..Default::default()
}
}
+53
View File
@@ -114,6 +114,11 @@ pub struct RenderOptions {
/// opaque, where 8-bit compositing leaves alpha 254 on some anti-aliased edges (#787). Off,
/// the faster 8-bit pipeline (the canvas).
pub precise: bool,
/// Exports: Art Optimized edges are supersampled ([`supersample_factor`]): the art is drawn
/// with hard edges at several times the size and averaged down, so shapes that meet edge to
/// edge leave no seam of the background between them (#983). Off, edges are smoothed shape by
/// shape (the canvas).
pub supersample: bool,
}
/// The opacity of the art around an isolated group or layer.
@@ -186,10 +191,48 @@ impl Default for RenderOptions {
smooth_images: true,
isolated: None,
precise: false,
supersample: false,
}
}
}
/// The most pixels a supersampled render draws (the frame and its compositing buffers hold a few
/// times this many bytes).
const MAX_SUPERSAMPLED_PIXELS: u64 = 40_000_000;
/// How many times wider and taller a `w`×`h` render is drawn when supersampled: 4, or less where
/// that would pass [`MAX_SUPERSAMPLED_PIXELS`] or the renderer's 65,535-pixel side; `None` when not
/// even twice fits (the edges are then smoothed shape by shape).
pub fn supersample_factor(w: u32, h: u32) -> Option<u32> {
(2..=4u32).rev().find(|k| {
let (sw, sh) = (u64::from(w) * u64::from(*k), u64::from(h) * u64::from(*k));
sw <= u64::from(u16::MAX) && sh <= u64::from(u16::MAX) && sw * sh <= MAX_SUPERSAMPLED_PIXELS
})
}
/// Average each `k`×`k` block of `src` (`w`×`h` pixels of 4 premultiplied bytes) into one pixel.
fn downsample(src: &[u8], w: usize, h: usize, k: usize) -> Vec<u8> {
let (ow, oh) = (w / k.max(1), h / k.max(1));
let n = (k * k).max(1) as u32;
let mut out = vec![0u8; ow * oh * 4];
for (o, px) in out.as_chunks_mut::<4>().0.iter_mut().enumerate() {
let (ox, oy) = (o % ow.max(1), o / ow.max(1));
let mut acc = [0u32; 4];
for y in oy * k..(oy + 1) * k {
let row = y * w + ox * k;
for p in src.get(row * 4..(row + k) * 4).unwrap_or_default().as_chunks::<4>().0 {
for (a, v) in acc.iter_mut().zip(p) {
*a += u32::from(*v);
}
}
}
for (d, a) in px.iter_mut().zip(acc) {
*d = ((a + n / 2) / n).min(255) as u8;
}
}
out
}
/// A rendered image (premultiplied RGBA8, row-major).
pub struct Rendered {
pub width: u32,
@@ -476,6 +519,16 @@ impl Renderer {
/// screen colours (4 bytes a pixel, see [`Self::render_region_inks`]).
fn render_as(&mut self, doc: &Document, width: u32, height: u32, view: Affine, opts: &RenderOptions, inks: bool) -> Rendered {
let start = now();
if opts.supersample && opts.anti_alias == AntiAlias::Art && opts.mask_view.is_none() {
let (w, h) = (width.clamp(1, u16::MAX as u32), height.clamp(1, u16::MAX as u32));
if let Some(k) = supersample_factor(w, h) {
let hard = RenderOptions { anti_alias: AntiAlias::None, supersample: false, ..opts.clone() };
let big = self.render_as(doc, w * k, h * k, Affine::scale(f64::from(k)) * view, &hard, inks);
let pixels = downsample(&big.pixels, big.width as usize, big.height as usize, k as usize);
self.stats.micros = now().saturating_sub(start);
return Rendered { width: w, height: h, pixels };
}
}
let prepared = proof::prepare(doc, opts);
let doc: &Document = &prepared;
let w = width.clamp(1, u16::MAX as u32) as u16;
+38
View File
@@ -385,3 +385,41 @@ fn images_take_their_nearest_pixel_without_smoothing() {
assert!(greys(true) >= 4, "smooth: a ramp from black to white");
assert_eq!(greys(false), 0, "nearest: black, then white");
}
/// Art Optimized exports are supersampled (#983): two red shapes meeting along a diagonal leave no
/// seam of the white background between them, where shape-by-shape smoothing shows one; the outer
/// edges stay smooth.
#[test]
fn supersampled_art_leaves_no_seam_between_shapes_that_meet() {
let red = || Paint::solid(Color::rgb(1.0, 0.0, 0.0));
let mut d = Document::new(100.0, 100.0);
let layer = d.layers[0].id;
// Two triangles sharing the diagonal of the square (10, 10)–(90, 90).
for pts in [[(10.0, 10.0), (90.0, 10.0), (90.0, 90.0)], [(10.0, 10.0), (90.0, 90.0), (10.0, 90.0)]] {
let mut b = vectorcraft_geom::BezPath::new();
b.move_to(pts[0]);
b.line_to(pts[1]);
b.line_to(pts[2]);
b.close_path();
let id = d.alloc_id();
d.insert(Some(layer), 0, Node::path(id, vectorcraft_geom::PathData::from_bezpath(&b), Appearance::basic(red(), Paint::None, 0.0))).unwrap();
}
let draw = |supersample: bool| {
let opts = RenderOptions { background: Some([255, 255, 255, 255]), precise: true, supersample, ..Default::default() };
Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts)
};
// The palest pixel on the shared diagonal, away from the corners.
let palest = |r: &Rendered| (20..80).map(|i| r.pixel(i, i)[1].max(r.pixel(i + 1, i)[1])).max().unwrap();
assert!(palest(&draw(false)) > 20, "shape-by-shape smoothing shows the seam");
assert_eq!(palest(&draw(true)), 0, "supersampled: no seam");
// The outer edge is still smoothed: a pixel half covered is between white and red.
let ss = draw(true);
let edge = ss.pixel(50, 9);
assert!(edge == [255, 255, 255, 255] || (edge[1] > 0 && edge[1] < 255) || ss.pixel(50, 10)[1] < 255, "{edge:?}");
// The factor fits the size and the renderer's limits.
assert_eq!(supersample_factor(100, 100), Some(4));
assert_eq!(supersample_factor(2000, 2000), Some(3));
assert_eq!(supersample_factor(3000, 3000), Some(2));
assert_eq!(supersample_factor(5000, 5000), None);
assert_eq!(supersample_factor(40_000, 10), None);
}
+4 -12
View File
@@ -2,7 +2,7 @@
//! repeated (every other period mirrored, for reflect) as many times as the painted area needs,
//! and the gradient stretched over those periods.
use vectorcraft_color::{GradientGeom, GradientKind, GradientStop};
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientStop};
use vectorcraft_geom::{Point, Rect};
/// Most periods a gradient is expanded to.
@@ -80,17 +80,9 @@ fn whole_period(stops: &[GradientStop]) -> Vec<GradientStop> {
/// A period mirrored: the stops in reverse, each midpoint measured from the other side.
fn mirror(period: &[GradientStop]) -> Vec<GradientStop> {
let rev: Vec<&GradientStop> = period.iter().rev().collect();
rev.iter()
.enumerate()
.map(|(i, s)| GradientStop {
offset: 1.0 - s.offset,
// The midpoint from this stop to the next one is the mirror of the one the next stop
// had towards this one.
midpoint: rev.get(i + 1).map_or(0.5, |next| 1.0 - next.midpoint),
..(*s).clone()
})
.collect()
let mut g = Gradient { kind: GradientKind::Linear, stops: period.to_vec() };
g.reverse();
g.stops
}
#[cfg(test)]
+20 -3
View File
@@ -232,9 +232,15 @@ impl Tool for CropImageTool {
if key != "rect" {
return;
}
let n: Vec<f64> = value.as_array().map(|v| v.iter().filter_map(Value::as_f64).filter(|n| n.is_finite()).collect()).unwrap_or_default();
self.rect = match n.as_slice() {
[x, y, w, h] => Some((None, Rect::new(*x, *y, x + w, y + h))),
// Invalid components must discard the whole box, not silently shift the
// coordinates by dropping a non-numeric entry.
let numbers = value
.as_array()
.filter(|a| a.len() == 4)
.and_then(|a| a.iter().map(Value::as_f64).collect::<Option<Vec<_>>>())
.filter(|a| a.iter().all(|n| n.is_finite()));
self.rect = match numbers.as_deref() {
Some([x, y, w, h]) => Some((None, Rect::new(*x, *y, x + w, y + h))),
_ => None,
};
}
@@ -336,6 +342,17 @@ mod tests {
assert!(t.overlays(&cx).is_empty() && !t.claims_key(&cx, ToolKey::Enter));
}
#[test]
fn crop_box_option_requires_exactly_four_finite_numeric_values() {
let mut tool = CropImageTool::default();
for invalid in [json!([10, "ignored", 20, 30, 40]), json!([10, 20, 30, null, 40]), json!([10, 20, 30, 40, 50]), json!("not a rectangle")] {
tool.set_option("rect", &invalid);
assert!(tool.options().get("rect").is_none(), "invalid box should reset crop: {invalid}");
}
tool.set_option("rect", &json!([10, 20, 30, 40]));
assert_eq!(tool.options()["rect"], json!([10.0, 20.0, 30.0, 40.0]));
}
#[test]
fn the_tool_is_made_by_its_id() {
assert_eq!(crate::create(ID).id(), ID);
+44 -3
View File
@@ -16,8 +16,8 @@
//! - `ui.dialog.set {field, value}` / `ui.dialog.confirm` / `ui.dialog.cancel`
//! - `ui.resize {width, height}`, `ui.focus`, `ui.screenshot {path?, data?}` (`data: true`: the PNG
//! comes back as `pngBase64` as well)
//! - `ui.render {path?, scale?, data?}`: render the active artboard headlessly (PNG; without a path,
//! or with `data: true`, as `pngBase64`)
//! - `ui.render {path?, scale?, artboard?, data?}`: render an artboard headlessly (`artboard`: its
//! 0-based index, default the first; PNG; without a path, or with `data: true`, as `pngBase64`)
//! - `app.open {path}` (any readable format) / `app.save {path?, svg?: {…SVG options}}` / `app.quit`
//! (`file.close`, `file.closeAll` and `app.quit` first open a `saveChanges` dialog for each
//! modified document: `ui.dialog.confirm` saves, set `discard: true` then confirm to discard)
@@ -321,7 +321,9 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
"ui.render" => {
let Some(st) = app.session.active() else { return err("no document") };
let doc = st.doc.clone();
let Some(r) = doc.artboards.first().map(|a| a.rect) else { return err("no artboard") };
// The artboard asked for (0-based, as headless), else the first.
let index = p.get("artboard").and_then(Value::as_u64).map_or(Some(0), |i| usize::try_from(i).ok());
let Some(r) = index.and_then(|i| doc.artboards.get(i)).map(|a| a.rect) else { return err("no such artboard") };
let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0);
if let Err(e) = vectorcraft_render::raster_size(r, scale) {
return err(e);
@@ -390,3 +392,42 @@ pub fn save_screenshot(app: &mut VectorcraftApp, image: &egui::ColorImage, path:
None => json!({"ok": false, "error": "no writer configured"}),
}
}
#[cfg(test)]
mod tests {
use serde_json::{Value, json};
use vectorcraft_engine::Session;
use super::ControlRequest;
use crate::{Services, VectorcraftApp};
/// #1004: `ui.render` (the MCP `screenshot` of a connected app) renders the artboard asked
/// for, at the scale asked for; the first without one.
#[test]
fn render_takes_the_artboard_asked_for() {
let (tx, rx) = std::sync::mpsc::channel();
let mut app = VectorcraftApp::new(Session::new(), Services::default()).with_control(rx);
let mut request = |method: &str, params: Value| {
let (req, reply) = ControlRequest::new(method, params);
tx.send(req).unwrap();
app.drain_control(&egui::Context::default());
reply.try_recv().unwrap()
};
let new = json!({"width": 64, "height": 64, "units": "Points", "artboards": 2});
request("engine.execute", json!({"command": "file.new", "params": new}));
let board = json!({"index": 1, "x": 100, "y": 0, "width": 96, "height": 48});
let r = request("engine.execute", json!({"command": "artboard.setProps", "params": board}));
assert_eq!(r["ok"], true, "{r}");
for (params, size) in [
(json!({}), [64, 64]),
(json!({"artboard": 0}), [64, 64]),
(json!({"artboard": 1}), [96, 48]),
(json!({"artboard": 1, "scale": 0.5}), [48, 24]),
] {
let r = request("ui.render", params.clone());
let got = [&r["result"]["width"], &r["result"]["height"]].map(Value::as_u64);
assert_eq!(got, size.map(Some), "{params}: {r}");
}
assert!(request("ui.render", json!({"artboard": 2}))["error"].is_string());
}
}
+24 -1
View File
@@ -248,7 +248,7 @@ pub(super) fn param_fields(
match v {
Value::Number(n) => {
if let Some(x) = crate::widgets::plain_field(ui, ("fx-num", &k), n.as_f64().unwrap_or(0.0), "", 3, 140.0) {
d.fields.insert(k, json!(x));
d.fields.insert(k, typed_number(x));
changed = true;
}
}
@@ -291,6 +291,13 @@ pub(super) fn param_fields(
changed
}
/// A number typed in a command's dialog as JSON: a whole number as an integer, as counts are read
/// (`as_u64`: Object Mosaic's columns and rows, #1000), which `12.0` isn't; a fraction stays one.
/// Commands that take any number read either.
fn typed_number(x: f64) -> Value {
if x.fract() == 0.0 && x.abs() < 1e15 { json!(x as i64) } else { json!(x) }
}
/// Editor for a plug-in's parameters from its schema (plug-in filter and effect dialogs): numbers
/// within their range, whole numbers, checkboxes and dropdowns, in declaration order. Returns
/// true when a value changed. The labels (from the parameter keys) and choices are the plug-in's
@@ -552,3 +559,19 @@ pub(super) fn caption(ui: &mut egui::Ui, text: &str) {
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new(tl!(text)).size(11.0).color(t.text_dim));
}
#[cfg(test)]
mod typed_number_tests {
use super::*;
#[test]
fn whole_numbers_are_integers_and_fractions_stay_fractions() {
// Whatever the field held before: a count typed after a fraction is a count again.
assert_eq!(typed_number(24.0).as_u64(), Some(24));
assert_eq!(typed_number(-4.0), json!(-4));
assert_eq!(typed_number(0.0), json!(0));
assert_eq!(typed_number(2.5), json!(2.5));
// Numbers past exact integers stay floats.
assert!(typed_number(1e20).is_f64());
}
}
+32
View File
@@ -434,6 +434,38 @@ fn a_dialog_opens_with_its_first_field_focused_so_typing_and_enter_apply() {
assert_eq!(node.appearance.effects.last().map(|e| e.params["scaleH"].clone()), Some(json!(50.0)));
}
/// #1000: a count typed in Object › Create Object Mosaic… reaches the command (a whole number typed
/// in a command's dialog is an integer), so the mosaic has that many columns, not the 10 × 10 grid.
#[test]
fn object_mosaic_makes_the_grid_typed_in_its_dialog() {
let mut app = app();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 60, "height": 40})).unwrap();
app.run("object.rasterize", json!({"ppi": 72})).unwrap();
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
theme::apply(&ctx, Default::default());
let frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui.ctx()));
out.textures_delta.clear();
};
let entry = crate::menus::menu_entries(&app).into_iter().find(|e| e.label == "Create Object Mosaic…").expect("menu item");
let (cmd, params) = crate::menus::click_target(&entry.label, entry.command.as_deref().unwrap(), &entry.params);
crate::menus::invoke(&mut app, &cmd, params);
// Its first field, Columns, is focused: type 3 over it and press Enter.
frame(&mut app, vec![]);
frame(&mut app, vec![egui::Event::Text("3".into())]);
let enter = egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() };
frame(&mut app, vec![enter]);
assert!(app.ui.dialog.is_none());
let mut tiles = None;
app.session.doc().unwrap().doc.walk(|n| {
if n.name.as_deref() == Some("Object Mosaic") {
tiles = n.children().map(|c| c.len());
}
});
assert_eq!(tiles, Some(3 * 10));
}
/// #793: Artboard Options lists its fields as Name, Width, Height, not by key.
#[test]
fn artboard_options_lists_name_then_width_and_height() {
+1
View File
@@ -3656,6 +3656,7 @@ theme Appearance Erscheinungsbild
@msg the image's pixels don't match its size die Pixel des Bilds passen nicht zu seiner Größe
@msg the layer has no objects to clip by die Ebene hat keine Objekte, an denen beschnitten werden könnte
@msg the layer or group is locked die Ebene oder Gruppe ist gesperrt
@msg the target layer is locked or hidden die Zielebene ist gesperrt oder ausgeblendet
@msg the layer to flatten into is hidden die Ebene, auf die reduziert werden soll, ist ausgeblendet
@msg the object already has an opacity mask das Objekt hat bereits eine Deckkraftmaske
@msg the object has no geometry das Objekt hat keine Geometrie
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3759,6 +3759,7 @@ axis Both Ambos
@msg the image's pixels don't match its size los píxeles de la imagen no coinciden con su tamaño
@msg the layer has no objects to clip by la capa no tiene objetos con los que recortar
@msg the layer or group is locked la capa o el grupo está bloqueado
@msg the target layer is locked or hidden la capa de destino está bloqueada u oculta
@msg the layer to flatten into is hidden la capa en la que acoplar está oculta
@msg the object already has an opacity mask el objeto ya tiene una máscara de opacidad
@msg the object has no geometry el objeto no tiene geometría
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3756,6 +3756,7 @@ axis Both Les deux
@msg the image's pixels don't match its size les pixels de l’image ne correspondent pas à sa taille
@msg the layer has no objects to clip by le calque n’a aucun objet servant à l’écrêtage
@msg the layer or group is locked le calque ou le groupe est verrouillé
@msg the target layer is locked or hidden le calque cible est verrouillé ou masqué
@msg the layer to flatten into is hidden le calque de destination de l’aplatissement est masqué
@msg the object already has an opacity mask l’objet a déjà un masque d’opacité
@msg the object has no geometry l’objet n’a pas de géométrie
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3759,6 +3759,7 @@ axis Both Entrambi
@msg the image's pixels don't match its size i pixel dell'immagine non corrispondono alle sue dimensioni
@msg the layer has no objects to clip by il livello non ha oggetti con cui ritagliare
@msg the layer or group is locked il livello o il gruppo è bloccato
@msg the target layer is locked or hidden il livello di destinazione è bloccato o nascosto
@msg the layer to flatten into is hidden il livello in cui appiattire è nascosto
@msg the object already has an opacity mask l'oggetto ha già una maschera di opacità
@msg the object has no geometry l'oggetto non ha geometria
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3497,6 +3497,7 @@ theme Appearance 外観
@msg no selection bounds 選択範囲に境界がありません
@msg the layer has no objects to clip by レイヤーにクリッピングマスクとして使えるオブジェクトがありません
@msg the layer or group is locked レイヤーまたはグループがロックされています
@msg the target layer is locked or hidden ターゲットレイヤーがロックされているか非表示です
@msg the layer to flatten into is hidden 統合先のレイヤーが非表示になっています
@msg there are no objects to release レイヤーに分配するオブジェクトがありません
@msg no library `{id}` ライブラリ `{id}` が見つかりません
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3755,6 +3755,7 @@ axis Both Оба
@msg the image's pixels don't match its size пиксели изображения не соответствуют его размеру
@msg the layer has no objects to clip by в слое нет объектов для обрезки
@msg the layer or group is locked слой или группа заблокированы
@msg the target layer is locked or hidden целевой слой заблокирован или скрыт
@msg the layer to flatten into is hidden слой для объединения скрыт
@msg the object already has an opacity mask у объекта уже есть непрозрачная маска
@msg the object has no geometry у объекта нет геометрии
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3742,6 +3742,7 @@ axis Both Обидві
@msg the image's pixels don't match its size пікселі зображення не відповідають його розміру
@msg the layer has no objects to clip by у шарі немає об’єктів для обрізки
@msg the layer or group is locked шар чи група заблоковані
@msg the target layer is locked or hidden цільовий шар заблокований або прихований
@msg the layer to flatten into is hidden шар для об’єднання прихований
@msg the object already has an opacity mask об’єкт вже має маску непрозорості.
@msg the object has no geometry об’єкт не має геометрії
Can't render this file because it contains an unexpected character in line 659 and column 40.
+10 -8
View File
@@ -579,6 +579,16 @@ impl VectorcraftApp {
return r;
}
let mut params = params;
// Paste in place, in front, in back (#693) and Select › All on Active Artboard (#1006): onto
// or on the active artboard, the view's.
if matches!(id, "edit.pasteInPlace" | "edit.pasteInFront" | "edit.pasteInBack" | "select.allOnArtboard")
&& params.get("artboard").is_none()
&& self.view().is_some()
&& let Some(n) = self.session.active().map(|d| d.doc.artboards.len())
&& let Some(p) = params.as_object_mut()
{
p.insert("artboard".into(), serde_json::json!(panels::artboards::selected(self, n)));
}
if id.starts_with("edit.paste") {
if let Err(e) = self.adopt_system_clipboard() {
self.ui.status = e.clone();
@@ -592,14 +602,6 @@ impl VectorcraftApp {
{
p.insert("center".into(), serde_json::json!([c.x, c.y]));
}
// In place, in front, in back: onto the active artboard (#693).
if matches!(id, "edit.pasteInPlace" | "edit.pasteInFront" | "edit.pasteInBack")
&& params.get("artboard").is_none()
&& let Some(i) = self.view().map(|v| v.artboard)
&& let Some(p) = params.as_object_mut()
{
p.insert("artboard".into(), serde_json::json!(i));
}
if let Some(r) = dialogs::swatch_conflict::ask(self, id, &params) {
return r;
}
+20
View File
@@ -235,6 +235,26 @@ mod tests {
crate::menus::invoke(&mut app, "view.goToArtboard", json!({"index": "previous"}));
assert_eq!(selected(&app, 2), 0, "and the panel follows the navigator");
}
/// #1006: Select › All on Active Artboard selects the art of the artboard made active here,
/// and an explicit artboard still wins.
#[test]
fn all_on_active_artboard_takes_the_active_artboard() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 100, "height": 100, "units": "Points", "artboards": 2})).unwrap();
let mut rect = |x| app.session.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone();
let (a, b) = (rect(10), rect(130));
let picked = |app: &mut VectorcraftApp, params| {
crate::menus::invoke(app, "select.allOnArtboard", params);
let ids: Vec<u64> = app.session.active().unwrap().selection.objects.iter().map(|n| n.0).collect();
json!(ids)
};
select(&mut app, 1);
assert_eq!(picked(&mut app, json!({})), json!([b]));
select(&mut app, 0);
assert_eq!(picked(&mut app, json!({})), json!([a]));
assert_eq!(picked(&mut app, json!({"artboard": 1})), json!([b]));
}
/// Double-clicking a row's number goes to its artboard and its name renames it; a row's
/// Options button edits that artboard with the Artboard tool, and the tool's artboard is the
/// active one.
@@ -139,3 +139,37 @@ fn weight_presets_display_cleanly_in_their_native_unit() {
// Units no stroke is measured in keep the pt ladder, in points.
assert_eq!(stroke::weight_presets(Unit::Meters), stroke::weight_presets(Unit::Points));
}
/// #991: the spinner left of the Stroke Weight field steps ten with Shift on both arrows and a
/// tenth with Ctrl/Cmd, as ↑/↓ do in the focused field, while a plain click steps one point.
#[test]
fn stroke_weight_stepper_uses_ten_point_shift_steps() {
use super::tests_appearance::frame_raw;
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut app = app_with_rect();
run(&mut app, "stroke.set", json!({"weight": 14}));
let t = texts(&ctx, &mut app, stroke::show);
// The 16 px spinner sits just left of the field's text.
let r = text_rect(&t, "14 pt");
let x = r.left() - 14.0;
let click_step = |app: &mut VectorcraftApp, y: f32, modifiers: egui::Modifiers| {
let pos = egui::pos2(x, y);
let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers };
for event in [egui::Event::PointerMoved(pos), button(true), button(false)] {
let events = vec![egui::Event::ModifiersChanged(modifiers), event];
frame_raw(&ctx, app, egui::RawInput { events, ..Default::default() }, stroke::show);
}
};
click_step(&mut app, r.center().y - 5.0, egui::Modifiers::SHIFT);
assert_eq!(app.session.shown_stroke().unwrap().width, 24.0);
click_step(&mut app, r.center().y + 5.0, egui::Modifiers::NONE);
assert_eq!(app.session.shown_stroke().unwrap().width, 23.0);
click_step(&mut app, r.center().y + 5.0, egui::Modifiers::SHIFT);
assert_eq!(app.session.shown_stroke().unwrap().width, 13.0);
// Ctrl/Cmd steps a tenth, as ↑/↓ and the wheel do in the field.
click_step(&mut app, r.center().y + 5.0, egui::Modifiers::COMMAND);
assert!((app.session.shown_stroke().unwrap().width - 12.9).abs() < 1e-6);
}
+28 -2
View File
@@ -1170,8 +1170,8 @@ fn spin_generic(
&& let Some(p) = sresp.interact_pointer_pos()
&& let Some(v) = value
{
let nv = if up.contains(p) { v + step } else { v - step };
out = Some(SpinPick::Value(nv.max(min)));
let modifiers = ui.input(|i| i.modifiers);
out = Some(SpinPick::Value(spinner_step(v, step, min, up.contains(p), modifiers)));
}
let fw = if presets.is_empty() { width - 15.0 } else { width - 34.0 };
if let Some(v) = field(ui, fw) {
@@ -1198,6 +1198,14 @@ fn spin_generic(
out
}
/// Spinner buttons follow the same modifier sizes as ↑/↓ in a focused number field:
/// Shift ×10, Ctrl/Cmd ÷10, otherwise the caller's normal step.
fn spinner_step(value: f64, step: f64, min: f64, up: bool, modifiers: egui::Modifiers) -> f64 {
let multiplier = STEPS.iter().find(|(m, _)| modifiers.matches_logically(*m)).map_or(1.0, |(_, factor)| *factor);
let delta = step * multiplier;
(value + if up { delta } else { -delta }).max(min)
}
/// Drag phase of a live slider/handle: previews while dragging, commits on release.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Live {
@@ -1826,3 +1834,21 @@ pub fn text_presets(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, val
});
out
}
#[cfg(test)]
mod spinner_modifier_tests {
use super::*;
#[test]
fn shift_steps_stroke_weight_by_ten_points() {
assert_eq!(spinner_step(14.0, 1.0, 0.0, true, egui::Modifiers::SHIFT), 24.0);
assert_eq!(spinner_step(14.0, 1.0, 0.0, false, egui::Modifiers::SHIFT), 4.0);
}
#[test]
fn unmodified_and_command_steps_keep_existing_behavior() {
assert_eq!(spinner_step(14.0, 1.0, 0.0, true, egui::Modifiers::NONE), 15.0);
assert_eq!(spinner_step(14.0, 1.0, 0.0, false, egui::Modifiers::COMMAND), 13.9);
assert_eq!(spinner_step(0.0, 1.0, 0.0, false, egui::Modifiers::SHIFT), 0.0);
}
}
+1 -1
View File
@@ -56,7 +56,7 @@ the two can still win that race.
| `ui.set` | `{brightness?, panel?, rulers?, outline?, grid?, smartGuides?, boundingBox?, controlBar?}` | |
| `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value}` | fill and submit the open dialog |
| `ui.screenshot` | `{path?, data?}` | capture the window (PNG), written to `path`; `data: true` sends it back as `pngBase64` too. Needs a presented frame: with the screen locked or the window minimized/covered it fails after ~8 s with an explanatory error |
| `ui.render` | `{path?, scale?, data?}` | render the artboard headlessly (PNG): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` |
| `ui.render` | `{path?, scale?, artboard?, data?}` | render an artboard headlessly (PNG; `artboard`: its 0-based index, default the first): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` |
| `ui.resize` / `ui.focus` | | |
| `app.open` / `app.save` / `app.export` / `app.quit` | `{path}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit |
| `app.export` with `useArtboards` | `{path, useArtboards: true, range? \| artboards?, …}` | Export As: PNG, JPEG and WebP write one file per chosen artboard (default all; SVG does so for a `range`), `<path stem>-<artboard>.<ext>`, and return `{path, files: [path…]}`; a PDF keeps them as pages of `path`. `useArtboards: false` covers the bounds of the visible art. The menu's Export As… is `file.exportAs`: the `exportAs` dialog (`format`, `useArtboards`, `all`, `range`), then a save dialog and the format's options: `pngOptions` / `jpgOptions` / `webpOptions`, `svgOptions`, or `savePdf` for a PDF with Use Artboards |
+1 -2
View File
@@ -86,8 +86,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
## G11 Correctness bugs in core tools
- **Missing:** Shape Builder misses planar regions and targets whole shapes (#937, #893); Asset Export
anti-aliasing ghost borders (#983); Transform effect dialog's invisible checkboxes and missing options (#885);
- **Missing:** Shape Builder misses planar regions and targets whole shapes (#937, #893); Transform effect dialog's invisible checkboxes and missing options (#885);
PDF text boxes moving on open (#722); SVG units reverting to points (#864); bezier drag preview freezing (#834).
- **Impact:** wrong output in everyday work.
- **Estimate:** 8–15 h.