mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-19 05:34:23 -04:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
980cc4fe44 | ||
|
|
6917984329 | ||
|
|
ba2fc64d89 | ||
|
|
5cf9233d26 | ||
|
|
db23e80ec4 | ||
|
|
da010c33a1 | ||
|
|
e47d542680 | ||
|
|
d8759492df | ||
|
|
6ba5e0585e | ||
|
|
4c1e16d791 | ||
|
|
dd8f144770 | ||
|
|
6c131431ed | ||
|
|
8ae59bf6b7 | ||
|
|
b47f611e18 | ||
|
|
02d02f660f |
No files matched your search
Generated
+1
@@ -4741,6 +4741,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml",
|
||||
"vectorcraft-brush",
|
||||
"vectorcraft-cad",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
|
||||
+5
-3
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
|
||||
|
||||
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
|
||||
|
||||
_Last updated: 2026-10-10 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
|
||||
_Last updated: 2026-10-10 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
|
||||
|
||||
## Where we are
|
||||
|
||||
@@ -67,7 +67,8 @@ Ordered by how much each gap blocks someone from switching. Sizes are one-agent
|
||||
composer choice is done), tab leaders, a
|
||||
spell-check dictionary (open licence), Touch Type and Snap to Glyph. Vertical point, area and path type have
|
||||
initial support. 34–47 h across type core and advanced.
|
||||
5. **Brushes, symbols and libraries:** brush options depth; original brush, symbol and graphic-style libraries
|
||||
5. **Brushes, symbols and libraries:** brush options depth (Scatter, Art and Pattern Brush Options; Calligraphic
|
||||
and Bristle are done, Calligraphic with pen pressure); original brush, symbol and graphic-style libraries
|
||||
generated in code (never Adobe's); dynamic symbols; Start Global Edit; brushes driven by pen tilt, bearing and
|
||||
barrel rotation (6D art pens, #372), which needs tablet input beyond pressure on every platform. 20–32 h.
|
||||
6. **Automation:** Variables (data merge), a scripting surface over the command registry, batch. 8–12 h.
|
||||
@@ -157,6 +158,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel follows each colour's own model and Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). An ICC CMYK profile converts straight into the RGB working space (not through sRGB, so a wider space such as Wide Gamut RGB keeps colours sRGB can't hold) with the colour settings' black-point compensation, on the canvas and in RGB exports alike. Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB greys separate into four-colour greys and a rich black as Illustrator does, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`).
|
||||
- **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles snap and honour Constrain Angle. Gradient stops stay linked to global and spot swatches. Freeform gradients (points and lines) are edited on the canvas; radial gradients have an extent ellipse, an aspect handle and an off-centre focal point (SVG `fx`/`fy`, PDF two-point radials). A stroke's gradient runs within, along or across it. Object ▸ Expand turns gradients into N objects or a gradient mesh.
|
||||
- **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance.
|
||||
- **Brushes (part of #852):** double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens Calligraphic Brush Options (Name; Angle, Roundness and Size, each Fixed, Random or Pressure with its Variation; live previews of the nib and of a light–heavy–light stroke) or Bristle Brush Options (Shape and the bristle sliders); OK edits the brush and every stroke painted with it. The Paintbrush records the pen pressure along each stroke (kept with the path, saved, and joined when a stroke continues a path); a Calligraphic value on Pressure goes from value − variation at the lightest pressure to value + variation at the heaviest, on the canvas and in SVG, PDF and the other exports. Brushes saved before keep their look. Still to come: Scatter, Art and Pattern Brush Options, Stylus Wheel, Tilt, Bearing and Rotation, and pen pressure on macOS.
|
||||
- **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups, whose blend-mode list and opacity slider stay open inside them (#430; the Control bar's Stroke popover too); Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export. Groups, layers and type have their own fills, strokes and effects (Contents and Characters rows); the Layers panel's target circles target layers, groups and objects; Expand Appearance turns fills, strokes, effects and brushes into objects.
|
||||
- **Masks and clipping:** opacity-mask controls work while the mask is being edited; transparency commands take ids and work without a selection; clipping sets clip by compound paths, even-odd paths, text and groups on screen and in raster export, matching SVG/PDF. Alt-click shows only the mask; SVG masks keep Clip and Invert; the transparency grid is per-document state; a clipping path's own fill and stroke paint behind and over the clipped art; layers can be clipping masks.
|
||||
- **Transparency:** three-state Knockout Group (on, neutral, off), Opacity & Mask Define Knockout Shape, Page Isolated Blending and Page Knockout Group, on screen and in SVG/PDF. Blend modes match the standard formulas on screen, in SVG and in PDF, and non-isolated groups blend with the art below. Flatten Transparency with presets, a dialog and a Flattener Preview panel.
|
||||
@@ -216,7 +218,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
|
||||
| Drawing tools | 7 | 85% | rough-square Shaper recognition and orientation steps corrected; editable Shaper Groups landed; remaining: Shaper construction widget/gesture refinements, pencil and the remaining pen modifier nuances, the freehand Tool Options' Keep selected, Alt to the Smooth tool, Blob Brush Merge Only with Selection and brush angle/roundness, Touch Type, Live Corners on corners between curved sides (and Relative rounding) | 8–13 |
|
||||
| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection, options (Tool Options dialog, clicking a stroke in merge mode) and Shift-drag marquee | 3–6 |
|
||||
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform and mixed spot/process gradients in SVG/PDF (exported as stops or process colours), pattern fills in Expand | 2–4 |
|
||||
| Strokes, brushes, width profiles | 5 | 88% | brush options depth, brush libraries (generated in code), brushes driven by pen tilt, bearing and barrel rotation (6D art pens) | 12–20 |
|
||||
| Strokes, brushes, width profiles | 5 | 89% | Scatter, Art and Pattern Brush Options (Calligraphic and Bristle done), brush libraries (generated in code), pen pressure on macOS (the windowing layer reports none there), brushes driven by pen tilt, bearing and barrel rotation (6D art pens) | 10–18 |
|
||||
| Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 |
|
||||
| Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters | 6–10 |
|
||||
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 25% | ~51 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video), Smart Blur's Edge Only / Overlay Edge modes and the Effect Gallery; Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur, Unsharp Mask and Glowing Edges are done | 26–40 |
|
||||
|
||||
@@ -274,9 +274,12 @@ fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
fileio::write_atomic(std::path::Path::new(path), bytes).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Linux: show the dialog for `request` over `parent` on a thread of its own, answering on the
|
||||
/// receiver. Shown on the UI thread, nothing would answer the compositor meanwhile, which then
|
||||
/// offers to kill the window as not responding (#592).
|
||||
/// Linux and macOS: show the dialog for `request` over `parent` on a thread of its own, answering
|
||||
/// on the receiver. Shown on the UI thread, nothing would answer the compositor meanwhile on Linux,
|
||||
/// which then offers to kill the window as not responding (#592); on macOS the panel ran modally
|
||||
/// inside the window's event handler, and resizing it delivered window events to that handler
|
||||
/// again, which crashed the app (#867). From another thread, rfd runs the panel on the main thread
|
||||
/// from the run loop, outside the handler.
|
||||
fn start_pick(request: PickRequest, parent: &Parent) -> Option<std::sync::mpsc::Receiver<Vec<String>>> {
|
||||
let dialog = file_dialog(&request, parent);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
@@ -296,9 +299,9 @@ fn services(parent: Parent) -> Services {
|
||||
pick_open: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Open(pick.clone()), &p1).into_iter().next())),
|
||||
pick_open_multi: Some(Box::new(move || pick_now(PickRequest::OpenMany, &p2))),
|
||||
pick_save: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Save(pick.clone()), &p3).into_iter().next())),
|
||||
// Windows and macOS dialogs run the window's events while they are open; Linux's don't.
|
||||
start_pick: cfg!(all(unix, not(target_os = "macos")))
|
||||
.then(|| Box::new(move |request: PickRequest| start_pick(request, &p5)) as vectorcraft_ui_egui::picks::StartPick),
|
||||
// Windows dialogs run the window's events while they are open; Linux's don't, and macOS
|
||||
// ones re-enter the window's event handler (#867).
|
||||
start_pick: cfg!(unix).then(|| Box::new(move |request: PickRequest| start_pick(request, &p5)) as vectorcraft_ui_egui::picks::StartPick),
|
||||
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
|
||||
write: Some(Box::new(write_file)),
|
||||
// Background Save and Export write from a worker thread.
|
||||
|
||||
+87
-30
@@ -4,13 +4,21 @@
|
||||
//! in the directions normal to the path. For nib axes `u`, `v` with semi-axes `a`, `b` the
|
||||
//! support point in direction `d` is `(a²(u·d)u + b²(v·d)v) / √(a²(u·d)² + b²(v·d)²)`, so the
|
||||
//! stroke's local width across direction `n` is `2·√(a²(u·n)² + b²(v·n)²)`.
|
||||
//!
|
||||
//! A Calligraphic brush with Pressure variation changes its nib along the stroke with the pen
|
||||
//! pressure recorded on it (`StrokeLayer::pressure`): the sweep is sampled densely and each
|
||||
//! sample takes the nib for the pressure there.
|
||||
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::Node;
|
||||
use vectorcraft_doc::{Node, PressureProfile, StrokeLayer};
|
||||
use vectorcraft_geom::{BezPath, PathData, Point, SubPath, Vec2};
|
||||
|
||||
use crate::track::{Rng, Track, normal, seed_of, tracks};
|
||||
use crate::{Bristle, BristleShape, Calligraphic, filled, tolerance};
|
||||
use crate::{Bristle, BristleShape, Calligraphic, Variation, filled, tolerance};
|
||||
|
||||
/// The most pieces a varying nib cuts one segment of a subpath into, and about how many samples
|
||||
/// it takes along a whole subpath.
|
||||
const MAX_STEPS: f64 = 1024.0;
|
||||
|
||||
/// An elliptical nib.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -43,39 +51,53 @@ fn rot(base: Vec2, angle: f64) -> Vec2 {
|
||||
base * angle.cos() + normal(base) * angle.sin()
|
||||
}
|
||||
|
||||
/// Samples (point, tangent) along a track, with extra tangents fanned in at corners so the
|
||||
/// outer edge of the sweep follows the nib around the corner.
|
||||
fn samples(t: &Track) -> Vec<(Point, Vec2)> {
|
||||
/// Samples (point, tangent, fraction of the track's length) along a track, with extra tangents
|
||||
/// fanned in at corners so the outer edge of the sweep follows the nib around the corner. `step`
|
||||
/// (for a nib that varies along the track): segments are cut into pieces at most that long.
|
||||
fn samples(t: &Track, step: Option<f64>) -> Vec<(Point, Vec2, f64)> {
|
||||
let mut out = vec![];
|
||||
let n = t.segments();
|
||||
let len = t.len().max(1e-12);
|
||||
for i in 0..=n {
|
||||
let p = t.verts[i];
|
||||
let f = t.cum[i] / len;
|
||||
if t.corner[i] {
|
||||
let (a, b) = t.in_out(i);
|
||||
let turn = a.cross(b).atan2(a.dot(b));
|
||||
let steps = (turn.abs() / 10f64.to_radians()).ceil().max(1.0) as usize;
|
||||
for k in 0..=steps {
|
||||
out.push((p, rot(a, turn * k as f64 / steps as f64)));
|
||||
out.push((p, rot(a, turn * k as f64 / steps as f64), f));
|
||||
}
|
||||
} else {
|
||||
out.push((p, t.tangents[i]));
|
||||
out.push((p, t.tangents[i], f));
|
||||
}
|
||||
if let Some(step) = step
|
||||
&& i < n
|
||||
{
|
||||
let seg = t.cum[i + 1] - t.cum[i];
|
||||
let pieces = (seg / step).ceil().clamp(1.0, MAX_STEPS) as usize;
|
||||
for k in 1..pieces {
|
||||
let u = k as f64 / pieces as f64;
|
||||
out.push((p.lerp(t.verts[i + 1], u), t.dirs[i], (t.cum[i] + seg * u) / len));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The swept outline of `nib` along `t` as closed subpaths.
|
||||
pub(crate) fn sweep(t: &Track, nib: &Nib) -> Vec<SubPath> {
|
||||
let s = samples(t);
|
||||
let left: Vec<Point> = s.iter().map(|(p, tg)| *p + nib.support(normal(*tg))).collect();
|
||||
let right: Vec<Point> = s.iter().map(|(p, tg)| *p + nib.support(-normal(*tg))).collect();
|
||||
/// The swept outline along `t` of the nib `nib(f)` (`f`: the fraction of the track's length) as
|
||||
/// closed subpaths. `step`: how often a varying nib is sampled (None: a constant nib).
|
||||
pub(crate) fn sweep(t: &Track, nib: &dyn Fn(f64) -> Nib, step: Option<f64>) -> Vec<SubPath> {
|
||||
let s = samples(t, step);
|
||||
let left: Vec<Point> = s.iter().map(|(p, tg, f)| *p + nib(*f).support(normal(*tg))).collect();
|
||||
let right: Vec<Point> = s.iter().map(|(p, tg, f)| *p + nib(*f).support(-normal(*tg))).collect();
|
||||
if t.closed {
|
||||
let mut r = right;
|
||||
r.reverse();
|
||||
return vec![SubPath::polyline(&left, true), SubPath::polyline(&r, true)];
|
||||
}
|
||||
let cap = |p: Point, tg: Vec2, from: f64, to: f64, out: &mut Vec<Point>| {
|
||||
let steps = 16;
|
||||
let cap = |(p, tg, f): (Point, Vec2, f64), from: f64, to: f64, out: &mut Vec<Point>| {
|
||||
let (steps, nib) = (16, nib(f));
|
||||
for k in 1..steps {
|
||||
let ang = from + (to - from) * k as f64 / steps as f64;
|
||||
out.push(p + nib.support(rot(tg, ang)));
|
||||
@@ -83,31 +105,66 @@ pub(crate) fn sweep(t: &Track, nib: &Nib) -> Vec<SubPath> {
|
||||
};
|
||||
let half = std::f64::consts::FRAC_PI_2;
|
||||
let mut poly = left.clone();
|
||||
let (Some(&(pe, te)), Some(&(ps, ts))) = (s.last(), s.first()) else { return Vec::new() };
|
||||
cap(pe, te, half, -half, &mut poly);
|
||||
let (Some(&end), Some(&start)) = (s.last(), s.first()) else { return Vec::new() };
|
||||
cap(end, half, -half, &mut poly);
|
||||
poly.extend(right.iter().rev());
|
||||
cap(ps, ts, -half, -3.0 * half, &mut poly);
|
||||
cap(start, -half, -3.0 * half, &mut poly);
|
||||
vec![SubPath::polyline(&poly, true)]
|
||||
}
|
||||
|
||||
fn randomized(c: &Calligraphic, rng: &mut Rng) -> (f64, f64, f64) {
|
||||
let [va, vr, vs] = c.variation;
|
||||
let angle = c.angle + rng.range(-va, va);
|
||||
let round = (c.roundness + rng.range(-vr, vr)).clamp(1.0, 100.0);
|
||||
let size = (c.size + rng.range(-vs, vs)).max(0.05);
|
||||
(angle, round, size)
|
||||
/// The stroke's angle, roundness and size: Random ones drawn for this stroke (from `rng`), the
|
||||
/// others the brush's values.
|
||||
fn randomized(c: &Calligraphic, rng: &mut Rng) -> [f64; 3] {
|
||||
let mut out = [c.angle, c.roundness, c.size];
|
||||
for ((v, var), mode) in out.iter_mut().zip(c.variation).zip(c.modes()) {
|
||||
// Every value draws, so a stroke's random values stay the same whatever the other modes.
|
||||
let r = rng.range(-var, var);
|
||||
if mode == Variation::Random {
|
||||
*v += r;
|
||||
}
|
||||
}
|
||||
let [angle, round, size] = out;
|
||||
[angle, round.clamp(1.0, 100.0), size.max(0.05)]
|
||||
}
|
||||
|
||||
pub(crate) fn calligraphic(c: &Calligraphic, bp: &BezPath, weight: f64, paint: &Paint, name: &str) -> Vec<Node> {
|
||||
/// The nib's angle, roundness and size at pen pressure `p` (0..1): each Pressure value goes from
|
||||
/// the brush's value less its variation (lightest) to the value plus the variation (heaviest),
|
||||
/// kept within its range; the others are `base`'s.
|
||||
fn pressured(c: &Calligraphic, base: [f64; 3], p: f64) -> [f64; 3] {
|
||||
let values = [c.angle, c.roundness, c.size];
|
||||
let mut out = base;
|
||||
for (i, (v, mode)) in out.iter_mut().zip(c.modes()).enumerate() {
|
||||
let (Some(&value), Some(&var), Some(&(lo, hi))) = (values.get(i), c.variation.get(i), Calligraphic::RANGES.get(i)) else { continue };
|
||||
if mode == Variation::Pressure {
|
||||
let x = value + var * (2.0 * p.clamp(0.0, 1.0) - 1.0);
|
||||
*v = if x.is_finite() { x.clamp(lo, hi) } else { value.clamp(lo, hi) };
|
||||
}
|
||||
}
|
||||
let [angle, round, size] = out;
|
||||
[angle, round.clamp(1.0, 100.0), size.max(0.05)]
|
||||
}
|
||||
|
||||
pub(crate) fn calligraphic(c: &Calligraphic, bp: &BezPath, weight: f64, stroke: &StrokeLayer, name: &str) -> Vec<Node> {
|
||||
let mut rng = Rng::new(seed_of(bp, name));
|
||||
let (angle, round, size) = randomized(c, &mut rng);
|
||||
let size = size * weight;
|
||||
let nib = Nib::new(angle, round / 100.0, size);
|
||||
let subs: Vec<SubPath> = tracks(bp, tolerance(size)).iter().flat_map(|t| sweep(t, &nib)).collect();
|
||||
let base = randomized(c, &mut rng);
|
||||
let subs: Vec<SubPath> = if c.uses_pressure() {
|
||||
let pressure = stroke.pressure.as_ref();
|
||||
let nib = |f: f64| {
|
||||
let [angle, round, size] = pressured(c, base, pressure.map_or(PressureProfile::MID, |p| p.at(f)));
|
||||
Nib::new(angle, round / 100.0, size * weight)
|
||||
};
|
||||
// Flattened for the largest nib the pressure gives.
|
||||
let size = pressured(c, base, 0.0)[2].max(pressured(c, base, 1.0)[2]) * weight;
|
||||
tracks(bp, tolerance(size)).iter().flat_map(|t| sweep(t, &nib, Some((t.len() / MAX_STEPS).max(0.25)))).collect()
|
||||
} else {
|
||||
let [angle, round, size] = base;
|
||||
let nib = Nib::new(angle, round / 100.0, size * weight);
|
||||
tracks(bp, tolerance(size * weight)).iter().flat_map(|t| sweep(t, &|_| nib, None)).collect()
|
||||
};
|
||||
if subs.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
vec![filled(PathData::new(subs), paint)]
|
||||
vec![filled(PathData::new(subs), &stroke.paint)]
|
||||
}
|
||||
|
||||
/// Offset a track sideways by `d` (a polyline following it).
|
||||
@@ -165,7 +222,7 @@ pub(crate) fn bristle(b: &Bristle, bp: &BezPath, weight: f64, paint: &Paint, nam
|
||||
let Some(sp) = offset_track(&t, off, t0.min(l * 0.3), t1.min(l * 0.3)) else { continue };
|
||||
let w = thick * (0.6 + 0.4 * edge) * rng.range(0.8, 1.2);
|
||||
let nib = Nib::new(0.0, 1.0, w);
|
||||
let subs: Vec<SubPath> = tracks(&sp, tolerance(w)).iter().flat_map(|tt| sweep(tt, &nib)).collect();
|
||||
let subs: Vec<SubPath> = tracks(&sp, tolerance(w)).iter().flat_map(|tt| sweep(tt, &|_| nib, None)).collect();
|
||||
if subs.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ fn diamond_tile() -> Node {
|
||||
}
|
||||
|
||||
fn calli(name: &str, angle: f64, roundness: f64, size: f64) -> Brush {
|
||||
Brush { name: name.into(), kind: BrushKind::Calligraphic(Calligraphic { angle, roundness, size, variation: [0.0; 3] }) }
|
||||
Brush { name: name.into(), kind: BrushKind::Calligraphic(Calligraphic { angle, roundness, size, ..Default::default() }) }
|
||||
}
|
||||
|
||||
/// The default library.
|
||||
|
||||
+59
-3
@@ -99,13 +99,69 @@ pub struct Calligraphic {
|
||||
pub roundness: f64,
|
||||
/// Nib diameter in points.
|
||||
pub size: f64,
|
||||
/// Random variation per stroke: angle (°), roundness (%), size (pt).
|
||||
/// How far angle (°), roundness (%) and size (pt) vary each way, as [`Self::modes`] say.
|
||||
pub variation: [f64; 3],
|
||||
/// How angle, roundness and size vary. `None` (brushes saved before there were modes): Random
|
||||
/// where the variation is non-zero, else Fixed — see [`Calligraphic::modes`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub modes: Option<[Variation; 3]>,
|
||||
}
|
||||
|
||||
impl Default for Calligraphic {
|
||||
fn default() -> Self {
|
||||
Self { angle: 0.0, roundness: 100.0, size: 3.0, variation: [0.0; 3] }
|
||||
Self { angle: 0.0, roundness: 100.0, size: 3.0, variation: [0.0; 3], modes: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl Calligraphic {
|
||||
/// The ranges of angle (°), roundness (%) and size (pt), and the most each varies.
|
||||
pub const RANGES: [(f64, f64); 3] = [(-180.0, 180.0), (0.0, 100.0), (0.0, 1296.0)];
|
||||
pub const MAX_VARIATION: [f64; 3] = [180.0, 100.0, 1296.0];
|
||||
|
||||
/// How angle, roundness and size vary.
|
||||
pub fn modes(&self) -> [Variation; 3] {
|
||||
self.modes.unwrap_or_else(|| self.variation.map(|v| if v != 0.0 { Variation::Random } else { Variation::Fixed }))
|
||||
}
|
||||
/// Does the pen's pressure change the nib?
|
||||
pub fn uses_pressure(&self) -> bool {
|
||||
self.modes().contains(&Variation::Pressure)
|
||||
}
|
||||
}
|
||||
|
||||
/// How a Calligraphic brush's angle, roundness or size varies (Brush Options). Saved by name: new
|
||||
/// modes are appended, never renamed.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Variation {
|
||||
/// Always the value.
|
||||
#[default]
|
||||
Fixed,
|
||||
/// A random value within the variation either side, new for each stroke.
|
||||
Random,
|
||||
/// From the value less the variation at the lightest pen pressure to the value plus the
|
||||
/// variation at the heaviest.
|
||||
Pressure,
|
||||
}
|
||||
|
||||
impl Variation {
|
||||
pub const ALL: [Variation; 3] = [Variation::Fixed, Variation::Random, Variation::Pressure];
|
||||
/// The name brush definitions use.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Variation::Fixed => "fixed",
|
||||
Variation::Random => "random",
|
||||
Variation::Pressure => "pressure",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Variation::Fixed => "Fixed",
|
||||
Variation::Random => "Random",
|
||||
Variation::Pressure => "Pressure",
|
||||
}
|
||||
}
|
||||
pub fn parse(id: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|v| v.id() == id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,7 +401,7 @@ fn tolerance(scale: f64) -> f64 {
|
||||
pub fn stroke_pieces(brush: &Brush, bp: &BezPath, stroke: &StrokeLayer) -> Vec<Node> {
|
||||
let w = if stroke.width > 0.0 { stroke.width } else { 1.0 };
|
||||
let mut out = match &brush.kind {
|
||||
BrushKind::Calligraphic(c) => calli::calligraphic(c, bp, w, &stroke.paint, &brush.name),
|
||||
BrushKind::Calligraphic(c) => calli::calligraphic(c, bp, w, stroke, &brush.name),
|
||||
BrushKind::Bristle(b) => calli::bristle(b, bp, w, &stroke.paint, &brush.name),
|
||||
BrushKind::Art(a) => warp::art(a, bp, w),
|
||||
BrushKind::Scatter(s) => warp::scatter(s, bp, w, &brush.name),
|
||||
|
||||
+114
-1
@@ -16,7 +16,7 @@ fn stroke(w: f64) -> StrokeLayer {
|
||||
}
|
||||
|
||||
fn calli(angle: f64, roundness: f64, size: f64) -> Brush {
|
||||
Brush { name: "c".into(), kind: BrushKind::Calligraphic(Calligraphic { angle, roundness, size, variation: [0.0; 3] }) }
|
||||
Brush { name: "c".into(), kind: BrushKind::Calligraphic(Calligraphic { angle, roundness, size, ..Default::default() }) }
|
||||
}
|
||||
|
||||
fn bounds(nodes: &[Node]) -> Rect {
|
||||
@@ -334,3 +334,116 @@ fn expand_keeps_fill_and_adds_pieces() {
|
||||
let plain = Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default_art());
|
||||
assert!(expand(&d, &plain).is_none());
|
||||
}
|
||||
|
||||
/// A Calligraphic brush (round nib) whose size follows the pen pressure by `var` either way.
|
||||
fn pressure_calli(size: f64, var: f64) -> Brush {
|
||||
let c = Calligraphic {
|
||||
size,
|
||||
variation: [0.0, 0.0, var],
|
||||
modes: Some([Variation::Fixed, Variation::Fixed, Variation::Pressure]),
|
||||
..Default::default()
|
||||
};
|
||||
Brush { name: "p".into(), kind: BrushKind::Calligraphic(c) }
|
||||
}
|
||||
|
||||
/// The stroke's half-width (largest |y|) among outline points with x in `x0..x1`.
|
||||
fn half_width(nodes: &[Node], x0: f64, x1: f64) -> f64 {
|
||||
let mut pts = vec![];
|
||||
for n in nodes {
|
||||
all_points(n, &mut pts);
|
||||
}
|
||||
pts.iter().filter(|p| p.x >= x0 && p.x <= x1).map(|p| p.y.abs()).fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
fn pressed(points: Vec<(f64, f64)>) -> StrokeLayer {
|
||||
StrokeLayer { pressure: Some(vectorcraft_doc::PressureProfile { points }), ..stroke(1.0) }
|
||||
}
|
||||
|
||||
/// #852: with Size on Pressure the nib goes from size − variation at the lightest pressure to
|
||||
/// size + variation at the heaviest, along the stroke where the pen pressed.
|
||||
#[test]
|
||||
fn pressure_maps_to_size_along_the_stroke() {
|
||||
let b = pressure_calli(10.0, 4.0);
|
||||
let light_to_heavy = pressed(vec![(0.0, 0.0), (1.0, 1.0)]);
|
||||
let out = stroke_pieces(&b, &line(0.0, 0.0, 100.0, 0.0), &light_to_heavy);
|
||||
// Lightest: 6 pt (half 3); heaviest: 14 pt (half 7); in between the pressure there.
|
||||
assert!(near(half_width(&out, 0.5, 1.5), 3.0, 0.1), "{}", half_width(&out, 0.5, 1.5));
|
||||
assert!(near(half_width(&out, 98.5, 99.5), 7.0, 0.1), "{}", half_width(&out, 98.5, 99.5));
|
||||
assert!(near(half_width(&out, 49.5, 50.5), 5.0, 0.1), "{}", half_width(&out, 49.5, 50.5));
|
||||
// The stroke weight scales it.
|
||||
let heavy = StrokeLayer { width: 2.0, ..light_to_heavy };
|
||||
let out = stroke_pieces(&b, &line(0.0, 0.0, 100.0, 0.0), &heavy);
|
||||
assert!(near(half_width(&out, 98.5, 99.5), 14.0, 0.2));
|
||||
// No pressure recorded (a mouse, or a path the brush was applied to): the size itself.
|
||||
let out = stroke_pieces(&b, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
|
||||
assert!(near(bounds(&out).height(), 10.0, 0.05), "{:?}", bounds(&out));
|
||||
// Kept within the valid range: 2 ± 4 never goes below nothing.
|
||||
let out = stroke_pieces(&pressure_calli(2.0, 4.0), &line(0.0, 0.0, 100.0, 0.0), &pressed(vec![(0.0, 0.0), (1.0, 0.0)]));
|
||||
assert!(!out.is_empty() && bounds(&out).height() < 0.2, "{:?}", bounds(&out));
|
||||
}
|
||||
|
||||
/// Angle and roundness follow the pressure the same way, clamped to their ranges.
|
||||
#[test]
|
||||
fn pressure_varies_angle_and_roundness() {
|
||||
let c = |modes| Calligraphic { angle: 0.0, roundness: 60.0, size: 10.0, variation: [90.0, 60.0, 0.0], modes: Some(modes) };
|
||||
let horizontal = line(0.0, 0.0, 100.0, 0.0);
|
||||
let draw = |c: Calligraphic, p: f64| {
|
||||
let b = Brush { name: "a".into(), kind: BrushKind::Calligraphic(c) };
|
||||
bounds(&stroke_pieces(&b, &horizontal, &pressed(vec![(0.0, p), (1.0, p)]))).height()
|
||||
};
|
||||
use Variation::{Fixed, Pressure};
|
||||
// Angle 0 ± 90: heavy pressure stands the flat nib up across the stroke.
|
||||
assert!(near(draw(c([Pressure, Fixed, Fixed]), 0.5), 6.0, 0.05));
|
||||
assert!(near(draw(c([Pressure, Fixed, Fixed]), 1.0), 10.0, 0.05));
|
||||
// Roundness 60 ± 60, kept within 1..100: light is a hairline, heavy is round.
|
||||
let vertical = Calligraphic { angle: 90.0, ..c([Fixed, Pressure, Fixed]) };
|
||||
let wide = |p| {
|
||||
let b = Brush { name: "r".into(), kind: BrushKind::Calligraphic(vertical.clone()) };
|
||||
bounds(&stroke_pieces(&b, &line(0.0, 0.0, 0.0, 100.0), &pressed(vec![(0.0, p), (1.0, p)]))).width()
|
||||
};
|
||||
assert!(near(wide(0.0), 0.1, 0.05), "{}", wide(0.0));
|
||||
assert!(near(wide(1.0), 10.0, 0.05), "{}", wide(1.0));
|
||||
}
|
||||
|
||||
/// Brushes saved before the variation modes keep their look: a variation means Random, none
|
||||
/// means Fixed, and they are written back without modes.
|
||||
#[test]
|
||||
fn brushes_without_modes_vary_randomly_as_before() {
|
||||
let old: Brush = serde_json::from_value(serde_json::json!({"name": "x", "type": "calligraphic", "size": 8, "variation": [0, 0, 3]})).unwrap();
|
||||
let BrushKind::Calligraphic(c) = &old.kind else { panic!() };
|
||||
assert_eq!(c.modes, None);
|
||||
assert_eq!(c.modes(), [Variation::Fixed, Variation::Fixed, Variation::Random]);
|
||||
assert!(!c.uses_pressure());
|
||||
assert!(serde_json::to_value(&old).unwrap().get("modes").is_none(), "written back as it was");
|
||||
// The same strokes as the brush with its modes spelled out.
|
||||
let explicit = Brush {
|
||||
kind: BrushKind::Calligraphic(Calligraphic { modes: Some([Variation::Fixed, Variation::Fixed, Variation::Random]), ..c.clone() }),
|
||||
..old.clone()
|
||||
};
|
||||
let bp = preview_path(120.0, 24.0);
|
||||
assert_eq!(stroke_pieces(&old, &bp, &stroke(1.0)), stroke_pieces(&explicit, &bp, &stroke(1.0)));
|
||||
// Fixed ignores the variation; Random still draws a different size per stroke.
|
||||
let fixed = Brush { kind: BrushKind::Calligraphic(Calligraphic { modes: Some([Variation::Fixed; 3]), ..c.clone() }), ..old.clone() };
|
||||
assert!(near(bounds(&stroke_pieces(&fixed, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0))).height(), 8.0, 0.05));
|
||||
// Recorded pressure changes nothing for a brush that doesn't use it.
|
||||
assert_eq!(stroke_pieces(&old, &bp, &stroke(1.0)), stroke_pieces(&old, &bp, &pressed(vec![(0.0, 0.0), (1.0, 1.0)])));
|
||||
// Modes are saved by name.
|
||||
assert_eq!(serde_json::to_value(Variation::ALL).unwrap(), serde_json::json!(["fixed", "random", "pressure"]));
|
||||
assert!(Variation::ALL.iter().all(|v| Variation::parse(v.id()) == Some(*v)));
|
||||
}
|
||||
|
||||
/// Junk definitions and pressure never panic and never draw non-finite art.
|
||||
#[test]
|
||||
fn junk_pressure_brushes_draw_finite_art() {
|
||||
let bp = preview_path(120.0, 24.0);
|
||||
for (size, var) in [(1e308, 1e308), (-1e308, 1e308), (0.0, -1e308), (f64::MAX, f64::MAX)] {
|
||||
for profile in [vec![], vec![(0.0, 2.0), (0.0, -1.0), (5.0, 0.5)], vec![(1.0, 1.0), (0.0, 0.0)]] {
|
||||
let out = stroke_pieces(&pressure_calli(size, var), &bp, &pressed(profile));
|
||||
let mut pts = vec![];
|
||||
for n in &out {
|
||||
all_points(n, &mut pts);
|
||||
}
|
||||
assert!(pts.iter().all(|p| p.x.is_finite() && p.y.is_finite()), "{size} {var}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -293,6 +293,129 @@ impl WidthProfile {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pen pressure recorded along a stroke (the Paintbrush with a graphics tablet): (position 0..1
|
||||
/// along the path, pressure 0..1) samples in order, positions measured as a [`WidthProfile`]'s
|
||||
/// are. Calligraphic brushes with Pressure variation read it; everything else ignores it.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PressureProfile {
|
||||
pub points: Vec<(f64, f64)>,
|
||||
}
|
||||
|
||||
impl PressureProfile {
|
||||
/// The pressure of a stroke drawn without one (a mouse, or a path the brush was applied to):
|
||||
/// half way, so a Pressure brush draws its own value there.
|
||||
pub const MID: f64 = 0.5;
|
||||
/// The most samples a stroke keeps (after simplifying them).
|
||||
pub const MAX_POINTS: usize = 512;
|
||||
/// How far (in pressure) simplifying may move the recorded curve.
|
||||
const TOLERANCE: f64 = 0.01;
|
||||
|
||||
/// The pressure at `t` (0..1), linear between samples; [`Self::MID`] without any.
|
||||
pub fn at(&self, t: f64) -> f64 {
|
||||
let p = &self.points;
|
||||
let (Some(first), Some(last)) = (p.first(), p.last()) else { return Self::MID };
|
||||
let v = if t <= first.0 {
|
||||
first.1
|
||||
} else {
|
||||
p.windows(2)
|
||||
.find_map(|w| match w {
|
||||
[a, b] if t <= b.0 => Some(a.1 + (b.1 - a.1) * ((t - a.0) / (b.0 - a.0).max(1e-9)).clamp(0.0, 1.0)),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or(last.1)
|
||||
};
|
||||
if v.is_finite() { v.clamp(0.0, 1.0) } else { Self::MID }
|
||||
}
|
||||
|
||||
/// A profile from (position, pressure) samples in order along the stroke: values are clamped
|
||||
/// into 0..1 (non-finite ones dropped), the curve is simplified to within 1 % and kept to
|
||||
/// [`Self::MAX_POINTS`]. `None` without a sample.
|
||||
pub fn from_samples(samples: impl IntoIterator<Item = (f64, f64)>) -> Option<Self> {
|
||||
let pts: Vec<(f64, f64)> =
|
||||
samples.into_iter().filter(|(t, p)| t.is_finite() && p.is_finite()).map(|(t, p)| (t.clamp(0.0, 1.0), p.clamp(0.0, 1.0))).collect();
|
||||
if pts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Simplifying is quadratic at worst: a huge stroke is thinned evenly first.
|
||||
let mut pts = thin(pts, 16 * Self::MAX_POINTS);
|
||||
// Positions only ever go forward.
|
||||
let mut prev = 0.0;
|
||||
for p in &mut pts {
|
||||
p.0 = p.0.max(prev);
|
||||
prev = p.0;
|
||||
}
|
||||
let keep = simplify_1d(&pts, Self::TOLERANCE);
|
||||
let pts = thin(pts.iter().zip(keep).filter(|(_, k)| *k).map(|(p, _)| *p).collect(), Self::MAX_POINTS);
|
||||
let round = |v: f64| (v * 1e4).round() / 1e4;
|
||||
Some(Self { points: pts.into_iter().map(|(t, p)| (round(t), round(p))).collect() })
|
||||
}
|
||||
|
||||
/// The pressure of a path `old_len` long (`old`; None: drawn without pressure) continued at its
|
||||
/// end by a stroke `added_len` long (`added`). None when neither has pressure.
|
||||
pub fn extended(old: Option<&Self>, old_len: f64, added: Option<&Self>, added_len: f64) -> Option<Self> {
|
||||
if old.is_none() && added.is_none() {
|
||||
return None;
|
||||
}
|
||||
let (old_len, added_len) = (old_len.max(0.0), added_len.max(0.0));
|
||||
let total = old_len + added_len;
|
||||
if !total.is_finite() || total <= 1e-9 {
|
||||
return old.or(added).cloned();
|
||||
}
|
||||
let flat = Self { points: vec![(0.0, Self::MID), (1.0, Self::MID)] };
|
||||
let (old, added) = (old.unwrap_or(&flat), added.unwrap_or(&flat));
|
||||
let o = old_len / total;
|
||||
let old_pts = old.points.iter().map(|&(t, p)| (t * o, p));
|
||||
Self::from_samples(old_pts.chain(added.points.iter().map(|&(u, p)| (o + u * (1.0 - o), p))))
|
||||
}
|
||||
|
||||
/// The pressure of the path reversed.
|
||||
pub fn reversed(&self) -> Self {
|
||||
Self { points: self.points.iter().rev().map(|&(t, p)| (1.0 - t, p)).collect() }
|
||||
}
|
||||
|
||||
/// A light–heavy–light stroke, for previews of pressure-sensitive brushes.
|
||||
pub fn sample() -> Self {
|
||||
Self { points: vec![(0.0, 0.0), (0.5, 1.0), (1.0, 0.0)] }
|
||||
}
|
||||
}
|
||||
|
||||
/// At most `max` of `pts`, evenly spread (the ends kept).
|
||||
fn thin(pts: Vec<(f64, f64)>, max: usize) -> Vec<(f64, f64)> {
|
||||
let n = pts.len();
|
||||
if n <= max || max < 2 {
|
||||
return pts;
|
||||
}
|
||||
(0..max).filter_map(|i| pts.get(i * (n - 1) / (max - 1)).copied()).collect()
|
||||
}
|
||||
|
||||
/// Douglas–Peucker over a function sampled at `pts`: which samples to keep so the polyline through
|
||||
/// them stays within `tol` (vertically) of every sample. The ends are always kept.
|
||||
fn simplify_1d(pts: &[(f64, f64)], tol: f64) -> Vec<bool> {
|
||||
let mut keep = vec![false; pts.len()];
|
||||
let Some(last) = pts.len().checked_sub(1) else { return keep };
|
||||
for i in [0, last] {
|
||||
if let Some(k) = keep.get_mut(i) {
|
||||
*k = true;
|
||||
}
|
||||
}
|
||||
let mut stack = vec![(0, last)];
|
||||
while let Some((i, j)) = stack.pop() {
|
||||
let (Some(&a), Some(&b)) = (pts.get(i), pts.get(j)) else { continue };
|
||||
let line = |t: f64| if b.0 - a.0 > 1e-12 { a.1 + (b.1 - a.1) * (t - a.0) / (b.0 - a.0) } else { (a.1 + b.1) / 2.0 };
|
||||
let far = (i + 1..j).filter_map(|k| pts.get(k).map(|q| (k, (q.1 - line(q.0)).abs()))).max_by(|x, y| x.1.total_cmp(&y.1));
|
||||
if let Some((k, d)) = far
|
||||
&& d > tol
|
||||
{
|
||||
if let Some(f) = keep.get_mut(k) {
|
||||
*f = true;
|
||||
}
|
||||
stack.push((i, k));
|
||||
stack.push((k, j));
|
||||
}
|
||||
}
|
||||
keep
|
||||
}
|
||||
|
||||
/// A width profile saved to the Profile list under a name (Add to Profiles), kept with the
|
||||
/// preferences.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -386,6 +509,11 @@ pub struct StrokeLayer {
|
||||
/// How a gradient paint maps onto the stroke (within, along or across it).
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub gradient_mode: StrokeGradientMode,
|
||||
/// The pen pressure the stroke was drawn with (Paintbrush with a tablet): pressure-sensitive
|
||||
/// brushes vary with it. It belongs to this path's shape, so appearances copied to other
|
||||
/// art leave it behind ([`Appearance::without_pressure`]).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub pressure: Option<PressureProfile>,
|
||||
}
|
||||
|
||||
/// How a gradient on a stroke is laid out (the Gradient panel's Stroke buttons). Saved by variant
|
||||
@@ -456,6 +584,7 @@ impl StrokeLayer {
|
||||
arrow_align: ArrowAlign::Extend,
|
||||
overprint: false,
|
||||
gradient_mode: StrokeGradientMode::Within,
|
||||
pressure: None,
|
||||
}
|
||||
}
|
||||
/// Weight of the start (`end == false`) or end arrowhead: stroke weight × its scale, at least
|
||||
@@ -640,6 +769,15 @@ impl Appearance {
|
||||
pub fn stroke_mut(&mut self) -> Option<&mut StrokeLayer> {
|
||||
self.items.iter_mut().rev().find_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s) } else { None })
|
||||
}
|
||||
/// This appearance for other art: the pen pressure its strokes were drawn with stays with them.
|
||||
pub fn without_pressure(mut self) -> Self {
|
||||
for it in &mut self.items {
|
||||
if let AppearanceItem::Stroke(s) = it {
|
||||
s.pressure = None;
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
pub fn fill_paint(&self) -> Paint {
|
||||
self.fill().map(|f| f.paint.clone()).unwrap_or(Paint::None)
|
||||
}
|
||||
@@ -1103,6 +1241,58 @@ mod tests {
|
||||
assert!(matches!(a.items[0], AppearanceItem::Fill(_)));
|
||||
}
|
||||
|
||||
/// #852: pen pressure is kept simplified along the stroke and read back by position.
|
||||
#[test]
|
||||
fn pressure_profile_samples_simplify_and_interpolate() {
|
||||
// A steady ramp keeps only its ends; a bump keeps its peak.
|
||||
let ramp = PressureProfile::from_samples((0..=100).map(|i| (i as f64 / 100.0, i as f64 / 100.0))).unwrap();
|
||||
assert_eq!(ramp.points, vec![(0.0, 0.0), (1.0, 1.0)]);
|
||||
assert!((ramp.at(0.25) - 0.25).abs() < 1e-9);
|
||||
let bump = PressureProfile::from_samples([(0.0, 0.2), (0.3, 0.2), (0.5, 0.9), (0.7, 0.2), (1.0, 0.2)]).unwrap();
|
||||
assert!(bump.points.contains(&(0.5, 0.9)) && (bump.at(0.4) - 0.55).abs() < 1e-9, "{bump:?}");
|
||||
// Junk is clamped or dropped; positions never go back; nothing at all is no profile.
|
||||
let junk = PressureProfile::from_samples([(f64::NAN, 0.5), (0.5, 7.0), (0.2, -1.0), (2.0, f64::INFINITY)]).unwrap();
|
||||
assert_eq!(junk.points, vec![(0.5, 1.0), (0.5, 0.0)]);
|
||||
assert!(PressureProfile::from_samples([(f64::NAN, 0.5)]).is_none());
|
||||
assert_eq!(PressureProfile::default().at(0.3), PressureProfile::MID);
|
||||
// A long noisy stroke stays bounded.
|
||||
let noisy = PressureProfile::from_samples((0..1_000_000).map(|i| (i as f64 / 1e6, (i % 2) as f64))).unwrap();
|
||||
assert!(noisy.points.len() <= PressureProfile::MAX_POINTS);
|
||||
// Out-of-order points (a hand-edited file) still read within 0..1.
|
||||
let odd = PressureProfile { points: vec![(1.0, 5.0), (0.0, -3.0)] };
|
||||
assert!((0.0..=1.0).contains(&odd.at(0.5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressure_profile_extends_and_reverses() {
|
||||
let old = PressureProfile { points: vec![(0.0, 0.0), (1.0, 1.0)] };
|
||||
let added = PressureProfile { points: vec![(0.0, 1.0), (1.0, 0.0)] };
|
||||
// A 30 pt path continued by 10 pt: its ramp fills the first three quarters.
|
||||
let joined = PressureProfile::extended(Some(&old), 30.0, Some(&added), 10.0).unwrap();
|
||||
assert!((joined.at(0.75) - 1.0).abs() < 1e-9 && (joined.at(0.375) - 0.5).abs() < 1e-9 && joined.at(1.0) == 0.0, "{joined:?}");
|
||||
// A side without pressure is half way.
|
||||
let half = PressureProfile::extended(None, 10.0, Some(&added), 10.0).unwrap();
|
||||
assert_eq!(half.at(0.25), PressureProfile::MID);
|
||||
assert!(PressureProfile::extended(None, 10.0, None, 10.0).is_none());
|
||||
assert_eq!(PressureProfile::extended(Some(&old), 0.0, None, f64::NAN), Some(old.clone()));
|
||||
assert_eq!(old.reversed().points, vec![(0.0, 1.0), (1.0, 0.0)]);
|
||||
}
|
||||
|
||||
/// Strokes saved before pen pressure load as they were, and are written without it.
|
||||
#[test]
|
||||
fn strokes_without_pressure_round_trip_unchanged() {
|
||||
let json = serde_json::to_value(StrokeLayer::new(Paint::None, 2.0)).unwrap();
|
||||
assert!(json.get("pressure").is_none());
|
||||
let st: StrokeLayer = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(st.pressure, None);
|
||||
let pressed = StrokeLayer { pressure: Some(PressureProfile::sample()), ..st };
|
||||
let back: StrokeLayer = serde_json::from_value(serde_json::to_value(&pressed).unwrap()).unwrap();
|
||||
assert_eq!(back, pressed);
|
||||
let mut ap = Appearance::basic(Paint::None, Paint::None, 1.0);
|
||||
ap.stroke_mut().unwrap().pressure = Some(PressureProfile::sample());
|
||||
assert_eq!(ap.without_pressure().stroke().unwrap().pressure, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_interp() {
|
||||
let p = WidthProfile::lens();
|
||||
|
||||
+12
-4
@@ -60,8 +60,8 @@ pub(crate) mod skip {
|
||||
|
||||
pub use appearance::StrokeGradientMode;
|
||||
pub use appearance::{
|
||||
Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, ProfilePreset, SavedProfile, StrokeAlign,
|
||||
StrokeLayer, WidthProfile,
|
||||
Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, PressureProfile, ProfilePreset, SavedProfile,
|
||||
StrokeAlign, StrokeLayer, WidthProfile,
|
||||
};
|
||||
pub use assets::ExportAsset;
|
||||
pub use corners::LiveCorners;
|
||||
@@ -960,8 +960,12 @@ impl Document {
|
||||
if c.is_layer() {
|
||||
collect(c, rect, all, out);
|
||||
} else if let Some(b) = c.geometric_bounds()
|
||||
&& rect.contains(Point::new(b.x0, b.y0))
|
||||
&& rect.contains(Point::new(b.x1, b.y1))
|
||||
// Inside it, edges included: art flush with the right or bottom edge is on it too
|
||||
// (`Rect::contains` leaves those out, #890).
|
||||
&& b.x0 >= rect.x0
|
||||
&& b.y0 >= rect.y0
|
||||
&& b.x1 <= rect.x1
|
||||
&& b.y1 <= rect.y1
|
||||
{
|
||||
out.push(c.id);
|
||||
}
|
||||
@@ -1174,6 +1178,10 @@ mod tests {
|
||||
let wide = Rect::new(-1.0, -1.0, 40.0, 15.0);
|
||||
assert_eq!(d.art_on_artboard(Rect::new(-1.0, -1.0, 15.0, 15.0), false), vec![a]);
|
||||
assert_eq!(d.art_on_artboard(Rect::new(-1.0, -1.0, 25.0, 15.0), false), vec![a], "b only half inside");
|
||||
// Flush with the artboard's edges counts as inside: the right and bottom ones too (#890).
|
||||
for board in [Rect::new(0.0, 0.0, 10.0, 10.0), Rect::new(-5.0, 0.0, 10.0, 15.0), Rect::new(0.0, -5.0, 15.0, 10.0)] {
|
||||
assert_eq!(d.art_on_artboard(board, false), vec![a], "{board:?}");
|
||||
}
|
||||
d.node_mut(a).unwrap().locked = true;
|
||||
assert_eq!(d.art_on_artboard(wide, false), vec![b]);
|
||||
// Move Locked and Hidden Artwork with Artboard (#394): hidden art stays too, unless on.
|
||||
|
||||
@@ -128,6 +128,20 @@ impl Selection {
|
||||
}
|
||||
self.target = Some(id);
|
||||
}
|
||||
/// Shift-clicking `id`'s target circle: it (a layer: its art) joins the selection, or leaves it
|
||||
/// when it is all selected already. Several objects are then selected, none targeted.
|
||||
pub fn toggle_target(&mut self, doc: &Document, id: NodeId) {
|
||||
let ids = match doc.node(id) {
|
||||
Some(n) if n.is_layer() => n.layer_art(true),
|
||||
_ => vec![id],
|
||||
};
|
||||
if ids.iter().all(|i| self.contains(*i)) {
|
||||
ids.iter().for_each(|i| self.remove(*i));
|
||||
} else {
|
||||
ids.into_iter().for_each(|i| self.add(i));
|
||||
}
|
||||
self.target = None;
|
||||
}
|
||||
/// What appearance and transparency edits act on: the targeted object, group or layer, else
|
||||
/// the selected objects.
|
||||
pub fn subjects(&self) -> &[NodeId] {
|
||||
|
||||
@@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Brush Options…",
|
||||
[],
|
||||
None,
|
||||
"{name, params?: {…fields to change}, newName?} edit a brush definition (strokes using it update) → {name}",
|
||||
"{name, params?: {…fields to change (brush.get shows them; calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure for angle, roundness, size]; pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest)}, newName?} edit a brush definition (strokes using it update) → {name}",
|
||||
has_doc,
|
||||
brush_options
|
||||
),
|
||||
|
||||
@@ -246,8 +246,9 @@ fn candidates(s: &Session, f: impl Fn(&Node) -> bool) -> Result<Vec<NodeId>> {
|
||||
|
||||
fn finish_same(s: &mut Session, cmd: &str, ids: Vec<NodeId>) -> Result<Value> {
|
||||
let n = ids.len();
|
||||
let from = s.doc()?.selection.objects.clone();
|
||||
s.select(|_, sel| sel.set(ids))?;
|
||||
s.doc_mut()?.last_selection_cmd = Some((cmd.to_string(), json!({})));
|
||||
s.doc_mut()?.last_selection_cmd = Some((cmd.to_string(), json!({}), from));
|
||||
Ok(json!({ "count": n }))
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::sync::Arc;
|
||||
use kurbo::{ParamCurve, ParamCurveNearest};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, PressureProfile, Selection};
|
||||
use vectorcraft_geom::hit::fill_contains;
|
||||
use vectorcraft_geom::{Anchor, AnchorKind, BezPath, FillRule, PathData, Point, Rect, SubPath, Vec2};
|
||||
use vectorcraft_pathops as po;
|
||||
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Pencil",
|
||||
[],
|
||||
None,
|
||||
"{points: [[x,y]…], fidelity?: pt (1.5), closed?, style?: \"pencil\"|\"brush\", fill?: bool, extend?: {id, end: \"start\"|\"end\"}} fit a freehand stroke → {id}",
|
||||
"{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic brushes with Pressure variation; a point without one presses fully), fidelity?: pt (1.5), closed?, style?: \"pencil\"|\"brush\", fill?: bool, extend?: {id, end: \"start\"|\"end\"}} fit a freehand stroke → {id}",
|
||||
has_doc,
|
||||
freehand
|
||||
),
|
||||
@@ -151,15 +151,21 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
/// `[[x,y]…]` or `[{x,y}…]`; non-finite points are dropped.
|
||||
fn points_param(p: &Value, key: &str, cmd: &str) -> Result<Vec<Point>> {
|
||||
Ok(samples_param(p, key, cmd)?.into_iter().map(|(pt, _)| pt).collect())
|
||||
}
|
||||
|
||||
/// [`points_param`] with each point's pen pressure, if it has one: `[[x,y,pressure]…]` or
|
||||
/// `[{x,y,pressure}…]` (0..1).
|
||||
fn samples_param(p: &Value, key: &str, cmd: &str) -> Result<Vec<(Point, Option<f64>)>> {
|
||||
let a = p.get(key).and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("missing array `{key}`")))?;
|
||||
let pts: Vec<Point> = a
|
||||
let pts: Vec<(Point, Option<f64>)> = a
|
||||
.iter()
|
||||
.filter_map(|v| match v {
|
||||
Value::Array(xy) => Some(Point::new(xy.first()?.as_f64()?, xy.get(1)?.as_f64()?)),
|
||||
Value::Object(_) => Some(Point::new(v.get("x")?.as_f64()?, v.get("y")?.as_f64()?)),
|
||||
Value::Array(xy) => Some((Point::new(xy.first()?.as_f64()?, xy.get(1)?.as_f64()?), xy.get(2).and_then(Value::as_f64))),
|
||||
Value::Object(_) => Some((Point::new(v.get("x")?.as_f64()?, v.get("y")?.as_f64()?), v.get("pressure").and_then(Value::as_f64))),
|
||||
_ => None,
|
||||
})
|
||||
.filter(|p| p.x.is_finite() && p.y.is_finite())
|
||||
.filter(|(p, _)| p.x.is_finite() && p.y.is_finite())
|
||||
.collect();
|
||||
if pts.is_empty() {
|
||||
return Err(bad(cmd, "need at least one point"));
|
||||
@@ -167,6 +173,31 @@ fn points_param(p: &Value, key: &str, cmd: &str) -> Result<Vec<Point>> {
|
||||
Ok(pts)
|
||||
}
|
||||
|
||||
/// The pen pressure along a freehand stroke (positions measured along its polyline), when any of
|
||||
/// its samples has one; samples without one count as full pressure, as a pen event without one.
|
||||
fn stroke_pressure(samples: &[(Point, Option<f64>)]) -> Option<PressureProfile> {
|
||||
if samples.iter().all(|(_, p)| p.is_none()) {
|
||||
return None;
|
||||
}
|
||||
let mut at = 0.0;
|
||||
let mut prev: Option<Point> = None;
|
||||
let along: Vec<(f64, f64)> = samples
|
||||
.iter()
|
||||
.map(|(pt, p)| {
|
||||
at += prev.map_or(0.0, |q| q.distance(*pt));
|
||||
prev = Some(*pt);
|
||||
(at, p.unwrap_or(1.0))
|
||||
})
|
||||
.collect();
|
||||
PressureProfile::from_samples(along.into_iter().map(|(s, p)| (if at > 1e-9 { s / at } else { 0.0 }, p)))
|
||||
}
|
||||
|
||||
/// The length of a subpath.
|
||||
fn subpath_len(sp: &SubPath) -> f64 {
|
||||
use kurbo::ParamCurveArclen;
|
||||
PathData::single(sp.clone()).to_bezpath().segments().map(|s| s.arclen(0.01)).sum()
|
||||
}
|
||||
|
||||
fn usize_req(p: &Value, key: &str, cmd: &str) -> Result<usize> {
|
||||
p.get(key).and_then(Value::as_u64).map(|v| v as usize).ok_or_else(|| bad(cmd, format!("missing integer `{key}`")))
|
||||
}
|
||||
@@ -501,7 +532,9 @@ fn fit_freehand(pts: &[Point], tol: f64, closed: bool) -> SubPath {
|
||||
|
||||
fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.freehand";
|
||||
let mut pts = points_param(p, "points", C)?;
|
||||
let samples = samples_param(p, "points", C)?;
|
||||
let pressure = stroke_pressure(&samples);
|
||||
let mut pts: Vec<Point> = samples.into_iter().map(|(pt, _)| pt).collect();
|
||||
let tol = f64_or(p, "fidelity", 1.5).clamp(0.05, 100.0);
|
||||
let closed = bool_or(p, "closed", false);
|
||||
let brush = str_param(p, "style") == Some("brush");
|
||||
@@ -511,6 +544,7 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (pd, _) = path_of(&s.doc()?.doc, id)?;
|
||||
let si = pd.subpaths.iter().rposition(|s| !s.closed && !s.anchors.is_empty()).ok_or_else(|| bad(C, "path has no open end"))?;
|
||||
let mut sp = pd.subpaths[si].clone();
|
||||
let old_len = subpath_len(&sp);
|
||||
if at_start {
|
||||
sp.reverse();
|
||||
}
|
||||
@@ -547,12 +581,24 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for a in &mut sp.anchors {
|
||||
*a = Anchor::with_handles(a.p, a.h_in, a.h_out);
|
||||
}
|
||||
// The path's pressure (in the direction it is continued in) followed by the stroke's.
|
||||
let old_pressure = s.doc()?.doc.node(id).and_then(|n| n.appearance.stroke()).and_then(|st| st.pressure.clone());
|
||||
let joined = (old_pressure.is_some() || pressure.is_some()).then(|| {
|
||||
let old = old_pressure.map(|p| if at_start { p.reversed() } else { p });
|
||||
let total = subpath_len(&sp);
|
||||
PressureProfile::extended(old.as_ref(), old_len, pressure.as_ref(), total - old_len)
|
||||
});
|
||||
s.edit(if brush { "Paintbrush" } else { "Pencil" }, |d, sel| {
|
||||
let path = path_mut(d, id)?;
|
||||
if at_start && !sp.closed {
|
||||
let reverse = at_start && !sp.closed;
|
||||
if reverse {
|
||||
sp.reverse();
|
||||
}
|
||||
path.subpaths[si] = sp;
|
||||
path_mut(d, id)?.subpaths[si] = sp;
|
||||
if let Some(joined) = joined
|
||||
&& let Some(st) = d.node_mut(id).and_then(|n| n.appearance.stroke_mut())
|
||||
{
|
||||
st.pressure = if reverse { joined.map(|p| p.reversed()) } else { joined };
|
||||
}
|
||||
sel.set([id]);
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -563,7 +609,10 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let sp = fit_freehand(&pts, tol, closed);
|
||||
let fill = if bool_or(p, "fill", false) { s.paint.fill.clone() } else { Paint::None };
|
||||
let look = s.new_art_look(fill, stroke_paint(s), s.paint.stroke_width.max(0.1));
|
||||
let mut look = s.new_art_look(fill, stroke_paint(s), s.paint.stroke_width.max(0.1));
|
||||
if let Some(st) = look.appearance.stroke_mut() {
|
||||
st.pressure = pressure;
|
||||
}
|
||||
add_look(s, if brush { "Paintbrush" } else { "Pencil" }, path_kind(PathData::single(sp)), look, None)
|
||||
}
|
||||
|
||||
|
||||
@@ -163,7 +163,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Target",
|
||||
[],
|
||||
None,
|
||||
"{id} target a layer, group or object as clicking its target circle in the Layers panel does: a layer gets its visible, unlocked art (its sublayers' too) selected and becomes the current layer, and appearance.*, effect.*, transparency.* and the opacity-mask commands without `ids` then act on the layer itself; anything else is selected. Any other selection change ends the targeting (`document.inspect` → target) → {id, selected: [..]}",
|
||||
"{id, add?: bool (Shift-click: the item joins the selection, or leaves it when all selected; nothing is targeted)} target a layer, group or object as clicking its target circle in the Layers panel does: a layer gets its visible, unlocked art (its sublayers' too) selected and becomes the current layer, and appearance.*, effect.*, transparency.* and the opacity-mask commands without `ids` then act on the layer itself; anything else is selected. Any other selection change ends the targeting (`document.inspect` → target) → {id, selected: [..]}",
|
||||
has_doc,
|
||||
target
|
||||
),
|
||||
@@ -362,7 +362,11 @@ fn target(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let layer = n.is_layer();
|
||||
let rows = st.layer_rows.clone();
|
||||
s.select(|d, sel| sel.set_target(d, id))?;
|
||||
if p.get("add").and_then(Value::as_bool).unwrap_or(false) {
|
||||
s.select(|d, sel| sel.toggle_target(d, id))?;
|
||||
} else {
|
||||
s.select(|d, sel| sel.set_target(d, id))?;
|
||||
}
|
||||
let st = s.doc_mut()?;
|
||||
// Targeting doesn't change which rows are highlighted.
|
||||
st.layer_rows = rows;
|
||||
|
||||
@@ -216,8 +216,20 @@ fn none(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn reselect(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let Some((c, p)) = s.doc()?.last_selection_cmd.clone() else { return ok() };
|
||||
s.execute(&c, &p)
|
||||
let st = s.doc()?;
|
||||
let Some((c, p, from)) = st.last_selection_cmd.clone() else { return ok() };
|
||||
// After Deselect, a Same command takes its reference again from the objects that were selected
|
||||
// when it ran, those still there (#903).
|
||||
let from: Vec<NodeId> = from.into_iter().filter(|id| st.doc.node(*id).is_some()).collect();
|
||||
let restore = st.selection.objects.is_empty() && !from.is_empty();
|
||||
if restore {
|
||||
s.select(|_, sel| sel.set(from))?;
|
||||
}
|
||||
let r = s.execute(&c, &p);
|
||||
if restore && r.is_err() {
|
||||
s.select(|_, sel| sel.clear())?;
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
fn inverse(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
@@ -401,8 +413,9 @@ fn select_where(s: &mut Session, cmd: &str, p: &Value, f: impl Fn(&Document, &No
|
||||
for l in st.doc.layers.iter().filter(|l| l.visible && !l.locked) {
|
||||
visit(&st.doc, l, &f, &mut ids);
|
||||
}
|
||||
let from = st.selection.objects.clone();
|
||||
s.select(|_, sel| sel.set(ids.iter().copied()))?;
|
||||
s.doc_mut()?.last_selection_cmd = Some((cmd.to_string(), p.clone()));
|
||||
s.doc_mut()?.last_selection_cmd = Some((cmd.to_string(), p.clone(), from));
|
||||
Ok(json!({ "count": ids.len() }))
|
||||
}
|
||||
|
||||
|
||||
@@ -215,9 +215,9 @@ fn unitized(mut ap: Appearance, from: Option<Rect>) -> Appearance {
|
||||
/// The appearance a new or redefined style takes from `n`: its own. A group without fills or
|
||||
/// strokes of its own lends those of its topmost painted object, and type its characters' fill
|
||||
/// and stroke. Placed gradients come relative to the unit box (from the bounds of the object
|
||||
/// they're on).
|
||||
/// they're on). The pen pressure of strokes stays with their own path.
|
||||
pub(crate) fn captured(n: &Node) -> Appearance {
|
||||
let mut ap = n.appearance.clone();
|
||||
let mut ap = n.appearance.clone().without_pressure();
|
||||
if !ap.items.is_empty() {
|
||||
return unitized(ap, n.geometric_bounds());
|
||||
}
|
||||
|
||||
@@ -126,8 +126,9 @@ pub struct DocState {
|
||||
pub undo_group: Option<UndoGroup>,
|
||||
/// For Object → Transform → Transform Again (⌘D).
|
||||
pub last_transform: Option<(Affine, bool)>,
|
||||
/// Selection saved by Select → Reselect.
|
||||
pub last_selection_cmd: Option<(String, Value)>,
|
||||
/// What Select → Reselect repeats: the last selection command, its params and the objects
|
||||
/// selected when it ran (a Same command's reference objects).
|
||||
pub last_selection_cmd: Option<(String, Value, Vec<NodeId>)>,
|
||||
/// Process-unique id of this open document (tab indices shift when tabs close).
|
||||
pub uid: u64,
|
||||
/// View Opacity Mask (Alt-click the mask thumbnail): the masked object whose mask the canvas
|
||||
|
||||
@@ -149,6 +149,18 @@ fn select_same_fill() {
|
||||
let r = s.execute("select.same.fillColor", &json!({})).unwrap();
|
||||
assert_eq!(r["count"], 2);
|
||||
assert!(!s.doc().unwrap().selection.contains(a));
|
||||
// Reselect after Deselect repeats it from the same reference object (#903).
|
||||
let blue = s.doc().unwrap().selection.objects.clone();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.execute("select.reselect", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.objects, blue);
|
||||
// With its reference object gone, it fails as Select Same does, and selects nothing.
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
for id in &blue {
|
||||
s.execute("edit.clear", &json!({"ids": [id.0]})).unwrap();
|
||||
}
|
||||
assert!(s.execute("select.reselect", &json!({})).is_err());
|
||||
assert!(s.doc().unwrap().selection.objects.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Brushes and symbols: commands, tools, rendering and persistence.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
use vectorcraft_doc::{Document, Node, NodeKind};
|
||||
use vectorcraft_geom::Rect;
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
|
||||
|
||||
@@ -193,6 +193,110 @@ fn paintbrush_tool_uses_the_current_brush() {
|
||||
assert!(s.execute("brush.setCurrent", &json!({"name": "Nope"})).is_err());
|
||||
}
|
||||
|
||||
/// A Size: Pressure variant of "3 pt. Round" (3 ± 3 pt).
|
||||
fn pressure_round(s: &mut Session) {
|
||||
let params = json!({"variation": [0, 0, 3], "modes": ["fixed", "fixed", "pressure"]});
|
||||
s.execute("brush.options", &json!({"name": "3 pt. Round", "params": params})).unwrap();
|
||||
}
|
||||
|
||||
fn pressure_of(s: &Session, id: NodeId) -> Option<vectorcraft_doc::PressureProfile> {
|
||||
node(s, id).appearance.stroke().and_then(|st| st.pressure.clone())
|
||||
}
|
||||
|
||||
/// #852: the Paintbrush records the pen pressure on the stroke, the brush draws it, and it lasts
|
||||
/// through saving and every export; a mouse stroke records none.
|
||||
#[test]
|
||||
fn paintbrush_records_pen_pressure_on_the_stroke() {
|
||||
let mut s = session();
|
||||
pressure_round(&mut s);
|
||||
s.execute("brush.setCurrent", &json!({"name": "3 pt. Round"})).unwrap();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("paintbrush", v).unwrap();
|
||||
let strokes = |s: &mut Session, y: f64, pressure: [f32; 4]| {
|
||||
let kinds = [PointerKind::Down, PointerKind::Drag, PointerKind::Drag, PointerKind::Up];
|
||||
for ((k, x), p) in kinds.into_iter().zip([20.0, 120.0, 220.0, 320.0]).zip(pressure) {
|
||||
s.pointer(&PointerEvent { pressure: p, ..PointerEvent::new(k, x, y) }, v).unwrap();
|
||||
}
|
||||
let id = s.doc().unwrap().selection.objects[0];
|
||||
// Deselected, so the next stroke starts a path of its own.
|
||||
select(s, &[]);
|
||||
id
|
||||
};
|
||||
let pen = strokes(&mut s, 60.0, [0.0, 0.4, 0.8, 1.0]);
|
||||
let p = pressure_of(&s, pen).expect("pressure recorded");
|
||||
assert!(p.at(0.0) < 0.05 && p.at(1.0) > 0.95 && (p.at(0.5) - 0.6).abs() < 0.05, "{p:?}");
|
||||
let mouse = strokes(&mut s, 200.0, [1.0; 4]);
|
||||
assert_eq!(pressure_of(&s, mouse), None, "a mouse presses fully: nothing to record");
|
||||
// Drawn: thin where the pen pressed lightly, 6 pt where it pressed fully.
|
||||
let doc: Document = (*s.doc().unwrap().doc).clone();
|
||||
let art = |d: &Document, id: NodeId| vectorcraft_brush::node_pieces(d, d.node(id).unwrap()).unwrap();
|
||||
let heavy_end = vectorcraft_brush::pieces_bounds(&art(&doc, pen)).unwrap();
|
||||
assert!((heavy_end.height() - 6.0).abs() < 0.3, "{heavy_end:?}");
|
||||
let even = vectorcraft_brush::pieces_bounds(&art(&doc, mouse)).unwrap();
|
||||
assert!((even.height() - 3.0).abs() < 0.1, "no pressure draws the size itself: {even:?}");
|
||||
// Saved and reopened as it was.
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap();
|
||||
assert_eq!(back.node(pen).unwrap().appearance.stroke().unwrap().pressure, Some(p));
|
||||
// SVG and PDF draw the brush through the same stroke code: without the pressure they differ.
|
||||
let mut flat = doc.clone();
|
||||
flat.node_mut(pen).unwrap().appearance.stroke_mut().unwrap().pressure = None;
|
||||
assert_ne!(vectorcraft_svg::export(&doc, &Default::default()), vectorcraft_svg::export(&flat, &Default::default()));
|
||||
let pdf = |d: &Document| {
|
||||
vectorcraft_pdf::export(d, &vectorcraft_pdf::PdfOptions { created: Some(0), ..vectorcraft_pdf::PdfOptions::uncompressed() }).unwrap()
|
||||
};
|
||||
assert_ne!(pdf(&doc), pdf(&flat));
|
||||
}
|
||||
|
||||
/// Continuing a pressure stroke keeps its pressure where it was and adds the new stroke's after it.
|
||||
#[test]
|
||||
fn continuing_a_stroke_joins_its_pressure() {
|
||||
let mut s = session();
|
||||
let r = s.execute("path.freehand", &json!({"points": [[0, 50, 0], [100, 50, 1]], "style": "brush"})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
s.execute("path.freehand", &json!({"points": [[100, 50, 1], [200, 50, 0]], "style": "brush", "extend": {"id": id.0, "end": "end"}})).unwrap();
|
||||
let p = pressure_of(&s, id).unwrap();
|
||||
assert!(p.at(0.0) < 0.05 && (p.at(0.5) - 1.0).abs() < 0.05 && p.at(1.0) < 0.05, "{p:?}");
|
||||
// Continued at its start: the new stroke comes first, running backwards from where it joined.
|
||||
let r = s.execute("path.freehand", &json!({"points": [[0, 150], [100, 150]], "style": "brush"})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
s.execute("path.freehand", &json!({"points": [[0, 150, 1], [-100, 150, 0]], "style": "brush", "extend": {"id": id.0, "end": "start"}})).unwrap();
|
||||
let p = pressure_of(&s, id).unwrap();
|
||||
assert!(p.at(0.0) < 0.05 && (p.at(0.5) - 1.0).abs() < 0.05, "{p:?}");
|
||||
assert_eq!(p.at(1.0), vectorcraft_doc::PressureProfile::MID, "the old part had no pressure");
|
||||
}
|
||||
|
||||
/// Pen pressure belongs to its path: new art, graphic styles and Brush Options' edits leave it.
|
||||
#[test]
|
||||
fn pressure_stays_with_its_own_path() {
|
||||
let mut s = session();
|
||||
s.execute("appearance.setNewArtBasic", &json!({"on": false})).unwrap();
|
||||
let r = s.execute("brush.freehand", &json!({"points": [[0, 50, 0.2], [100, 50, 0.9]], "style": "brush", "brush": "3 pt. Round"})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
assert!(pressure_of(&s, id).is_some());
|
||||
select(&mut s, &[id]);
|
||||
s.execute("graphicStyle.new", &json!({"name": "Inked"})).unwrap();
|
||||
let style = s.doc().unwrap().doc.graphic_style("Inked").unwrap().appearance.clone();
|
||||
assert!(style.stroke().is_some_and(|st| st.pressure.is_none()));
|
||||
let other = rect(&mut s, 10.0, 100.0, 50.0, 50.0);
|
||||
assert_eq!(pressure_of(&s, other), None, "new art takes the look, not the pressure");
|
||||
pressure_round(&mut s);
|
||||
assert!(pressure_of(&s, id).is_some(), "editing the brush keeps the strokes' pressure");
|
||||
}
|
||||
|
||||
/// Brush Options sets the variation modes; bad modes are refused, never a panic.
|
||||
#[test]
|
||||
fn brush_options_set_variation_modes() {
|
||||
let mut s = session();
|
||||
pressure_round(&mut s);
|
||||
let b = s.execute("brush.get", &json!({"name": "3 pt. Round"})).unwrap();
|
||||
assert_eq!(b["modes"], json!(["fixed", "fixed", "pressure"]));
|
||||
assert_eq!(b["variation"], json!([0.0, 0.0, 3.0]));
|
||||
for bad in [json!({"modes": ["fixed", "fixed", "tilt"]}), json!({"modes": "pressure"}), json!({"variation": "x"}), json!({"modes": [1, 2, 3]})] {
|
||||
assert!(s.execute("brush.options", &json!({"name": "3 pt. Round", "params": bad})).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(s.execute("brush.get", &json!({"name": "3 pt. Round"})).unwrap(), b, "a refused edit changes nothing");
|
||||
}
|
||||
|
||||
// ---------- symbols ----------
|
||||
|
||||
fn make_symbol(s: &mut Session) -> (NodeId, String, Rect) {
|
||||
|
||||
@@ -214,6 +214,19 @@ fn structured_junk() {
|
||||
("path.freehand", json!({"points": [[0, 0]]})),
|
||||
("path.freehand", json!({"points": [[0, 0], [0, 0], [0, 0], [0, 0]]})),
|
||||
("path.freehand", json!({"points": [[1e308, 1e308], [-1e308, 0]], "fidelity": 0})),
|
||||
("path.freehand", json!({"points": [[0, 0, -5], [10, 0, 1e308], [20, 0, "x"], [30, 0, null], [40, 0, 0.5]], "style": "brush"})),
|
||||
("path.freehand", json!({"points": [[0, 0, 0.2], [0, 0, 0.9], [0, 0, 0.1]]})),
|
||||
(
|
||||
"path.freehand",
|
||||
json!({"points": [{"x": 0, "y": 0, "pressure": 2}, {"x": 1e308, "y": 0, "pressure": -1}], "extend": {"id": 2, "end": "start"}}),
|
||||
),
|
||||
("brush.freehand", json!({"points": [[0, 0, 0], [50, 50, 1]], "brush": "3 pt. Round"})),
|
||||
("brush.options", json!({"name": "3 pt. Round", "params": {"modes": ["pressure", "random", "nope"]}})),
|
||||
("brush.options", json!({"name": "3 pt. Round", "params": {"modes": "pressure", "variation": [1e308, -1e308, 1e308]}})),
|
||||
(
|
||||
"brush.options",
|
||||
json!({"name": "3 pt. Round", "params": {"modes": ["pressure", "pressure", "pressure"], "variation": [1e308, 1e308, 1e308], "size": 1e308, "angle": -1e308}}),
|
||||
),
|
||||
("path.curvature", json!({"points": [{"x": 0, "y": 0}]})),
|
||||
("path.curvatureEdit", json!({"id": 2, "op": "move", "anchor": 1000, "x": 0, "y": 0})),
|
||||
("path.curvatureEdit", json!({"id": 2, "op": "move", "anchor": 0, "x": 1e308, "y": -1e308})),
|
||||
|
||||
+16
-10
@@ -32,7 +32,7 @@ use vectorcraft_geom::{Anchor, PathData, Point, Rect};
|
||||
|
||||
use crate::bbox::move_delta;
|
||||
use crate::corners::{self, CornerDrag, over_widget};
|
||||
use crate::guides::{HandleSnap, PointSnap, Targets};
|
||||
use crate::guides::{HandleSnap, Leave, PointSnap, Targets};
|
||||
use crate::meshedit::MeshEdit;
|
||||
use crate::pathtype::{self, BracketDrag, over_bracket};
|
||||
use crate::rulerguide::GuideEdit;
|
||||
@@ -428,12 +428,12 @@ impl Tool for DirectSelectionTool {
|
||||
self.anchor_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection)));
|
||||
}
|
||||
let mut d = move_delta(start, p, ev.mods.shift);
|
||||
// The grabbed anchor snaps and the others follow it; Shift keeps the angle instead.
|
||||
// The grabbed anchor snaps and the others follow it. Shift keeps the move at its
|
||||
// angle, and the anchor slides along it onto what Smart Guides find there (#886).
|
||||
self.guides.clear();
|
||||
if !ev.mods.shift
|
||||
&& let Some(snap) = &self.anchor_snap
|
||||
{
|
||||
let (q, guides) = snap.snap(cx, grab + d);
|
||||
if let Some(snap) = &self.anchor_snap {
|
||||
let shift = ev.mods.shift.then(|| Leave::segment(cx, grab, true));
|
||||
let (q, guides) = snap.snap_from(cx, grab + (p - start), shift.as_ref());
|
||||
(d, self.guides) = (q - grab, guides);
|
||||
}
|
||||
out.push(Action::Preview("path.moveAnchors".into(), json!({"dx": d.x, "dy": d.y})));
|
||||
@@ -1022,10 +1022,16 @@ mod tests {
|
||||
// In line with B's own bottom-left corner, which stays put.
|
||||
let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (303.0, 330.0), none);
|
||||
assert_eq!((v, labels), (json!({"dx": 0.0, "dy": 30.0}), vec!["align".to_string()]));
|
||||
// Shift keeps the move at 45° steps, unsnapped.
|
||||
let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (203.0, 199.0), Mods { shift: true, ..none });
|
||||
let s = move_delta(Point::new(300.0, 300.0), Point::new(203.0, 199.0), true);
|
||||
assert_eq!((v, labels), (json!({"dx": s.x, "dy": s.y}), vec![]));
|
||||
// Shift keeps the move at 45° steps and still snaps along that line (#886): on the
|
||||
// diagonal from B's corner, onto A's bottom-right corner…
|
||||
let shift = Mods { shift: true, ..none };
|
||||
let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (203.0, 199.0), shift);
|
||||
let near = |v: &serde_json::Value, (x, y): (f64, f64)| (v["dx"].as_f64().unwrap() - x).hypot(v["dy"].as_f64().unwrap() - y) < 1e-9;
|
||||
assert!(near(&v, (-100.0, -100.0)) && labels == ["anchor"], "{v} {labels:?}");
|
||||
// …and with nothing in reach, at the pointer's distance along its angle.
|
||||
let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (380.0, 302.0), shift);
|
||||
let s = move_delta(Point::new(300.0, 300.0), Point::new(380.0, 302.0), true);
|
||||
assert!(near(&v, (s.x, s.y)) && labels.is_empty(), "{v} {labels:?}");
|
||||
// Two anchors: the one pressed on (B's top-right) lands on A's corner.
|
||||
let two = anchors_of(b, &[(0, 0), (0, 1)]);
|
||||
let c = cx(&d, &two, &p);
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
//! and commit on release; Alt on release (or ending near the start) closes the path, and starting
|
||||
//! near an end of a selected open path continues it. The Pencil's new path starts and ends on
|
||||
//! Smart Guides ([`DrawSnap`]: an anchor, a path, in line with the art), hovering too; the points
|
||||
//! between follow the hand. The other tools collect the drag polyline (shown as an overlay) and run
|
||||
//! between follow the hand. The Paintbrush records the pen pressure of each point (a stroke a pen
|
||||
//! pressed less than fully on sends `[x, y, pressure]` points) for pressure-sensitive brushes. The
|
||||
//! other tools collect the drag polyline (shown as an overlay) and run
|
||||
//! one command on release.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
@@ -19,6 +21,8 @@ use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, Tool
|
||||
pub struct GestureTool {
|
||||
id: &'static str,
|
||||
points: Vec<Point>,
|
||||
/// The pen pressure of each of `points` (the Paintbrush only).
|
||||
pressures: Vec<f32>,
|
||||
active: bool,
|
||||
began: bool,
|
||||
/// Pencil/Paintbrush continuing a selected open path: (path, continue from its start).
|
||||
@@ -53,6 +57,7 @@ impl GestureTool {
|
||||
Self {
|
||||
id,
|
||||
points: vec![],
|
||||
pressures: vec![],
|
||||
active: false,
|
||||
began: false,
|
||||
extend: None,
|
||||
@@ -74,6 +79,30 @@ impl GestureTool {
|
||||
matches!(self.id, "pencil" | "paintbrush")
|
||||
}
|
||||
|
||||
/// Add a stroke sample.
|
||||
fn push(&mut self, p: Point, pressure: f32) {
|
||||
self.points.push(p);
|
||||
if self.id == "paintbrush" {
|
||||
self.pressures.push(pressure);
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget the stroke.
|
||||
fn clear(&mut self) {
|
||||
self.points.clear();
|
||||
self.pressures.clear();
|
||||
}
|
||||
|
||||
/// The stroke's points, each `[x, y, pressure]` when a pen pressed less than fully anywhere
|
||||
/// along it (a mouse always presses fully).
|
||||
fn stroke_json(&self) -> Value {
|
||||
if self.pressures.len() == self.points.len() && self.pressures.iter().any(|p| *p < 1.0) {
|
||||
Value::Array(self.points.iter().zip(&self.pressures).map(|(p, f)| json!([p.x, p.y, (f64::from(*f) * 1000.0).round() / 1000.0])).collect())
|
||||
} else {
|
||||
points_json(&self.points)
|
||||
}
|
||||
}
|
||||
|
||||
fn label(&self) -> &'static str {
|
||||
match self.id {
|
||||
"paintbrush" => "Paintbrush",
|
||||
@@ -110,7 +139,7 @@ impl GestureTool {
|
||||
/// Parameters of `path.freehand` for the current stroke.
|
||||
pub fn freehand_params(&self, closed: bool) -> Value {
|
||||
let mut v = json!({
|
||||
"points": points_json(&self.points),
|
||||
"points": self.stroke_json(),
|
||||
"fidelity": self.fidelity,
|
||||
"closed": closed,
|
||||
"style": if self.id == "paintbrush" { "brush" } else { "pencil" },
|
||||
@@ -168,7 +197,8 @@ impl Tool for GestureTool {
|
||||
self.extend = if self.draws_path() { self.find_extend(cx, p) } else { None };
|
||||
// A path continued starts at its end; a new one where it snaps.
|
||||
let start = if self.snaps() && self.extend.is_none() { self.snap.press(cx, p, &[], None) } else { p };
|
||||
self.points = vec![start];
|
||||
self.clear();
|
||||
self.push(start, ev.pressure);
|
||||
vec![]
|
||||
}
|
||||
PointerKind::Drag => {
|
||||
@@ -182,7 +212,7 @@ impl Tool for GestureTool {
|
||||
if self.points.last().is_some_and(|l| l.distance(p) < cx.tol(1.0)) {
|
||||
return vec![];
|
||||
}
|
||||
self.points.push(p);
|
||||
self.push(p, ev.pressure);
|
||||
if !self.draws_path() {
|
||||
return vec![];
|
||||
}
|
||||
@@ -201,7 +231,7 @@ impl Tool for GestureTool {
|
||||
let end = if self.snaps() { self.snap.drag(cx, p, None) } else { p };
|
||||
self.snap.clear();
|
||||
if self.points.last().is_some_and(|l| l.distance(p) >= cx.tol(1.0)) {
|
||||
self.points.push(end);
|
||||
self.push(end, ev.pressure);
|
||||
} else if self.snaps()
|
||||
&& self.points.len() > 1
|
||||
&& let Some(last) = self.points.last_mut()
|
||||
@@ -212,7 +242,7 @@ impl Tool for GestureTool {
|
||||
let out = self.finish(cx, ev.mods);
|
||||
self.active = false;
|
||||
self.began = false;
|
||||
self.points.clear();
|
||||
self.clear();
|
||||
self.extend = None;
|
||||
out
|
||||
}
|
||||
@@ -226,7 +256,7 @@ impl Tool for GestureTool {
|
||||
self.snap.clear();
|
||||
self.active = false;
|
||||
self.began = false;
|
||||
self.points.clear();
|
||||
self.clear();
|
||||
if began { vec![Action::Cancel] } else { vec![] }
|
||||
}
|
||||
ToolKey::BracketLeft if matches!(self.id, "blobBrush" | "eraser") => {
|
||||
@@ -245,7 +275,7 @@ impl Tool for GestureTool {
|
||||
self.snap.clear();
|
||||
self.active = false;
|
||||
self.began = false;
|
||||
self.points.clear();
|
||||
self.clear();
|
||||
if began { vec![Action::Commit] } else { vec![] }
|
||||
}
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
@@ -321,6 +351,29 @@ mod tests {
|
||||
assert_eq!(v["points"][0], json!([102.0, 98.0]));
|
||||
}
|
||||
|
||||
/// #852: the Paintbrush sends each point's pen pressure; a mouse (always fully pressed) and
|
||||
/// the Pencil send plain points.
|
||||
#[test]
|
||||
fn paintbrush_sends_pen_pressure() {
|
||||
let (d, _) = doc_with_rect();
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let stroke = |id: &str, pressures: [f32; 3]| {
|
||||
let mut t = GestureTool::new(id);
|
||||
let kinds = [PointerKind::Down, PointerKind::Drag, PointerKind::Up];
|
||||
let mut last = vec![];
|
||||
for ((k, x), f) in kinds.into_iter().zip([0.0, 40.0, 80.0]).zip(pressures) {
|
||||
last = t.pointer(&cx, &PointerEvent { pressure: f, ..PointerEvent::new(k, x, 0.0) });
|
||||
}
|
||||
let Some(Action::Preview(_, v)) = last.first() else { panic!("{last:?}") };
|
||||
v["points"].clone()
|
||||
};
|
||||
assert_eq!(stroke("paintbrush", [0.25, 0.5, 1.0]), json!([[0.0, 0.0, 0.25], [40.0, 0.0, 0.5], [80.0, 0.0, 1.0]]));
|
||||
assert_eq!(stroke("paintbrush", [1.0; 3]), json!([[0.0, 0.0], [40.0, 0.0], [80.0, 0.0]]));
|
||||
assert_eq!(stroke("pencil", [0.25, 0.5, 1.0]), json!([[0.0, 0.0], [40.0, 0.0], [80.0, 0.0]]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pencil_previews_and_commits() {
|
||||
let (d, _) = doc_with_rect();
|
||||
|
||||
@@ -12,6 +12,7 @@ workspace = true
|
||||
vectorcraft-geom = { workspace = true }
|
||||
vectorcraft-color = { workspace = true }
|
||||
vectorcraft-doc = { workspace = true }
|
||||
vectorcraft-brush = { workspace = true }
|
||||
vectorcraft-render = { workspace = true }
|
||||
vectorcraft-text = { workspace = true }
|
||||
vectorcraft-tools = { workspace = true }
|
||||
|
||||
@@ -512,13 +512,16 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon {
|
||||
fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: egui::Rect) {
|
||||
let line = ui.ctx().options(|o| o.input_options.line_scroll_speed);
|
||||
let wheel_zooms = app.session.prefs.zoom_with_mouse_wheel;
|
||||
let alt_id = egui::Id::new("canvas-alt-wheel");
|
||||
let alt_before = ui.data(|d| d.get_temp(alt_id)).unwrap_or(false);
|
||||
let mut alt_turn = alt_before;
|
||||
let turn_id = egui::Id::new("canvas-wheel-turn");
|
||||
let turn_before: WheelTurn = ui.data(|d| d.get_temp(turn_id)).unwrap_or_default();
|
||||
let mut turn = turn_before;
|
||||
let (pointer, m, space, (factor, scroll)) =
|
||||
ui.input(|i| (i.pointer.clone(), i.modifiers, i.key_down(egui::Key::Space), wheel(i, wheel_zooms, line, rect.height(), &mut alt_turn)));
|
||||
if alt_turn != alt_before {
|
||||
ui.data_mut(|d| d.insert_temp(alt_id, alt_turn));
|
||||
ui.input(|i| (i.pointer.clone(), i.modifiers, i.key_down(egui::Key::Space), wheel(i, wheel_zooms, line, rect.height(), &mut turn)));
|
||||
if turn != turn_before {
|
||||
ui.data_mut(|d| d.insert_temp(turn_id, turn));
|
||||
}
|
||||
if turn.glide != 0.0 {
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
let v = *app.view().unwrap_or(&View::default());
|
||||
let xf = Xf::new(rect, &v);
|
||||
@@ -717,6 +720,18 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
|
||||
|
||||
/// How much one point of wheel motion zooms (as `exp(points × WHEEL_ZOOM)`).
|
||||
const WHEEL_ZOOM: f64 = 0.01;
|
||||
/// How fast a wheel notch's zoom glides in with Zoom with Mouse Wheel: the time constant (s) of
|
||||
/// the part still to come.
|
||||
const WHEEL_GLIDE: f64 = 0.05;
|
||||
|
||||
/// What the wheel did across frames ([`wheel`]).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
struct WheelTurn {
|
||||
/// The last wheel turn was an Alt-wheel one.
|
||||
alt: bool,
|
||||
/// The zoom (natural log) wheel notches still have to glide in.
|
||||
glide: f64,
|
||||
}
|
||||
/// Animated Zoom: how much one point of sideways drag zooms (100 points to the right double it).
|
||||
const SCRUB_ZOOM: f64 = std::f64::consts::LN_2 / 100.0;
|
||||
/// Animated Zoom: how far the pointer moves before a press is a drag, in points.
|
||||
@@ -754,19 +769,19 @@ fn marquee(p: &egui::Painter, r: egui::Rect) {
|
||||
/// a zoom factor (about the pointer) and a scroll (screen points the content moves). The wheel scrolls, Cmd- and Alt-wheel (Option
|
||||
/// on the Mac) zoom. With General › Zoom with Mouse Wheel (`wheel_zooms`) the wheel and Alt-wheel
|
||||
/// zoom, Shift-wheel scrolls up and down and Cmd/Ctrl-wheel sideways. `line` and `page`: points
|
||||
/// per wheel line and page. `alt_turn`: the last wheel turn was an Alt-wheel one (kept by the
|
||||
/// caller across frames).
|
||||
fn wheel(i: &egui::InputState, wheel_zooms: bool, line: f32, page: f32, alt_turn: &mut bool) -> (f64, egui::Vec2) {
|
||||
/// per wheel line and page. `turn`: kept by the caller across frames. A mouse wheel's notches zoom
|
||||
/// in a glide over a few frames, as scrolling does (#888); a trackpad's fine steps at once.
|
||||
fn wheel(i: &egui::InputState, wheel_zooms: bool, line: f32, page: f32, turn: &mut WheelTurn) -> (f64, egui::Vec2) {
|
||||
// Fingers on a touch screen pan the content with them (a trackpad sends scrolls instead).
|
||||
let pan = i.multi_touch().map_or(egui::Vec2::ZERO, |t| t.translation_delta);
|
||||
if !wheel_zooms {
|
||||
// egui makes Cmd-wheel (and a pinch) its zoom and the rest a scroll it spreads over a few
|
||||
// frames: the rest of an Alt-wheel turn zooms too, however soon Alt is let go.
|
||||
if let Some(m) = i.events.iter().rev().find_map(|e| if let egui::Event::MouseWheel { modifiers, .. } = e { Some(modifiers) } else { None }) {
|
||||
*alt_turn = m.alt && !m.command;
|
||||
turn.alt = m.alt && !m.command;
|
||||
}
|
||||
let (zoom, scroll) = (f64::from(i.zoom_delta()), i.smooth_scroll_delta);
|
||||
return if *alt_turn && scroll != egui::Vec2::ZERO {
|
||||
return if turn.alt && scroll != egui::Vec2::ZERO {
|
||||
(zoom * (f64::from(scroll.x + scroll.y) * WHEEL_ZOOM).exp(), pan)
|
||||
} else {
|
||||
(zoom, scroll + pan)
|
||||
@@ -787,16 +802,23 @@ fn wheel(i: &egui::InputState, wheel_zooms: bool, line: f32, page: f32, alt_turn
|
||||
scroll.x += d.x + d.y;
|
||||
} else if modifiers.shift {
|
||||
scroll.y += d.x + d.y;
|
||||
} else {
|
||||
} else if *unit == egui::MouseWheelUnit::Point {
|
||||
zoom *= (f64::from(d.y) * WHEEL_ZOOM).exp();
|
||||
scroll.x += d.x;
|
||||
} else {
|
||||
turn.glide += f64::from(d.y) * WHEEL_ZOOM;
|
||||
scroll.x += d.x;
|
||||
}
|
||||
}
|
||||
egui::Event::Zoom(f) => zoom *= f64::from(*f),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(zoom, scroll)
|
||||
// This frame's part of the glide: what is left shrinks by e every WHEEL_GLIDE seconds.
|
||||
let part = if turn.glide.abs() < 1e-3 { 1.0 } else { 1.0 - (-f64::from(i.stable_dt.min(0.1)) / WHEEL_GLIDE).exp() };
|
||||
let step = turn.glide * part;
|
||||
turn.glide -= step;
|
||||
(zoom * step.exp(), scroll)
|
||||
}
|
||||
|
||||
/// Enable Touch Gestures: this frame's finger contacts on the canvas ([`crate::touch`]). A second
|
||||
@@ -2868,6 +2890,43 @@ mod tests {
|
||||
assert!((zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0, "on: Cmd-wheel scrolls sideways ({zoom}, {scroll:?})");
|
||||
}
|
||||
|
||||
/// #888: with Zoom with Mouse Wheel, a wheel notch's zoom glides in over a few frames, as a
|
||||
/// scroll does, instead of jumping on the frame it arrives; it ends at the same zoom.
|
||||
#[test]
|
||||
fn wheel_notches_zoom_in_a_glide() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
app.session.execute("prefs.set", &json!({"key": "zoomWithMouseWheel", "value": true})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let run = |app: &mut VectorcraftApp| {
|
||||
let mut raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() };
|
||||
app.raw_input_hook(&mut raw);
|
||||
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
|
||||
out.textures_delta.clear();
|
||||
};
|
||||
run(&mut app);
|
||||
let at = app.canvas_rect.unwrap().center();
|
||||
let before = app.view().unwrap().zoom;
|
||||
crate::tests_synthetic::control(&mut app, &ctx, "ui.wheel", json!({"x": at.x, "y": at.y, "dy": 1}));
|
||||
// The first frame the zoom moves in (the event may land a frame later).
|
||||
let mut first = 1.0;
|
||||
for _ in 0..3 {
|
||||
run(&mut app);
|
||||
first = app.view().unwrap().zoom / before;
|
||||
if first != 1.0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
for _ in 0..60 {
|
||||
run(&mut app);
|
||||
}
|
||||
let done = app.view().unwrap().zoom / before;
|
||||
assert!(done > 1.01, "the notch zooms in ({done})");
|
||||
assert!(first > 1.0 && first < done, "the first frame takes only part of it ({first} of {done})");
|
||||
run(&mut app);
|
||||
assert_eq!(app.view().unwrap().zoom / before, done, "and it stops");
|
||||
}
|
||||
|
||||
/// Two fingers dragged together on a touch screen pan the canvas with them, under either
|
||||
/// Zoom with Mouse Wheel setting, and draw nothing (#449).
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
//! Brush Options (double-click a brush in the Brushes panel, or the panel menu's Brush Options…).
|
||||
//! Calligraphic Brush Options: Name, then Angle, Roundness and Size, each Fixed, Random or
|
||||
//! Pressure with its Variation, over live previews of the nib and of a stroke (light, heavy, light
|
||||
//! pressure). Bristle Brush Options: Name, Shape and the bristle sliders. OK runs `brush.options`
|
||||
//! (strokes painted with the brush update); a name another brush has keeps the dialog open.
|
||||
//!
|
||||
//! Fields: `__brush` (the brush's name), `__type` (`calligraphic` | `bristle`), `name`.
|
||||
//! Calligraphic: `angle` (−180..180°), `roundness` (0..100 %), `size` (0..1296 pt), and per value
|
||||
//! `angleMode`, `roundnessMode`, `sizeMode` (`fixed` | `random` | `pressure`) and
|
||||
//! `angleVariation`, `roundnessVariation`, `sizeVariation`. Bristle: `shape` (`roundPoint`…),
|
||||
//! `size` (pt), `length` (25..300 %), `density`, `thickness`, `opacity`, `stiffness` (1..100 %).
|
||||
|
||||
use egui::{Color32, Sense, Stroke, pos2, vec2};
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_brush::{Brush, BrushKind, Calligraphic, Variation};
|
||||
|
||||
use super::swatch_options::{grid, label};
|
||||
use super::{DialogSpec, form};
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::Tokens;
|
||||
use crate::{VectorcraftApp, widgets};
|
||||
|
||||
/// The dialog kind of Brush Options.
|
||||
pub const KIND: &str = "brushOptions";
|
||||
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, min_width: 440.0, ..DialogSpec::FORM };
|
||||
|
||||
/// The brush types that have an options dialog.
|
||||
pub const TYPES: [&str; 2] = ["calligraphic", "bristle"];
|
||||
|
||||
/// Calligraphic values: (field, label, suffix, decimals), in [`Calligraphic::RANGES`] order.
|
||||
const CALLI: [(&str, &str, &str, usize); 3] = [("angle", "Angle:", "°", 0), ("roundness", "Roundness:", "%", 0), ("size", "Size:", " pt", 1)];
|
||||
|
||||
/// Bristle sliders: (field, label, min, max), in percent.
|
||||
const BRISTLE: [(&str, &str, f64, f64); 5] = [
|
||||
("length", "Bristle Length:", 25.0, 300.0),
|
||||
("density", "Bristle Density:", 1.0, 100.0),
|
||||
("thickness", "Bristle Thickness:", 1.0, 100.0),
|
||||
("opacity", "Paint Opacity:", 1.0, 100.0),
|
||||
("stiffness", "Stiffness:", 1.0, 100.0),
|
||||
];
|
||||
|
||||
/// Bristle tip shapes: (value, label).
|
||||
const SHAPES: [(&str, &str); 10] = [
|
||||
("roundPoint", "Round Point"),
|
||||
("flatPoint", "Flat Point"),
|
||||
("roundBlunt", "Round Blunt"),
|
||||
("flatBlunt", "Flat Blunt"),
|
||||
("roundCurve", "Round Curve"),
|
||||
("flatCurve", "Flat Curve"),
|
||||
("roundAngle", "Round Angle"),
|
||||
("flatAngle", "Flat Angle"),
|
||||
("roundFan", "Round Fan"),
|
||||
("flatFan", "Flat Fan"),
|
||||
];
|
||||
|
||||
/// Width of the bristle dialog's label column.
|
||||
const LABEL_W: f32 = 112.0;
|
||||
|
||||
/// Widths of the nib preview and of the whole preview row.
|
||||
const NIB_W: f32 = 120.0;
|
||||
const STROKE_W: f32 = 400.0;
|
||||
|
||||
fn heading(d: &Dialog) -> String {
|
||||
match d.str("__type").as_str() {
|
||||
"bristle" => tl!("Bristle Brush Options").into(),
|
||||
_ => tl!("Calligraphic Brush Options").into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn mode_key(key: &str) -> String {
|
||||
format!("{key}Mode")
|
||||
}
|
||||
|
||||
fn variation_key(key: &str) -> String {
|
||||
format!("{key}Variation")
|
||||
}
|
||||
|
||||
/// Open the options of brush `name` (default: the selected path's brush, else the current one).
|
||||
pub fn open(app: &mut VectorcraftApp, name: Option<&str>) -> Result<Value, String> {
|
||||
let name = match name {
|
||||
Some(n) => n.to_string(),
|
||||
None => target(app).ok_or("no brush selected")?,
|
||||
};
|
||||
let def = app.run("brush.get", json!({ "name": name }))?;
|
||||
let b: Brush = serde_json::from_value(def).map_err(|e| e.to_string())?;
|
||||
let mut f = Map::new();
|
||||
f.insert("__brush".into(), json!(b.name));
|
||||
f.insert("name".into(), json!(b.name));
|
||||
f.insert("__type".into(), json!(b.kind.type_id()));
|
||||
match &b.kind {
|
||||
BrushKind::Calligraphic(c) => {
|
||||
let values = [c.angle, c.roundness, c.size];
|
||||
for (i, (key, ..)) in CALLI.iter().enumerate() {
|
||||
let (Some(v), Some(var), Some(mode)) = (values.get(i), c.variation.get(i), c.modes().get(i).copied()) else { continue };
|
||||
f.insert((*key).into(), json!(v));
|
||||
f.insert(variation_key(key), json!(var));
|
||||
f.insert(mode_key(key), json!(mode.id()));
|
||||
}
|
||||
}
|
||||
BrushKind::Bristle(b) => {
|
||||
let v = serde_json::to_value(b).map_err(|e| e.to_string())?;
|
||||
for key in ["shape", "size"].into_iter().chain(BRISTLE.map(|r| r.0)) {
|
||||
f.insert(key.into(), v.get(key).cloned().unwrap_or(Value::Null));
|
||||
}
|
||||
}
|
||||
_ => return Err("this kind of brush has no options dialog yet".into()),
|
||||
}
|
||||
app.ui.dialog = Some(Dialog { kind: KIND.into(), fields: f });
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// Can Brush Options open without a name (its brush has a type with a dialog)?
|
||||
pub fn available(app: &VectorcraftApp) -> bool {
|
||||
let Some(st) = app.session.active() else { return false };
|
||||
target(app).and_then(|n| vectorcraft_brush::find(&st.doc, &n)).is_some_and(|b| TYPES.contains(&b.kind.type_id()))
|
||||
}
|
||||
|
||||
/// The brush Brush Options opens on without a name: the selected path's brush, else the current one.
|
||||
fn target(app: &VectorcraftApp) -> Option<String> {
|
||||
let st = app.session.active()?;
|
||||
let selected = st.selection.subjects().first().and_then(|id| st.doc.node(*id)).and_then(|n| n.appearance.stroke()?.brush.clone());
|
||||
selected.or_else(|| vectorcraft_brush::current(&st.doc))
|
||||
}
|
||||
|
||||
/// The value of field `key` within `range`.
|
||||
fn clamped(d: &Dialog, key: &str, (lo, hi): (f64, f64)) -> f64 {
|
||||
d.f64(key, lo).clamp(lo, hi)
|
||||
}
|
||||
|
||||
/// The Calligraphic brush the fields describe.
|
||||
fn calligraphic(d: &Dialog) -> Calligraphic {
|
||||
let value = |i: usize| CALLI.get(i).zip(Calligraphic::RANGES.get(i)).map_or(0.0, |((key, ..), r)| clamped(d, key, *r));
|
||||
let variation =
|
||||
|i: usize| CALLI.get(i).zip(Calligraphic::MAX_VARIATION.get(i)).map_or(0.0, |((key, ..), max)| clamped(d, &variation_key(key), (0.0, *max)));
|
||||
let mode = |i: usize| CALLI.get(i).and_then(|(key, ..)| Variation::parse(&d.str(&mode_key(key)))).unwrap_or_default();
|
||||
Calligraphic { angle: value(0), roundness: value(1), size: value(2), variation: [0, 1, 2].map(variation), modes: Some([0, 1, 2].map(mode)) }
|
||||
}
|
||||
|
||||
/// `brush.options` params from the fields.
|
||||
fn params(d: &Dialog) -> Value {
|
||||
match d.str("__type").as_str() {
|
||||
"bristle" => {
|
||||
let mut p = json!({ "shape": d.str("shape"), "size": clamped(d, "size", (0.1, 1296.0)) });
|
||||
for (key, _, lo, hi) in BRISTLE {
|
||||
p[key] = json!(clamped(d, key, (lo, hi)));
|
||||
}
|
||||
p
|
||||
}
|
||||
_ => serde_json::to_value(calligraphic(d)).unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
grid(ui, |ui| {
|
||||
label(ui, tl!("Name:"));
|
||||
form::text(ui, d, "name", 240.0);
|
||||
ui.end_row();
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
match d.str("__type").as_str() {
|
||||
"bristle" => bristle_body(app, ui, d),
|
||||
_ => calligraphic_body(ui, d),
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn calligraphic_body(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
let c = calligraphic(d);
|
||||
ui.horizontal(|ui| {
|
||||
nib_preview(ui, &c, vec2(NIB_W, 84.0));
|
||||
ui.add_space(8.0);
|
||||
stroke_preview(ui, "calligraphic", serde_json::to_value(&c).unwrap_or_default(), vec2(STROKE_W - NIB_W - 8.0, 84.0));
|
||||
});
|
||||
ui.add_space(12.0);
|
||||
egui::Grid::new("brush-options-calli").num_columns(5).spacing([8.0, 8.0]).show(ui, |ui| {
|
||||
for (i, (key, text, suffix, decimals)) in CALLI.into_iter().enumerate() {
|
||||
let (Some(&range), Some(&max)) = (Calligraphic::RANGES.get(i), Calligraphic::MAX_VARIATION.get(i)) else { continue };
|
||||
label(ui, tl!(text));
|
||||
if let Some(v) = widgets::range_field(ui, ("brush-value", key), clamped(d, key, range), range.0..=range.1, suffix, decimals, 64.0) {
|
||||
d.fields.insert(key.into(), json!(v));
|
||||
}
|
||||
let mode = Variation::parse(&d.str(&mode_key(key))).unwrap_or_default();
|
||||
let labels = Variation::ALL.map(Variation::label);
|
||||
if let Some(m) = widgets::dropdown(ui, ("brush-mode", key), mode.label(), &labels, 104.0).and_then(|i| Variation::ALL.get(i)) {
|
||||
d.fields.insert(mode_key(key), json!(m.id()));
|
||||
}
|
||||
label(ui, tl!("Variation:"));
|
||||
let vkey = variation_key(key);
|
||||
ui.add_enabled_ui(mode != Variation::Fixed, |ui| {
|
||||
if let Some(v) = widgets::range_field(ui, ("brush-var", key), clamped(d, &vkey, (0.0, max)), 0.0..=max, suffix, decimals, 64.0) {
|
||||
d.fields.insert(vkey.clone(), json!(v));
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn bristle_body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
form::choice(ui, d, "shape", tl!("Shape:"), (LABEL_W, 150.0), &SHAPES);
|
||||
widgets::label_row(ui, tl!("Size:"), LABEL_W, |ui| {
|
||||
form::length(ui, d, "size", app.session.general_unit(), 80.0);
|
||||
});
|
||||
let rail = Tokens::get(ui.ctx()).input_border;
|
||||
for (key, text, lo, hi) in BRISTLE {
|
||||
widgets::label_row(ui, tl!(text), LABEL_W, |ui| form::slider_field(ui, d, key, lo..=hi, "%", &|_| rail));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
let p = params(d);
|
||||
stroke_preview(ui, "bristle", p, vec2(STROKE_W, 64.0));
|
||||
}
|
||||
|
||||
/// A stroke painted with the brush the fields describe (type `ty`, definition fields `def`).
|
||||
fn stroke_preview(ui: &mut egui::Ui, ty: &str, mut def: Value, size: egui::Vec2) {
|
||||
def["name"] = json!("preview");
|
||||
def["type"] = json!(ty);
|
||||
let (r, _) = ui.allocate_exact_size(size, Sense::hover());
|
||||
crate::panels::brushes::chip(ui, r, &def);
|
||||
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, Tokens::get(ui.ctx()).input_border), egui::StrokeKind::Inside);
|
||||
}
|
||||
|
||||
/// The nib of `c` at its values (black), and — for values that vary — at their lightest and
|
||||
/// heaviest (grey), on white like the brush previews, its largest nib filling the box.
|
||||
fn nib_preview(ui: &mut egui::Ui, c: &Calligraphic, size: egui::Vec2) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, _) = ui.allocate_exact_size(size, Sense::hover());
|
||||
let p = ui.painter_at(r);
|
||||
p.rect_filled(r, 0.0, Color32::WHITE);
|
||||
let guide = Stroke::new(1.0, Color32::from_gray(220));
|
||||
p.line_segment([pos2(r.left(), r.center().y), pos2(r.right(), r.center().y)], guide);
|
||||
p.line_segment([pos2(r.center().x, r.top()), pos2(r.center().x, r.bottom())], guide);
|
||||
let values = [c.angle, c.roundness, c.size];
|
||||
let at = |sign: f64| -> [f64; 3] {
|
||||
let mut v = values;
|
||||
for (i, (x, mode)) in v.iter_mut().zip(c.modes()).enumerate() {
|
||||
let (Some(&var), Some(&(lo, hi))) = (c.variation.get(i), Calligraphic::RANGES.get(i)) else { continue };
|
||||
if mode != Variation::Fixed {
|
||||
*x = (*x + sign * var).clamp(lo, hi);
|
||||
}
|
||||
}
|
||||
v
|
||||
};
|
||||
let nibs = [at(-1.0), at(1.0), values];
|
||||
let largest = nibs.iter().map(|n| n[2]).fold(0.0, f64::max).max(1e-6);
|
||||
let scale = (r.height().min(r.width()) - 12.0) / largest as f32;
|
||||
for (k, [angle, round, size]) in nibs.into_iter().enumerate() {
|
||||
let fill = if k < 2 { Color32::from_gray(200) } else { Color32::BLACK };
|
||||
let (a, b) = ((size as f32 * scale / 2.0).max(0.5), (size * round.clamp(1.0, 100.0) / 100.0) as f32 * scale / 2.0);
|
||||
let th = (angle as f32).to_radians();
|
||||
let (u, v) = (vec2(th.cos(), -th.sin()), vec2(th.sin(), th.cos()));
|
||||
let pts: Vec<egui::Pos2> = (0..48)
|
||||
.map(|i| {
|
||||
let s = i as f32 / 48.0 * std::f32::consts::TAU;
|
||||
r.center() + u * (a * s.cos()) + v * (b.max(0.5) * s.sin())
|
||||
})
|
||||
.collect();
|
||||
p.add(egui::Shape::convex_polygon(pts, fill, Stroke::NONE));
|
||||
}
|
||||
p.rect_stroke(r, 0.0, Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside);
|
||||
}
|
||||
|
||||
/// OK: edit the brush (and rename it). A name another brush has keeps the dialog open.
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let name = d.str("name").trim().to_string();
|
||||
let r = app.run("brush.options", json!({ "name": d.str("__brush"), "newName": name, "params": params(d) }));
|
||||
if r.is_ok() {
|
||||
app.ui.dialog = None;
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
fn app() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
fn set(app: &mut VectorcraftApp, fields: Value) {
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
for (k, v) in fields.as_object().unwrap() {
|
||||
d.fields.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn shown(app: &mut VectorcraftApp) -> String {
|
||||
crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calligraphic_options_show_each_value_with_its_variation_and_ok_applies_them() {
|
||||
let mut app = app();
|
||||
app.run("ui.brushOptions", json!({"name": "6 pt. Flat"})).unwrap();
|
||||
let text = shown(&mut app);
|
||||
for label in ["Calligraphic Brush Options", "Name:", "Angle:", "Roundness:", "Size:", "Variation:", "Fixed", "OK", "Cancel"] {
|
||||
assert!(text.contains(label), "{label} in {text}");
|
||||
}
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!((d.f64("angle", 0.0), d.f64("roundness", 0.0), d.f64("size", 0.0)), (45.0, 15.0, 6.0));
|
||||
// Out-of-range values are kept within their ranges.
|
||||
set(&mut app, json!({"angleMode": "random", "angleVariation": 500, "roundness": 140, "sizeMode": "pressure", "sizeVariation": 4}));
|
||||
assert!(shown(&mut app).contains("Pressure"));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
let def = app.run("brush.get", json!({"name": "6 pt. Flat"})).unwrap();
|
||||
assert_eq!(def["modes"], json!(["random", "fixed", "pressure"]));
|
||||
assert_eq!((def["variation"].clone(), def["roundness"].clone()), (json!([180.0, 0.0, 4.0]), json!(100.0)));
|
||||
// Opened again, it shows what was set.
|
||||
app.run("ui.brushOptions", json!({"name": "6 pt. Flat"})).unwrap();
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!((d.str("angleMode"), d.f64("sizeVariation", 0.0)), ("random".into(), 4.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_taken_name_keeps_the_dialog_open() {
|
||||
let mut app = app();
|
||||
app.run("ui.brushOptions", json!({"name": "6 pt. Flat"})).unwrap();
|
||||
set(&mut app, json!({"name": "3 pt. Round"}));
|
||||
assert!(super::super::confirm(&mut app).is_err());
|
||||
assert!(app.ui.dialog.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bristle_options_and_brushes_without_a_dialog() {
|
||||
let mut app = app();
|
||||
app.run("ui.brushOptions", json!({"name": "Bristle Round"})).unwrap();
|
||||
let text = shown(&mut app);
|
||||
for label in ["Bristle Brush Options", "Shape:", "Bristle Length:", "Stiffness:", "Paint Opacity:"] {
|
||||
assert!(text.contains(label), "{label} in {text}");
|
||||
}
|
||||
set(&mut app, json!({"shape": "flatFan", "density": 0, "length": 120}));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
let def = app.run("brush.get", json!({"name": "Bristle Round"})).unwrap();
|
||||
assert_eq!((def["shape"].clone(), def["density"].clone(), def["length"].clone()), (json!("flatFan"), json!(1.0), json!(120.0)));
|
||||
// Art, scatter and pattern brushes have no dialog yet: an error, nothing opens.
|
||||
assert!(app.run("ui.brushOptions", json!({"name": "Arrow"})).is_err());
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert!(app.run("ui.brushOptions", json!({"name": "Nope"})).is_err());
|
||||
}
|
||||
}
|
||||
@@ -393,18 +393,30 @@ pub(super) fn slider_w(
|
||||
track: &dyn Fn(f32) -> egui::Color32,
|
||||
) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_sized([label_w, 22.0], egui::Label::new(egui::RichText::new(tl!(label)).color(t.text)));
|
||||
slider_field(ui, d, key, range, suffix, track);
|
||||
});
|
||||
}
|
||||
|
||||
/// The rail and value field of [`slider`] without its label (for a row that draws its own).
|
||||
pub(super) fn slider_field(
|
||||
ui: &mut egui::Ui,
|
||||
d: &mut Dialog,
|
||||
key: &str,
|
||||
range: std::ops::RangeInclusive<f64>,
|
||||
suffix: &str,
|
||||
track: &dyn Fn(f32) -> egui::Color32,
|
||||
) {
|
||||
let (min, max) = (*range.start(), *range.end());
|
||||
let v = d.f64(key, 0.0).clamp(min, max);
|
||||
let mut new = None;
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_sized([label_w, 22.0], egui::Label::new(egui::RichText::new(tl!(label)).color(t.text)));
|
||||
if let (Some(x), _) = crate::widgets::color_slider(ui, ("dlg-slider", key), ((v - min) / (max - min)) as f32, SLIDER_WIDTH, track) {
|
||||
new = Some((min + x as f64 * (max - min)).round());
|
||||
}
|
||||
if let Some(x) = crate::widgets::plain_field(ui, ("dlg-field", key), v, suffix, 0, 52.0) {
|
||||
new = Some(x.round().clamp(min, max));
|
||||
}
|
||||
});
|
||||
if let (Some(x), _) = crate::widgets::color_slider(ui, ("dlg-slider", key), ((v - min) / (max - min)) as f32, SLIDER_WIDTH, track) {
|
||||
new = Some((min + x as f64 * (max - min)).round());
|
||||
}
|
||||
if let Some(x) = crate::widgets::plain_field(ui, ("dlg-field", key), v, suffix, 0, 52.0) {
|
||||
new = Some(x.round().clamp(min, max));
|
||||
}
|
||||
if let Some(n) = new.filter(|n| *n != v) {
|
||||
d.fields.insert(key.into(), json!(n));
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ mod about;
|
||||
mod all_tools;
|
||||
mod artboard_options;
|
||||
pub mod blend_options;
|
||||
pub mod brush_options;
|
||||
pub mod color_balance;
|
||||
pub mod color_guide_options;
|
||||
mod color_picker;
|
||||
@@ -278,6 +279,7 @@ registry! {
|
||||
PerspectiveGridPresets: [perspective_presets::KIND] => perspective_presets::SPEC,
|
||||
PerspectiveGridOptions: [perspective_options::KIND] => perspective_options::SPEC,
|
||||
BlendOptions: [blend_options::KIND] => blend_options::SPEC,
|
||||
BrushOptions: [brush_options::KIND] => brush_options::SPEC,
|
||||
EditSelection: [edit_selection::KIND] => edit_selection::SPEC,
|
||||
PerspectivePlane: [perspective_plane::KIND] => perspective_plane::SPEC,
|
||||
LayerOptions: [layer_options::KIND] => layer_options::SPEC,
|
||||
|
||||
@@ -234,6 +234,8 @@ fn dialogs_do_not_stretch_to_the_screen() {
|
||||
("ui.swatchOptions", json!({"name": "White"})),
|
||||
("ui.colorPicker", json!({})),
|
||||
("ui.graphicStyleOptions", json!({})),
|
||||
("ui.brushOptions", json!({"name": "3 pt. Round"})),
|
||||
("ui.brushOptions", json!({"name": "Bristle Round"})),
|
||||
("ui.colorGuideOptions", json!({})),
|
||||
("ui.colorBalanceDialog", json!({})),
|
||||
("ui.saturateDialog", json!({})),
|
||||
|
||||
@@ -1545,6 +1545,27 @@
|
||||
Artboard Mesa de trabajo
|
||||
Exit Salir
|
||||
New Bristle Brush Nuevo pincel de cerdas
|
||||
Calligraphic Brush Options Opciones de pincel caligráfico
|
||||
Bristle Brush Options Opciones de pincel de cerdas
|
||||
Roundness: Redondez:
|
||||
Fixed Fijo
|
||||
Pressure Presión
|
||||
Shape: Forma:
|
||||
Bristle Length: Longitud de cerdas:
|
||||
Bristle Density: Densidad de cerdas:
|
||||
Bristle Thickness: Grosor de cerdas:
|
||||
Paint Opacity: Opacidad de pintura:
|
||||
Stiffness: Rigidez:
|
||||
Round Point Punta redonda
|
||||
Flat Point Punta plana
|
||||
Round Blunt Romo redondo
|
||||
Flat Blunt Romo plano
|
||||
Round Curve Curvo redondo
|
||||
Flat Curve Curvo plano
|
||||
Round Angle Sesgado redondo
|
||||
Flat Angle Sesgado plano
|
||||
Round Fan Abanico redondo
|
||||
Flat Fan Abanico plano
|
||||
New Brush… Nuevo pincel…
|
||||
New Calligraphic Brush Nuevo pincel caligráfico
|
||||
New Character Style Nuevo estilo de carácter
|
||||
@@ -3422,6 +3443,8 @@ axis Both Ambos
|
||||
@msg no blend options no hay opciones de fusión
|
||||
@msg no bounds sin límites
|
||||
@msg no brush named `{name}` no hay ningún pincel llamado `{name}`
|
||||
@msg no brush selected no hay ningún pincel seleccionado
|
||||
@msg this kind of brush has no options dialog yet este tipo de pincel aún no tiene un cuadro de opciones
|
||||
@msg no catalog no hay catálogo
|
||||
@msg no changes since the last save no hay cambios desde la última vez que se guardó
|
||||
@msg no clock to keep a heartbeat by no hay reloj para mantener la señal de actividad
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1542,6 +1542,27 @@
|
||||
New Art Has Basic Appearance Aspect de base pour les nouvelles illustrations
|
||||
New Artboard Nouveau plan de travail
|
||||
New Bristle Brush Nouvelle forme Soies
|
||||
Calligraphic Brush Options Options de forme calligraphique
|
||||
Bristle Brush Options Options de forme Soies
|
||||
Roundness: Arrondi :
|
||||
Fixed Fixe
|
||||
Pressure Pression
|
||||
Shape: Forme :
|
||||
Bristle Length: Longueur des soies :
|
||||
Bristle Density: Densité des soies :
|
||||
Bristle Thickness: Épaisseur des soies :
|
||||
Paint Opacity: Opacité de la peinture :
|
||||
Stiffness: Rigidité :
|
||||
Round Point Pointe ronde
|
||||
Flat Point Pointe plate
|
||||
Round Blunt Rond émoussé
|
||||
Flat Blunt Plat émoussé
|
||||
Round Curve Rond incurvé
|
||||
Flat Curve Plat incurvé
|
||||
Round Angle Rond biseauté
|
||||
Flat Angle Plat biseauté
|
||||
Round Fan Éventail rond
|
||||
Flat Fan Éventail plat
|
||||
New Brush… Nouvelle forme…
|
||||
New Calligraphic Brush Nouvelle forme calligraphique
|
||||
New Character Style Nouveau style de caractère
|
||||
@@ -3419,6 +3440,8 @@ axis Both Les deux
|
||||
@msg no blend options aucune option de dégradé de formes
|
||||
@msg no bounds aucune limite
|
||||
@msg no brush named `{name}` aucune forme nommée `{name}`
|
||||
@msg no brush selected aucune forme sélectionnée
|
||||
@msg this kind of brush has no options dialog yet ce type de forme n’a pas encore de boîte de dialogue d’options
|
||||
@msg no catalog aucun catalogue
|
||||
@msg no changes since the last save aucune modification depuis le dernier enregistrement
|
||||
@msg no clock to keep a heartbeat by aucune horloge pour entretenir le signal d’activité
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1545,6 +1545,27 @@
|
||||
Artboard Tavola da disegno
|
||||
Exit Esci
|
||||
New Bristle Brush Nuovo pennello a setole
|
||||
Calligraphic Brush Options Opzioni pennello calligrafico
|
||||
Bristle Brush Options Opzioni pennello a setole
|
||||
Roundness: Rotondità:
|
||||
Fixed Fisso
|
||||
Pressure Pressione
|
||||
Shape: Forma:
|
||||
Bristle Length: Lunghezza setole:
|
||||
Bristle Density: Densità setole:
|
||||
Bristle Thickness: Spessore setole:
|
||||
Paint Opacity: Opacità colore:
|
||||
Stiffness: Rigidità:
|
||||
Round Point Punta tonda
|
||||
Flat Point Punta piatta
|
||||
Round Blunt Tonda smussata
|
||||
Flat Blunt Piatta smussata
|
||||
Round Curve Tonda curva
|
||||
Flat Curve Piatta curva
|
||||
Round Angle Tonda angolata
|
||||
Flat Angle Piatta angolata
|
||||
Round Fan Ventaglio tondo
|
||||
Flat Fan Ventaglio piatto
|
||||
New Brush… Nuovo pennello…
|
||||
New Calligraphic Brush Nuovo pennello calligrafico
|
||||
New Character Style Nuovo stile carattere
|
||||
@@ -3422,6 +3443,8 @@ axis Both Entrambi
|
||||
@msg no blend options nessuna opzione di fusione
|
||||
@msg no bounds nessun limite
|
||||
@msg no brush named `{name}` nessun pennello denominato `{name}`
|
||||
@msg no brush selected nessun pennello selezionato
|
||||
@msg this kind of brush has no options dialog yet questo tipo di pennello non ha ancora una finestra di opzioni
|
||||
@msg no catalog nessun catalogo
|
||||
@msg no changes since the last save nessuna modifica dall'ultimo salvataggio
|
||||
@msg no clock to keep a heartbeat by nessun orologio con cui mantenere il segnale di attività
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1677,6 +1677,27 @@
|
||||
Artboard アートボード
|
||||
Exit 終了
|
||||
New Bristle Brush 新規剛毛ブラシ
|
||||
Calligraphic Brush Options 絵筆ブラシオプション
|
||||
Bristle Brush Options 剛毛ブラシオプション
|
||||
Roundness: 真円率:
|
||||
Fixed 固定
|
||||
Pressure 筆圧
|
||||
Shape: 形状:
|
||||
Bristle Length: 毛の長さ:
|
||||
Bristle Density: 毛の密度:
|
||||
Bristle Thickness: 毛の太さ:
|
||||
Paint Opacity: 塗料の不透明度:
|
||||
Stiffness: 硬さ:
|
||||
Round Point 丸・先細
|
||||
Flat Point 平・先細
|
||||
Round Blunt 丸・先丸
|
||||
Flat Blunt 平・先丸
|
||||
Round Curve 丸・カーブ
|
||||
Flat Curve 平・カーブ
|
||||
Round Angle 丸・斜め
|
||||
Flat Angle 平・斜め
|
||||
Round Fan 丸・扇形
|
||||
Flat Fan 平・扇形
|
||||
New Brush… 新規ブラシ…
|
||||
New Calligraphic Brush 新規絵筆ブラシ
|
||||
New Character Style 新規文字スタイル
|
||||
@@ -3237,6 +3258,8 @@ theme Appearance 外観
|
||||
@msg Show All Hidden Attributes: nothing is hidden 非表示の属性をすべて表示: 非表示の属性はありません
|
||||
@msg Expand: no brush strokes selected 拡張: ブラシストロークが選択されていません
|
||||
@msg no brush named `{name}` `{name}` という名前のブラシが見つかりません
|
||||
@msg no brush selected ブラシが選択されていません
|
||||
@msg this kind of brush has no options dialog yet この種類のブラシにはまだオプションダイアログがありません
|
||||
@msg no symbol instances selected シンボルインスタンスが選択されていません
|
||||
@msg select a symbol instance シンボルインスタンスを選択してください
|
||||
@msg the art has no bounds アートに境界がありません
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1541,6 +1541,27 @@
|
||||
New Art Has Basic Appearance Новый рисунок имеет основное оформление
|
||||
New Artboard Новая монтажная область
|
||||
New Bristle Brush Новая щетинная кисть
|
||||
Calligraphic Brush Options Параметры каллиграфической кисти
|
||||
Bristle Brush Options Параметры щетинной кисти
|
||||
Roundness: Округлость:
|
||||
Fixed Фиксированный
|
||||
Pressure Нажим
|
||||
Shape: Форма:
|
||||
Bristle Length: Длина щетинок:
|
||||
Bristle Density: Плотность щетинок:
|
||||
Bristle Thickness: Толщина щетинок:
|
||||
Paint Opacity: Непрозрачность краски:
|
||||
Stiffness: Жёсткость:
|
||||
Round Point Круглая заострённая
|
||||
Flat Point Плоская заострённая
|
||||
Round Blunt Круглая тупая
|
||||
Flat Blunt Плоская тупая
|
||||
Round Curve Круглая изогнутая
|
||||
Flat Curve Плоская изогнутая
|
||||
Round Angle Круглая скошенная
|
||||
Flat Angle Плоская скошенная
|
||||
Round Fan Круглая веерная
|
||||
Flat Fan Плоская веерная
|
||||
New Brush… Новая кисть…
|
||||
New Calligraphic Brush Новая каллиграфическая кисть
|
||||
New Character Style Новый стиль символа
|
||||
@@ -3418,6 +3439,8 @@ axis Both Оба
|
||||
@msg no blend options нет параметров перехода
|
||||
@msg no bounds нет границ
|
||||
@msg no brush named `{name}` нет кисти с именем `{name}`
|
||||
@msg no brush selected кисть не выбрана
|
||||
@msg this kind of brush has no options dialog yet у кисти этого типа пока нет окна параметров
|
||||
@msg no catalog нет каталога
|
||||
@msg no changes since the last save нет изменений с момента последнего сохранения
|
||||
@msg no clock to keep a heartbeat by нет часов для поддержания сигнала
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1537,6 +1537,27 @@
|
||||
New Art Has Basic Appearance Новий малюнок має основне оформлення
|
||||
New Artboard Нова монтажна область
|
||||
New Bristle Brush Новий щетинний пензель
|
||||
Calligraphic Brush Options Параметри каліграфічного пензля
|
||||
Bristle Brush Options Параметри щетинного пензля
|
||||
Roundness: Округлість:
|
||||
Fixed Фіксований
|
||||
Pressure Натиск
|
||||
Shape: Форма:
|
||||
Bristle Length: Довжина щетинок:
|
||||
Bristle Density: Щільність щетинок:
|
||||
Bristle Thickness: Товщина щетинок:
|
||||
Paint Opacity: Непрозорість фарби:
|
||||
Stiffness: Жорсткість:
|
||||
Round Point Кругла загострена
|
||||
Flat Point Пласка загострена
|
||||
Round Blunt Кругла тупа
|
||||
Flat Blunt Пласка тупа
|
||||
Round Curve Кругла вигнута
|
||||
Flat Curve Пласка вигнута
|
||||
Round Angle Кругла скошена
|
||||
Flat Angle Пласка скошена
|
||||
Round Fan Кругла віялова
|
||||
Flat Fan Пласка віялова
|
||||
New Brush… Новий пензель…
|
||||
New Calligraphic Brush Новий каліграфічний пензель
|
||||
New Character Style Новий стиль символу
|
||||
@@ -3405,6 +3426,8 @@ axis Both Обидві
|
||||
@msg no blend options немає параметрів переходу
|
||||
@msg no bounds немає кордонів
|
||||
@msg no brush named `{name}` немає пензля з ім’ям `{name}`
|
||||
@msg no brush selected пензель не вибрано
|
||||
@msg this kind of brush has no options dialog yet пензель цього типу ще не має вікна параметрів
|
||||
@msg no catalog немає каталогу
|
||||
@msg no changes since the last save немає змін з моменту останнього збереження
|
||||
@msg no clock to keep a heartbeat by немає годинника для підтримки сигналу
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 632 and column 40.
|
@@ -1501,6 +1501,27 @@
|
||||
New Artboard 新建画板
|
||||
Exit 退出
|
||||
New Bristle Brush 新建鬃毛画笔
|
||||
Calligraphic Brush Options 书法画笔选项
|
||||
Bristle Brush Options 鬃毛画笔选项
|
||||
Roundness: 圆度:
|
||||
Fixed 固定
|
||||
Pressure 压力
|
||||
Shape: 形状:
|
||||
Bristle Length: 鬃毛长度:
|
||||
Bristle Density: 鬃毛密度:
|
||||
Bristle Thickness: 鬃毛粗细:
|
||||
Paint Opacity: 上色不透明度:
|
||||
Stiffness: 硬度:
|
||||
Round Point 圆尖头
|
||||
Flat Point 扁尖头
|
||||
Round Blunt 圆钝头
|
||||
Flat Blunt 扁钝头
|
||||
Round Curve 圆弯头
|
||||
Flat Curve 扁弯头
|
||||
Round Angle 圆斜头
|
||||
Flat Angle 扁斜头
|
||||
Round Fan 圆扇头
|
||||
Flat Fan 扁扇头
|
||||
New Brush… 新建画笔…
|
||||
New Calligraphic Brush 新建书法画笔
|
||||
New Character Style 新建字符样式
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1545,6 +1545,27 @@
|
||||
Artboard 工作區域
|
||||
Exit 結束
|
||||
New Bristle Brush 新增鬃毛筆刷
|
||||
Calligraphic Brush Options 書法筆刷選項
|
||||
Bristle Brush Options 鬃毛筆刷選項
|
||||
Roundness: 圓度:
|
||||
Fixed 固定
|
||||
Pressure 壓力
|
||||
Shape: 形狀:
|
||||
Bristle Length: 鬃毛長度:
|
||||
Bristle Density: 鬃毛密度:
|
||||
Bristle Thickness: 鬃毛粗細:
|
||||
Paint Opacity: 上色不透明度:
|
||||
Stiffness: 硬度:
|
||||
Round Point 圓尖頭
|
||||
Flat Point 扁尖頭
|
||||
Round Blunt 圓鈍頭
|
||||
Flat Blunt 扁鈍頭
|
||||
Round Curve 圓彎頭
|
||||
Flat Curve 扁彎頭
|
||||
Round Angle 圓斜頭
|
||||
Flat Angle 扁斜頭
|
||||
Round Fan 圓扇頭
|
||||
Flat Fan 扁扇頭
|
||||
New Brush… 新增筆刷…
|
||||
New Calligraphic Brush 新增書法筆刷
|
||||
New Character Style 新增字元樣式
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -337,6 +337,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"",
|
||||
"{stroke?: bool (default: the active proxy), color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray} (default: the proxy's colour)} open the Color Picker (fields: hex or color, channel, webOnly, swatches); OK runs paint.setFill / paint.setStroke",
|
||||
),
|
||||
(
|
||||
"ui.brushOptions",
|
||||
"Brush Options…",
|
||||
"",
|
||||
"{name?} (default: the selected path's brush, else the current brush) open its Brush Options, what double-clicking a brush in the Brushes panel opens: Calligraphic (dialog `brushOptions`, fields name, angle −180..180, roundness 0..100, size 0..1296 pt, angleMode/roundnessMode/sizeMode: fixed|random|pressure, angleVariation/roundnessVariation/sizeVariation) or Bristle (fields name, shape, size, length, density, thickness, opacity, stiffness); OK runs brush.options. Other brush types: error",
|
||||
),
|
||||
(
|
||||
"ui.graphicStyleOptions",
|
||||
"Graphic Style Options…",
|
||||
@@ -1206,6 +1212,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
||||
}
|
||||
"ui.colorPicker" => crate::dialogs::open_color_picker(app, p),
|
||||
"ui.graphicStyleOptions" => crate::dialogs::graphic_style_options::open(app, s("name").as_deref()),
|
||||
"ui.brushOptions" => crate::dialogs::brush_options::open(app, s("name").as_deref()),
|
||||
"tool.options" => crate::toolbar::open_options(app, &s("tool").unwrap_or_default()),
|
||||
"ui.colorGuideOptions" => {
|
||||
crate::dialogs::color_guide_options::open(app);
|
||||
@@ -1871,6 +1878,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
|
||||
"ui.findFontsInFolder" => crate::picks::can(app, &crate::picks::PickRequest::Folder) && app.session.active().is_some(),
|
||||
"ui.swatchOptions" | "ui.newSwatch" | "ui.newColorGroup" => app.session.active().is_some(),
|
||||
"ui.graphicStyleOptions" => app.session.active().is_some(),
|
||||
"ui.brushOptions" => crate::dialogs::brush_options::available(app),
|
||||
"ui.colorBalanceDialog" | "ui.saturateDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()),
|
||||
"ui.saveSwatchLibrary" => app.session.active().is_some(),
|
||||
id if id.starts_with(crate::panels::swatches::USER_SLOT) => crate::panels::swatches::user_library(app, id).is_some(),
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
//! Brushes panel: the document's brush library with rendered stroke previews. Clicking a brush
|
||||
//! applies it to the selected paths (and makes it the Paintbrush's current brush).
|
||||
//! applies it to the selected paths (and makes it the Paintbrush's current brush); double-clicking
|
||||
//! one opens its Brush Options.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use egui::{Sense, Ui, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, color::Color, color::Paint};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, PressureProfile, color::Color, color::Paint};
|
||||
|
||||
use super::{first_selected, pstate, set_pstate};
|
||||
use crate::VectorcraftApp;
|
||||
use crate::dialogs::brush_options;
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::{self, PanelDrag, menu_item};
|
||||
|
||||
@@ -26,14 +28,17 @@ pub fn brushes(app: &mut VectorcraftApp) -> (Vec<(String, String)>, Option<Strin
|
||||
}
|
||||
|
||||
/// A stroke preview for brush definition `def`, rendered at `size` and cached by the definition's
|
||||
/// JSON.
|
||||
/// JSON. The stroke goes from light to heavy pen pressure and back, so pressure-sensitive brushes
|
||||
/// show how they vary.
|
||||
fn preview(ui: &Ui, def: &Value, size: egui::Vec2) -> Option<egui::TextureHandle> {
|
||||
widgets::doc_preview(ui, &format!("brush:{def}"), size, |w, h| {
|
||||
let mut doc = Document::new(w, h);
|
||||
doc.unknown.insert("brushes".into(), json!([def]));
|
||||
let name = def["name"].as_str()?.to_string();
|
||||
let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
|
||||
ap.stroke_mut()?.brush = Some(name);
|
||||
let st = ap.stroke_mut()?;
|
||||
st.brush = Some(name);
|
||||
st.pressure = Some(PressureProfile::sample());
|
||||
// A gentle S-curve across the swatch.
|
||||
let mut bp = vectorcraft_geom::BezPath::new();
|
||||
let (x0, x1) = (h * 0.5, w - h * 0.5);
|
||||
@@ -68,6 +73,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let defs: HashMap<String, Value> =
|
||||
list.iter().filter_map(|(n, _)| app.run("brush.get", json!({"name": n})).ok().map(|v| (n.clone(), v))).collect();
|
||||
let mut clicked: Option<String> = None;
|
||||
let mut options: Option<String> = None;
|
||||
let list_rect = widgets::list_box(ui, |ui| {
|
||||
ui.set_min_height(110.0);
|
||||
ui.set_width(ui.available_width());
|
||||
@@ -83,6 +89,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
if group.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let has_options = brush_options::TYPES.contains(&ty);
|
||||
let row = |ui: &mut Ui, name: &str, size: egui::Vec2, label: bool| -> egui::Response {
|
||||
let (r, resp) = ui.allocate_exact_size(size, Sense::click_and_drag());
|
||||
// Dragged onto a path, the brush is applied to it.
|
||||
@@ -112,18 +119,22 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
resp.on_hover_text(name)
|
||||
};
|
||||
// A click applies the brush; the second click of a double-click opens its options.
|
||||
let mut pick = |resp: egui::Response, name: &String| {
|
||||
if resp.double_clicked() && has_options {
|
||||
options = Some(name.clone());
|
||||
} else if resp.clicked() {
|
||||
clicked = Some(name.clone());
|
||||
}
|
||||
};
|
||||
if list_view {
|
||||
for (name, _) in group {
|
||||
if row(ui, name, vec2(ui.available_width(), 26.0), true).clicked() {
|
||||
clicked = Some(name.clone());
|
||||
}
|
||||
pick(row(ui, name, vec2(ui.available_width(), 26.0), true), name);
|
||||
}
|
||||
} else {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (name, _) in group {
|
||||
if row(ui, name, vec2(64.0, 28.0), false).clicked() {
|
||||
clicked = Some(name.clone());
|
||||
}
|
||||
pick(row(ui, name, vec2(64.0, 28.0), false), name);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -146,6 +157,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
app.run("brush.setCurrent", json!({"name": name})).ok();
|
||||
}
|
||||
}
|
||||
if let Some(name) = options
|
||||
&& let Err(e) = app.run("ui.brushOptions", json!({ "name": name }))
|
||||
{
|
||||
app.status(e);
|
||||
}
|
||||
let has_brush = sel_brush.is_some();
|
||||
let target = sel_brush.clone().or(current.clone());
|
||||
let has_sel = app.session.active().is_some_and(|d| !d.selection.is_empty());
|
||||
@@ -204,6 +220,12 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
if menu_item(ui, tl!("Expand Brush Strokes"), sel.is_some(), false) {
|
||||
app.run("object.expandBrush", json!({})).ok();
|
||||
}
|
||||
// On the selected path's brush, else the current one, as the other items.
|
||||
if menu_item(ui, tl!("Brush Options…"), brush_options::available(app), false)
|
||||
&& let Err(e) = app.run("ui.brushOptions", json!({}))
|
||||
{
|
||||
app.status(e);
|
||||
}
|
||||
ui.separator();
|
||||
let mut hidden: Vec<String> = pstate(ui.ctx(), "br-hidden");
|
||||
for (ty, label) in KINDS {
|
||||
@@ -246,6 +268,48 @@ mod tests {
|
||||
egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }
|
||||
}
|
||||
|
||||
/// #852: double-clicking a brush opens its Brush Options; OK changes the brush (and strokes
|
||||
/// painted with it). A single click only applies the brush.
|
||||
#[test]
|
||||
fn double_clicking_a_brush_opens_its_options() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let clicks = |at: Pos2, n: usize| -> Vec<egui::Event> {
|
||||
std::iter::once(egui::Event::PointerMoved(at)).chain((0..n).flat_map(|_| [button(at, true), button(at, false)])).collect()
|
||||
};
|
||||
let at_time = |app: &mut VectorcraftApp, time: f64, events: Vec<egui::Event>| {
|
||||
let raw = egui::RawInput {
|
||||
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(236.0, 600.0))),
|
||||
time: Some(time),
|
||||
events,
|
||||
..Default::default()
|
||||
};
|
||||
ctx.run_ui(raw, |ui| show(app, ui)).textures_delta.clear();
|
||||
};
|
||||
at_time(&mut app, 0.0, vec![]);
|
||||
let (list, _) = brushes(&mut app);
|
||||
let first = list[0].0.clone();
|
||||
assert_eq!(list[0].1, "calligraphic");
|
||||
// The first tile, clicked once: it becomes current, no dialog.
|
||||
let tile = pos2(36.0, 16.0);
|
||||
at_time(&mut app, 1.0, clicks(tile, 1));
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert_eq!(brushes(&mut app).1.as_deref(), Some(first.as_str()));
|
||||
// Double-clicked: Calligraphic Brush Options on that brush.
|
||||
at_time(&mut app, 3.0, clicks(tile, 2));
|
||||
let d = app.ui.dialog.as_mut().expect("Brush Options opened");
|
||||
assert_eq!((d.kind.as_str(), d.str("name"), d.str("sizeMode")), (crate::dialogs::brush_options::KIND, first.clone(), "fixed".into()));
|
||||
d.fields.insert("sizeMode".into(), json!("pressure"));
|
||||
d.fields.insert("sizeVariation".into(), json!(2));
|
||||
d.fields.insert("name".into(), json!("Pressure Round"));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
let def = app.run("brush.get", json!({"name": "Pressure Round"})).unwrap();
|
||||
assert_eq!((def["modes"].clone(), def["variation"][2].clone()), (json!(["fixed", "fixed", "pressure"]), json!(2.0)));
|
||||
assert_eq!(brushes(&mut app).1.as_deref(), Some("Pressure Round"), "renamed, still current");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_dropped_on_the_panel_becomes_an_art_brush_and_a_brush_drags_out() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
|
||||
@@ -664,7 +664,7 @@ fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) {
|
||||
}
|
||||
let m = ui.input(|i| i.modifiers);
|
||||
if tresp.clicked() {
|
||||
out.actions.push(("layer.target".into(), json!({"id": n.id.0})));
|
||||
out.actions.push(("layer.target".into(), json!({"id": n.id.0, "add": m.shift})));
|
||||
} else if sq_resp.clicked() {
|
||||
out.actions.push(("layer.selectAll".into(), json!({"id": n.id.0, "add": m.shift})));
|
||||
} else if resp.clicked() {
|
||||
@@ -1239,6 +1239,13 @@ mod tests {
|
||||
}
|
||||
let st = app.session.active().unwrap();
|
||||
assert_eq!((st.selection.target, st.selection.objects.clone()), (Some(a), vec![a]));
|
||||
// Shift-clicking another object's circle adds it to the selection (#901), and again takes
|
||||
// it out; nothing is targeted meanwhile.
|
||||
click_with(&mut app, &ctx, c[1], egui::Modifiers::SHIFT);
|
||||
let st = app.session.active().unwrap();
|
||||
assert_eq!((st.selection.target, st.selection.objects.clone()), (None, vec![a, b]));
|
||||
click_with(&mut app, &ctx, c[1], egui::Modifiers::SHIFT);
|
||||
assert_eq!(app.session.active().unwrap().selection.objects, vec![a]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
//!
|
||||
//! The app asks for a path where it needs one (Open, Save As, Export, Place, Relink, presets…)
|
||||
//! and goes on with it at once, so a dialog shown on the UI thread holds the window until it
|
||||
//! closes. On Windows and macOS the dialog runs the window's events meanwhile; on Linux nothing
|
||||
//! does, so a Wayland compositor finds the window not answering and offers to kill it. There the
|
||||
//! host shows dialogs on another thread ([`Services::start_pick`]): asking returns no path for now,
|
||||
//! closes. On Windows the dialog runs the window's events meanwhile; on Linux nothing does, so a
|
||||
//! Wayland compositor finds the window not answering and offers to kill it, and on macOS they
|
||||
//! re-enter the window's event handler, which crashed the app when the panel was resized (#867).
|
||||
//! There the host shows dialogs on another thread ([`Services::start_pick`]): asking returns no path for now,
|
||||
//! and when the dialog answers, what asked (the command, the dialog's OK, a panel's button: the
|
||||
//! *entry*) runs again and gets the picked path at once.
|
||||
|
||||
|
||||
Reference in new issue
Block a user