Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bc6c66c94 | ||
|
|
5b198f3c00 | ||
|
|
471c47f96a | ||
|
|
bb778a50aa | ||
|
|
7241726e56 | ||
|
|
3bdaa45607 | ||
|
|
b4c89f7301 | ||
|
|
20c1a1621e | ||
|
|
0044143880 |
No files matched your search
@@ -201,10 +201,10 @@ For the experimental, unsupported 64-bit Windows 7 build, see [Windows 7 instruc
|
||||
VectorCraft is under active development. [**ROADMAP.md**](ROADMAP.md) covers what ships today, the
|
||||
milestones, and honest time-to-parity estimates.
|
||||
|
||||
**Where we are (2026-10-09):** roughly 69–75% of Illustrator's features exist and work, and about 40–55% of
|
||||
**Where we are (2026-10-10):** roughly 69–75% of Illustrator's features exist and work, and about 40–55% of
|
||||
"a power user can't tell the difference". Everyday vector illustration is close to usable: drawing and path tools,
|
||||
Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles, threading and Hebrew/Arabic bidirectional layout, and
|
||||
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, EPS, DXF, EMF/WMF, raster formats and PSD, Print, Package).
|
||||
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, Print, Package).
|
||||
Affinity documents (`.af` from Affinity 3, `.afdesign`, `.afpub` and, by their content, `.afphoto` from Affinity 1 and 2) open
|
||||
and place natively: layers, groups, artboards and pages, curves and shapes, fills, gradients and strokes, clipping
|
||||
and masks, text and images, with what didn't come in (effects, adjustments, brushes, master pages…) listed in the
|
||||
|
||||
+10
-8
@@ -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
|
||||
|
||||
@@ -68,10 +68,12 @@ 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 (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.
|
||||
5. **Brushes, symbols and libraries:** brush options depth (Brush Options exist for every brush type, Calligraphic
|
||||
and Scatter with pen pressure; still missing are the Art brush's Overlap, auto-generated Pattern brush corners,
|
||||
new tile art picked in Pattern Brush Options and the Key Color eyedropper); 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. 17–28 h.
|
||||
6. **Automation:** Variables (data merge), a scripting surface over the command registry, batch. 8–12 h.
|
||||
7. **Views and windows:** multiple windows and arrange, Consolidate All Windows, video
|
||||
rulers. 15–25 h.
|
||||
@@ -138,7 +140,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them. On a rectangle that isn't square every corner is the same circle: a radius past half the shorter side stops there for all of them, the widgets sit on the corners as drawn and turn the edited corners red at that limit, CSS export writes the drawn radii, and rectangles saved stretched by older versions get circular corners when their corners are next set (#442). Live Corners work on any path's corners too, as on a rectangle's (#511): a star's, a live polygon's (which also shows them to the Selection tool, has a Corner Radius field in the Properties and Transform panels and keeps its radius as its sides change) and a pen path's, wherever two straight sides meet at an anchor. Direct Selection shows the widgets of every corner, or of the corners holding selected anchors; each corner is a circle tangent to both sides, drawn no larger than takes its cut halfway along its shorter side; a path keeps its uncut outline so its corners stay editable, and the same command, dialog, angle preference and View › Hide Corner Widget apply.
|
||||
- **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. A press on the first anchor of the path being drawn closes it whatever the drag then does: the drag shapes the closing curve (symmetric handles, Alt breaks them) and the release closes the path wherever it ends (#501). Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead. Alt held over a handle end or an anchor of a selected path (the one being drawn too) works as the Anchor Point tool, with its convert cursor: dragging a handle moves it alone, clicking a smooth anchor makes it a corner, dragging an anchor pulls out new symmetric handles, dragging a segment reshapes it; drawing then goes on. Cmd (Ctrl) held drags with the selection tool chosen last, Direct Selection until one is (handles, anchors and segments of the path being drawn or another), and the Pen then goes on drawing its path while that stays selected; the pointer shows that tool's while Cmd is held. The same holds for agents (`pointer_gesture` with `mods.cmd`), as the borrowing now lives in the engine (#494).
|
||||
- **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path).
|
||||
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Design, Column and Marker artwork remain open.
|
||||
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Graph Design, Column and Marker artwork remain open.
|
||||
- **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446; Object › Artboards › Rearrange All Artboards lays them out in a grid by row or by column, in one row or one column, left to right or right to left, from the menu, the Artboards panel or Properties' Rearrange All, #681; Scale Artwork with Artboard, in the Control bar and Properties beside Move Artwork with Artboard, resizes the artboard proportionally with the art fully inside it and its guides, strokes and corners per the Scale preferences, #602), Measure, Type, Hand, Zoom, Rotate View.
|
||||
- **Editing type:** double-clicking type with a selection tool switches to the Type tool with the caret where it was clicked.
|
||||
- **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool.
|
||||
@@ -160,7 +162,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.
|
||||
- **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. Scatter Brush Options (Size, Spacing, Scatter and Rotation, each Fixed, Random between two values or Pressure from the first at the lightest pressure to the second at the heaviest; Rotation relative to Page or Path), Art Brush Options (Width, Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with the guides' start and end, the four Directions, Flip Along and Across) and Pattern Brush Options (Scale, Spacing, the five tiles shown, Flip Along and Across, Stretch to fit, Add space to fit or Approximate path) each have Colorization (None, Tints, Tints and Shades, Hue Shift with its Key Color picked from the art's colours) and a live stroke preview; values from files and commands are clamped to their ranges. Still to come: the Art brush's Overlap, auto-generated Pattern brush corners, new tile art picked in 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.
|
||||
@@ -220,7 +222,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 | 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 |
|
||||
| Strokes, brushes, width profiles | 5 | 90% | the Art brush's Overlap, auto-generated Pattern brush corners and new tile art in Pattern Brush Options (Brush Options for every brush type 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) | 8–15 |
|
||||
| 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, the Transform effect's Transform Objects/Patterns and Scale Strokes & Effects options | 6–10 |
|
||||
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 29% | ~45 Photoshop-style filters (Artistic, Brush Strokes, Distort, Sketch, Stylize, Texture), 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, Glowing Edges, Pixelate (Color Halftone, Crystallize, Mezzotint, Pointillize) and Video (De-Interlace, NTSC Colors) are done | 23–36 |
|
||||
|
||||
@@ -115,3 +115,31 @@ pub fn instance_art(art: &Node, inst: &Node) -> Node {
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
/// The solid colours of the fills and strokes under `nodes`, most used first (then in the order
|
||||
/// they first appear): the colours Brush Options offers as Hue Shift's key colour, the first being
|
||||
/// its default.
|
||||
pub fn art_colors<'a>(nodes: impl IntoIterator<Item = &'a Node>) -> Vec<Color> {
|
||||
fn walk(n: &Node, seen: &mut std::collections::HashMap<String, (usize, usize, Color)>) {
|
||||
for it in &n.appearance.items {
|
||||
let (visible, paint) = match it {
|
||||
AppearanceItem::Fill(f) => (f.visible, &f.paint),
|
||||
AppearanceItem::Stroke(s) => (s.visible, &s.paint),
|
||||
};
|
||||
if let (true, Some(c)) = (visible, paint.color()) {
|
||||
let order = seen.len();
|
||||
seen.entry(c.to_hex()).or_insert((0, order, c)).0 += 1;
|
||||
}
|
||||
}
|
||||
for c in n.children().into_iter().flatten() {
|
||||
walk(c, seen);
|
||||
}
|
||||
}
|
||||
let mut seen = std::collections::HashMap::new();
|
||||
for n in nodes {
|
||||
walk(n, &mut seen);
|
||||
}
|
||||
let mut v: Vec<(usize, usize, Color)> = seen.into_values().collect();
|
||||
v.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
|
||||
v.into_iter().map(|(.., c)| c).collect()
|
||||
}
|
||||
@@ -172,6 +172,7 @@ pub fn defaults() -> &'static [Brush] {
|
||||
rotation: (-180.0, 180.0),
|
||||
rotation_relative_to_path: false,
|
||||
colorization: Colorization::Tints,
|
||||
modes: None,
|
||||
}),
|
||||
},
|
||||
Brush {
|
||||
@@ -184,6 +185,7 @@ pub fn defaults() -> &'static [Brush] {
|
||||
rotation: (-35.0, 35.0),
|
||||
rotation_relative_to_path: true,
|
||||
colorization: Colorization::None,
|
||||
modes: None,
|
||||
}),
|
||||
},
|
||||
Brush {
|
||||
|
||||
+167
-10
@@ -23,7 +23,7 @@ use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{BezPath, Rect};
|
||||
|
||||
pub use colorize::{colorize, instance_art, stain, tint_node};
|
||||
pub use colorize::{art_colors, colorize, instance_art, stain, tint_node};
|
||||
pub use defaults::defaults;
|
||||
pub use serde_json;
|
||||
|
||||
@@ -71,6 +71,15 @@ impl BrushKind {
|
||||
BrushKind::Bristle(_) => "bristle",
|
||||
}
|
||||
}
|
||||
/// How the brush's art takes the stroke colour (Calligraphic and Bristle brushes paint with it).
|
||||
pub fn colorization(&self) -> Colorization {
|
||||
match self {
|
||||
BrushKind::Art(a) => a.colorization,
|
||||
BrushKind::Scatter(s) => s.colorization,
|
||||
BrushKind::Pattern(p) => p.colorization,
|
||||
BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => Colorization::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How brush art takes the stroke colour.
|
||||
@@ -88,6 +97,29 @@ pub enum Colorization {
|
||||
HueShift { key: vectorcraft_color::Color },
|
||||
}
|
||||
|
||||
impl Colorization {
|
||||
pub const IDS: [&'static str; 4] = ["none", "tints", "tintsAndShades", "hueShift"];
|
||||
/// The name brush definitions use (`method`).
|
||||
pub fn id(&self) -> &'static str {
|
||||
match self {
|
||||
Colorization::None => "none",
|
||||
Colorization::Tints => "tints",
|
||||
Colorization::TintsAndShades => "tintsAndShades",
|
||||
Colorization::HueShift { .. } => "hueShift",
|
||||
}
|
||||
}
|
||||
/// The method named `id` (`key`: the key colour of Hue Shift).
|
||||
pub fn parse(id: &str, key: vectorcraft_color::Color) -> Option<Self> {
|
||||
Some(match id {
|
||||
"none" => Colorization::None,
|
||||
"tints" => Colorization::Tints,
|
||||
"tintsAndShades" => Colorization::TintsAndShades,
|
||||
"hueShift" => Colorization::HueShift { key },
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Calligraphic brush: an elliptical nib swept along the path. Sizes are at 1 pt stroke weight
|
||||
/// (the stroke weight scales the brush).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -181,6 +213,12 @@ pub struct Scatter {
|
||||
/// Rotation relative to the path direction (else to the page).
|
||||
pub rotation_relative_to_path: bool,
|
||||
pub colorization: Colorization,
|
||||
/// How size, spacing, scatter and rotation vary between their two values: Fixed takes the
|
||||
/// first, Random any value between them, Pressure the first at the lightest pen pressure and
|
||||
/// the second at the heaviest. `None` (brushes saved before there were modes): Random where
|
||||
/// the two differ, else Fixed — see [`Scatter::modes`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub modes: Option<[Variation; 4]>,
|
||||
}
|
||||
|
||||
impl Default for Scatter {
|
||||
@@ -193,10 +231,25 @@ impl Default for Scatter {
|
||||
rotation: (0.0, 0.0),
|
||||
rotation_relative_to_path: false,
|
||||
colorization: Colorization::None,
|
||||
modes: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Scatter {
|
||||
/// The ranges of size (%), spacing (%), scatter (%) and rotation (°).
|
||||
pub const RANGES: [(f64, f64); 4] = [(1.0, 10000.0), (1.0, 10000.0), (-1000.0, 1000.0), (-180.0, 180.0)];
|
||||
|
||||
/// Size, spacing, scatter and rotation, each as its (first, second) values.
|
||||
pub fn values(&self) -> [(f64, f64); 4] {
|
||||
[self.size, self.spacing, self.scatter, self.rotation]
|
||||
}
|
||||
/// How size, spacing, scatter and rotation vary.
|
||||
pub fn modes(&self) -> [Variation; 4] {
|
||||
self.modes.unwrap_or_else(|| self.values().map(|(a, b)| if a != b { Variation::Random } else { Variation::Fixed }))
|
||||
}
|
||||
}
|
||||
|
||||
/// Direction the art runs in the brush definition.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -236,6 +289,11 @@ pub struct ArtBrush {
|
||||
pub colorization: Colorization,
|
||||
}
|
||||
|
||||
impl ArtBrush {
|
||||
/// The range of the width (%).
|
||||
pub const WIDTH_RANGE: (f64, f64) = (1.0, 1000.0);
|
||||
}
|
||||
|
||||
impl Default for ArtBrush {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -286,6 +344,12 @@ pub struct PatternBrush {
|
||||
pub colorization: Colorization,
|
||||
}
|
||||
|
||||
impl PatternBrush {
|
||||
/// The ranges of the scale and of the spacing (%).
|
||||
pub const SCALE_RANGE: (f64, f64) = (1.0, 10000.0);
|
||||
pub const SPACING_RANGE: (f64, f64) = (0.0, 10000.0);
|
||||
}
|
||||
|
||||
impl Default for PatternBrush {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -340,12 +404,104 @@ pub struct Bristle {
|
||||
pub stiffness: f64,
|
||||
}
|
||||
|
||||
impl Bristle {
|
||||
/// The ranges of the size (pt), the bristle length (%) and the other percentages.
|
||||
pub const SIZE_RANGE: (f64, f64) = (0.1, 1296.0);
|
||||
pub const LENGTH_RANGE: (f64, f64) = (25.0, 300.0);
|
||||
pub const PERCENT_RANGE: (f64, f64) = (1.0, 100.0);
|
||||
}
|
||||
|
||||
impl Default for Bristle {
|
||||
fn default() -> Self {
|
||||
Self { shape: BristleShape::RoundPoint, size: 6.0, length: 100.0, density: 50.0, thickness: 40.0, opacity: 75.0, stiffness: 50.0 }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- validation ----------
|
||||
|
||||
/// `v` within `(lo, hi)`; `fallback` (kept within too) when it isn't a number.
|
||||
fn within(v: f64, (lo, hi): (f64, f64), fallback: f64) -> f64 {
|
||||
if v.is_finite() { v.clamp(lo, hi) } else { fallback.clamp(lo, hi) }
|
||||
}
|
||||
|
||||
fn within_pair((a, b): (f64, f64), range: (f64, f64), fallback: f64) -> (f64, f64) {
|
||||
(within(a, range, fallback), within(b, range, fallback))
|
||||
}
|
||||
|
||||
impl Colorization {
|
||||
/// A Hue Shift key colour that isn't a colour (components out of reach) becomes black.
|
||||
fn sanitize(&mut self) {
|
||||
if let Colorization::HueShift { key } = self
|
||||
&& !key.to_rgb().iter().all(|c| c.is_finite())
|
||||
{
|
||||
*key = vectorcraft_color::Color::BLACK;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Brush {
|
||||
/// The art the brush is made of: a scatter or art brush's art, a pattern brush's tiles.
|
||||
pub fn art(&self) -> Vec<&Node> {
|
||||
match &self.kind {
|
||||
BrushKind::Scatter(s) => vec![&s.art],
|
||||
BrushKind::Art(a) => vec![&a.art],
|
||||
BrushKind::Pattern(p) => {
|
||||
std::iter::once(&p.side).chain([&p.outer_corner, &p.inner_corner, &p.start, &p.end].into_iter().flatten()).collect()
|
||||
}
|
||||
BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep every value of the definition within its range (definitions come from files and
|
||||
/// commands, so they are untrusted): numbers are clamped, non-numbers take the default.
|
||||
pub fn sanitize(&mut self) {
|
||||
match &mut self.kind {
|
||||
BrushKind::Calligraphic(c) => {
|
||||
let d = Calligraphic::default();
|
||||
let [angle, roundness, size] = Calligraphic::RANGES;
|
||||
c.angle = within(c.angle, angle, d.angle);
|
||||
c.roundness = within(c.roundness, roundness, d.roundness);
|
||||
c.size = within(c.size, size, d.size);
|
||||
for (v, max) in c.variation.iter_mut().zip(Calligraphic::MAX_VARIATION) {
|
||||
*v = within(*v, (0.0, max), 0.0);
|
||||
}
|
||||
}
|
||||
BrushKind::Scatter(s) => {
|
||||
let d = Scatter::default();
|
||||
let [size, spacing, scatter, rotation] = Scatter::RANGES;
|
||||
s.size = within_pair(s.size, size, d.size.0);
|
||||
s.spacing = within_pair(s.spacing, spacing, d.spacing.0);
|
||||
s.scatter = within_pair(s.scatter, scatter, 0.0);
|
||||
s.rotation = within_pair(s.rotation, rotation, 0.0);
|
||||
s.colorization.sanitize();
|
||||
}
|
||||
BrushKind::Art(a) => {
|
||||
a.width = within(a.width, ArtBrush::WIDTH_RANGE, 100.0);
|
||||
if let ArtScale::BetweenGuides { start, end } = &mut a.scale {
|
||||
*start = within(*start, (0.0, 1.0), 0.0);
|
||||
*end = within(*end, (0.0, 1.0), 1.0);
|
||||
}
|
||||
a.colorization.sanitize();
|
||||
}
|
||||
BrushKind::Pattern(p) => {
|
||||
p.scale = within(p.scale, PatternBrush::SCALE_RANGE, 100.0);
|
||||
p.spacing = within(p.spacing, PatternBrush::SPACING_RANGE, 0.0);
|
||||
p.colorization.sanitize();
|
||||
}
|
||||
BrushKind::Bristle(b) => {
|
||||
let d = Bristle::default();
|
||||
b.size = within(b.size, Bristle::SIZE_RANGE, d.size);
|
||||
b.length = within(b.length, Bristle::LENGTH_RANGE, d.length);
|
||||
for (v, fallback) in
|
||||
[(&mut b.density, d.density), (&mut b.thickness, d.thickness), (&mut b.opacity, d.opacity), (&mut b.stiffness, d.stiffness)]
|
||||
{
|
||||
*v = within(*v, Bristle::PERCENT_RANGE, fallback);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- library ----------
|
||||
|
||||
/// The brush library of `doc`: its stored brushes, or the defaults when it has none.
|
||||
@@ -356,9 +512,15 @@ pub fn library(doc: &Document) -> Vec<Brush> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a stored library value (malformed entries are skipped).
|
||||
/// Parse a stored library value (malformed entries are skipped, values kept within their
|
||||
/// ranges — see [`Brush::sanitize`]).
|
||||
pub fn parse_library(v: &serde_json::Value) -> Vec<Brush> {
|
||||
v.as_array().map(|a| a.iter().filter_map(|b| serde_json::from_value(b.clone()).ok()).collect()).unwrap_or_default()
|
||||
let parse = |b: &serde_json::Value| {
|
||||
let mut b: Brush = serde_json::from_value(b.clone()).ok()?;
|
||||
b.sanitize();
|
||||
Some(b)
|
||||
};
|
||||
v.as_array().map(|a| a.iter().filter_map(parse).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Store `lib` as the document's brush library.
|
||||
@@ -404,15 +566,10 @@ pub fn stroke_pieces(brush: &Brush, bp: &BezPath, stroke: &StrokeLayer) -> Vec<N
|
||||
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),
|
||||
BrushKind::Scatter(s) => warp::scatter(s, bp, w, stroke.pressure.as_ref(), &brush.name),
|
||||
BrushKind::Pattern(p) => warp::pattern(p, bp, w),
|
||||
};
|
||||
let colorization = match &brush.kind {
|
||||
BrushKind::Art(a) => a.colorization,
|
||||
BrushKind::Scatter(s) => s.colorization,
|
||||
BrushKind::Pattern(p) => p.colorization,
|
||||
_ => Colorization::None,
|
||||
};
|
||||
let colorization = brush.kind.colorization();
|
||||
for n in &mut out {
|
||||
colorize(n, colorization, &stroke.paint);
|
||||
}
|
||||
|
||||
@@ -447,3 +447,77 @@ fn junk_pressure_brushes_draw_finite_art() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A scatter brush of 10 × 4 pt rectangles whose size goes from `lo` % to `hi` % as `mode` says.
|
||||
fn sized_scatter(lo: f64, hi: f64, mode: Variation) -> Brush {
|
||||
let s = scatter_brush(100.0);
|
||||
let BrushKind::Scatter(sc) = s.kind else { panic!("not a scatter brush") };
|
||||
let modes = [mode, Variation::Fixed, Variation::Fixed, Variation::Fixed];
|
||||
Brush { kind: BrushKind::Scatter(Scatter { size: (lo, hi), modes: Some(modes), ..sc }), ..s }
|
||||
}
|
||||
|
||||
/// Scatter Brush Options: Fixed takes the first value, Pressure goes from the first at the
|
||||
/// lightest pen pressure to the second at the heaviest; brushes saved before modes keep their look.
|
||||
#[test]
|
||||
fn scatter_values_follow_their_modes() {
|
||||
let bp = line(0.0, 0.0, 400.0, 0.0);
|
||||
let heights =
|
||||
|b: &Brush, st: &StrokeLayer| -> Vec<f64> { stroke_pieces(b, &bp, st).iter().map(|n| n.geometric_bounds().unwrap().height()).collect() };
|
||||
// Fixed: every copy at 50 %, whatever the second value.
|
||||
assert!(heights(&sized_scatter(50.0, 300.0, Variation::Fixed), &stroke(1.0)).iter().all(|h| near(*h, 2.0, 1e-6)));
|
||||
// Pressure: light at the start (50 %: 2 pt tall), heavy at the end (300 %: 12 pt).
|
||||
let ramp = pressed(vec![(0.0, 0.0), (1.0, 1.0)]);
|
||||
let h = heights(&sized_scatter(50.0, 300.0, Variation::Pressure), &ramp);
|
||||
let (first, last) = (h[0], h[h.len() - 1]);
|
||||
assert!(near(first, 2.0, 0.1) && last > 10.0, "{h:?}");
|
||||
assert!(h.windows(2).all(|w| w[1] >= w[0] - 1e-9), "grows with the pressure: {h:?}");
|
||||
// Without recorded pressure, half way between the two.
|
||||
assert!(heights(&sized_scatter(50.0, 300.0, Variation::Pressure), &stroke(1.0)).iter().all(|h| near(*h, 7.0, 1e-6)));
|
||||
// No modes saved: Random where the values differ, as before; Fixed and Random draw the same.
|
||||
let old = sized_scatter(50.0, 300.0, Variation::Random);
|
||||
let BrushKind::Scatter(sc) = &old.kind else { panic!("not a scatter brush") };
|
||||
assert_eq!(Scatter { modes: None, ..sc.clone() }.modes(), [Variation::Random, Variation::Fixed, Variation::Fixed, Variation::Fixed]);
|
||||
let unsaved = Brush { kind: BrushKind::Scatter(Scatter { modes: None, ..sc.clone() }), ..old.clone() };
|
||||
assert_eq!(stroke_pieces(&old, &bp, &stroke(1.0)), stroke_pieces(&unsaved, &bp, &stroke(1.0)));
|
||||
assert!(serde_json::to_value(&unsaved).unwrap().get("modes").is_none(), "not saved when unset");
|
||||
}
|
||||
|
||||
/// Definitions from files and commands are kept within their ranges.
|
||||
#[test]
|
||||
fn sanitize_keeps_values_within_their_ranges() {
|
||||
let mut v = serde_json::to_value(sized_scatter(1e308, -1e308, Variation::Random)).unwrap();
|
||||
v["rotation"] = serde_json::json!([720.0, -1e300]);
|
||||
v["scatter"] = serde_json::json!([5000.0, 0.0]);
|
||||
let lib = parse_library(&serde_json::json!([
|
||||
v,
|
||||
{"name": "a", "type": "art", "art": rect_art(10.0, 4.0), "width": 1e308, "scale": {"mode": "betweenGuides", "start": -3.0, "end": 9.0}},
|
||||
{"name": "p", "type": "pattern", "side": rect_art(10.0, 4.0), "scale": 0.0, "spacing": -5.0},
|
||||
{"name": "c", "type": "calligraphic", "angle": 400.0, "roundness": -1.0, "size": 1e9, "variation": [1e9, -1.0, 2.0]},
|
||||
{"name": "b", "type": "bristle", "size": 0.0, "length": 1000.0, "density": 0.0},
|
||||
]));
|
||||
let [s, a, p, c, b] = lib.as_slice() else { panic!("{lib:?}") };
|
||||
let BrushKind::Scatter(s) = &s.kind else { panic!() };
|
||||
assert_eq!((s.size, s.rotation, s.scatter), ((10000.0, 1.0), (180.0, -180.0), (1000.0, 0.0)));
|
||||
let BrushKind::Art(a) = &a.kind else { panic!() };
|
||||
assert_eq!((a.width, a.scale), (1000.0, ArtScale::BetweenGuides { start: 0.0, end: 1.0 }));
|
||||
let BrushKind::Pattern(p) = &p.kind else { panic!() };
|
||||
assert_eq!((p.scale, p.spacing), (1.0, 0.0));
|
||||
let BrushKind::Calligraphic(c) = &c.kind else { panic!() };
|
||||
assert_eq!((c.angle, c.roundness, c.size, c.variation), (180.0, 0.0, 1296.0, [180.0, 0.0, 2.0]));
|
||||
let BrushKind::Bristle(b) = &b.kind else { panic!() };
|
||||
assert_eq!((b.size, b.length, b.density), (0.1, 300.0, 1.0));
|
||||
}
|
||||
|
||||
/// Hue Shift's key colour defaults to the art's most used colour.
|
||||
#[test]
|
||||
fn art_colors_list_the_most_used_first() {
|
||||
let red = Color::rgb(1.0, 0.0, 0.0);
|
||||
let blue = Color::rgb(0.0, 0.0, 1.0);
|
||||
let path =
|
||||
|c: Color| Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::basic(Paint::solid(c), Paint::None, 0.0));
|
||||
let g = Node::group(NodeId(0), vec![Arc::new(path(red)), Arc::new(path(blue)), Arc::new(path(blue))]);
|
||||
assert_eq!(art_colors([&g]), vec![blue, red]);
|
||||
let b = defaults().iter().find(|b| b.name == "Chain").unwrap();
|
||||
assert_eq!(b.art().len(), 3, "the side and both corner tiles");
|
||||
assert!(calli(0.0, 100.0, 3.0).art().is_empty());
|
||||
}
|
||||
@@ -6,11 +6,11 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
use vectorcraft_doc::{Node, NodeKind, PressureProfile};
|
||||
use vectorcraft_geom::{Affine, BezPath, PathData, PathEl, Point, Rect, SubPath};
|
||||
|
||||
use crate::track::{Rng, Track, seed_of, tracks};
|
||||
use crate::{ArtBrush, ArtScale, Direction, PatternBrush, PatternFit, Scatter, tolerance};
|
||||
use crate::{ArtBrush, ArtScale, Direction, PatternBrush, PatternFit, Scatter, Variation, tolerance};
|
||||
|
||||
/// Longest mapped segment (points in document space).
|
||||
const MAX_SEG: f64 = 1.5;
|
||||
@@ -188,13 +188,28 @@ pub(crate) fn art(a: &ArtBrush, bp: &BezPath, weight: f64) -> Vec<Node> {
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, name: &str) -> Vec<Node> {
|
||||
/// One of a scatter brush's values for the next copy: Fixed takes the first of `(a, b)`, Random a
|
||||
/// value between them, Pressure the first at pen pressure 0 and the second at 1. Every value
|
||||
/// draws, so a copy's random values stay the same whatever the other modes.
|
||||
fn vary(rng: &mut Rng, (a, b): (f64, f64), mode: Variation, pressure: f64) -> f64 {
|
||||
let r = rng.range(a, b);
|
||||
match mode {
|
||||
Variation::Fixed => a,
|
||||
Variation::Random => r,
|
||||
Variation::Pressure => a + (b - a) * pressure.clamp(0.0, 1.0),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, pressure: Option<&PressureProfile>, name: &str) -> Vec<Node> {
|
||||
let Some(b) = sc.art.geometric_bounds() else { return vec![] };
|
||||
let (aw, ah) = (b.width().max(1e-3), b.height().max(1e-3));
|
||||
let mut rng = Rng::new(seed_of(bp, name));
|
||||
let [size_mode, spacing_mode, scatter_mode, rotation_mode] = sc.modes();
|
||||
let mut out = vec![];
|
||||
for t in tracks(bp, 0.05) {
|
||||
let l = t.len();
|
||||
// The pen pressure at arc length `at` (half way without a recorded one).
|
||||
let pressure_at = |at: f64| pressure.map_or(PressureProfile::MID, |p| p.at(if l > 1e-9 { at / l } else { 0.0 }));
|
||||
let mut s: Option<f64> = None;
|
||||
let mut guard = 0;
|
||||
loop {
|
||||
@@ -202,8 +217,10 @@ pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, name: &str) -> Ve
|
||||
if guard > 20000 {
|
||||
break;
|
||||
}
|
||||
let size = rng.range(sc.size.0, sc.size.1).max(1.0) / 100.0 * weight;
|
||||
let spacing = rng.range(sc.spacing.0, sc.spacing.1).max(1.0) / 100.0;
|
||||
// Size and spacing place the next copy, so they take the pressure at the last one.
|
||||
let p = pressure_at(s.unwrap_or(0.0));
|
||||
let size = vary(&mut rng, sc.size, size_mode, p).max(1.0) / 100.0 * weight;
|
||||
let spacing = vary(&mut rng, sc.spacing, spacing_mode, p).max(1.0) / 100.0;
|
||||
let step = aw * size * spacing;
|
||||
let pos = match s {
|
||||
None => step / 2.0,
|
||||
@@ -213,8 +230,9 @@ pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, name: &str) -> Ve
|
||||
break;
|
||||
}
|
||||
s = Some(pos);
|
||||
let off = rng.range(sc.scatter.0, sc.scatter.1) / 100.0 * ah * size;
|
||||
let rot = rng.range(sc.rotation.0, sc.rotation.1).to_radians();
|
||||
let p = pressure_at(pos);
|
||||
let off = vary(&mut rng, sc.scatter, scatter_mode, p) / 100.0 * ah * size;
|
||||
let rot = vary(&mut rng, sc.rotation, rotation_mode, p).to_radians();
|
||||
let (p, tg) = t.frame(pos);
|
||||
let place = p + crate::track::normal(tg) * off;
|
||||
// Page rotation is counter-clockwise on screen (y-down), like the calligraphic angle.
|
||||
|
||||
+12
-1
@@ -103,14 +103,24 @@ pub struct GraphSpec {
|
||||
pub ticks: usize,
|
||||
pub axis_min: Option<f64>,
|
||||
pub axis_max: Option<f64>,
|
||||
/// Line graphs: mark data points, connect them, fill (area-like) the lines.
|
||||
/// Line, scatter and radar graphs: mark data points, connect them.
|
||||
pub mark_points: bool,
|
||||
pub connect_points: bool,
|
||||
/// Line graphs: run the lines from edge to edge of the plot; off (the default for new graphs), the points sit
|
||||
/// at the centres of their categories, like the columns of a column graph. Graphs saved before the option
|
||||
/// existed were drawn edge to edge and keep it.
|
||||
#[serde(default = "edge_to_edge_before_the_option")]
|
||||
pub edge_to_edge: bool,
|
||||
/// Bounds of the art when it was last generated (detects moves/scales of the graph group).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub placed: Option<Rect>,
|
||||
}
|
||||
|
||||
/// Edge-to-Edge Lines for a graph saved before the option existed: on, as it was drawn then.
|
||||
fn edge_to_edge_before_the_option() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Default for GraphSpec {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -128,6 +138,7 @@ impl Default for GraphSpec {
|
||||
axis_max: None,
|
||||
mark_points: true,
|
||||
connect_points: true,
|
||||
edge_to_edge: false,
|
||||
placed: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Brush Options…",
|
||||
[],
|
||||
None,
|
||||
"{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}",
|
||||
"{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). 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). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest), rotation_relative_to_path. art: width 1..1000%, scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour)}}, newName?} edit a brush definition (strokes using it update) → {name}",
|
||||
has_doc,
|
||||
brush_options
|
||||
),
|
||||
@@ -383,12 +383,18 @@ fn brush_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
v[k] = serde_json::to_value(&art).unwrap_or_default();
|
||||
}
|
||||
let b: Brush = serde_json::from_value(v).map_err(|e| bad(C, format!("invalid brush: {e}")))?;
|
||||
lib.push(b);
|
||||
lib.push(parse_brush(C, v)?);
|
||||
save_library(s, "New Brush", lib, None)?;
|
||||
Ok(json!({ "name": name }))
|
||||
}
|
||||
|
||||
/// A brush definition from command params, its values kept within their ranges.
|
||||
fn parse_brush(cmd: &str, v: Value) -> Result<Brush> {
|
||||
let mut b: Brush = serde_json::from_value(v).map_err(|e| bad(cmd, format!("invalid brush: {e}")))?;
|
||||
b.sanitize();
|
||||
Ok(b)
|
||||
}
|
||||
|
||||
fn brush_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = name_param(p, "brush.delete")?.to_string();
|
||||
let mut lib = brush::library(&s.doc()?.doc);
|
||||
@@ -431,7 +437,7 @@ fn brush_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
v["name"] = json!(name);
|
||||
}
|
||||
v["type"] = json!(lib[i].kind.type_id());
|
||||
lib[i] = serde_json::from_value(v).map_err(|e| bad(C, format!("invalid brush: {e}")))?;
|
||||
lib[i] = parse_brush(C, v)?;
|
||||
let out = lib[i].name.clone();
|
||||
let rename = new_name.map(|nn| (name, Some(nn)));
|
||||
save_library(s, "Brush Options", lib, rename)?;
|
||||
|
||||
+134
-18
@@ -41,7 +41,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Type…",
|
||||
["Object", "Graph"],
|
||||
None,
|
||||
"{id?, type?, columnWidth?: %, clusterWidth?: %, legend?: bool, markPoints?: bool, connectPoints?: bool, ticks?: n, axisMin?, axisMax?} change the graph type and options; no options → the current ones",
|
||||
"{id?, type?, columnWidth?: %, clusterWidth?: %, legend?: bool, markPoints?: bool, connectPoints?: bool, edgeToEdge?: bool (line graphs: true runs the lines across the whole plot, false puts the points at the centres of their categories), ticks?: n, axisMin?, axisMax?} change the graph type and options (axisMin and axisMax together override the calculated value axis: exactly that range in `ticks` divisions, 5 when 0); no options → the current ones",
|
||||
has_selection,
|
||||
set_type
|
||||
),
|
||||
@@ -166,6 +166,37 @@ fn nice_axis(lo: f64, hi: f64, ticks: usize) -> (f64, f64, f64) {
|
||||
((lo / step).floor() * step, (hi / step).ceil() * step, step)
|
||||
}
|
||||
|
||||
/// The value axis: with both `axis_min` and `axis_max` set (Graph Type › Tick Values › Override Calculated Values),
|
||||
/// exactly that range split into `ticks` divisions (5 when automatic); otherwise a nice axis around the data, using
|
||||
/// whichever bound was given.
|
||||
fn value_axis(g: &GraphSpec, lo: f64, hi: f64) -> (f64, f64, f64) {
|
||||
match (g.axis_min, g.axis_max) {
|
||||
(Some(min), Some(max)) if max > min && (max - min).is_finite() => {
|
||||
let n = if g.ticks == 0 { 5 } else { g.ticks.clamp(1, 100) } as f64;
|
||||
(min, max, (max - min) / n)
|
||||
}
|
||||
_ => {
|
||||
// An override too wide for f64 (or NaN) gives a non-finite axis, which would reach `clamp` with NaN bounds
|
||||
// and panic: fall back to the data, then to 0..1.
|
||||
let finite = |(a, b, s): (f64, f64, f64)| a.is_finite() && b.is_finite() && s.is_finite() && b > a && s > 0.0;
|
||||
[nice_axis(g.axis_min.unwrap_or(lo), g.axis_max.unwrap_or(hi), g.ticks), nice_axis(lo, hi, g.ticks)]
|
||||
.into_iter()
|
||||
.find(|&a| finite(a))
|
||||
.unwrap_or((0.0, 1.0, 0.2))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The tick values from `lo` to `hi` every `step`, both ends included: counted, not accumulated, so the last tick
|
||||
/// lands on `hi` and a step too small for the range (or not finite) can't loop forever. At most 1000 ticks.
|
||||
fn tick_values(lo: f64, hi: f64, step: f64) -> Vec<f64> {
|
||||
let n = ((hi - lo) / step).round();
|
||||
if !(lo.is_finite() && step.is_finite() && step > 0.0 && n.is_finite() && n >= 0.0) {
|
||||
return vec![];
|
||||
}
|
||||
(0..=(n as usize).min(1000)).map(|i| lo + i as f64 * step).collect()
|
||||
}
|
||||
|
||||
struct Gen<'a> {
|
||||
d: &'a mut Document,
|
||||
out: Vec<Arc<Node>>,
|
||||
@@ -270,8 +301,7 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
GraphKind::Radar => {
|
||||
let centre = r.center();
|
||||
let rad = (r.width().min(r.height()) / 2.0).max(1.0);
|
||||
let hi = g.axis_max.unwrap_or_else(|| g.rows.iter().flatten().copied().fold(0.0, f64::max));
|
||||
let (lo, hi, step) = nice_axis(g.axis_min.unwrap_or(0.0), hi, g.ticks);
|
||||
let (lo, hi, step) = value_axis(g, 0.0, g.rows.iter().flatten().copied().fold(0.0, f64::max));
|
||||
let at = |c: usize, v: f64| {
|
||||
let a = -std::f64::consts::FRAC_PI_2 + std::f64::consts::TAU * c as f64 / ncat as f64;
|
||||
centre + vectorcraft_geom::Vec2::new(a.cos(), a.sin()) * (rad * ((v - lo) / (hi - lo)).clamp(0.0, 1.0))
|
||||
@@ -284,21 +314,23 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
axes.push(b.text(Point::new(p.x, p.y + LABEL_SIZE * 0.35), cat, Justify::Center));
|
||||
}
|
||||
}
|
||||
let mut v = lo + step;
|
||||
while v <= hi + step * 1e-6 {
|
||||
for v in tick_values(lo, hi, step).into_iter().skip(1) {
|
||||
let ring: Vec<Point> = (0..ncat).map(|c| at(c, v)).collect();
|
||||
axes.push(b.path(polyline(&ring, true), Paint::None, Paint::solid(Color::rgb(0.6, 0.6, 0.6)), 0.25));
|
||||
v += step;
|
||||
}
|
||||
let ax = b.group("Axes", axes);
|
||||
b.out.push(ax);
|
||||
for (s, items) in series.iter_mut().enumerate() {
|
||||
let pts: Vec<Point> = (0..ncat).map(|c| at(c, val(c, s))).collect();
|
||||
let (fill, stroke, w) = mark(s, false, default_series_paint(s), 1.0);
|
||||
items.push(b.path(polyline(&pts, true), fill, stroke, w));
|
||||
for p in pts {
|
||||
let (fill, stroke, w) = mark(s, true, Paint::None, 0.0);
|
||||
items.push(b.path(marker(p, 4.0), fill, stroke, w));
|
||||
if g.connect_points && pts.len() > 1 {
|
||||
let (fill, stroke, w) = mark(s, false, default_series_paint(s), 1.0);
|
||||
items.push(b.path(polyline(&pts, true), fill, stroke, w));
|
||||
}
|
||||
if g.mark_points {
|
||||
for p in pts {
|
||||
let (fill, stroke, w) = mark(s, true, Paint::None, 0.0);
|
||||
items.push(b.path(marker(p, 4.0), fill, stroke, w));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -327,7 +359,7 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let (lo, hi, step) = nice_axis(g.axis_min.unwrap_or(lo), g.axis_max.unwrap_or(hi), g.ticks);
|
||||
let (lo, hi, step) = value_axis(g, lo, hi);
|
||||
// Value → coordinate along the value axis (y for columns/lines, x for bars).
|
||||
let vpos = |v: f64| {
|
||||
let t = (v - lo) / (hi - lo);
|
||||
@@ -335,8 +367,7 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
};
|
||||
let mut axes = vec![];
|
||||
// Value axis with ticks and labels.
|
||||
let mut v = lo;
|
||||
while v <= hi + step * 1e-6 {
|
||||
for v in tick_values(lo, hi, step) {
|
||||
let q = vpos(v);
|
||||
if horizontal {
|
||||
axes.push(b.line(Point::new(q, r.y1), Point::new(q, r.y1 + 4.0)));
|
||||
@@ -345,7 +376,6 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
axes.push(b.line(Point::new(r.x0 - 4.0, q), Point::new(r.x0, q)));
|
||||
axes.push(b.text(Point::new(r.x0 - 6.0, q + LABEL_SIZE * 0.35), &fmt_value(v), Justify::Right));
|
||||
}
|
||||
v += step;
|
||||
}
|
||||
if horizontal {
|
||||
axes.push(b.line(Point::new(r.x0, r.y1), Point::new(r.x1, r.y1)));
|
||||
@@ -358,7 +388,8 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
let span = if horizontal { r.height() } else { r.width() };
|
||||
let cat_w = span / ncat as f64;
|
||||
let cat_start = |c: usize| if horizontal { r.y0 + c as f64 * cat_w } else { r.x0 + c as f64 * cat_w };
|
||||
let points_mode = matches!(g.kind, GraphKind::Line | GraphKind::Area);
|
||||
// Area bands always span the plot; line graphs only with Edge-to-Edge Lines.
|
||||
let points_mode = g.kind == GraphKind::Area || (g.kind == GraphKind::Line && g.edge_to_edge);
|
||||
let cat_mid = |c: usize| {
|
||||
if points_mode && ncat > 1 { r.x0 + r.width() * c as f64 / (ncat - 1) as f64 } else { cat_start(c) + cat_w / 2.0 }
|
||||
};
|
||||
@@ -676,11 +707,12 @@ fn set_type(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "graph.setType";
|
||||
let id = target(s, p, C)?;
|
||||
let mut spec = spec_of(s, id)?;
|
||||
let keys = ["type", "columnWidth", "clusterWidth", "legend", "markPoints", "connectPoints", "ticks", "axisMin", "axisMax"];
|
||||
let keys = ["type", "columnWidth", "clusterWidth", "legend", "markPoints", "connectPoints", "edgeToEdge", "ticks", "axisMin", "axisMax"];
|
||||
if !keys.iter().any(|k| p.get(*k).is_some()) {
|
||||
return Ok(json!({
|
||||
"type": spec.kind.id(), "columnWidth": spec.column_width, "clusterWidth": spec.cluster_width, "legend": spec.legend,
|
||||
"markPoints": spec.mark_points, "connectPoints": spec.connect_points, "ticks": spec.ticks,
|
||||
"markPoints": spec.mark_points, "connectPoints": spec.connect_points, "edgeToEdge": spec.edge_to_edge, "ticks": spec.ticks,
|
||||
"axisMin": spec.axis_min, "axisMax": spec.axis_max,
|
||||
}));
|
||||
}
|
||||
if let Some(t) = str_param(p, "type") {
|
||||
@@ -691,6 +723,7 @@ fn set_type(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
spec.legend = bool_or(p, "legend", spec.legend);
|
||||
spec.mark_points = bool_or(p, "markPoints", spec.mark_points);
|
||||
spec.connect_points = bool_or(p, "connectPoints", spec.connect_points);
|
||||
spec.edge_to_edge = bool_or(p, "edgeToEdge", spec.edge_to_edge);
|
||||
spec.ticks = p.get("ticks").and_then(Value::as_u64).map_or(spec.ticks, |t| t.min(100) as usize);
|
||||
if let Some(v) = p.get("axisMin") {
|
||||
spec.axis_min = v.as_f64();
|
||||
@@ -790,6 +823,89 @@ mod tests {
|
||||
assert!(matches!(n.kind, NodeKind::Group { .. }));
|
||||
}
|
||||
|
||||
/// The x of every marker of series `index`, left to right.
|
||||
fn marker_xs(s: &Session, id: NodeId, index: u32) -> Vec<f64> {
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let mut xs: Vec<f64> = series(&n, index)
|
||||
.children()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|c| c.geometric_bounds().unwrap())
|
||||
.filter(|b| (b.width() - 5.0).abs() < 1e-6)
|
||||
.map(|b| b.center().x)
|
||||
.collect();
|
||||
xs.sort_by(f64::total_cmp);
|
||||
xs
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_to_edge_lines_off_puts_line_points_at_category_centres() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
// 300 pt wide, three categories: centres at 150, 250, 350 (off by default); edges at 100 and 400.
|
||||
let id = graph(&mut s, "line");
|
||||
assert_eq!(marker_xs(&s, id, 0), vec![150.0, 250.0, 350.0]);
|
||||
assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["edgeToEdge"], json!(false));
|
||||
s.execute("graph.setType", &json!({"edgeToEdge": true})).unwrap();
|
||||
assert_eq!(marker_xs(&s, id, 0), vec![100.0, 250.0, 400.0]);
|
||||
// A graph saved before the option existed keeps its edge-to-edge lines.
|
||||
let old: GraphSpec = serde_json::from_value(json!({"kind": "line"})).unwrap();
|
||||
assert!(old.edge_to_edge);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radar_graphs_follow_mark_and_connect_data_points() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let id = graph(&mut s, "radar");
|
||||
let parts = |s: &Session| series(&s.doc().unwrap().doc.node(id).unwrap().clone(), 0).children().unwrap().len();
|
||||
// The ring, three markers and the legend swatch.
|
||||
assert_eq!(parts(&s), 5);
|
||||
s.execute("graph.setType", &json!({"markPoints": false})).unwrap();
|
||||
assert_eq!(parts(&s), 2);
|
||||
s.execute("graph.setType", &json!({"markPoints": true, "connectPoints": false})).unwrap();
|
||||
assert_eq!(parts(&s), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_overridden_value_axis_is_used_exactly() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let r = s.execute("graph.create", &json!({"type": "column", "x": 20, "y": 20, "width": 170, "height": 74, "series": ["a"], "categories": ["x", "y"], "rows": [[4839], [10488]]})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
let tallest = |s: &Session| -> f64 {
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
let bars = series(&n, 0).children().unwrap();
|
||||
bars.iter().map(|c| c.geometric_bounds().unwrap().height()).fold(0.0, f64::max)
|
||||
};
|
||||
// Calculated: a nice axis (0 to 12500 here), so the 10488 column falls short of 10488 / 12000 of the plot.
|
||||
assert!((tallest(&s) - 74.0 * 10488.0 / 12000.0).abs() > 1.0);
|
||||
s.execute("graph.setType", &json!({"axisMin": 0, "axisMax": 12000})).unwrap();
|
||||
assert!((tallest(&s) - 74.0 * 10488.0 / 12000.0).abs() < 1e-6, "{}", tallest(&s));
|
||||
let o = s.execute("graph.setType", &json!({})).unwrap();
|
||||
assert_eq!((o["axisMin"].as_f64(), o["axisMax"].as_f64()), (Some(0.0), Some(12000.0)));
|
||||
// One bound only keeps the calculated (nice) axis around it.
|
||||
s.execute("graph.setType", &json!({"axisMax": null})).unwrap();
|
||||
assert!((tallest(&s) - 74.0 * 10488.0 / 12000.0).abs() > 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tick_values_end_on_the_maximum_and_never_run_away() {
|
||||
// Counted, not accumulated: an offset range keeps its last tick.
|
||||
let t = super::tick_values(1e10, 1e10 + 1.0, 0.01);
|
||||
assert_eq!(t.len(), 101);
|
||||
assert!((t[100] - (1e10 + 1.0)).abs() < 1e-3);
|
||||
// A range too wide for f64, or a step too small to move, gives no ticks or a capped list.
|
||||
assert!(super::tick_values(-1.7e308, 1.7e308, f64::INFINITY).is_empty());
|
||||
assert!(super::tick_values(1e20, 1e20 + 16384.0, 163.84).len() <= 1001);
|
||||
assert!(super::tick_values(0.0, 1.0, 0.0).is_empty());
|
||||
// An override whose span overflows falls back to the calculated axis instead of hanging.
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
graph(&mut s, "column");
|
||||
s.execute("graph.setType", &json!({"axisMin": -1.7e308, "axisMax": 1.7e308})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_selection_paint_becomes_the_series_colour_and_survives_regeneration() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -297,6 +297,77 @@ fn brush_options_set_variation_modes() {
|
||||
assert_eq!(s.execute("brush.get", &json!({"name": "3 pt. Round"})).unwrap(), b, "a refused edit changes nothing");
|
||||
}
|
||||
|
||||
/// The bounds of the brush art `id`'s strokes draw.
|
||||
fn brushed_bounds(s: &Session, id: NodeId) -> Rect {
|
||||
let st = s.doc().unwrap();
|
||||
let pieces = vectorcraft_brush::node_pieces(&st.doc, &node(s, id)).expect("brushed");
|
||||
vectorcraft_brush::pieces_bounds(&pieces).expect("draws")
|
||||
}
|
||||
|
||||
/// Scatter, Art and Pattern Brush Options: the params reach the brush and every stroke painted
|
||||
/// with it redraws; values out of range are clamped, unknown choices refused.
|
||||
#[test]
|
||||
fn scatter_art_and_pattern_brush_options_update_strokes() {
|
||||
let mut s = session();
|
||||
// A rectangle as an art brush (straight edges: no flattening between sizes).
|
||||
let r = rect(&mut s, 10.0, 200.0, 40.0, 8.0);
|
||||
let bar = s.execute("brush.new", &json!({"type": "art", "name": "Bar", "ids": [r.0]})).unwrap()["name"].as_str().unwrap().to_string();
|
||||
let id = line(&mut s);
|
||||
select(&mut s, &[id]);
|
||||
let edit = |s: &mut Session, name: &str, params: Value| -> f64 {
|
||||
s.execute("brush.apply", &json!({ "name": name })).unwrap();
|
||||
let before = brushed_bounds(s, id).height();
|
||||
s.execute("brush.options", &json!({ "name": name, "params": params })).unwrap();
|
||||
brushed_bounds(s, id).height() / before
|
||||
};
|
||||
// Art: Width 300 % draws the art three times as wide across the path.
|
||||
let art = json!({"width": 300, "direction": "rightToLeft", "flip_across": true});
|
||||
assert!((edit(&mut s, &bar, art) - 3.0).abs() < 1e-6);
|
||||
s.execute("brush.options", &json!({"name": bar, "params": {"scale": {"mode": "betweenGuides", "start": 0.2, "end": 0.6}}})).unwrap();
|
||||
let b = s.execute("brush.get", &json!({ "name": bar })).unwrap();
|
||||
assert_eq!(
|
||||
(&b["direction"], &b["flip_across"], &b["scale"]),
|
||||
(&json!("rightToLeft"), &json!(true), &json!({"mode": "betweenGuides", "start": 0.2, "end": 0.6}))
|
||||
);
|
||||
// Pattern: Scale 200 % doubles the tiles.
|
||||
assert!((edit(&mut s, "Stitches", json!({"scale": 200, "spacing": 50, "fit": "addSpace", "flip_along": true})) - 2.0).abs() < 0.05);
|
||||
let b = s.execute("brush.get", &json!({"name": "Stitches"})).unwrap();
|
||||
assert_eq!((&b["spacing"], &b["fit"]), (&json!(50.0), &json!("addSpace")));
|
||||
// Scatter: a fixed Size of 200 % doubles the dots; the modes and Hue Shift are kept.
|
||||
let key = json!({"model": "rgb", "r": 0.0, "g": 0.0, "b": 0.0});
|
||||
let scatter = json!({"size": [200, 50], "modes": ["fixed", "fixed", "fixed", "random"], "rotation_relative_to_path": true, "colorization": {"method": "hueShift", "key": key}});
|
||||
assert!((edit(&mut s, "Dots", scatter) - 2.0).abs() < 0.05);
|
||||
let b = s.execute("brush.get", &json!({"name": "Dots"})).unwrap();
|
||||
assert_eq!(
|
||||
(&b["modes"], &b["colorization"]["method"], &b["colorization"]["key"]),
|
||||
(&json!(["fixed", "fixed", "fixed", "random"]), &json!("hueShift"), &key)
|
||||
);
|
||||
// Out of range: clamped. Unknown choices: refused, nothing changes.
|
||||
let junk = json!({"size": [1e308, -1e308], "spacing": [0, 1e308], "rotation": [720, -1e9], "scatter": [-1e308, 5]});
|
||||
s.execute("brush.options", &json!({"name": "Dots", "params": junk})).unwrap();
|
||||
let b = s.execute("brush.get", &json!({"name": "Dots"})).unwrap();
|
||||
assert_eq!(
|
||||
(&b["size"], &b["spacing"], &b["rotation"], &b["scatter"]),
|
||||
(&json!([10000.0, 1.0]), &json!([1.0, 10000.0]), &json!([180.0, -180.0]), &json!([-1000.0, 5.0]))
|
||||
);
|
||||
assert!(brushed_bounds(&s, id).is_finite());
|
||||
for (name, bad) in [
|
||||
("Dots", json!({"modes": ["fixed", "tilt", "fixed", "fixed"]})),
|
||||
("Dots", json!({"modes": ["fixed"]})),
|
||||
("Dots", json!({"colorization": {"method": "sparkle"}})),
|
||||
("Arrow", json!({"direction": "diagonal"})),
|
||||
("Arrow", json!({"width": "wide"})),
|
||||
("Chain", json!({"fit": "squeeze"})),
|
||||
] {
|
||||
let before = s.execute("brush.get", &json!({ "name": name })).unwrap();
|
||||
assert!(s.execute("brush.options", &json!({ "name": name, "params": bad })).is_err(), "{bad}");
|
||||
assert_eq!(s.execute("brush.get", &json!({ "name": name })).unwrap(), before);
|
||||
}
|
||||
s.execute("brush.options", &json!({"name": "Chain", "params": {"scale": 1e308, "spacing": -1e308}})).unwrap();
|
||||
let b = s.execute("brush.get", &json!({"name": "Chain"})).unwrap();
|
||||
assert_eq!((&b["scale"], &b["spacing"]), (&json!(10000.0), &json!(0.0)));
|
||||
}
|
||||
|
||||
// ---------- symbols ----------
|
||||
|
||||
fn make_symbol(s: &mut Session) -> (NodeId, String, Rect) {
|
||||
|
||||
@@ -227,6 +227,20 @@ fn structured_junk() {
|
||||
"brush.options",
|
||||
json!({"name": "3 pt. Round", "params": {"modes": ["pressure", "pressure", "pressure"], "variation": [1e308, 1e308, 1e308], "size": 1e308, "angle": -1e308}}),
|
||||
),
|
||||
(
|
||||
"brush.options",
|
||||
json!({"name": "Confetti", "params": {"size": [1e308, -1e308], "spacing": [0, 1e308], "scatter": [-1e308, 1e308], "rotation": [1e308, -1e308], "modes": ["pressure", "random", "fixed", "pressure"]}}),
|
||||
),
|
||||
("brush.options", json!({"name": "Dots", "params": {"modes": ["fixed", "tilt", "fixed", "fixed"], "size": "x"}})),
|
||||
("brush.options", json!({"name": "Dots", "params": {"modes": ["fixed"], "colorization": {"method": "sparkle"}}})),
|
||||
(
|
||||
"brush.options",
|
||||
json!({"name": "Dots", "params": {"colorization": {"method": "hueShift", "key": {"model": "rgb", "r": 1e308, "g": -1e308, "b": 0}}}}),
|
||||
),
|
||||
("brush.options", json!({"name": "Arrow", "params": {"width": 1e308, "scale": {"mode": "betweenGuides", "start": 1e308, "end": -1e308}}})),
|
||||
("brush.options", json!({"name": "Arrow", "params": {"width": -5, "direction": "diagonal", "flip_along": "yes"}})),
|
||||
("brush.options", json!({"name": "Chain", "params": {"scale": 1e308, "spacing": -1e308, "fit": "approximate", "flip_across": true}})),
|
||||
("brush.options", json!({"name": "Chain", "params": {"scale": 0, "spacing": 1e308, "fit": "nope", "side": null}})),
|
||||
("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})),
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
//! Scatter, Art and Pattern Brush Options: the [`super::brush_options`] of the brushes made of
|
||||
//! art. Each has the brush's own values, Colorization (Method, and Key Color for Hue Shift, picked
|
||||
//! from the art's colours) and a live preview of a stroke.
|
||||
//!
|
||||
//! Fields (besides `__brush`, `__type` and `name`): `__def` (the brush definition, for the preview),
|
||||
//! `__colors` (the art's colours, most used first), `colorization` (`none` | `tints` |
|
||||
//! `tintsAndShades` | `hueShift`), `keyColor` (a colour).
|
||||
//! Scatter: per value (`size`, `spacing`, `scatter` %, `rotation` °) `…Min`, `…Max` and `…Mode`
|
||||
//! (`fixed` | `random` | `pressure`), `rotationRelativeTo` (`page` | `path`).
|
||||
//! Art: `width` (%), `scaleMode` (`proportional` | `stretch` | `betweenGuides`), `guideStart` and
|
||||
//! `guideEnd` (% of the art's length), `direction` (`leftToRight` …), `flipAlong`, `flipAcross`.
|
||||
//! Pattern: `scale`, `spacing` (%), `fit` (`stretch` | `addSpace` | `approximate`), `flipAlong`,
|
||||
//! `flipAcross`, `__tiles` (slot → preview key, for the tiles the brush has).
|
||||
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
use egui::{Color32, Sense, Stroke, pos2, vec2};
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_brush::{ArtBrush, ArtScale, Brush, BrushKind, Colorization, Direction, PatternBrush, PatternFit, Scatter, Variation};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Document, Node};
|
||||
|
||||
use super::brush_options::{clamped, mode_key, stroke_preview};
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets;
|
||||
|
||||
/// Scatter values: (field, suffix), in [`Scatter::RANGES`] order.
|
||||
const SCATTER: [(&str, &str); 4] = [("size", "%"), ("spacing", "%"), ("scatter", "%"), ("rotation", "°")];
|
||||
|
||||
/// Art brush directions, in the order of their arrows (→ ← ↓ ↑).
|
||||
const DIRECTIONS: [Direction; 4] = [Direction::LeftToRight, Direction::RightToLeft, Direction::TopToBottom, Direction::BottomToTop];
|
||||
|
||||
/// Pattern brush tiles: (field of the definition, slot), in the dialog's order.
|
||||
const TILES: [(&str, &str); 5] =
|
||||
[("outer_corner", "outerCorner"), ("side", "side"), ("inner_corner", "innerCorner"), ("start", "start"), ("end", "end")];
|
||||
|
||||
/// The most key colours offered.
|
||||
const MAX_COLORS: usize = 16;
|
||||
|
||||
/// Width of the label column.
|
||||
const LABEL_W: f32 = 132.0;
|
||||
|
||||
/// Width of the stroke preview.
|
||||
const PREVIEW_W: f32 = 420.0;
|
||||
|
||||
pub(super) fn heading(ty: &str) -> &'static str {
|
||||
match ty {
|
||||
"scatter" => tl!("Scatter Brush Options"),
|
||||
"art" => tl!("Art Brush Options"),
|
||||
_ => tl!("Pattern Brush Options"),
|
||||
}
|
||||
}
|
||||
|
||||
fn min_key(key: &str) -> String {
|
||||
format!("{key}Min")
|
||||
}
|
||||
|
||||
fn max_key(key: &str) -> String {
|
||||
format!("{key}Max")
|
||||
}
|
||||
|
||||
/// A definition id of a serde enum (`leftToRight`, `addSpace` …).
|
||||
fn id_of<T: serde::Serialize>(v: &T) -> String {
|
||||
serde_json::to_value(v).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The value of the serde enum `T` that field `key` names, else `T`'s default.
|
||||
fn parse_field<T: serde::de::DeserializeOwned + Default>(d: &Dialog, key: &str) -> T {
|
||||
serde_json::from_value(json!(d.str(key))).unwrap_or_default()
|
||||
}
|
||||
|
||||
// ---------- fields ----------
|
||||
|
||||
/// The fields of brush `b` (a scatter, art or pattern brush) whose definition is `def`.
|
||||
pub(super) fn fields(b: &Brush, def: Value, f: &mut Map<String, Value>) {
|
||||
match &b.kind {
|
||||
BrushKind::Scatter(s) => {
|
||||
for ((key, _), ((lo, hi), mode)) in SCATTER.iter().zip(s.values().into_iter().zip(s.modes())) {
|
||||
f.insert(min_key(key), json!(lo));
|
||||
f.insert(max_key(key), json!(hi));
|
||||
f.insert(mode_key(key), json!(mode.id()));
|
||||
}
|
||||
f.insert("rotationRelativeTo".into(), json!(if s.rotation_relative_to_path { "path" } else { "page" }));
|
||||
}
|
||||
BrushKind::Art(a) => {
|
||||
let (mode, start, end) = match a.scale {
|
||||
ArtScale::Proportional => ("proportional", 0.0, 1.0),
|
||||
ArtScale::Stretch => ("stretch", 0.0, 1.0),
|
||||
ArtScale::BetweenGuides { start, end } => ("betweenGuides", start, end),
|
||||
};
|
||||
f.insert("width".into(), json!(a.width));
|
||||
f.insert("scaleMode".into(), json!(mode));
|
||||
f.insert("guideStart".into(), json!(start * 100.0));
|
||||
f.insert("guideEnd".into(), json!(end * 100.0));
|
||||
f.insert("direction".into(), json!(id_of(&a.direction)));
|
||||
f.insert("flipAlong".into(), json!(a.flip_along));
|
||||
f.insert("flipAcross".into(), json!(a.flip_across));
|
||||
}
|
||||
BrushKind::Pattern(p) => {
|
||||
f.insert("scale".into(), json!(p.scale));
|
||||
f.insert("spacing".into(), json!(p.spacing));
|
||||
f.insert("fit".into(), json!(id_of(&p.fit)));
|
||||
f.insert("flipAlong".into(), json!(p.flip_along));
|
||||
f.insert("flipAcross".into(), json!(p.flip_across));
|
||||
// A preview key per tile the brush has: its art's hash, so a changed tile redraws.
|
||||
let tiles: Map<String, Value> = TILES
|
||||
.iter()
|
||||
.filter_map(|(field, slot)| {
|
||||
let art = def.get(*field).filter(|v| !v.is_null())?;
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
art.to_string().hash(&mut h);
|
||||
Some(((*slot).to_string(), json!(format!("brush-tile:{:x}", h.finish()))))
|
||||
})
|
||||
.collect();
|
||||
f.insert("__tiles".into(), Value::Object(tiles));
|
||||
}
|
||||
BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => {}
|
||||
}
|
||||
let colors = vectorcraft_brush::art_colors(b.art());
|
||||
let colorization = b.kind.colorization();
|
||||
let key = match colorization {
|
||||
Colorization::HueShift { key } => key,
|
||||
_ => colors.first().copied().unwrap_or(Color::BLACK),
|
||||
};
|
||||
f.insert("colorization".into(), json!(colorization.id()));
|
||||
f.insert("keyColor".into(), serde_json::to_value(key).unwrap_or_default());
|
||||
f.insert("__colors".into(), serde_json::to_value(colors.into_iter().take(MAX_COLORS).collect::<Vec<_>>()).unwrap_or_default());
|
||||
f.insert("__def".into(), def);
|
||||
}
|
||||
|
||||
fn key_color(d: &Dialog) -> Color {
|
||||
d.fields.get("keyColor").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or(Color::BLACK)
|
||||
}
|
||||
|
||||
fn colorization(d: &Dialog) -> Colorization {
|
||||
Colorization::parse(&d.str("colorization"), key_color(d)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// How scatter value `key` varies.
|
||||
fn scatter_mode(d: &Dialog, key: &str) -> Variation {
|
||||
Variation::parse(&d.str(&mode_key(key))).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `brush.options` params from the fields.
|
||||
pub(super) fn params(d: &Dialog) -> Value {
|
||||
let mut p = match d.str("__type").as_str() {
|
||||
"scatter" => {
|
||||
let mut p = json!({ "rotation_relative_to_path": d.str("rotationRelativeTo") == "path" });
|
||||
for ((key, _), range) in SCATTER.iter().zip(Scatter::RANGES) {
|
||||
p[*key] = json!([clamped(d, &min_key(key), range), clamped(d, &max_key(key), range)]);
|
||||
}
|
||||
p["modes"] = json!(SCATTER.map(|(key, _)| scatter_mode(d, key).id()));
|
||||
p
|
||||
}
|
||||
"art" => {
|
||||
let scale = match d.str("scaleMode").as_str() {
|
||||
"proportional" => ArtScale::Proportional,
|
||||
"betweenGuides" => ArtScale::BetweenGuides {
|
||||
start: clamped(d, "guideStart", (0.0, 100.0)) / 100.0,
|
||||
end: clamped(d, "guideEnd", (0.0, 100.0)) / 100.0,
|
||||
},
|
||||
_ => ArtScale::Stretch,
|
||||
};
|
||||
json!({
|
||||
"width": clamped(d, "width", ArtBrush::WIDTH_RANGE),
|
||||
"scale": serde_json::to_value(scale).unwrap_or_default(),
|
||||
"direction": id_of(&parse_field::<Direction>(d, "direction")),
|
||||
"flip_along": d.bool("flipAlong"),
|
||||
"flip_across": d.bool("flipAcross"),
|
||||
})
|
||||
}
|
||||
_ => json!({
|
||||
"scale": clamped(d, "scale", PatternBrush::SCALE_RANGE),
|
||||
"spacing": clamped(d, "spacing", PatternBrush::SPACING_RANGE),
|
||||
"fit": id_of(&parse_field::<PatternFit>(d, "fit")),
|
||||
"flip_along": d.bool("flipAlong"),
|
||||
"flip_across": d.bool("flipAcross"),
|
||||
}),
|
||||
};
|
||||
p["colorization"] = serde_json::to_value(colorization(d)).unwrap_or_default();
|
||||
p
|
||||
}
|
||||
|
||||
// ---------- body ----------
|
||||
|
||||
pub(super) fn body(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
match d.str("__type").as_str() {
|
||||
"scatter" => scatter_body(ui, d),
|
||||
"art" => art_body(ui, d),
|
||||
_ => pattern_body(ui, d),
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
colorization_body(ui, d);
|
||||
ui.add_space(10.0);
|
||||
// The brush as OK would make it, on a sample stroke.
|
||||
let mut def = d.fields.get("__def").cloned().unwrap_or_default();
|
||||
if let (Some(o), Value::Object(p)) = (def.as_object_mut(), params(d)) {
|
||||
o.extend(p);
|
||||
}
|
||||
let ty = d.str("__type");
|
||||
stroke_preview(ui, &ty, def, vec2(PREVIEW_W, 64.0));
|
||||
}
|
||||
|
||||
/// A whole-number field (`suffix`: % or °) bound to `d.fields[key]`, kept within `range`.
|
||||
fn number(ui: &mut egui::Ui, d: &mut Dialog, key: &str, range: (f64, f64), suffix: &str, enabled: bool) {
|
||||
ui.add_enabled_ui(enabled, |ui| {
|
||||
if let Some(v) = widgets::range_field(ui, ("brush-art-value", key), clamped(d, key, range), range.0..=range.1, suffix, 0, 72.0) {
|
||||
d.fields.insert(key.into(), json!(v));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A row of radio buttons bound to the string field `key`: (value, label).
|
||||
fn radios(ui: &mut egui::Ui, d: &mut Dialog, key: &str, options: &[(&str, &str)]) {
|
||||
let cur = d.str(key);
|
||||
for (value, label) in options {
|
||||
if widgets::radio(ui, label, cur == *value, true) {
|
||||
d.fields.insert(key.into(), json!(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn flips(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
widgets::label_row(ui, tl!("Flip:"), LABEL_W, |ui| {
|
||||
super::form::check(ui, d, "flipAlong", tl!("Flip Along"));
|
||||
ui.add_space(12.0);
|
||||
super::form::check(ui, d, "flipAcross", tl!("Flip Across"));
|
||||
});
|
||||
}
|
||||
|
||||
fn scatter_body(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
let labels = [tl!("Size:"), tl!("Spacing:"), tl!("Scatter:"), tl!("Rotation:")];
|
||||
let modes = Variation::ALL.map(Variation::label);
|
||||
egui::Grid::new("brush-options-scatter").num_columns(4).spacing([8.0, 8.0]).show(ui, |ui| {
|
||||
for (((key, suffix), range), text) in SCATTER.into_iter().zip(Scatter::RANGES).zip(labels) {
|
||||
super::swatch_options::label(ui, text);
|
||||
let mode = scatter_mode(d, key);
|
||||
for (field, enabled) in [(min_key(key), true), (max_key(key), mode != Variation::Fixed)] {
|
||||
ui.add_enabled_ui(enabled, |ui| {
|
||||
if let Some(v) =
|
||||
widgets::range_field(ui, ("brush-scatter", field.as_str()), clamped(d, &field, range), range.0..=range.1, suffix, 0, 72.0)
|
||||
{
|
||||
d.fields.insert(field.clone(), json!(v));
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(m) = widgets::dropdown(ui, ("brush-scatter-mode", key), mode.label(), &modes, 104.0).and_then(|i| Variation::ALL.get(i)) {
|
||||
d.fields.insert(mode_key(key), json!(m.id()));
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
let relative = [("page", tl!("Page")), ("path", tl!("Path"))];
|
||||
super::form::choice(ui, d, "rotationRelativeTo", tl!("Rotation relative to:"), (LABEL_W, 104.0), &relative);
|
||||
}
|
||||
|
||||
fn art_body(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
widgets::label_row(ui, tl!("Width:"), LABEL_W, |ui| number(ui, d, "width", ArtBrush::WIDTH_RANGE, "%", true));
|
||||
ui.add_space(4.0);
|
||||
widgets::section_header(ui, tl!("Brush Scale Options"));
|
||||
let scales = [
|
||||
("proportional", tl!("Scale Proportionately")),
|
||||
("stretch", tl!("Stretch to Fit Stroke Length")),
|
||||
("betweenGuides", tl!("Stretch Between Guides")),
|
||||
];
|
||||
radios(ui, d, "scaleMode", &scales);
|
||||
let guides = d.str("scaleMode") == "betweenGuides";
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(18.0);
|
||||
widgets::label_row(ui, tl!("Start:"), 40.0, |ui| number(ui, d, "guideStart", (0.0, 100.0), "%", guides));
|
||||
ui.add_space(8.0);
|
||||
widgets::label_row(ui, tl!("End:"), 34.0, |ui| number(ui, d, "guideEnd", (0.0, 100.0), "%", guides));
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
widgets::label_row(ui, tl!("Direction:"), LABEL_W, |ui| {
|
||||
let tips = [tl!("Left to Right"), tl!("Right to Left"), tl!("Top to Bottom"), tl!("Bottom to Top")];
|
||||
let cur = parse_field::<Direction>(d, "direction");
|
||||
for (dir, tip) in DIRECTIONS.into_iter().zip(tips) {
|
||||
if arrow_button(ui, dir, dir == cur, tip) {
|
||||
d.fields.insert("direction".into(), json!(id_of(&dir)));
|
||||
}
|
||||
}
|
||||
});
|
||||
flips(ui, d);
|
||||
}
|
||||
|
||||
/// A toggle button showing an arrow pointing the way `dir` runs. Whether it was clicked.
|
||||
fn arrow_button(ui: &mut egui::Ui, dir: Direction, selected: bool, tip: &str) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(26.0, 24.0), Sense::click());
|
||||
let p = ui.painter();
|
||||
if selected {
|
||||
p.rect_filled(r, 3.0, t.row_selected);
|
||||
} else if resp.hovered() {
|
||||
p.rect_filled(r, 3.0, t.hover);
|
||||
}
|
||||
p.rect_stroke(r, 3.0, Stroke::new(1.0, if selected { t.accent } else { t.input_border }), egui::StrokeKind::Inside);
|
||||
let u = match dir {
|
||||
Direction::LeftToRight => vec2(1.0, 0.0),
|
||||
Direction::RightToLeft => vec2(-1.0, 0.0),
|
||||
Direction::TopToBottom => vec2(0.0, 1.0),
|
||||
Direction::BottomToTop => vec2(0.0, -1.0),
|
||||
};
|
||||
let (c, n) = (r.center(), vec2(-u.y, u.x));
|
||||
let (tail, tip_at) = (c - u * 7.0, c + u * 7.0);
|
||||
let stroke = Stroke::new(1.5, if selected { t.text_strong } else { t.text });
|
||||
p.line_segment([tail, tip_at], stroke);
|
||||
p.line_segment([tip_at, tip_at - u * 4.0 + n * 4.0], stroke);
|
||||
p.line_segment([tip_at, tip_at - u * 4.0 - n * 4.0], stroke);
|
||||
resp.on_hover_text(tip).clicked()
|
||||
}
|
||||
|
||||
fn pattern_body(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
widgets::label_row(ui, tl!("Scale:"), LABEL_W, |ui| number(ui, d, "scale", PatternBrush::SCALE_RANGE, "%", true));
|
||||
widgets::label_row(ui, tl!("Spacing:"), LABEL_W, |ui| number(ui, d, "spacing", PatternBrush::SPACING_RANGE, "%", true));
|
||||
ui.add_space(6.0);
|
||||
tiles(ui, d);
|
||||
ui.add_space(6.0);
|
||||
flips(ui, d);
|
||||
widgets::section_header(ui, tl!("Fit"));
|
||||
let fits = [("stretch", tl!("Stretch to fit")), ("addSpace", tl!("Add space to fit")), ("approximate", tl!("Approximate path"))];
|
||||
radios(ui, d, "fit", &fits);
|
||||
}
|
||||
|
||||
/// The pattern's five tiles, each with its art (or None when the brush has no such tile).
|
||||
fn tiles(ui: &mut egui::Ui, d: &Dialog) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let names = [tl!("Outer Corner Tile"), tl!("Side Tile"), tl!("Inner Corner Tile"), tl!("Start Tile"), tl!("End Tile")];
|
||||
let keys = d.fields.get("__tiles");
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 6.0;
|
||||
for ((field, slot), name) in TILES.into_iter().zip(names) {
|
||||
ui.vertical(|ui| {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(76.0, 44.0), Sense::hover());
|
||||
ui.painter().rect_filled(r, 0.0, Color32::WHITE);
|
||||
let key = keys.and_then(|k| k.get(slot)).and_then(Value::as_str);
|
||||
let tex = key.and_then(|key| {
|
||||
widgets::doc_preview(ui, key, r.size(), |w, h| {
|
||||
let art: Node = serde_json::from_value(d.fields.get("__def")?.get(field)?.clone()).ok()?;
|
||||
tile_doc(&art, w, h)
|
||||
})
|
||||
});
|
||||
match tex {
|
||||
Some(tex) => {
|
||||
ui.painter().image(tex.id(), r, egui::Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
None => {
|
||||
ui.painter().text(
|
||||
r.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
tl!("None"),
|
||||
egui::FontId::proportional(11.5),
|
||||
Color32::from_gray(140),
|
||||
);
|
||||
}
|
||||
}
|
||||
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside);
|
||||
resp.on_hover_text(name);
|
||||
ui.add_sized(vec2(76.0, 14.0), egui::Label::new(egui::RichText::new(name).size(10.5).color(t.text_dim)).truncate());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A document of `w` × `h` showing `art` scaled to fit, centred.
|
||||
fn tile_doc(art: &Node, w: f64, h: f64) -> Option<Document> {
|
||||
let b = art.visual_bounds().or_else(|| art.geometric_bounds())?;
|
||||
let k = ((w - 8.0) / b.width().max(1e-6)).min((h - 8.0) / b.height().max(1e-6));
|
||||
if !k.is_finite() || k <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let mut doc = Document::new(w, h);
|
||||
let mut n = doc.reid(art);
|
||||
let xf = vectorcraft_geom::Affine::translate((w / 2.0, h / 2.0))
|
||||
* vectorcraft_geom::Affine::scale(k)
|
||||
* vectorcraft_geom::Affine::translate(-b.center().to_vec2());
|
||||
n.transform(xf, true);
|
||||
let l = doc.layers.first()?.id;
|
||||
doc.insert(Some(l), 0, n).ok()?;
|
||||
Some(doc)
|
||||
}
|
||||
|
||||
fn colorization_body(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
widgets::section_header(ui, tl!("Colorization"));
|
||||
let methods = [tl!("None"), tl!("Tints"), tl!("Tints and Shades"), tl!("Hue Shift")];
|
||||
let cur = d.str("colorization");
|
||||
let at = Colorization::IDS.iter().position(|id| *id == cur).unwrap_or(0);
|
||||
widgets::label_row(ui, tl!("Method:"), LABEL_W, |ui| {
|
||||
if let Some(id) = widgets::dropdown_names(ui, "brush-colorization", methods.get(at).copied().unwrap_or_default(), &methods, 150.0)
|
||||
.and_then(|i| Colorization::IDS.get(i))
|
||||
{
|
||||
d.fields.insert("colorization".into(), json!(id));
|
||||
}
|
||||
});
|
||||
// Hue Shift's key colour: the art colour that becomes the stroke colour.
|
||||
let colors: Vec<Color> = d.fields.get("__colors").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();
|
||||
let hue_shift = cur == "hueShift";
|
||||
let key = key_color(d);
|
||||
widgets::label_row(ui, tl!("Key Color:"), LABEL_W, |ui| {
|
||||
ui.add_enabled_ui(hue_shift, |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 3.0;
|
||||
for c in colors {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click());
|
||||
widgets::swatch_tile(ui, r, &Paint::solid(c), hue_shift && c == key, resp.hovered());
|
||||
if resp.on_hover_text(c.to_hex()).clicked() {
|
||||
d.fields.insert("keyColor".into(), serde_json::to_value(c).unwrap_or_default());
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
//! 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.
|
||||
//! pressure). Bristle Brush Options: Name, Shape and the bristle sliders. Scatter, Art and Pattern
|
||||
//! Brush Options: see [`super::brush_art_options`]. 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`.
|
||||
//! Fields: `__brush` (the brush's name), `__type` (`calligraphic` | `bristle` | `scatter` | `art` |
|
||||
//! `pattern`), `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`…),
|
||||
@@ -12,10 +14,10 @@
|
||||
|
||||
use egui::{Color32, Sense, Stroke, pos2, vec2};
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_brush::{Brush, BrushKind, Calligraphic, Variation};
|
||||
use vectorcraft_brush::{Bristle, Brush, BrushKind, Calligraphic, Variation};
|
||||
|
||||
use super::swatch_options::{grid, label};
|
||||
use super::{DialogSpec, form};
|
||||
use super::{DialogSpec, brush_art_options, form};
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::Tokens;
|
||||
use crate::{VectorcraftApp, widgets};
|
||||
@@ -25,19 +27,16 @@ 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 sliders: (field, label, range), in percent.
|
||||
const BRISTLE: [(&str, &str, (f64, f64)); 5] = [
|
||||
("length", "Bristle Length:", Bristle::LENGTH_RANGE),
|
||||
("density", "Bristle Density:", Bristle::PERCENT_RANGE),
|
||||
("thickness", "Bristle Thickness:", Bristle::PERCENT_RANGE),
|
||||
("opacity", "Paint Opacity:", Bristle::PERCENT_RANGE),
|
||||
("stiffness", "Stiffness:", Bristle::PERCENT_RANGE),
|
||||
];
|
||||
|
||||
/// Bristle tip shapes: (value, label).
|
||||
@@ -64,11 +63,12 @@ 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(),
|
||||
"calligraphic" => tl!("Calligraphic Brush Options").into(),
|
||||
ty => brush_art_options::heading(ty).into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn mode_key(key: &str) -> String {
|
||||
pub(super) fn mode_key(key: &str) -> String {
|
||||
format!("{key}Mode")
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ pub fn open(app: &mut VectorcraftApp, name: Option<&str>) -> Result<Value, Strin
|
||||
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 b: Brush = serde_json::from_value(def.clone()).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));
|
||||
@@ -104,16 +104,17 @@ pub fn open(app: &mut VectorcraftApp, name: Option<&str>) -> Result<Value, Strin
|
||||
f.insert(key.into(), v.get(key).cloned().unwrap_or(Value::Null));
|
||||
}
|
||||
}
|
||||
_ => return Err("this kind of brush has no options dialog yet".into()),
|
||||
// The definition goes along, for the preview (and the art it carries).
|
||||
BrushKind::Scatter(_) | BrushKind::Art(_) | BrushKind::Pattern(_) => brush_art_options::fields(&b, def, &mut f),
|
||||
}
|
||||
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)?
|
||||
/// Can Brush Options open without a name (the selected path's brush, else the current one, exists)?
|
||||
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()))
|
||||
target(app).is_some_and(|n| vectorcraft_brush::find(&st.doc, &n).is_some())
|
||||
}
|
||||
|
||||
/// The brush Brush Options opens on without a name: the selected path's brush, else the current one.
|
||||
@@ -124,7 +125,7 @@ fn target(app: &VectorcraftApp) -> Option<String> {
|
||||
}
|
||||
|
||||
/// The value of field `key` within `range`.
|
||||
fn clamped(d: &Dialog, key: &str, (lo, hi): (f64, f64)) -> f64 {
|
||||
pub(super) fn clamped(d: &Dialog, key: &str, (lo, hi): (f64, f64)) -> f64 {
|
||||
d.f64(key, lo).clamp(lo, hi)
|
||||
}
|
||||
|
||||
@@ -141,13 +142,14 @@ fn calligraphic(d: &Dialog) -> Calligraphic {
|
||||
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)));
|
||||
let mut p = json!({ "shape": d.str("shape"), "size": clamped(d, "size", Bristle::SIZE_RANGE) });
|
||||
for (key, _, range) in BRISTLE {
|
||||
p[key] = json!(clamped(d, key, range));
|
||||
}
|
||||
p
|
||||
}
|
||||
_ => serde_json::to_value(calligraphic(d)).unwrap_or_default(),
|
||||
"calligraphic" => serde_json::to_value(calligraphic(d)).unwrap_or_default(),
|
||||
_ => brush_art_options::params(d),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +162,8 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
ui.add_space(10.0);
|
||||
match d.str("__type").as_str() {
|
||||
"bristle" => bristle_body(app, ui, d),
|
||||
_ => calligraphic_body(ui, d),
|
||||
"calligraphic" => calligraphic_body(ui, d),
|
||||
_ => brush_art_options::body(ui, d),
|
||||
}
|
||||
false
|
||||
}
|
||||
@@ -203,7 +206,7 @@ fn bristle_body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
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 {
|
||||
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);
|
||||
@@ -212,7 +215,7 @@ fn bristle_body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
pub(super) 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());
|
||||
@@ -325,7 +328,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bristle_options_and_brushes_without_a_dialog() {
|
||||
fn bristle_options_and_an_unknown_brush() {
|
||||
let mut app = app();
|
||||
app.run("ui.brushOptions", json!({"name": "Bristle Round"})).unwrap();
|
||||
let text = shown(&mut app);
|
||||
@@ -336,9 +339,158 @@ mod tests {
|
||||
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());
|
||||
// No such brush: an error, nothing opens.
|
||||
app.ui.dialog = None;
|
||||
assert!(app.run("ui.brushOptions", json!({"name": "Nope"})).is_err());
|
||||
assert!(app.ui.dialog.is_none());
|
||||
}
|
||||
|
||||
/// A path painted with brush `name`, and the height of the art its stroke draws.
|
||||
fn brushed(app: &mut VectorcraftApp, name: &str) -> (vectorcraft_doc::NodeId, f64) {
|
||||
let id = app.run("shape.line", json!({"x1": 20, "y1": 100, "x2": 280, "y2": 100})).unwrap()["id"].as_u64().unwrap();
|
||||
let id = vectorcraft_doc::NodeId(id);
|
||||
app.run("brush.apply", json!({ "name": name, "ids": [id.0] })).unwrap();
|
||||
(id, drawn_height(app, id))
|
||||
}
|
||||
|
||||
fn drawn_height(app: &VectorcraftApp, id: vectorcraft_doc::NodeId) -> f64 {
|
||||
let st = app.session.active().unwrap();
|
||||
let pieces = vectorcraft_brush::node_pieces(&st.doc, st.doc.node(id).unwrap()).unwrap();
|
||||
vectorcraft_brush::pieces_bounds(&pieces).unwrap().height()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scatter_options_show_each_value_with_its_mode_and_ok_applies_them() {
|
||||
let mut app = app();
|
||||
let (id, before) = brushed(&mut app, "Confetti");
|
||||
app.run("ui.brushOptions", json!({"name": "Confetti"})).unwrap();
|
||||
let text = shown(&mut app);
|
||||
for label in [
|
||||
"Scatter Brush Options",
|
||||
"Name:",
|
||||
"Size:",
|
||||
"Spacing:",
|
||||
"Scatter:",
|
||||
"Rotation:",
|
||||
"Random",
|
||||
"Rotation relative to:",
|
||||
"Page",
|
||||
"Colorization",
|
||||
"Method:",
|
||||
"Tints",
|
||||
"Key Color:",
|
||||
] {
|
||||
assert!(text.contains(label), "{label} in {text}");
|
||||
}
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!((d.f64("sizeMin", 0.0), d.f64("sizeMax", 0.0), d.str("sizeMode")), (60.0, 140.0, "random".into()));
|
||||
assert_eq!((d.str("rotationRelativeTo"), d.str("colorization")), ("page".into(), "tints".into()));
|
||||
// Values are kept within their ranges (rotation here); Fixed takes the first value.
|
||||
set(
|
||||
&mut app,
|
||||
json!({"sizeMode": "fixed", "sizeMin": 5000, "spacingMode": "pressure", "rotationMin": -400, "rotationRelativeTo": "path", "colorization": "hueShift"}),
|
||||
);
|
||||
assert!(shown(&mut app).contains("Hue Shift"));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
let def = app.run("brush.get", json!({"name": "Confetti"})).unwrap();
|
||||
assert_eq!(
|
||||
(&def["size"], &def["rotation"][0], &def["modes"]),
|
||||
(&json!([5000.0, 140.0]), &json!(-180.0), &json!(["fixed", "pressure", "random", "random"]))
|
||||
);
|
||||
assert_eq!((&def["rotation_relative_to_path"], &def["colorization"]["method"]), (&json!(true), &json!("hueShift")));
|
||||
assert!(def["colorization"]["key"].is_object(), "the art's colour as the key: {def}");
|
||||
assert!(drawn_height(&app, id) > before, "the stroke redraws with the bigger confetti");
|
||||
// Opened again, it shows what was set.
|
||||
app.run("ui.brushOptions", json!({"name": "Confetti"})).unwrap();
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!((d.str("sizeMode"), d.str("spacingMode"), d.str("colorization")), ("fixed".into(), "pressure".into(), "hueShift".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_options_set_width_scale_direction_flips_and_colorization() {
|
||||
let mut app = app();
|
||||
let (id, before) = brushed(&mut app, "Arrow");
|
||||
app.run("ui.brushOptions", json!({"name": "Arrow"})).unwrap();
|
||||
let text = shown(&mut app);
|
||||
for label in [
|
||||
"Art Brush Options",
|
||||
"Width:",
|
||||
"Brush Scale Options",
|
||||
"Scale Proportionately",
|
||||
"Stretch to Fit Stroke Length",
|
||||
"Stretch Between Guides",
|
||||
"Start:",
|
||||
"End:",
|
||||
"Direction:",
|
||||
"Flip Along",
|
||||
"Flip Across",
|
||||
"Colorization",
|
||||
] {
|
||||
assert!(text.contains(label), "{label} in {text}");
|
||||
}
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
(d.f64("width", 0.0), d.str("scaleMode"), d.f64("guideEnd", 0.0), d.str("direction")),
|
||||
(100.0, "betweenGuides".into(), 78.0, "leftToRight".into())
|
||||
);
|
||||
set(
|
||||
&mut app,
|
||||
json!({"width": 250, "guideStart": 10, "guideEnd": 140, "direction": "bottomToTop", "flipAcross": true, "colorization": "none"}),
|
||||
);
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
let def = app.run("brush.get", json!({"name": "Arrow"})).unwrap();
|
||||
assert_eq!(
|
||||
(&def["width"], &def["scale"], &def["direction"]),
|
||||
(&json!(250.0), &json!({"mode": "betweenGuides", "start": 0.1, "end": 1.0}), &json!("bottomToTop"))
|
||||
);
|
||||
assert_eq!((&def["flip_across"], &def["flip_along"], &def["colorization"]), (&json!(true), &json!(false), &json!({"method": "none"})));
|
||||
assert!(drawn_height(&app, id) != before, "the stroke redraws");
|
||||
// Stretch to Fit drops the guides.
|
||||
app.run("ui.brushOptions", json!({"name": "Arrow"})).unwrap();
|
||||
set(&mut app, json!({"scaleMode": "stretch"}));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!(app.run("brush.get", json!({"name": "Arrow"})).unwrap()["scale"], json!({"mode": "stretch"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_options_show_the_tiles_and_set_scale_spacing_fit() {
|
||||
let mut app = app();
|
||||
app.run("ui.brushOptions", json!({"name": "Chain"})).unwrap();
|
||||
let text = shown(&mut app);
|
||||
for label in [
|
||||
"Pattern Brush Options",
|
||||
"Scale:",
|
||||
"Spacing:",
|
||||
"Outer Corner Tile",
|
||||
"Side Tile",
|
||||
"Inner Corner Tile",
|
||||
"Start Tile",
|
||||
"End Tile",
|
||||
"None",
|
||||
"Fit",
|
||||
"Stretch to fit",
|
||||
"Add space to fit",
|
||||
"Approximate path",
|
||||
"Flip Along",
|
||||
] {
|
||||
assert!(text.contains(label), "{label} in {text}");
|
||||
}
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
let mut tiles: Vec<&String> = d.fields["__tiles"].as_object().unwrap().keys().collect();
|
||||
tiles.sort();
|
||||
assert_eq!(tiles, ["innerCorner", "outerCorner", "side"], "Chain has side and corner tiles, no start or end");
|
||||
// Diamonds (straight-edged tiles, so sizes compare exactly).
|
||||
let (id, before) = brushed(&mut app, "Diamonds");
|
||||
app.run("ui.brushOptions", json!({"name": "Diamonds"})).unwrap();
|
||||
assert_eq!((app.ui.dialog.as_ref().unwrap().f64("spacing", 0.0), app.ui.dialog.as_ref().unwrap().str("fit")), (25.0, "addSpace".into()));
|
||||
set(&mut app, json!({"scale": 200, "spacing": 30, "fit": "approximate", "flipAlong": true}));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
let def = app.run("brush.get", json!({"name": "Diamonds"})).unwrap();
|
||||
assert_eq!(
|
||||
(&def["scale"], &def["spacing"], &def["fit"], &def["flip_along"]),
|
||||
(&json!(200.0), &json!(30.0), &json!("approximate"), &json!(true))
|
||||
);
|
||||
assert!((drawn_height(&app, id) / before - 2.0).abs() < 1e-6, "tiles twice the size");
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ mod about;
|
||||
mod all_tools;
|
||||
pub mod artboard_options;
|
||||
pub mod blend_options;
|
||||
mod brush_art_options;
|
||||
pub mod brush_options;
|
||||
pub mod color_balance;
|
||||
pub mod color_guide_options;
|
||||
|
||||
@@ -1588,6 +1588,37 @@
|
||||
New Bristle Brush Nuevo pincel de cerdas
|
||||
Calligraphic Brush Options Opciones de pincel caligráfico
|
||||
Bristle Brush Options Opciones de pincel de cerdas
|
||||
Scatter Brush Options Opciones de pincel de dispersión
|
||||
Art Brush Options Opciones de pincel de arte
|
||||
Pattern Brush Options Opciones de pincel de motivo
|
||||
Scatter: Dispersión:
|
||||
Rotation: Rotación:
|
||||
Rotation relative to: Rotación relativa a:
|
||||
Brush Scale Options Opciones de escala del pincel
|
||||
Scale Proportionately Escalar proporcionalmente
|
||||
Stretch to Fit Stroke Length Estirar para ajustar a la longitud del trazo
|
||||
Stretch Between Guides Estirar entre guías
|
||||
Start: Inicio:
|
||||
End: Final:
|
||||
Left to Right De izquierda a derecha
|
||||
Right to Left De derecha a izquierda
|
||||
Top to Bottom De arriba abajo
|
||||
Bottom to Top De abajo arriba
|
||||
Flip: Voltear:
|
||||
Outer Corner Tile Mosaico de esquina exterior
|
||||
Side Tile Mosaico lateral
|
||||
Inner Corner Tile Mosaico de esquina interior
|
||||
Start Tile Mosaico inicial
|
||||
End Tile Mosaico final
|
||||
Fit Ajuste
|
||||
Stretch to fit Estirar para ajustar
|
||||
Add space to fit Agregar espacio para ajustar
|
||||
Approximate path Aproximar trazado
|
||||
Colorization Coloración
|
||||
Tints Matices
|
||||
Tints and Shades Matices y sombras
|
||||
Hue Shift Cambio de tono
|
||||
Key Color: Color clave:
|
||||
Roundness: Redondez:
|
||||
Fixed Fijo
|
||||
Pressure Presión
|
||||
@@ -3502,7 +3533,6 @@ axis Both Ambos
|
||||
@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.
|
@@ -1585,6 +1585,37 @@
|
||||
New Bristle Brush Nouvelle forme Soies
|
||||
Calligraphic Brush Options Options de forme calligraphique
|
||||
Bristle Brush Options Options de forme Soies
|
||||
Scatter Brush Options Options de forme de diffusion
|
||||
Art Brush Options Options de forme artistique
|
||||
Pattern Brush Options Options de forme de motif
|
||||
Scatter: Dispersion :
|
||||
Rotation: Rotation :
|
||||
Rotation relative to: Rotation par rapport à :
|
||||
Brush Scale Options Options de mise à l'échelle de la forme
|
||||
Scale Proportionately Mettre à l'échelle proportionnellement
|
||||
Stretch to Fit Stroke Length Étirer selon la longueur du contour
|
||||
Stretch Between Guides Étirer entre les repères
|
||||
Start: Début :
|
||||
End: Fin :
|
||||
Left to Right De gauche à droite
|
||||
Right to Left De droite à gauche
|
||||
Top to Bottom De haut en bas
|
||||
Bottom to Top De bas en haut
|
||||
Flip: Retourner :
|
||||
Outer Corner Tile Carreau d'angle extérieur
|
||||
Side Tile Carreau latéral
|
||||
Inner Corner Tile Carreau d'angle intérieur
|
||||
Start Tile Carreau de début
|
||||
End Tile Carreau de fin
|
||||
Fit Ajustement
|
||||
Stretch to fit Étirer pour ajuster
|
||||
Add space to fit Ajouter de l'espace pour ajuster
|
||||
Approximate path Tracé approximatif
|
||||
Colorization Colorisation
|
||||
Tints Teintes
|
||||
Tints and Shades Teintes et ombres
|
||||
Hue Shift Décalage de teinte
|
||||
Key Color: Couleur clé :
|
||||
Roundness: Arrondi :
|
||||
Fixed Fixe
|
||||
Pressure Pression
|
||||
@@ -3499,7 +3530,6 @@ axis Both Les deux
|
||||
@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.
|
@@ -1588,6 +1588,37 @@
|
||||
New Bristle Brush Nuovo pennello a setole
|
||||
Calligraphic Brush Options Opzioni pennello calligrafico
|
||||
Bristle Brush Options Opzioni pennello a setole
|
||||
Scatter Brush Options Opzioni pennello a dispersione
|
||||
Art Brush Options Opzioni pennello artistico
|
||||
Pattern Brush Options Opzioni pennello motivo
|
||||
Scatter: Dispersione:
|
||||
Rotation: Rotazione:
|
||||
Rotation relative to: Rotazione relativa a:
|
||||
Brush Scale Options Opzioni di scala del pennello
|
||||
Scale Proportionately Ridimensiona proporzionalmente
|
||||
Stretch to Fit Stroke Length Allunga per adattare alla lunghezza del tratto
|
||||
Stretch Between Guides Allunga tra le guide
|
||||
Start: Inizio:
|
||||
End: Fine:
|
||||
Left to Right Da sinistra a destra
|
||||
Right to Left Da destra a sinistra
|
||||
Top to Bottom Dall'alto in basso
|
||||
Bottom to Top Dal basso in alto
|
||||
Flip: Capovolgi:
|
||||
Outer Corner Tile Tassello angolo esterno
|
||||
Side Tile Tassello laterale
|
||||
Inner Corner Tile Tassello angolo interno
|
||||
Start Tile Tassello iniziale
|
||||
End Tile Tassello finale
|
||||
Fit Adattamento
|
||||
Stretch to fit Allunga per adattare
|
||||
Add space to fit Aggiungi spazio per adattare
|
||||
Approximate path Approssima tracciato
|
||||
Colorization Colorazione
|
||||
Tints Tinte
|
||||
Tints and Shades Tinte e ombre
|
||||
Hue Shift Variazione tonalità
|
||||
Key Color: Colore chiave:
|
||||
Roundness: Rotondità:
|
||||
Fixed Fisso
|
||||
Pressure Pressione
|
||||
@@ -3502,7 +3533,6 @@ axis Both Entrambi
|
||||
@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.
|
@@ -1681,6 +1681,37 @@
|
||||
New Bristle Brush 新規剛毛ブラシ
|
||||
Calligraphic Brush Options 絵筆ブラシオプション
|
||||
Bristle Brush Options 剛毛ブラシオプション
|
||||
Scatter Brush Options 散布ブラシオプション
|
||||
Art Brush Options アートブラシオプション
|
||||
Pattern Brush Options パターンブラシオプション
|
||||
Scatter: 散布:
|
||||
Rotation: 回転:
|
||||
Rotation relative to: 回転の基準:
|
||||
Brush Scale Options ブラシ伸縮オプション
|
||||
Scale Proportionately 縦横比を保持して拡大・縮小
|
||||
Stretch to Fit Stroke Length ストロークの長さに合わせて伸縮
|
||||
Stretch Between Guides ガイド間で伸縮
|
||||
Start: 始点:
|
||||
End: 終点:
|
||||
Left to Right 左から右
|
||||
Right to Left 右から左
|
||||
Top to Bottom 上から下
|
||||
Bottom to Top 下から上
|
||||
Flip: 反転:
|
||||
Outer Corner Tile 外角タイル
|
||||
Side Tile サイドタイル
|
||||
Inner Corner Tile 内角タイル
|
||||
Start Tile 始点タイル
|
||||
End Tile 終点タイル
|
||||
Fit 調整
|
||||
Stretch to fit 伸縮して合わせる
|
||||
Add space to fit 間隔を空けて合わせる
|
||||
Approximate path パスに近似
|
||||
Colorization 着色
|
||||
Tints 淡彩
|
||||
Tints and Shades 淡彩と陰影
|
||||
Hue Shift 色相シフト
|
||||
Key Color: キーカラー:
|
||||
Roundness: 真円率:
|
||||
Fixed 固定
|
||||
Pressure 筆圧
|
||||
@@ -3317,7 +3348,6 @@ theme Appearance 外観
|
||||
@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.
|
@@ -1584,6 +1584,37 @@
|
||||
New Bristle Brush Новая щетинная кисть
|
||||
Calligraphic Brush Options Параметры каллиграфической кисти
|
||||
Bristle Brush Options Параметры щетинной кисти
|
||||
Scatter Brush Options Параметры кисти-распыления
|
||||
Art Brush Options Параметры художественной кисти
|
||||
Pattern Brush Options Параметры узорной кисти
|
||||
Scatter: Рассеивание:
|
||||
Rotation: Поворот:
|
||||
Rotation relative to: Поворот относительно:
|
||||
Brush Scale Options Параметры масштабирования кисти
|
||||
Scale Proportionately Масштабировать пропорционально
|
||||
Stretch to Fit Stroke Length Растянуть по длине обводки
|
||||
Stretch Between Guides Растянуть между направляющими
|
||||
Start: Начало:
|
||||
End: Конец:
|
||||
Left to Right Слева направо
|
||||
Right to Left Справа налево
|
||||
Top to Bottom Сверху вниз
|
||||
Bottom to Top Снизу вверх
|
||||
Flip: Отразить:
|
||||
Outer Corner Tile Фрагмент внешнего угла
|
||||
Side Tile Боковой фрагмент
|
||||
Inner Corner Tile Фрагмент внутреннего угла
|
||||
Start Tile Начальный фрагмент
|
||||
End Tile Конечный фрагмент
|
||||
Fit Подгонка
|
||||
Stretch to fit Растянуть для подгонки
|
||||
Add space to fit Добавить интервалы для подгонки
|
||||
Approximate path Приблизить к контуру
|
||||
Colorization Перекрашивание
|
||||
Tints Оттенки
|
||||
Tints and Shades Оттенки и тени
|
||||
Hue Shift Сдвиг тона
|
||||
Key Color: Ключевой цвет:
|
||||
Roundness: Округлость:
|
||||
Fixed Фиксированный
|
||||
Pressure Нажим
|
||||
@@ -3498,7 +3529,6 @@ axis Both Оба
|
||||
@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.
|
@@ -1580,6 +1580,37 @@
|
||||
New Bristle Brush Новий щетинний пензель
|
||||
Calligraphic Brush Options Параметри каліграфічного пензля
|
||||
Bristle Brush Options Параметри щетинного пензля
|
||||
Scatter Brush Options Параметри пензля-розпорошення
|
||||
Art Brush Options Параметри художнього пензля
|
||||
Pattern Brush Options Параметри візерункового пензля
|
||||
Scatter: Розсіювання:
|
||||
Rotation: Поворот:
|
||||
Rotation relative to: Поворот відносно:
|
||||
Brush Scale Options Параметри масштабування пензля
|
||||
Scale Proportionately Масштабувати пропорційно
|
||||
Stretch to Fit Stroke Length Розтягнути за довжиною обведення
|
||||
Stretch Between Guides Розтягнути між напрямними
|
||||
Start: Початок:
|
||||
End: Кінець:
|
||||
Left to Right Зліва направо
|
||||
Right to Left Справа наліво
|
||||
Top to Bottom Згори вниз
|
||||
Bottom to Top Знизу вгору
|
||||
Flip: Відобразити:
|
||||
Outer Corner Tile Фрагмент зовнішнього кута
|
||||
Side Tile Бічний фрагмент
|
||||
Inner Corner Tile Фрагмент внутрішнього кута
|
||||
Start Tile Початковий фрагмент
|
||||
End Tile Кінцевий фрагмент
|
||||
Fit Припасування
|
||||
Stretch to fit Розтягнути для припасування
|
||||
Add space to fit Додати інтервали для припасування
|
||||
Approximate path Наблизити до контуру
|
||||
Colorization Перефарбування
|
||||
Tints Відтінки
|
||||
Tints and Shades Відтінки й тіні
|
||||
Hue Shift Зсув тону
|
||||
Key Color: Ключовий колір:
|
||||
Roundness: Округлість:
|
||||
Fixed Фіксований
|
||||
Pressure Натиск
|
||||
@@ -3485,7 +3516,6 @@ axis Both Обидві
|
||||
@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 659 and column 40.
|
@@ -1519,6 +1519,34 @@
|
||||
New Bristle Brush 新建鬃毛画笔
|
||||
Calligraphic Brush Options 书法画笔选项
|
||||
Bristle Brush Options 鬃毛画笔选项
|
||||
Scatter Brush Options 散布画笔选项
|
||||
Art Brush Options 艺术画笔选项
|
||||
Pattern Brush Options 图案画笔选项
|
||||
Scatter: 散布:
|
||||
Rotation: 旋转:
|
||||
Rotation relative to: 旋转相对于:
|
||||
Brush Scale Options 画笔缩放选项
|
||||
Scale Proportionately 等比缩放
|
||||
Stretch to Fit Stroke Length 伸展以适合描边长度
|
||||
Stretch Between Guides 在参考线之间伸展
|
||||
Start: 起点:
|
||||
End: 终点:
|
||||
Left to Right 从左到右
|
||||
Right to Left 从右到左
|
||||
Top to Bottom 从上到下
|
||||
Bottom to Top 从下到上
|
||||
Flip: 翻转:
|
||||
Outer Corner Tile 外角拼贴
|
||||
Side Tile 边线拼贴
|
||||
Inner Corner Tile 内角拼贴
|
||||
Start Tile 起点拼贴
|
||||
End Tile 终点拼贴
|
||||
Fit 适合
|
||||
Stretch to fit 伸展以适合
|
||||
Add space to fit 添加间距以适合
|
||||
Approximate path 近似路径
|
||||
Colorization 着色
|
||||
Key Color: 主色:
|
||||
Roundness: 圆度:
|
||||
Fixed 固定
|
||||
Pressure 压力
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1563,6 +1563,37 @@
|
||||
New Bristle Brush 新增鬃毛筆刷
|
||||
Calligraphic Brush Options 書法筆刷選項
|
||||
Bristle Brush Options 鬃毛筆刷選項
|
||||
Scatter Brush Options 散佈筆刷選項
|
||||
Art Brush Options 藝術筆刷選項
|
||||
Pattern Brush Options 圖樣筆刷選項
|
||||
Scatter: 散佈:
|
||||
Rotation: 旋轉:
|
||||
Rotation relative to: 旋轉相對於:
|
||||
Brush Scale Options 筆刷縮放選項
|
||||
Scale Proportionately 等比例縮放
|
||||
Stretch to Fit Stroke Length 延伸以符合筆畫長度
|
||||
Stretch Between Guides 在參考線之間延伸
|
||||
Start: 起點:
|
||||
End: 終點:
|
||||
Left to Right 由左至右
|
||||
Right to Left 由右至左
|
||||
Top to Bottom 由上而下
|
||||
Bottom to Top 由下而上
|
||||
Flip: 翻轉:
|
||||
Outer Corner Tile 外角拼貼
|
||||
Side Tile 邊線拼貼
|
||||
Inner Corner Tile 內角拼貼
|
||||
Start Tile 起點拼貼
|
||||
End Tile 終點拼貼
|
||||
Fit 符合
|
||||
Stretch to fit 延伸以符合
|
||||
Add space to fit 增加間距以符合
|
||||
Approximate path 近似路徑
|
||||
Colorization 上色
|
||||
Tints 淺色
|
||||
Tints and Shades 淺色與暗色
|
||||
Hue Shift 色相偏移
|
||||
Key Color: 主要顏色:
|
||||
Roundness: 圓度:
|
||||
Fixed 固定
|
||||
Pressure 壓力
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -346,7 +346,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"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",
|
||||
"{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 (dialog `brushOptions`): Calligraphic (fields name, angle −180..180, roundness 0..100, size 0..1296 pt, angleMode/roundnessMode/sizeMode: fixed|random|pressure, angleVariation/roundnessVariation/sizeVariation), Bristle (fields name, shape, size, length, density, thickness, opacity, stiffness), Scatter (fields sizeMin/sizeMax/sizeMode, spacingMin/spacingMax/spacingMode, scatterMin/scatterMax/scatterMode, rotationMin/rotationMax/rotationMode with modes fixed|random|pressure, rotationRelativeTo: page|path), Art (fields width %, scaleMode: proportional|stretch|betweenGuides, guideStart/guideEnd % of the art length, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flipAlong, flipAcross) or Pattern (fields scale %, spacing %, fit: stretch|addSpace|approximate, flipAlong, flipAcross); Scatter, Art and Pattern also colorization: none|tints|tintsAndShades|hueShift and keyColor; OK runs brush.options",
|
||||
),
|
||||
(
|
||||
"ui.graphicStyleOptions",
|
||||
|
||||
@@ -89,7 +89,6 @@ 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.
|
||||
@@ -121,7 +120,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
};
|
||||
// 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 {
|
||||
if resp.double_clicked() {
|
||||
options = Some(name.clone());
|
||||
} else if resp.clicked() {
|
||||
clicked = Some(name.clone());
|
||||
|
||||
Reference in new issue
Block a user