Compare commits

...
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 258d61ced0 #968: centre the macOS window buttons on the app bar
AppKit places close, minimize and zoom in a standard title bar, shorter
than the 44-point app bar they sit over, so they rode high on it. They
are now moved to the middle of the bar, inset from its left as far as
from its top, with the title bar container made as tall as the bar so
they stay clickable. AppKit lays the title bar out again on resize and
when leaving full screen, so the placement runs every frame and sets a
frame only when it is out of place. The app bar keeps the room the
buttons take, measured, instead of a fixed 78 points.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:56:18 +07:00
BFlat 5ff1b97376 Merge pull request #970 from storytold/fix/962-inner-glow-source
#962: Inner Glow's Source is a Center/Edge dropdown
2026-10-10 20:45:36 +07:00
bflatasticandClaude Opus 5.5 9216c47204 #962: Inner Glow's Source is a Center/Edge dropdown
The effect dialog showed Inner Glow's `source` as a text field, so the
only way to pick Center was to type the parameter value. It now lists
Center and Edge like the other fields that pick one of a few values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:32:47 +07:00
BFlat 4eaf5e7b50 Merge pull request #967 from storytold/feat/link-watch
Links: Update Links applies while a document is open (from #956)
2026-10-10 20:20:24 +07:00
bflatasticandClaude Opus 5.5 068a934f6a Links: one watch for changed linked files
The link watch checks the active document's linked files every two
seconds and as the window comes back to the front, so the separate check
on focus that #925 added (missing_links::after_focus and its asked-files
set) goes: with both, a change was acted on twice. The watch also
walks the document's links only when a look is due, not every frame, and
wakes the app every two seconds only while the document has links. The
doc comments of status and links::groups are back on their functions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:19:27 +07:00
KattandClaude Opus 5.5 079580257a Links: Update Links applies while a document is open
Linked files another app changes while their document is open now follow
Preferences > File Handling > Update Links, as they do when it opens:
Automatically reads them again (an Update Links undo step), Ask When
Modified offers to (a question worded for files changed on disk while
working), Manually leaves them modified in the Links panel.

The desktop app stamps the active document's linked files (size +
modification time) on a worker thread every two seconds and when its
window comes back to the front; no look starts while a dialog is open.
Stamps are kept per document; a change is acted on once; a file still
being written is tried again. links.updateChanged reads changed files
again regardless of the preference (CLI, MCP).

New strings translated in all ten catalogs; ROADMAP updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:19:27 +07:00
BFlat f81c0f51d7 Merge pull request #959 from IHAGI-c/fix/graph-combine-types
Graph Type: combine graph types, one type per series
2026-10-10 20:09:10 +07:00
IHAGI-c 01a65c44ab Graph Type: combine graph types, one type per series
Graph Type on series picked with Group Selection (or listed in the new
`seriesIndexes` param) now sets the type of those series only, so a
column graph can carry a line series:
https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/combine-different-graph-types.html

- Column, stacked column, line and area mix on the vertical value axis;
  bar and stacked bar on the horizontal one. Scatter, pie and radar take
  no other type. The help page doesn't list which types mix, so this is
  our own choice: types that share the value axis.
- Stacked columns take one slot of the column cluster (a graph of
  stacked columns only is unchanged); stacked series stack with each
  other, areas with each other, and the value axis covers both totals.
- Areas draw at the back, lines in front of columns. Category labels,
  areas and lines sit at the category centres once a series that isn't
  edge to edge is in the graph.
- With only series selected, the Graph Type query returns their type,
  so the dialog's OK keeps it, and they stay selected after the change.
  An explicit `id`, or anything else in the selection, is the whole
  graph, as before.
- A series given the graph's own type follows the graph again. A type
  the graph's type can't take is drawn as the graph's and kept.

Series types are stored in a new `seriesKinds` field, written only when
a series has its own type; versions that don't know it draw the whole
graph as the graph's type. Graphs of one type draw the same art as
before.
2026-10-10 20:08:33 +07:00
BFlat 11c3a99bfb Merge pull request #960 from storytold/feat/texture-effects
Part of gap 2: Effect › Texture (Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer)
2026-10-10 19:44:02 +07:00
bflatasticandClaude Opus 5.5 30ba774e6f Part of gap 2: Effect › Texture (Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer)
Six raster effects on the pixel-filter pipeline
(vectorcraft_effects::pixel::texture), under a new Effect › Texture
submenu between Sharpen and Video in the Raster Effects section:

- Craquelure (crackSpacing pt 2..100, default 15; crackDepth 0..10, 6;
  crackBrightness 0..10, 9): the object on relief plaster that cracks into
  plates about Crack Spacing apart (a Voronoi network over jittered points)
  and along the quarter, half and three-quarter contours of its softened
  tones. Depth sets how dark and wide the cracks are and how high the
  plates' bevels and the tones stand (lit from the top left); Brightness
  how brightly the surface is lit.
- Grain (intensity 0..100, 40; contrast 0..100, 50 = unchanged; grainType
  Regular, Soft, Sprinkles, Clumped, Contrasty, Enlarged, Stippled,
  Horizontal, Vertical, Speckle; default Regular): coloured 1-point grains,
  smooth ones, clumps, 2-point grains, streaks along rows or columns, dark
  specks; Sprinkles and Stippled use the background colour, white.
- Mosaic Tiles (tileSize pt 2..100, 12; groutWidth 1..15, 3; lightenGrout
  0..10, 9): irregular tiles bevelled at their edges, with sunken grout half
  a point wide per step, lightened as Lighten Grout says.
- Patchwork (squareSize pt 0..10, 4; relief 0..25, 8): squares filled with
  the colour around their centres, raised to heights that follow the
  highlights and shadows, a little more or less at random.
- Stained Glass (cellSize pt 2..50, 10; borderThickness 1..20, 4;
  lightIntensity 0..10, 3): single-coloured panes leaded in the foreground
  colour (black, half a point per step), lit from behind at the object's
  centre and fading out towards its corners (PixelSpace gains `extent`).
- Texturizer (texture Brick, Burlap, Canvas, Sandstone, default Canvas;
  scaling % 50..200, 100; relief 0..50, 4; lightDirection Bottom, Bottom
  Left, Left, Top Left, Top, Top Right, Right, Bottom Right, default Top;
  invert): the object on a surface in relief. The four surfaces are height
  fields generated in code (bricks in stretcher bond, plain weaves coarse
  and fine, octaves of smooth noise); no image files. Load Texture is left
  out.

Every pattern lies in document space around the object's centre, so the
result is the same at any zoom or raster resolution, moves with the object
and is identical on every redraw (hash noise of cell indices); slopes are
taken in document units. Work per pixel is bounded (a 5 x 5 cell search,
four or five height samples, box blurs); parameters are clamped to their
ranges and finite-checked. The Pixelate helpers (Place, the cell search,
the softened colours) are shared, Mezzotint uses the shared smooth noise,
and the effect dialogs' dropdowns translate their labels in an "effect"
context where the plain word means something else (Soft, Horizontal and
Vertical are type settings in Japanese and Chinese).

Catalogue rows with parameter docs, outsets and reaches, menu entries,
dropdowns (Grain Type, Texture, Light Direction), translations, tests
(parameters and clamping, cracks and contours, ten distinct grains, tiles
and grout, squares in relief, leaded panes and the centre light, the
textures lit and inverted, the same at twice the resolution and when
moved, canvas, multithreaded, PDF and SVG, the command sweep with junk
parameters, the dialogs and the menu) and ROADMAP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:31:47 +07:00
BFlat 32a688d68b Merge pull request #953 from crqch/fix-contributor-links
Fix contributor links
2026-10-10 18:50:11 +07:00
BFlat 5d07d646a9 Merge pull request #950 from seldoms/i18n/chinese-translations
i18n: wrap hardcoded UI strings for Chinese localization
2026-10-10 18:40:44 +07:00
bflatasticandClaude Opus 5.5 22443d8711 Keep CJK UI fallback fonts to ui_fonts
The app already finds an installed font for CJK (and other) characters
the UI fonts lack, the first time a frame paints one (ui_fonts): reading
a system CJK font at every startup duplicated that, cost a font file's
read and memory for interfaces that never show CJK, and broke its test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:36:15 +07:00
Michał Kosior crqch a0cc4a4b94 Fix contributor links 2026-10-10 13:35:36 +02:00
seldoms f0349fb2f8 i18n: wrap hardcoded UI strings for Chinese localization
- Wrap remaining hardcoded UI labels and buttons with tr()/tl!() calls
- Add missing Simplified and Traditional Chinese translations
- Add entries to other language catalogs for consistency
2026-10-10 18:27:56 +07:00
BFlat d428de6caa Merge pull request #952 from storytold/fix/949-artboard-label-width
Fix #949: artboard names fit their artboard when zoomed out
2026-10-10 18:25:02 +07:00
bflatasticandClaude Opus 5.5 6a13088840 Fix #949: artboard names fit their artboard when zoomed out
Artboard names are drawn at a fixed screen size above each artboard, so
zoomed out, where artboards shrink on screen, the names ran past them
and over their neighbours. A name is now no wider than its artboard on
screen, ending in an ellipsis when longer, and an artboard narrower
than 24 px shows none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:24:50 +07:00
BFlat 18c9d27c8d Merge pull request #947 from IHAGI-c/fix/graph-blank-values
Graph Data: blank cells are left out, not drawn as zero; quoted numbers are labels
2026-10-10 18:16:26 +07:00
BFlat 3b32ea9626 Merge pull request #946 from rschuerer/fix/861-draw-non-native-art
Part of #861: draw non-native art in .ai editing data from its PDF
2026-10-10 18:13:52 +07:00
bflatasticandClaude Opus 5.5 42b198e3cb Graph data: constants after the imports
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:59:47 +07:00
IHAGI-c 35c4a428ff Graph Data: blank cells are left out, not drawn as zero; quoted numbers are labels
A graph's data can now hold blank cells: an empty CSV cell, a null in
`rows` (any cell that isn't a finite number), the rest of a short row,
or a row that has only a label.

- Columns, bars and stacked segments leave a blank cell out instead of
  drawing a zero-height mark.
- Line and scatter graphs break their lines around it and draw no
  marker there; the value axis ignores it.
- Area, pie and radar graphs draw it as 0, which is what it adds to
  them.

The help pages don't describe blank cells, so this is our own choice:
a cell with no value has nothing to draw, which also matches how a
series with a missing year reads in a report.

In the file a blank cell keeps a 0 in `rows` and is listed in a new
`blanks` field ([category, series] pairs, written only when there are
any). A version that doesn't know blanks ignores the field and reads
the zeros, so files still open there, and files without blanks are
unchanged.

A number in straight quotes is a label, as the help page on graph
labels says (a year as a category name, for one). The CSV reader used
to strip the quotes first and read it as a value. Label columns are now
decided for the whole column: once any row starts with a label, every
row's first cell is its label, so a row with an empty label no longer
shifts its values one place left. The data window writes a header row,
labels that are empty, read as numbers or have spaces around them in
quotes, and blanks as empty cells, so they come back as they were.
graph.setData {} returns blanks in `rows` as null, so the rows it
returns can be edited and sent back. A label-only row keeps its place
with blank values; a row with neither a value nor a label is dropped,
and a first row that is a label followed by empty cells (`Year,,`) is
still read as a header with no series names.

The data grid is built in full for drawing and for the data window, so
it now stops at 10,000 categories of 256 series, which keeps a file
with one very long row from allocating without bound.

Behaviour (quoted labels): Add graph labels and data sets:
https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/add-graph-labels-and-data-sets.html
2026-10-10 17:59:45 +07:00
bflatasticandClaude Opus 5.5 1446b4f5dc Keep the doc comment of unreadable on unreadable
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:58:55 +07:00
Reinhard SchuererandClaude Fable 5.1 79dd751d3b Draw non-native art in .ai editing data from its PDF
A /ForeignObject (non-native art: the content of a placed PDF, which
Illustrator shows but doesn't edit) was left out with a note. The
letterhead of #861 keeps its fan of swooshes that way: a radial gradient
in a 3-point PDF, scaled 271 times and clipped by the swoosh paths, so
the page lost its main graphic.

The reader now decodes the object's ASCII85 /Data, has the PDF importer
read the PDF, and fits its art to the object's box as a group named
"Non-native art": /Bounds measured from /Origin with y down, then
/RTransform, which lands exactly on the clipping paths around both
objects of that file; the PDF's content is kept in its own y-up
coordinates, so it is flipped into the box. Ids are the document's and
the PDF's swatches stay unlinked; its images join the document by
content key. A PDF that can't be read is left out with a note, as
before.

The EPS crate now uses the PDF crate (same layer; allowed in the layer
check like render and metafile).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ZwZjADUZP4VzvrFGpiZch
2026-10-10 17:58:55 +07:00
BFlat 7d6be31711 Merge pull request #948 from storytold/feat/brush-options-scatter-art-pattern
Part of gap 5: Scatter, Art and Pattern Brush Options
2026-10-10 17:57:25 +07:00
52 changed files with 2910 additions and 362 deletions

No files matched your search

Generated
+2
View File
@@ -4335,6 +4335,7 @@ dependencies = [
"objc2-core-foundation",
"objc2-core-text",
"objc2-foundation 0.3.2",
"raw-window-handle",
"rfd",
"serde_json",
"vectorcraft-engine",
@@ -4499,6 +4500,7 @@ dependencies = [
"vectorcraft-effects",
"vectorcraft-geom",
"vectorcraft-metafile",
"vectorcraft-pdf",
"vectorcraft-render",
"vectorcraft-testkit",
"vectorcraft-text",
+11 -8
View File
@@ -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
@@ -35,7 +35,7 @@ since 2026-10-01.
|---|---|---|
| **Breadth:** menus, tools and panels exist | ~90% | 13 menu items still stubbed; every tool implemented except Touch Type; 51 panel modules |
| **Depth:** each feature behaves like Illustrator | ~69–75% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~45%; inline symbols in text are beyond parity and don't raise it), brushes and symbols (in progress), raster effects (~20%) |
| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~10 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku, tate-chu-yoko and the font's vertical metrics; ruby, mojikumi and proportional vertical metrics open), Variables (data merge), scripting |
| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~16 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku, tate-chu-yoko and the font's vertical metrics; ruby, mojikumi and proportional vertical metrics open), Variables (data merge), scripting |
| **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first |
| **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured |
| **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai`, EPS, DXF, EMF/WMF, raster formats, PSD export and PSD/PSB placement (merged image), Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against 51 pinned public files (33 current `.af`), with selected saved-thumbnail comparisons and every-board native/SVG/PDF/PSD checks for current files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. The editing copy inside an Illustrator EPS or `.ai` is read for its structure: layers, groups, names, hidden objects and type, colours, gradients, transparency, images and every artboard (see Out of scope for what it doesn't read yet); DWG has no open spec |
@@ -53,13 +53,15 @@ Ordered by how much each gap blocks someone from switching. Sizes are one-agent
1. **Interaction fidelity:** go tool by tool and panel by panel against `plan/illustrator/05-tools.md`,
`06-panels.md` and `09-shortcuts.md`, covering modifier keys, cursors, the Properties panel per context and
isolation mode. The Shaper’s rough-square recognition and shape orientation steps are corrected; Shaper Groups now retain editable originals for region merges/punches and separate fill/stroke edits; their construction-mode widget and gesture refinements remain. 60–90 h.
2. **Photoshop-style raster effects and the Effect Gallery:** about 45 filters (Artistic, Brush Strokes, Distort,
Sketch, Stylize, Texture) and the Effect Gallery. The pixel-filter pipeline is in place
2. **Photoshop-style raster effects and the Effect Gallery:** about 39 filters (Artistic, Brush Strokes, Distort,
Sketch, Stylize) and the Effect Gallery. The pixel-filter pipeline is in place
(`vectorcraft_effects::pixel`: the object rendered offscreen, filtered in stack order with Gaussian Blur and
Feather, cached while panning, images in PDF/SVG/Expand Appearance) with Blur › Radial Blur and Smart Blur
(Normal mode only), Pixelate › Color Halftone, Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask,
Stylize › Glowing Edges and Video › De-Interlace and NTSC Colors; each new filter is now one function, a catalogue row, menu and translations. Parallelizes
well across agents. 23–36 h.
Stylize › Glowing Edges, Texture › Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer (its
Brick, Burlap, Canvas and Sandstone surfaces made in code; Load Texture is missing) and Video › De-Interlace and
NTSC Colors; each new filter is now one function, a catalogue row, menu and translations. Parallelizes
well across agents. 20–33 h.
3. **3D and Materials:** Extrude & Bevel, Revolve, Inflate and Rotate with lighting and materials, using a software
renderer in its own crate (layering allows it below L6), with output in SVG/PDF as rasters or projected vectors.
The largest single gap. 50–80 h.
@@ -140,7 +142,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 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.
- **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. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. A second value axis (Left/Right/Both, separate scales), 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.
@@ -177,6 +179,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- Affinity documents in (AF.1–AF.4): `.af` (Affinity 3) and `.afdesign`/`.afphoto`/`.afpub` (Affinity 1 and 2, container versions 8–12) open and place as editable art: layers and sublayers, groups (pass-through or isolated), artboards (with their names and backgrounds) and Publisher pages side by side, curves with live corners, parametric shapes (rectangles with corner radii, ellipses, polygons, stars, pies, triangles, trapezoids), compound shapes, solid/linear/elliptical gradient fills and strokes (weight, dashes, alignment, scale-with-object), clipping by nesting, vector and pixel masks, artistic and frame text with fonts, sizes, tracking, leading and alignment, placed images (their original JPEG/PNG) and pixel layers (8/16-bit RGB, CMYK), opacity, visibility, locking and blend modes, at the document's resolution in points. Effects, adjustments, brushes and pressure profiles, transparency gradients, master pages, symbol instances (imported as their cached picture), special shapes (cloud, heart, cog…, imported as ellipses) and Affinity-only blend modes are named in the import warning. A file whose native data can't be read opens as its embedded preview with the reason, and that preview can't be placed. Save never writes back over the Affinity file and there is no Affinity export. AF.4 adds Affinity 3 artboard recognition and actual-outline clipping, source-backed JPEG/PNG raster tiles, Unicode text-run recovery, stored fallback/exact installed fonts, OpenType feature flags and compound-geometry limits. 51 public CC0/MIT/Apache-2.0 files (33 current `.af`), pinned and sha256-verified (`cargo xtask corpus --affinity`), test native retention and every-board native/SVG/PDF/PSD output for current files; selected files compare their saved thumbnails. No Affinity reopen or independent paired PSD oracle was available; [source audit and complex-font gaps](docs/affinity-validation.md); [scope, provenance and limits](crates/affinity/README.md).
- Placed documents: File › Place of a `.vectorcraft` file links to it by default (`file.place`'s `link`): one locked object showing an artboard of the document (or its art's bounds), read the way placing it without Link reads it, its symbols, patterns and images kept apart from the document's; without Link (or from bytes, not a file) it is an editable copy as before. It stays vectors in every output (SVG, PDF, EPS, EMF, DXF), and so do placed documents inside it, read from their own files up to 8 levels deep (documents that place each other stop there). On screen they draw from bitmaps cached per file and zoom step, shared by every copy and made in the background (a placeholder until ready), so a sheet of them redraws in about a millisecond; exports and prints draw them exactly. Like linked images, a save keeps only a preview of each (JPEG when opaque) unless Include Linked Files; output, and zooming past the preview, read the file again, falling back to the preview with a warning when it is gone or changed. Saves for older versions write them as plain art. Links check, update, relink, Update Links on open, Link Info (the artboard's size), Placement Options, Break Link (also Embed, Object › Expand: the editable copy placing without Link gives, its resources joining the document), Edit Original (the document in a new tab) and the Links panel cover them; saving a document updates the open documents that place it. The native format (v4) keeps data of 8 KB or more after the JSON instead of base64 in it.
- Place and the Links panel, Package (placed `.vectorcraft` documents packaged with their own linked files and fonts, relinked to the copies: #777), File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); on Linux the Paste menu checks the clipboard off the UI thread, so an X11 owner that never answers can't freeze the window (#510); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457).
- Linked files another app changes while their document is open follow Preferences › File Handling › Update Links, as they do when it opens: Automatically reads them again (an Update Links undo step), Ask When Modified offers to, Manually leaves them modified in the Links panel. The desktop app compares the active document’s linked files by size and modification time every two seconds and when its window comes back to the front, on a worker thread; a file still being written is tried again (`links.updateChanged`).
- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped.
- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so any GPU that can show the window will do. Preferences › Performance › Graphics Processor (`gpuPreference`) is Automatic by default: the power-saving GPU on Windows and macOS (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306), and the GPU the desktop runs on under Linux, where KWin on an NVIDIA GPU refused frames from a Ryzen's integrated GPU and 0.5.0 crashed at startup (#502). Power Saving and High Performance pick a kind of GPU; `WGPU_POWER_PREF`, `WGPU_ADAPTER_NAME` and `WGPU_BACKEND` override them. The app picks among the adapters that can present to the window (native backends before OpenGL, hardware before software, and on Windows a GPU's DX12 adapter before its Vulkan one: Intel's Vulkan driver made the whole window flicker black, #545), and if the window fails on one while starting up it starts again without it instead of crashing; Help › About, `ui.inspect` and the log name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369). On Windows and Linux the tools' cursors are OS cursors (bitmaps rasterized once per display scale), which the system moves itself: painted into the window, they trailed the pointer by the frames waiting to be shown, and the window now queues one frame instead of two (#444). macOS and the web still paint them (macOS sizes cursor bitmaps in points, eframe has no bitmap cursors on the web). Pens and touch drive the tools as the mouse does, and a tap pressed and lifted within one frame clicks where it tapped (#491); pen pressure reaches the Liquify tools' Use Pressure Pen on Windows, while winit 0.30 reports none on macOS or Linux and egui none on the web. On Linux, see `docs/development.md` for pens under Wayland.
- **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`).
@@ -225,7 +228,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| 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 |
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 32% | ~39 Photoshop-style filters (Artistic, Brush Strokes, Distort, Sketch, Stylize), Smart Blur's Edge Only / Overlay Edge modes, Texturizer's Load Texture 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), Texture (Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer) and Video (De-Interlace, NTSC Colors) are done | 20–33 |
| 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 |
| Type core | 9 | 80% | Middle Eastern features beyond bidi layout and Paragraph Direction (Character Direction, digit types, kashidas, the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); Hyphenation… and Justification… options (Every-line Composer for justified and ragged text, per object, is done; it is tuned by eye, not yet compared side by side with Illustrator; mixed type sizes in a paragraph still break single-line), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 14–19 |
| Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, proportional vertical metrics, manual tate-chu-yoko), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 |
+5 -2
View File
@@ -51,10 +51,13 @@ windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite"] }
# with default features off). Pinned exactly: review the diff before bumping.
muda = { version = "=0.21.1", default-features = false }
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSMenu", "NSEvent", "NSPasteboard", "NSResponder", "NSTextInputContext"] }
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSButton", "NSControl", "NSMenu", "NSEvent", "NSPasteboard", "NSResponder", "NSTextInputContext", "NSView", "NSWindow"] }
# Files opened from Finder and the Dock (src/open_documents.rs) and pasted bitmaps (src/clipboard.rs); both already come with winit, muda and rfd.
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSData", "NSNotification", "NSOperation", "NSString", "NSURL"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSData", "NSGeometry", "NSNotification", "NSOperation", "NSString", "NSURL"] }
block2 = "0.6"
# The window's AppKit view, to centre its buttons on the app bar (src/mac_window.rs). MIT OR
# Apache-2.0 OR Zlib, already in the build through eframe and winit.
raw-window-handle = "0.6"
# CoreText's font manager (src/mac_fonts.rs): fonts that apps and font managers register from their
# own folders (#579). Zlib OR Apache-2.0 OR MIT; objc2-core-foundation already comes with
# objc2-foundation.
+74
View File
@@ -0,0 +1,74 @@
//! macOS: the window buttons (close, minimize, zoom) centred on the app bar (#968).
//!
//! AppKit puts the buttons in a standard title bar, shorter than the app bar they sit over, so they
//! rode high on it. [`center_buttons`] moves them to the middle of the bar, inset from its left as
//! far as from its top, and makes the title bar as tall as the app bar so they stay clickable.
//! AppKit lays its title bar out again when the window resizes or leaves full screen, so this runs
//! every frame and moves the buttons only when they are out of place.
//!
//! Reaching the title bar's views from the window's content view has no safe API, hence the scoped
//! `unsafe_code` allowance.
#![allow(unsafe_code)]
use objc2::runtime::AnyObject;
use objc2_app_kit::{NSView, NSWindowButton, NSWindowStyleMask};
use objc2_foundation::NSPoint;
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
/// The room the app bar keeps at its left for the buttons in AppKit's own place, before they are
/// centred (and in full screen, where AppKit shows them elsewhere): their width and a gap.
pub const DEFAULT_INSET: f32 = 78.0;
/// Differences below this (in points) leave a frame as it is, so a frame in place isn't set again.
const SLACK: f64 = 0.25;
/// Centre the window's buttons on an app bar `bar` egui points tall, at the window's `zoom` factor
/// (native points per egui point). Returns the room the app bar keeps clear at its left for them,
/// in egui points, or `None` when they aren't over the bar (full screen) or can't be found.
pub fn center_buttons(frame: &eframe::Frame, zoom: f32, bar: f32) -> Option<f32> {
let RawWindowHandle::AppKit(handle) = frame.window_handle().ok()?.as_raw() else {
return None;
};
// SAFETY: the handle's view is the window's content view, which AppKit keeps alive while the
// window is open, and eframe hands out the handle only while it is.
let view = unsafe { handle.ns_view.cast::<NSView>().as_ref() };
let window = view.window()?;
if window.styleMask().contains(NSWindowStyleMask::FullScreen) {
return None;
}
let close = window.standardWindowButton(NSWindowButton::CloseButton)?;
let minimize = window.standardWindowButton(NSWindowButton::MiniaturizeButton)?;
let zoom_button = window.standardWindowButton(NSWindowButton::ZoomButton)?;
// The buttons sit in the title bar view, inside the title bar container that sets its height.
// SAFETY: both views belong to the window, alive while it is; used right away.
let container = unsafe { close.superview().and_then(|titlebar| titlebar.superview()) }?;
// Another layout (a later macOS) is left as AppKit made it.
if AnyObject::class(&container).name() != c"NSTitlebarContainerView" {
return None;
}
let size = close.frame().size;
let gap = minimize.frame().origin.x - (close.frame().origin.x + size.width);
let height = f64::from(bar * zoom);
let margin = (height - size.height) / 2.0;
if !(margin.is_finite() && margin > 0.0 && gap.is_finite() && gap >= 0.0 && zoom > 0.0) {
return None;
}
let mut bounds = container.frame();
let top = window.frame().size.height - height;
if (bounds.size.height - height).abs() > SLACK || (bounds.origin.y - top).abs() > SLACK {
bounds.size.height = height;
bounds.origin.y = top;
container.setFrame(bounds);
}
let mut x = margin;
for button in [close, minimize, zoom_button] {
let at = NSPoint::new(x, (height - button.frame().size.height) / 2.0);
let now = button.frame().origin;
if (now.x - at.x).abs() > SLACK || (now.y - at.y).abs() > SLACK {
button.setFrameOrigin(at);
}
x += size.width + gap;
}
// The last button's right edge (`x` steps one gap past it) and the margin again after it.
Some(((x - gap + margin) / f64::from(zoom)) as f32)
}
+10 -2
View File
@@ -31,6 +31,8 @@ mod mac_fonts;
#[cfg(target_os = "macos")]
mod mac_menu;
#[cfg(target_os = "macos")]
mod mac_window;
#[cfg(target_os = "macos")]
mod open_documents;
mod prefs_dir;
mod printing;
@@ -69,7 +71,14 @@ impl eframe::App for App {
}
file_access::unconfined(|| self.app.raw_input_hook(raw));
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
#[cfg_attr(not(target_os = "macos"), allow(unused_variables))]
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
#[cfg(target_os = "macos")]
{
let bar = vectorcraft_ui_egui::chrome::APP_BAR_HEIGHT;
let inset = mac_window::center_buttons(frame, ui.ctx().zoom_factor(), bar);
self.app.titlebar_inset = inset.unwrap_or(mac_window::DEFAULT_INSET);
}
file_access::unconfined(|| self.app.ui(ui));
#[cfg(target_os = "macos")]
if self.app.take_ime_discard() {
@@ -566,7 +575,6 @@ fn main() -> std::process::ExitCode {
{
app.graphics_adapter = Some("OpenGL (Windows 7 compatibility)".into());
}
app.integrated_titlebar = cfg!(target_os = "macos");
app.custom_titlebar = CUSTOM_TITLEBAR;
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
+3
View File
@@ -26,6 +26,9 @@ sync_github_name = false
real_name = "Riccardo Balloch"
sync_github_name = false
[people.crqch]
sync_github_name = true
[people.mo0kid]
sync_github_name = true
+53
View File
@@ -1,8 +1,15 @@
//! Graphs (Illustrator's graph tools): a group whose art is generated from a [`GraphSpec`].
use std::collections::HashSet;
use serde::{Deserialize, Serialize};
use vectorcraft_geom::Rect;
/// Most categories (rows) and series (columns) a graph's data grid holds: the grid is built in full for drawing and
/// for the data window, so a file can't make it allocate without bound.
pub const MAX_GRAPH_CATEGORIES: usize = 10_000;
pub const MAX_GRAPH_SERIES: usize = 256;
/// The nine graph types, in the Graph tool group's order.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -62,6 +69,15 @@ impl GraphKind {
GraphKind::Radar => "Radar",
}
}
/// Whether a series of this type can be drawn in a graph of type `graph` (Combine different graph types): the
/// column, stacked column, line and area types share a vertical value axis, the bar types a horizontal one.
/// Scatter, pie and radar lay their data out on their own and take no other types.
pub fn combines_with(self, graph: GraphKind) -> bool {
use GraphKind::*;
let vertical = |k| matches!(k, Column | StackedColumn | Line | Area);
let horizontal = |k| matches!(k, Bar | StackedBar);
self == graph || (vertical(self) && vertical(graph)) || (horizontal(self) && horizontal(graph))
}
}
/// The fill and stroke captured from one series' generated marks. An absent paint keeps whatever
@@ -88,11 +104,19 @@ pub struct GraphSpec {
/// entries keep the generator's own fill and stroke.
pub series_paints: Vec<SeriesPaint>,
pub kind: GraphKind,
/// Graph types chosen for single series (Combine different graph types), by series index; `None` and missing
/// entries draw as [`Self::kind`].
#[serde(skip_serializing_if = "Vec::is_empty")]
pub series_kinds: Vec<Option<GraphKind>>,
/// The plot rectangle (the area the graph tool dragged), document points.
pub rect: Rect,
pub series: Vec<String>,
pub categories: Vec<String>,
pub rows: Vec<Vec<f64>>,
/// Blank cells as `[category, series]`: their `rows` entry is a 0 placeholder, so a file still reads (as zeros)
/// in a version that doesn't know blanks. Cells past the end of a short row are blank too.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub blanks: Vec<[usize; 2]>,
/// Column/bar width as a % of the available category width (90).
pub column_width: f64,
/// Cluster width as a % (80).
@@ -121,15 +145,44 @@ fn edge_to_edge_before_the_option() -> bool {
true
}
impl GraphSpec {
/// The type series `s` is drawn as: its own type when it combines with the graph's, else the graph's.
pub fn series_kind(&self, s: usize) -> GraphKind {
self.series_kinds.get(s).copied().flatten().filter(|k| k.combines_with(self.kind)).unwrap_or(self.kind)
}
/// The data with blank cells as `None`, every row as long as the longest (at most [`MAX_GRAPH_CATEGORIES`] rows
/// of [`MAX_GRAPH_SERIES`] cells).
pub fn cells(&self) -> Vec<Vec<Option<f64>>> {
let width = self.rows.iter().map(Vec::len).max().unwrap_or(0).min(MAX_GRAPH_SERIES);
let blank: HashSet<[usize; 2]> = self.blanks.iter().copied().collect();
self.rows
.iter()
.take(MAX_GRAPH_CATEGORIES)
.enumerate()
.map(|(c, r)| (0..width).map(|s| r.get(s).copied().filter(|_| !blank.contains(&[c, s]))).collect())
.collect()
}
/// Store `cells`, a blank (`None`) as a 0 placeholder listed in `blanks`.
pub fn set_cells(&mut self, cells: Vec<Vec<Option<f64>>>) {
self.blanks =
cells.iter().enumerate().flat_map(|(c, r)| r.iter().enumerate().filter(|(_, v)| v.is_none()).map(move |(s, _)| [c, s])).collect();
self.rows = cells.into_iter().map(|r| r.into_iter().map(|v| v.unwrap_or(0.0)).collect()).collect();
}
}
impl Default for GraphSpec {
fn default() -> Self {
Self {
series_paints: vec![],
kind: GraphKind::Column,
series_kinds: vec![],
rect: Rect::new(0.0, 0.0, 200.0, 150.0),
series: vec![],
categories: vec![],
rows: vec![vec![1.0]],
blanks: vec![],
column_width: 90.0,
cluster_width: 80.0,
legend: true,
+1 -1
View File
@@ -65,7 +65,7 @@ pub use appearance::{
};
pub use assets::ExportAsset;
pub use corners::LiveCorners;
pub use graph::{GraphKind, GraphSpec, SeriesPaint};
pub use graph::{GraphKind, GraphSpec, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, SeriesPaint};
pub use hit::{Hit, HitKind};
pub use links::{LinkInfo, PlacementOptions};
pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint};
+48 -1
View File
@@ -130,6 +130,10 @@ fn lengths_of(id: &str) -> Lengths {
"stylize.glowingEdges" => always(&["edgeWidth", "smoothness"]),
"pixelate.colorHalftone" => always(&["maxRadius"]),
"pixelate.crystallize" | "pixelate.pointillize" => always(&["cellSize"]),
"texture.craquelure" => always(&["crackSpacing"]),
"texture.mosaicTiles" => always(&["tileSize"]),
"texture.patchwork" => always(&["squareSize"]),
"texture.stainedGlass" => always(&["cellSize"]),
_ => Lengths::default(),
}
}
@@ -144,6 +148,7 @@ const WARP: &[&str] = &["Effect", "Warp"];
const BLUR: &[&str] = &["Effect", "Blur"];
const SHARPEN: &[&str] = &["Effect", "Sharpen"];
const PIXELATE: &[&str] = &["Effect", "Pixelate"];
const TEXTURE: &[&str] = &["Effect", "Texture"];
const VIDEO: &[&str] = &["Effect", "Video"];
const PATHFINDER: &[&str] = &["Effect", "Pathfinder"];
const PLUGINS: &[&str] = &["Effect", "Plug-ins"];
@@ -264,7 +269,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"stylize.innerGlow",
"Inner Glow…",
STYLIZE,
"{mode: blend mode (\"screen\"), opacity: % (75), blur: pt (5), color: \"#rrggbb\" (\"#ffffff\"), source: \"edge\"|\"center\"}",
"{mode: blend mode (\"screen\"), opacity: % (75), blur: pt (5), color: \"#rrggbb\" (\"#ffffff\"), source: \"center\"|\"edge\" (\"edge\")}",
json!({"mode": "screen", "opacity": 75.0, "blur": 5.0, "color": "#ffffff", "source": "edge"}),
),
r(
@@ -332,6 +337,48 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"{cellSize: pt 3..300 (5)} redraws the object as randomly placed dots of its colours on a white canvas",
json!({"cellSize": 5.0}),
),
r(
"texture.craquelure",
"Craquelure…",
TEXTURE,
"{crackSpacing: pt 2..100 (15; how far apart the cracks are), crackDepth: 0..10 (6; how deep and wide the cracks are, and how high the plates and the tones stand), crackBrightness: 0..10 (9; how brightly the plaster is lit)} paints the object on relief plaster cracked into plates and along the contours of its tones",
json!({"crackSpacing": 15.0, "crackDepth": 6.0, "crackBrightness": 9.0}),
),
r(
"texture.grain",
"Grain…",
TEXTURE,
"{intensity: 0..100 (40), contrast: 0..100 (50; the image's contrast, 50 leaves it as it is), grainType: \"regular\"|\"soft\"|\"sprinkles\"|\"clumped\"|\"contrasty\"|\"enlarged\"|\"stippled\"|\"horizontal\"|\"vertical\"|\"speckle\" (\"regular\")} adds grain in 1 pt grains (2 pt Enlarged, clumps Clumped, streaks Horizontal and Vertical); Sprinkles and Stippled use the background colour, white",
json!({"intensity": 40.0, "contrast": 50.0, "grainType": "regular"}),
),
r(
"texture.mosaicTiles",
"Mosaic Tiles…",
TEXTURE,
"{tileSize: pt 2..100 (12), groutWidth: 1..15 (3; the grout is half a point wide per step), lightenGrout: 0..10 (9; how light the grout is)} lays the object in irregular tiles bevelled at their edges, with sunken grout between them",
json!({"tileSize": 12.0, "groutWidth": 3.0, "lightenGrout": 9.0}),
),
r(
"texture.patchwork",
"Patchwork…",
TEXTURE,
"{squareSize: pt 0..10 (4; 0 draws 1 pt squares), relief: 0..25 (8)} redraws the object in squares of the colour around their centres, raised to heights that follow its highlights and shadows, a little more or less at random, lit from the top left",
json!({"squareSize": 4.0, "relief": 8.0}),
),
r(
"texture.stainedGlass",
"Stained Glass…",
TEXTURE,
"{cellSize: pt 2..50 (10), borderThickness: 1..20 (4; the lead between the panes, half a point wide per step, in the foreground colour: black), lightIntensity: 0..10 (3; a light behind the object's centre, fading out towards its corners)} redraws the object as single-coloured panes around random points about cellSize apart",
json!({"cellSize": 10.0, "borderThickness": 4.0, "lightIntensity": 3.0}),
),
r(
"texture.texturizer",
"Texturizer…",
TEXTURE,
"{texture: \"brick\"|\"burlap\"|\"canvas\"|\"sandstone\" (\"canvas\"; surfaces made in code), scaling: % 50..200 (100), relief: 0..50 (4), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\"), invert: bool (false; turns the surface's heights over)} paints the object on a surface in relief",
json!({"texture": "canvas", "scaling": 100.0, "relief": 4.0, "lightDirection": "top", "invert": false}),
),
r(
"video.deinterlace",
"De-Interlace…",
+95 -6
View File
@@ -1,6 +1,7 @@
//! Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Pixelate › Color
//! Halftone, Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Stylize › Glowing
//! Edges, and Video › De-Interlace and NTSC Colors): filters over premultiplied RGBA8 pixels.
//! Edges, Texture › Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer, and
//! Video › De-Interlace and NTSC Colors): filters over premultiplied RGBA8 pixels.
//!
//! - Every distance is in document units (points) and becomes pixels through the raster's
//! [`PixelSpace::px`], so an effect looks the same at any zoom and at any Document Raster Effects
@@ -13,17 +14,19 @@ mod blur;
mod edges;
mod pixelate;
mod sharpen;
mod texture;
mod video;
use serde_json::Value;
use vectorcraft_geom::{Affine, Point, Rect};
use crate::util::{num, text};
use crate::util::{flag, num, text};
pub use pixelate::{MEZZOTINT_TYPES, Mezzotint};
pub use texture::{GRAIN_TYPES, Grain, LIGHT_DIRECTIONS, Light, TEXTURES, Texture};
/// The Photoshop-style effect ids (all raster effects, see [`crate::is_raster`]).
pub const PIXEL_EFFECTS: [&str; 10] = [
pub const PIXEL_EFFECTS: [&str; 16] = [
"blur.radial",
"blur.smart",
"pixelate.colorHalftone",
@@ -32,6 +35,12 @@ pub const PIXEL_EFFECTS: [&str; 10] = [
"pixelate.pointillize",
"sharpen.unsharpMask",
"stylize.glowingEdges",
"texture.craquelure",
"texture.grain",
"texture.mosaicTiles",
"texture.patchwork",
"texture.stainedGlass",
"texture.texturizer",
"video.deinterlace",
"video.ntscColors",
];
@@ -62,6 +71,24 @@ pub enum PixelFx {
/// Pixelate › Pointillize: random dots about `cell` across of the object's colours on a white
/// canvas.
Pointillize { cell: f64 },
/// Texture › Craquelure: cracked relief plaster, plates about `spacing` apart, cracks as deep
/// as `depth` (0..10), lit as brightly as `brightness` (0..10).
Craquelure { spacing: f64, depth: f64, brightness: f64 },
/// Texture › Grain: noise of `kind` as strong as `intensity` (0..100), the image's contrast
/// set by `contrast` (0..100, 50 leaves it).
Grain { intensity: f64, contrast: f64, kind: Grain },
/// Texture › Mosaic Tiles: irregular tiles about `tile` across with sunken grout `grout` / 2
/// wide (1..15), lightened by `lighten` (0..10).
MosaicTiles { tile: f64, grout: f64, lighten: f64 },
/// Texture › Patchwork: squares `square` across of the colour around their centres, raised by
/// up to `relief` (0..25).
Patchwork { square: f64, relief: f64 },
/// Texture › Stained Glass: panes about `cell` across leaded `border` / 2 wide (1..20) in
/// black, lit at the centre by `light` (0..10).
StainedGlass { cell: f64, border: f64, light: f64 },
/// Texture › Texturizer: a `texture` surface scaled by `scaling` (0.5..2), in relief as strong
/// as `relief` (0..50), lit from `light`, its heights turned over when `invert`.
Texturizer { texture: Texture, scaling: f64, relief: f64, light: Light, invert: bool },
/// Video › De-Interlace: the odd (or `even`) field lines made again from the others, by
/// duplication or, with `interpolate`, by averaging.
DeInterlace { even: bool, interpolate: bool },
@@ -100,6 +127,9 @@ pub struct PixelSpace {
/// The height of a line of the document's raster grid (document units: 72 / Document Raster
/// Effects Settings › Resolution), the fields De-Interlace works on.
pub line: f64,
/// How far the corners of the object's bounds lie from its centre (document units): Stained
/// Glass's light fades out over it.
pub extent: f64,
}
/// Effect `id`'s parameters `p` (defaults merged in) as a [`PixelFx`]; `None` for other effects.
@@ -137,6 +167,36 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
"pixelate.crystallize" => PixelFx::Crystallize { cell: num(p, "cellSize", 10.0).clamp(3.0, 300.0) },
"pixelate.mezzotint" => PixelFx::Mezzotint { kind: Mezzotint::parse(text(p, "type", "fineDots")) },
"pixelate.pointillize" => PixelFx::Pointillize { cell: num(p, "cellSize", 5.0).clamp(3.0, 300.0) },
"texture.craquelure" => PixelFx::Craquelure {
spacing: num(p, "crackSpacing", 15.0).clamp(2.0, 100.0),
depth: num(p, "crackDepth", 6.0).clamp(0.0, 10.0),
brightness: num(p, "crackBrightness", 9.0).clamp(0.0, 10.0),
},
"texture.grain" => PixelFx::Grain {
intensity: num(p, "intensity", 40.0).clamp(0.0, 100.0),
contrast: num(p, "contrast", 50.0).clamp(0.0, 100.0),
kind: Grain::parse(text(p, "grainType", "regular")),
},
"texture.mosaicTiles" => PixelFx::MosaicTiles {
tile: num(p, "tileSize", 12.0).clamp(2.0, 100.0),
grout: num(p, "groutWidth", 3.0).clamp(1.0, 15.0),
lighten: num(p, "lightenGrout", 9.0).clamp(0.0, 10.0),
},
"texture.patchwork" => {
PixelFx::Patchwork { square: num(p, "squareSize", 4.0).clamp(0.0, 10.0), relief: num(p, "relief", 8.0).clamp(0.0, 25.0) }
}
"texture.stainedGlass" => PixelFx::StainedGlass {
cell: num(p, "cellSize", 10.0).clamp(2.0, 50.0),
border: num(p, "borderThickness", 4.0).clamp(1.0, 20.0),
light: num(p, "lightIntensity", 3.0).clamp(0.0, 10.0),
},
"texture.texturizer" => PixelFx::Texturizer {
texture: Texture::parse(text(p, "texture", "canvas")),
scaling: num(p, "scaling", 100.0).clamp(50.0, 200.0) / 100.0,
relief: num(p, "relief", 4.0).clamp(0.0, 50.0),
light: Light::parse(text(p, "lightDirection", "top")),
invert: flag(p, "invert", false),
},
"video.deinterlace" => PixelFx::DeInterlace {
even: text(p, "eliminate", "odd").eq_ignore_ascii_case("even"),
interpolate: text(p, "create", "duplication").eq_ignore_ascii_case("interpolation"),
@@ -159,9 +219,18 @@ impl PixelFx {
PixelFx::SmartBlur { radius, .. } => radius,
PixelFx::UnsharpMask { .. } => 0.0,
PixelFx::GlowingEdges { width, smoothness, .. } => width.max(smoothness * 3.0),
PixelFx::ColorHalftone { .. } | PixelFx::Mezzotint { .. } | PixelFx::DeInterlace { .. } | PixelFx::NtscColors => 0.0,
// A crystal's or dot's point inside the object reaches out by up to its cell.
PixelFx::Crystallize { cell } | PixelFx::Pointillize { cell } => 1.5 * cell,
PixelFx::ColorHalftone { .. }
| PixelFx::Mezzotint { .. }
| PixelFx::Craquelure { .. }
| PixelFx::Grain { .. }
| PixelFx::MosaicTiles { .. }
| PixelFx::Texturizer { .. }
| PixelFx::DeInterlace { .. }
| PixelFx::NtscColors => 0.0,
// A crystal's, dot's or pane's point inside the object reaches out by up to its cell.
PixelFx::Crystallize { cell } | PixelFx::Pointillize { cell } | PixelFx::StainedGlass { cell, .. } => 1.5 * cell,
// A square whose centre is on the object is drawn whole.
PixelFx::Patchwork { square, .. } => square.max(1.0),
}
}
@@ -179,6 +248,12 @@ impl PixelFx {
PixelFx::Mezzotint { .. } => Some(0.0),
// The dots' points, and the softened colour around them.
PixelFx::Pointillize { cell } => Some(2.5 * cell),
// The softened tones around the pixel.
PixelFx::Craquelure { spacing, .. } => Some(3.0 * texture::CONTOUR_SOFTNESS * spacing),
PixelFx::Grain { .. } | PixelFx::MosaicTiles { .. } | PixelFx::Texturizer { .. } => Some(0.0),
// The square's or pane's point, and the softened colour around it.
PixelFx::Patchwork { square, .. } => Some(2.0 * square.max(1.0)),
PixelFx::StainedGlass { cell, .. } => Some(2.5 * cell),
// The lines above and below.
PixelFx::DeInterlace { .. } => Some(video::MAX_LINE),
PixelFx::NtscColors => Some(0.0),
@@ -199,12 +274,26 @@ impl PixelFx {
PixelFx::Crystallize { cell } => pixelate::crystallize(px, w, h, space, cell),
PixelFx::Mezzotint { kind } => pixelate::mezzotint(px, w, h, space, kind),
PixelFx::Pointillize { cell } => pixelate::pointillize(px, w, h, space, cell),
PixelFx::Craquelure { spacing, depth, brightness } => texture::craquelure(px, w, h, space, spacing, depth, brightness),
PixelFx::Grain { intensity, contrast, kind } => texture::grain(px, w, h, space, intensity, contrast, kind),
PixelFx::MosaicTiles { tile, grout, lighten } => texture::mosaic_tiles(px, w, h, space, tile, grout, lighten),
PixelFx::Patchwork { square, relief } => texture::patchwork(px, w, h, space, square, relief),
PixelFx::StainedGlass { cell, border, light } => texture::stained_glass(px, w, h, space, cell, border, light),
PixelFx::Texturizer { texture, scaling, relief, light, invert } => {
texture::texturizer(px, w, h, space, texture, scaling, relief, light, invert)
}
PixelFx::DeInterlace { even, interpolate } => video::deinterlace(px, w, h, space, even, interpolate),
PixelFx::NtscColors => video::ntsc(px),
}
}
}
/// The entry of `all` whose value in `table` (the same order) is `value`, any case; `default`
/// otherwise.
fn pick<T: Copy>(table: &[(&str, &str)], all: &[T], value: &str, default: T) -> T {
table.iter().zip(all).find(|((_, v), _)| v.eq_ignore_ascii_case(value)).map_or(default, |(_, t)| *t)
}
/// `data` as `w` × `h` RGBA pixels, `None` when it isn't that size (or is empty).
fn pixels(data: &mut [u8], w: usize, h: usize) -> Option<&mut [[u8; 4]]> {
let (px, rest) = data.as_chunks_mut::<4>();
+66 -49
View File
@@ -8,7 +8,7 @@ use std::sync::OnceLock;
use vectorcraft_geom::{Affine, Point};
use super::{Channels, PixelSpace, gaussian, to16};
use super::{Channels, PixelSpace, Px16, gaussian, pick, to16};
use crate::util::noise;
/// Mezzotint's patterns, in its Type menu's order: (label, parameter value).
@@ -62,7 +62,7 @@ impl Mezzotint {
/// The pattern of Type `value` (a [`MEZZOTINT_TYPES`] value, any case); Fine Dots otherwise.
pub fn parse(value: &str) -> Self {
MEZZOTINT_TYPES.iter().zip(MEZZOTINT_KINDS).find(|((_, v), _)| v.eq_ignore_ascii_case(value)).map_or(Self::FINE_DOTS, |(_, kind)| kind)
pick(&MEZZOTINT_TYPES, &MEZZOTINT_KINDS, value, Self::FINE_DOTS)
}
}
@@ -70,48 +70,48 @@ impl Mezzotint {
const SEED: u64 = 0x5049_5845_4C41_5445;
/// A random number in [0, 1) for cell (`i`, `j`) of pattern `salt`; `k` picks one of several.
fn rand(salt: u64, i: i64, j: i64, k: u64) -> f64 {
pub(super) fn rand(salt: u64, i: i64, j: i64, k: u64) -> f64 {
0.5 * (noise(SEED ^ salt, i as u64, j as u64, k) + 1.0)
}
/// The cell holding coordinate `v` (in cells); `None` far beyond any raster (or not finite).
fn cell(v: f64) -> Option<i64> {
pub(super) fn cell(v: f64) -> Option<i64> {
let f = v.floor();
(f.abs() < 1e15).then_some(f as i64)
}
/// Where a raster's pixels lie relative to the object's centre, in document units.
struct Place {
pub(super) struct Place {
/// Pixel coordinates → document coordinates relative to the centre.
to_rel: Affine,
pub(super) to_rel: Affine,
/// The other way.
to_px: Affine,
pub(super) to_px: Affine,
/// A pixel's size in document units.
px: f64,
pub(super) px: f64,
w: usize,
h: usize,
}
impl Place {
fn new(space: &PixelSpace, w: usize, h: usize) -> Option<Self> {
pub(super) fn new(space: &PixelSpace, w: usize, h: usize) -> Option<Self> {
let to_rel = Affine::translate(-space.center.to_vec2()) * space.to_doc;
let to_px = to_rel.inverse();
(to_rel.is_finite() && to_px.is_finite() && space.px.is_finite() && space.px > 0.0).then_some(Self { to_rel, to_px, px: space.px, w, h })
}
/// The centre of pixel (`x`, `y`).
fn at(&self, x: usize, y: usize) -> Point {
pub(super) fn at(&self, x: usize, y: usize) -> Point {
self.to_rel * Point::new(x as f64 + 0.5, y as f64 + 0.5)
}
/// The index of the pixel holding point `p`; `None` outside the raster.
fn index(&self, p: Point) -> Option<usize> {
pub(super) fn index(&self, p: Point) -> Option<usize> {
let q = self.to_px * p;
(q.x >= 0.0 && q.y >= 0.0 && q.x < self.w as f64 && q.y < self.h as f64).then(|| q.y as usize * self.w + q.x as usize)
}
/// Run `f(x, y, pixel)` on every pixel.
fn each(&self, px: &mut [[u8; 4]], mut f: impl FnMut(usize, usize, &mut [u8; 4])) {
pub(super) fn each(&self, px: &mut [[u8; 4]], mut f: impl FnMut(usize, usize, &mut [u8; 4])) {
for (y, row) in px.chunks_exact_mut(self.w.max(1)).enumerate().take(self.h) {
for (x, p) in row.iter_mut().enumerate() {
f(x, y, p);
@@ -122,7 +122,7 @@ impl Place {
/// The cells around a pixel's cell, in the order they can hold its nearest random point: the
/// cell itself, its 8 neighbours, then the ring around them.
const AROUND: [(i64, i64); 25] = [
pub(super) const AROUND: [(i64, i64); 25] = [
(0, 0),
(-1, 0),
(1, 0),
@@ -152,18 +152,18 @@ const AROUND: [(i64, i64); 25] = [
/// What the cells [`AROUND`] one cell hold, made when first asked for and kept while consecutive
/// pixels stay in that cell.
struct Around<T> {
pub(super) struct Around<T> {
cell: Option<(i64, i64)>,
items: [Option<T>; 25],
}
impl<T: Copy> Around<T> {
fn new() -> Self {
pub(super) fn new() -> Self {
Self { cell: None, items: [None; 25] }
}
/// Item `k` of [`AROUND`] cell (`i`, `j`): `make(cell i', j')`.
fn get(&mut self, (i, j): (i64, i64), k: usize, make: impl FnOnce(i64, i64) -> T) -> Option<T> {
pub(super) fn get(&mut self, (i, j): (i64, i64), k: usize, make: impl FnOnce(i64, i64) -> T) -> Option<T> {
if self.cell != Some((i, j)) {
self.cell = Some((i, j));
self.items = [None; 25];
@@ -175,10 +175,26 @@ impl<T: Copy> Around<T> {
}
/// The random point of cell (`i`, `j`) of pattern `salt`, in cells.
fn point_in(salt: u64, i: i64, j: i64) -> Point {
pub(super) fn point_in(salt: u64, i: i64, j: i64) -> Point {
Point::new(i as f64 + rand(salt, i, j, 0), j as f64 + rand(salt, i, j, 1))
}
/// How far cell `c` lies from coordinate `t` (in cells) along one axis.
pub(super) fn gap(c: i64, t: f64) -> f64 {
(c as f64 - t).max(t - c as f64 - 1.0).max(0.0)
}
/// Smooth random values in [0, 1) of pattern `salt` at whole coordinates, blended between them.
pub(super) fn value_noise(salt: u64, x: f64, y: f64) -> f64 {
let (Some(i), Some(j)) = (cell(x), cell(y)) else { return 0.5 };
let smooth = |t: f64| t * t * (3.0 - 2.0 * t);
let (fx, fy) = (smooth(x - i as f64), smooth(y - j as f64));
let at = |di: i64, dj: i64| rand(salt, i.wrapping_add(di), j.wrapping_add(dj), 0);
let top = at(0, 0) + (at(1, 0) - at(0, 0)) * fx;
let bottom = at(0, 1) + (at(1, 1) - at(0, 1)) * fx;
top + (bottom - top) * fy
}
/// Crystallize: every pixel takes the colour at the nearest of one random point per `cell` ×
/// `cell` square (a Voronoi diagram over a jittered grid): polygon crystals of solid colour.
pub(super) fn crystallize(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, cell_size: f64) {
@@ -188,8 +204,6 @@ pub(super) fn crystallize(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelS
}
let src = px.to_vec();
let mut around = Around::new();
// How far cell `c` lies from coordinate `t` (in cells) along one axis.
let gap = |c: i64, t: f64| (c as f64 - t).max(t - c as f64 - 1.0).max(0.0);
place.each(px, |x, y, out| {
let q = place.at(x, y);
let (u, v) = (q.x / cell_size, q.y / cell_size);
@@ -215,6 +229,34 @@ pub(super) fn crystallize(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelS
});
}
/// The object's colours softened by a Gaussian of `sigma` pixels, with its coverage as it is: what
/// the cell filters (Pointillize, Patchwork, Stained Glass) fill their cells with.
pub(super) struct Soft {
colour: Vec<Px16>,
alpha: Vec<u8>,
}
impl Soft {
pub(super) fn new(px: &[[u8; 4]], w: usize, sigma: f64) -> Self {
let mut colour = to16(px);
gaussian(&mut colour, w, sigma);
Self { colour, alpha: px.iter().map(|p| p[3]).collect() }
}
/// The colour around pixel `k` (premultiplied, 0..1), as opaque as the object is at `k`; clear
/// outside the raster.
pub(super) fn at(&self, k: Option<usize>) -> [f32; 4] {
let a = k.and_then(|k| self.alpha.get(k)).map_or(0.0, |a| f32::from(*a) / 255.0);
match k.and_then(|k| self.colour.get(k)) {
Some(p) if p[3] > 0 => {
let s = a / f32::from(p[3]);
[f32::from(p[0]) * s, f32::from(p[1]) * s, f32::from(p[2]) * s, a]
}
_ => [0.0; 4],
}
}
}
/// One of Pointillize's dots: where (in cells), its radius (document units), its stacking order
/// and its colour (premultiplied, 0..1).
#[derive(Clone, Copy)]
@@ -237,20 +279,10 @@ pub(super) fn pointillize(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelS
}
// Each dot takes the colour around its centre, not of a single pixel there, and is as opaque
// as the object at its centre (whole dots on the object, none beside it).
let alpha: Vec<u8> = px.iter().map(|p| p[3]).collect();
let mut soft = to16(px);
gaussian(&mut soft, w, cell_size / place.px / 4.0);
let soft = Soft::new(px, w, cell_size / place.px / 4.0);
let dot = |i: i64, j: i64| {
let at = point_in(2, i, j);
let k = place.index(Point::new(at.x * cell_size, at.y * cell_size));
let a = k.and_then(|k| alpha.get(k)).map_or(0.0, |a| f32::from(*a) / 255.0);
let colour = match k.and_then(|k| soft.get(k)) {
Some(p) if p[3] > 0 => {
let s = a / f32::from(p[3]);
[f32::from(p[0]) * s, f32::from(p[1]) * s, f32::from(p[2]) * s, a]
}
_ => [0.0; 4],
};
let colour = soft.at(place.index(Point::new(at.x * cell_size, at.y * cell_size)));
Dot { at, radius: (DOT_RADIUS.0 + DOT_RADIUS.1 * rand(2, i, j, 2)) * cell_size, z: rand(2, i, j, 3), colour }
};
let mut around = Around::new();
@@ -499,19 +531,6 @@ pub(super) fn mezzotint(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpa
/// Mezzotint's threshold in [0, 1) at point `q` (document units around the centre) of pattern
/// `salt`.
fn threshold(kind: Mezzotint, salt: u64, q: Point) -> f64 {
let smooth = |t: f64| t * t * (3.0 - 2.0 * t);
// Random values at whole coordinates, blended smoothly between them along x (and y).
let blend = |x: f64, y: f64, along_y: bool| {
let (Some(i), Some(j)) = (cell(x), cell(y)) else { return 0.5 };
let (fx, fy) = (smooth(x - i as f64), smooth(y - j as f64));
let at = |di: i64, dj: i64| rand(salt, i.wrapping_add(di), j.wrapping_add(dj), 0);
let top = at(0, 0) + (at(1, 0) - at(0, 0)) * fx;
if !along_y {
return top;
}
let bottom = at(0, 1) + (at(1, 1) - at(0, 1)) * fx;
top + (bottom - top) * fy
};
let grain = |size: f64| match (cell(q.x / size), cell(q.y / size)) {
(Some(i), Some(j)) => rand(salt, i, j, 1),
_ => 0.5,
@@ -520,12 +539,10 @@ fn threshold(kind: Mezzotint, salt: u64, q: Point) -> f64 {
Mezzotint::Dots { size, smooth: false, .. } => grain(f64::from(size)),
Mezzotint::Dots { size, grainy, .. } => {
let s = f64::from(size);
let blob = blend(q.x / s, q.y / s, true);
let blob = value_noise(salt, q.x / s, q.y / s);
if grainy { 0.5 * (blob + grain(1.0)) } else { blob }
}
Mezzotint::Lines { length, width } => {
let row = (q.y / f64::from(width)).floor();
blend(q.x / f64::from(length), row, false)
}
// Blended along x only: whole rows.
Mezzotint::Lines { length, width } => value_noise(salt, q.x / f64::from(length), (q.y / f64::from(width)).floor()),
}
}
+306 -5
View File
@@ -5,9 +5,17 @@ use vectorcraft_geom::{Affine, Point, Rect};
use super::*;
use crate::{RasterFx, raster_effects};
/// A `w` × `h` raster whose pixels are document points (origin top-left).
/// A `w` × `h` raster whose pixels are document points (origin top-left), of an object that fills it.
fn space(w: usize, h: usize) -> PixelSpace {
PixelSpace { to_doc: Affine::IDENTITY, px: 1.0, center: Point::new(w as f64 / 2.0, h as f64 / 2.0), channels: Channels::Rgb, line: 1.0 }
let (w, h) = (w as f64, h as f64);
PixelSpace {
to_doc: Affine::IDENTITY,
px: 1.0,
center: Point::new(w / 2.0, h / 2.0),
channels: Channels::Rgb,
line: 1.0,
extent: 0.5 * w.hypot(h),
}
}
fn image(w: usize, h: usize, f: impl Fn(usize, usize) -> [u8; 4]) -> Vec<u8> {
@@ -224,8 +232,14 @@ fn lengths_are_document_units() {
let edge = |scale: usize| image(40 * scale, 4, move |x, _| grey(if x < 20 * scale { 100 } else { 150 }));
let run = |scale: usize| {
let mut d = edge(scale);
let s =
PixelSpace { to_doc: Affine::scale(1.0 / scale as f64), px: 1.0 / scale as f64, center: Point::ZERO, channels: Channels::Rgb, line: 1.0 };
let s = PixelSpace {
to_doc: Affine::scale(1.0 / scale as f64),
px: 1.0 / scale as f64,
center: Point::ZERO,
channels: Channels::Rgb,
line: 1.0,
extent: 0.0,
};
fx("sharpen.unsharpMask", json!({"amount": 100, "radius": 2})).apply(&mut d, 40 * scale, 4, &s);
(0..40 * scale).filter(|x| at(&d, 40 * scale, *x, 2)[0] < 99).count()
};
@@ -254,7 +268,7 @@ fn gaussian_and_plane_blurs_keep_their_mass() {
fn pixel_timings() {
let (w, h) = (2000, 2000);
let src = image(w, h, |x, y| [(x % 256) as u8, (y % 256) as u8, ((x ^ y) % 256) as u8, 255]);
let s = PixelSpace { to_doc: Affine::IDENTITY, px: 1.0, center: Point::new(1000.0, 1000.0), channels: Channels::Rgb, line: 1.0 };
let s = PixelSpace { to_doc: Affine::IDENTITY, px: 1.0, center: Point::new(1000.0, 1000.0), channels: Channels::Rgb, line: 1.0, extent: 0.0 };
let cases = [
("blur.radial", json!({"quality": "draft"})),
("blur.radial", json!({"quality": "good"})),
@@ -268,6 +282,14 @@ fn pixel_timings() {
("pixelate.mezzotint", json!({"type": "grainyDots"})),
("pixelate.pointillize", json!({"cellSize": 3})),
("pixelate.pointillize", json!({"cellSize": 300})),
("texture.craquelure", json!({"crackSpacing": 2})),
("texture.craquelure", json!({"crackSpacing": 100})),
("texture.grain", json!({"grainType": "clumped"})),
("texture.mosaicTiles", json!({"tileSize": 2})),
("texture.patchwork", json!({"squareSize": 0})),
("texture.stainedGlass", json!({"cellSize": 2})),
("texture.texturizer", json!({"texture": "sandstone", "scaling": 50})),
("texture.texturizer", json!({"texture": "burlap"})),
];
for (id, p) in cases {
let mut d = src.clone();
@@ -525,3 +547,282 @@ fn ntsc_colors_tame_only_colours_too_strong_for_the_signal() {
assert!((luma(a) - luma(b)).abs() < 3.0, "{x}: as bright {a:?} {b:?}");
}
}
#[test]
fn texture_params_take_defaults_and_stay_in_range() {
assert_eq!(fx("texture.craquelure", json!({})), PixelFx::Craquelure { spacing: 15.0, depth: 6.0, brightness: 9.0 });
assert_eq!(
fx("texture.craquelure", json!({"crackSpacing": 1e9, "crackDepth": -1, "crackBrightness": "x"})),
PixelFx::Craquelure { spacing: 100.0, depth: 0.0, brightness: 9.0 }
);
assert_eq!(fx("texture.grain", json!({})), PixelFx::Grain { intensity: 40.0, contrast: 50.0, kind: Grain::Regular });
assert_eq!(
fx("texture.grain", json!({"intensity": 1e300, "contrast": -5, "grainType": "SPECKLE"})),
PixelFx::Grain { intensity: 100.0, contrast: 0.0, kind: Grain::Speckle }
);
assert_eq!(fx("texture.mosaicTiles", json!({})), PixelFx::MosaicTiles { tile: 12.0, grout: 3.0, lighten: 9.0 });
assert_eq!(
fx("texture.mosaicTiles", json!({"tileSize": 0, "groutWidth": 99, "lightenGrout": 11})),
PixelFx::MosaicTiles { tile: 2.0, grout: 15.0, lighten: 10.0 }
);
assert_eq!(fx("texture.patchwork", json!({})), PixelFx::Patchwork { square: 4.0, relief: 8.0 });
assert_eq!(fx("texture.patchwork", json!({"squareSize": -1, "relief": 26})), PixelFx::Patchwork { square: 0.0, relief: 25.0 });
assert_eq!(fx("texture.stainedGlass", json!({})), PixelFx::StainedGlass { cell: 10.0, border: 4.0, light: 3.0 });
assert_eq!(
fx("texture.stainedGlass", json!({"cellSize": 1, "borderThickness": 21, "lightIntensity": "1e999"})),
PixelFx::StainedGlass { cell: 2.0, border: 20.0, light: 3.0 }
);
let canvas = PixelFx::Texturizer { texture: Texture::Canvas, scaling: 1.0, relief: 4.0, light: Light::Top, invert: false };
assert_eq!(fx("texture.texturizer", json!({})), canvas);
assert_eq!(
fx("texture.texturizer", json!({"texture": "Brick", "scaling": 10, "relief": 60, "lightDirection": "bottomRight", "invert": true})),
PixelFx::Texturizer { texture: Texture::Brick, scaling: 0.5, relief: 50.0, light: Light::BottomRight, invert: true }
);
assert_eq!(
fx("texture.texturizer", json!({"texture": 3, "lightDirection": "up", "scaling": 1e9})),
PixelFx::Texturizer { texture: Texture::Canvas, scaling: 2.0, relief: 4.0, light: Light::Top, invert: false }
);
assert_eq!(fx("texture.grain", json!({"grainType": ["x"]})), fx("texture.grain", json!({})));
// Every menu value is documented in the catalogue and parses to a choice of its own.
for (id, key, table) in [
("texture.grain", "grainType", GRAIN_TYPES.as_slice()),
("texture.texturizer", "texture", TEXTURES.as_slice()),
("texture.texturizer", "lightDirection", LIGHT_DIRECTIONS.as_slice()),
] {
let doc = crate::effect_info(id).unwrap().params;
let parsed: Vec<PixelFx> = table.iter().map(|(_, v)| fx(id, json!({ key: v }))).collect();
for (i, (_, v)) in table.iter().enumerate() {
assert!(doc.contains(&format!("\"{v}\"")), "{id} {v}");
assert!(parsed.iter().skip(i + 1).all(|p| *p != parsed[i]), "{id} {v}");
}
}
// Panes and squares reach beyond the object; the surfaces stay on it.
let b = Rect::new(0.0, 0.0, 100.0, 50.0);
assert_eq!(fx("texture.stainedGlass", json!({"cellSize": 20})).outset(b), 30.0);
assert_eq!(fx("texture.patchwork", json!({"squareSize": 0})).outset(b), 1.0);
for id in ["texture.craquelure", "texture.grain", "texture.mosaicTiles", "texture.texturizer"] {
assert_eq!(fx(id, json!({})).outset(b), 0.0, "{id}");
}
assert_eq!(fx("texture.grain", json!({})).reach(), Some(0.0));
assert_eq!(fx("texture.craquelure", json!({"crackSpacing": 12})).reach(), Some(6.0));
}
/// The pixels of `d` (`w` wide) in the square `lo..hi` on both axes.
fn square(d: &[u8], w: usize, lo: usize, hi: usize) -> Vec<[u8; 4]> {
(lo..hi).flat_map(|y| (lo..hi).map(move |x| (x, y))).map(|(x, y)| at(d, w, x, y)).collect()
}
/// A `w`-square raster of one colour.
fn flat(w: usize, c: [u8; 4]) -> Vec<u8> {
image(w, w, |_, _| c)
}
#[test]
fn craquelure_cracks_plates_and_contours() {
let w = 96;
let tan = [180, 150, 110, 255];
let mut d = flat(w, tan);
fx("texture.craquelure", json!({"crackBrightness": 10})).apply(&mut d, w, w, &space(w, w));
let px = square(&d, w, 16, 80);
let dark = px.iter().filter(|p| p[0] < 120).count() as f64 / px.len() as f64;
assert!((0.03..0.4).contains(&dark), "a network of dark cracks: {dark}");
assert!(px.iter().all(|p| p[3] == 255 && p[0] >= p[1] && p[1] >= p[2]), "the colour stays, darkened or lit");
assert!(px.iter().any(|p| p[0] > 182), "plate edges catch the light");
// Without depth there are no cracks and no relief; brightness lights the surface.
let mut flat_lit = flat(w, tan);
fx("texture.craquelure", json!({"crackDepth": 0, "crackBrightness": 10})).apply(&mut flat_lit, w, w, &space(w, w));
assert_eq!(flat_lit, flat(w, tan));
let mut dim = flat(w, tan);
fx("texture.craquelure", json!({"crackDepth": 0, "crackBrightness": 0})).apply(&mut dim, w, w, &space(w, w));
assert_eq!(at(&dim, w, 40, 40), [90, 75, 55, 255]);
// A dark half and a light half: a crack follows the contour between them, straight down (a
// dark line between lighter pixels in nearly every row), where one tone has none.
let craquelure = fx("texture.craquelure", json!({"crackSpacing": 100, "crackBrightness": 10}));
let rows_cracked = |src: Vec<u8>| {
let mut d = src;
craquelure.apply(&mut d, w, w, &space(w, w));
let v = |x: usize, y: usize| i32::from(at(&d, w, x, y)[0]);
(20..76).filter(|y| (26..35).any(|x| v(x, *y) + 8 < v(x - 2, *y) && v(x, *y) + 8 < v(x + 2, *y))).count()
};
let (contour, plain) = (rows_cracked(image(w, w, |x, _| grey(if x < 30 { 60 } else { 200 }))), rows_cracked(flat(w, grey(200))));
assert!(contour >= 48 && contour >= plain + 25, "{contour} rows cracked along the contour, {plain} without one");
}
#[test]
fn grain_types_each_add_their_own_noise() {
let w = 48;
let mid = grey(128);
let mut seen: Vec<Vec<u8>> = Vec::new();
for (_, kind) in GRAIN_TYPES {
let mut d = flat(w, mid);
fx("texture.grain", json!({ "grainType": kind })).apply(&mut d, w, w, &space(w, w));
assert_ne!(d, flat(w, mid), "{kind} adds grain");
assert!(d.as_chunks::<4>().0.iter().all(|p| p[3] == 255), "{kind}: coverage stays");
assert!(!seen.contains(&d), "{kind}: a grain of its own");
seen.push(d);
}
// Regular grain is 1-point grains of every colour around the image's own.
let mut d = flat(w, mid);
fx("texture.grain", json!({})).apply(&mut d, w, w, &space(w, w));
let px = d.as_chunks::<4>().0;
let mean = px.iter().map(|p| f64::from(p[0])).sum::<f64>() / px.len() as f64;
assert!((mean - 128.0).abs() < 8.0, "{mean}");
assert!(px.iter().any(|p| p[0] != p[1]), "coloured grains");
// Horizontal grain runs in streaks along the rows.
let mut d = flat(w, mid);
fx("texture.grain", json!({"grainType": "horizontal", "intensity": 100})).apply(&mut d, w, w, &space(w, w));
let along = (0..w - 1).map(|x| u32::from(at(&d, w, x, 10)[0].abs_diff(at(&d, w, x + 1, 10)[0]))).sum::<u32>();
let across = (0..w - 1).map(|y| u32::from(at(&d, w, 10, y)[0].abs_diff(at(&d, w, 10, y + 1)[0]))).sum::<u32>();
assert!(along * 3 < across, "{along} along, {across} across");
// No intensity at neutral contrast leaves the image; contrast spreads its tones.
let ramp = image(w, w, |x, _| grey((x * 5) as u8));
let mut d = ramp.clone();
fx("texture.grain", json!({"intensity": 0})).apply(&mut d, w, w, &space(w, w));
assert_eq!(d, ramp);
fx("texture.grain", json!({"intensity": 0, "contrast": 100})).apply(&mut d, w, w, &space(w, w));
assert!(at(&d, w, 2, 0)[0] < at(&ramp, w, 2, 0)[0] && at(&d, w, 45, 0)[0] > at(&ramp, w, 45, 0)[0]);
// Sprinkles are the background colour, white.
let mut d = flat(w, [0, 0, 160, 255]);
fx("texture.grain", json!({"grainType": "sprinkles", "intensity": 100})).apply(&mut d, w, w, &space(w, w));
assert!(d.as_chunks::<4>().0.iter().any(|p| p[0] > 200 && p[2] > 240));
}
#[test]
fn mosaic_tiles_lay_tiles_in_light_grout() {
let w = 96;
let blue = [40, 60, 160, 255];
let mut d = flat(w, blue);
fx("texture.mosaicTiles", json!({})).apply(&mut d, w, w, &space(w, w));
let px = square(&d, w, 8, 88);
let grout = px.iter().filter(|p| p[0] > 170).count() as f64 / px.len() as f64;
assert!((0.08..0.5).contains(&grout), "light grout between the tiles: {grout}");
assert!(px.iter().filter(|p| **p == blue).count() > px.len() / 10, "tile faces keep the colour");
// Dark grout without lightening.
let mut d = flat(w, blue);
fx("texture.mosaicTiles", json!({"lightenGrout": 0})).apply(&mut d, w, w, &space(w, w));
let px = square(&d, w, 8, 88);
let grout = px.iter().filter(|p| p[2] < 100).count() as f64 / px.len() as f64;
assert!((0.08..0.5).contains(&grout) && px.iter().all(|p| p[0] < 170), "dark grout: {grout}");
// Transparency around the object stays.
let mut d = image(w, w, |x, _| if x < 48 { blue } else { [0; 4] });
fx("texture.mosaicTiles", json!({})).apply(&mut d, w, w, &space(w, w));
assert!((0..w).all(|y| (48..w).all(|x| at(&d, w, x, y) == [0; 4])));
}
#[test]
fn patchwork_fills_squares_with_one_colour_in_relief() {
let w = 64;
let src = image(w, w, |x, y| [(x * 4) as u8, (y * 4) as u8, 90, 255]);
let mut d = src.clone();
fx("texture.patchwork", json!({"squareSize": 8, "relief": 0})).apply(&mut d, w, w, &space(w, w));
// Squares of 8 points (the object's centre is a corner), each one colour.
for (i, j) in [(1, 1), (2, 5), (6, 3)] {
let first = at(&d, w, 8 * i, 8 * j);
assert!((0..8).all(|y| (0..8).all(|x| at(&d, w, 8 * i + x, 8 * j + y) == first)), "square {i} {j}");
}
assert_ne!(at(&d, w, 8, 8), at(&d, w, 16, 8));
// In relief, the squares' sides catch the light from the top left and fall into shade.
let mut lit = src.clone();
fx("texture.patchwork", json!({"squareSize": 8, "relief": 25})).apply(&mut lit, w, w, &space(w, w));
let (face, top, bottom) = (at(&lit, w, 28, 28)[1], at(&lit, w, 28, 24)[1], at(&lit, w, 28, 31)[1]);
assert!(top > face && bottom < face, "{top} {face} {bottom}");
// Squares around the object's edge are drawn whole.
let mut d = image(w, w, |x, _| if x < 30 { [200, 0, 0, 255] } else { [0; 4] });
fx("texture.patchwork", json!({"squareSize": 8, "relief": 0})).apply(&mut d, w, w, &space(w, w));
assert!((0..w).all(|y| at(&d, w, 31, y)[3] == 255 && at(&d, w, 33, y)[3] == 0));
}
#[test]
fn stained_glass_leads_panes_and_lights_the_centre() {
let w = 96;
let src = image(w, w, |x, y| [(40 + x) as u8, (40 + y) as u8, 120, 255]);
let mut d = src.clone();
fx("texture.stainedGlass", json!({"lightIntensity": 0})).apply(&mut d, w, w, &space(w, w));
let px = square(&d, w, 16, 80);
let lead = px.iter().filter(|p| p[0] == 0 && p[1] == 0 && p[2] == 0).count() as f64 / px.len() as f64;
assert!((0.1..0.6).contains(&lead), "black lead: {lead}");
let same = (16..80).flat_map(|y| (16..79).map(move |x| (x, y))).filter(|(x, y)| at(&d, w, *x, *y) == at(&d, w, x + 1, *y)).count();
assert!(same * 5 > 2 * 64 * 63, "single-coloured panes: {same}");
assert!(px.iter().all(|p| p[3] == 255));
// The light shines through the middle, not the corners.
let mut lit = src.clone();
fx("texture.stainedGlass", json!({"lightIntensity": 10})).apply(&mut lit, w, w, &space(w, w));
let brighter = |x: usize, y: usize| i32::from(at(&lit, w, x, y)[2]) - i32::from(at(&d, w, x, y)[2]);
let centre = (44..52).flat_map(|y| (44..52).map(move |x| (x, y))).map(|(x, y)| brighter(x, y)).max().unwrap();
let corner = (0..6).flat_map(|y| (0..6).map(move |x| (x, y))).map(|(x, y)| brighter(x, y)).max().unwrap();
assert!(centre > 40 && corner * 3 < centre, "{centre} at the centre, {corner} in the corner");
// Panes beyond the object stay clear; lead outlines it.
let mut d = image(w, w, |x, y| if (24..72).contains(&x) && (24..72).contains(&y) { [200, 40, 40, 255] } else { [0; 4] });
fx("texture.stainedGlass", json!({})).apply(&mut d, w, w, &space(w, w));
assert!((0..6).all(|y| (0..w).all(|x| at(&d, w, x, y) == [0; 4])));
assert!(d.as_chunks::<4>().0.iter().all(|p| p[0] <= p[3] && p[1] <= p[3] && p[2] <= p[3]));
}
#[test]
fn texturizer_lights_each_surface_from_its_side() {
let w = 64;
let mid = grey(150);
let run = |p: serde_json::Value| {
let mut d = flat(w, mid);
fx("texture.texturizer", p).apply(&mut d, w, w, &space(w, w));
d
};
let mut seen: Vec<Vec<u8>> = Vec::new();
for (_, texture) in TEXTURES {
let d = run(json!({ "texture": texture, "relief": 20 }));
assert_ne!(d, flat(w, mid), "{texture}");
assert!(!seen.contains(&d), "{texture}: a surface of its own");
// Inverted, the lit slopes are the shaded ones, and the other way round.
let inv = run(json!({ "texture": texture, "relief": 20, "invert": true }));
let shift = |d: &[u8]| d.as_chunks::<4>().0.iter().map(|p| i32::from(p[0]) - 150).collect::<Vec<_>>();
let (a, b) = (shift(&d), shift(&inv));
let opposite = a.iter().zip(&b).filter(|(a, b)| a.abs() > 4 && b.abs() > 4 && a.signum() != b.signum()).count();
let lit_or_shaded = a.iter().filter(|a| a.abs() > 4).count();
assert!(lit_or_shaded > 100 && opposite * 10 > lit_or_shaded * 8, "{texture}: {opposite} of {lit_or_shaded}");
// From the other side as well.
assert_ne!(run(json!({ "texture": texture, "relief": 20, "lightDirection": "bottom" })), d, "{texture}");
// No relief, no change.
assert_eq!(run(json!({ "texture": texture, "relief": 0 })), flat(w, mid), "{texture}");
seen.push(d);
}
// Scaling enlarges the surface: 200 % at 2 points a pixel draws as 100 % at 1.
let mut big = flat(w, mid);
let coarse = PixelSpace { to_doc: Affine::scale(2.0), px: 2.0, center: Point::new(64.0, 64.0), ..space(w, w) };
fx("texture.texturizer", json!({"texture": "brick", "scaling": 200, "relief": 20})).apply(&mut big, w, w, &coarse);
assert_eq!(big, run(json!({"texture": "brick", "relief": 20})));
}
/// The Texture filters' patterns lie in document space around the object's centre: the same at
/// twice the resolution, and moving with the object.
#[test]
fn texture_patterns_follow_the_object_at_any_resolution() {
let (w, h) = (48, 48);
let art = |x: f64, y: f64| -> [u8; 4] {
if (8.0..40.0).contains(&x) && (8.0..40.0).contains(&y) { [(x * 5.0) as u8, (y * 5.0) as u8, 120, 255] } else { [0; 4] }
};
for (id, p) in [
("texture.craquelure", json!({"crackSpacing": 8})),
("texture.grain", json!({"grainType": "enlarged"})),
("texture.mosaicTiles", json!({"tileSize": 8, "groutWidth": 2})),
("texture.patchwork", json!({"squareSize": 6})),
("texture.stainedGlass", json!({"cellSize": 8, "borderThickness": 2})),
("texture.texturizer", json!({"texture": "sandstone", "relief": 10})),
] {
let f = fx(id, p);
let mut one = image(w, h, |x, y| art(x as f64 + 0.5, y as f64 + 0.5));
f.apply(&mut one, w, h, &space(w, h));
let mut two = image(2 * w, 2 * h, |x, y| art((x as f64 + 0.5) / 2.0, (y as f64 + 0.5) / 2.0));
let fine = PixelSpace { to_doc: Affine::scale(0.5), px: 0.5, ..space(w, h) };
f.apply(&mut two, 2 * w, 2 * h, &fine);
let down = image(w, h, |x, y| {
let q = [(0, 0), (1, 0), (0, 1), (1, 1)].map(|(dx, dy)| at(&two, 2 * w, 2 * x + dx, 2 * y + dy));
std::array::from_fn(|c| (q.iter().map(|p| u32::from(p[c])).sum::<u32>() / 4) as u8)
});
let diff = one.iter().zip(&down).map(|(a, b)| f64::from(a.abs_diff(*b))).sum::<f64>() / one.len() as f64;
assert!(diff < 12.0, "{id}: {diff}");
let moved = PixelSpace { to_doc: Affine::translate((7.0, 3.0)), center: Point::new(31.0, 27.0), ..space(w, h) };
let mut shifted = image(w, h, |x, y| art(x as f64 + 0.5, y as f64 + 0.5));
f.apply(&mut shifted, w, h, &moved);
assert_eq!(shifted, one, "{id}");
}
}
+576
View File
@@ -0,0 +1,576 @@
//! Texture › Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer.
//!
//! Like the Pixelate filters' patterns, theirs (cracks, grain, tiles, squares, panes and the
//! Texturizer's surfaces, all generated here, no image files) lie in document space around the
//! object's centre: the same at any zoom or resolution, moving with the object, and the same on
//! every redraw (hash noise of the pattern's cell indices). Surfaces in relief are height fields
//! whose slopes, taken in document units, are lit from one side.
use std::f64::consts::{FRAC_1_SQRT_2, PI};
use vectorcraft_geom::{Point, Vec2};
use super::pixelate::{AROUND, Around, Place, Soft, cell, gap, point_in, rand, value_noise};
use super::{PixelSpace, blur_plane, pick};
/// Grain's types, in its Grain Type menu's order: (label, parameter value).
pub const GRAIN_TYPES: [(&str, &str); 10] = [
("Regular", "regular"),
("Soft", "soft"),
("Sprinkles", "sprinkles"),
("Clumped", "clumped"),
("Contrasty", "contrasty"),
("Enlarged", "enlarged"),
("Stippled", "stippled"),
("Horizontal", "horizontal"),
("Vertical", "vertical"),
("Speckle", "speckle"),
];
/// A Grain type ([`GRAIN_TYPES`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Grain {
Regular,
Soft,
Sprinkles,
Clumped,
Contrasty,
Enlarged,
Stippled,
Horizontal,
Vertical,
Speckle,
}
impl Grain {
const ALL: [Grain; 10] = [
Grain::Regular,
Grain::Soft,
Grain::Sprinkles,
Grain::Clumped,
Grain::Contrasty,
Grain::Enlarged,
Grain::Stippled,
Grain::Horizontal,
Grain::Vertical,
Grain::Speckle,
];
/// The type of Grain Type `value` (a [`GRAIN_TYPES`] value, any case); Regular otherwise.
pub fn parse(value: &str) -> Self {
pick(&GRAIN_TYPES, &Self::ALL, value, Grain::Regular)
}
}
/// Texturizer's textures, in its Texture menu's order: (label, parameter value).
pub const TEXTURES: [(&str, &str); 4] = [("Brick", "brick"), ("Burlap", "burlap"), ("Canvas", "canvas"), ("Sandstone", "sandstone")];
/// A Texturizer surface ([`TEXTURES`]), made in code.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Texture {
Brick,
Burlap,
Canvas,
Sandstone,
}
impl Texture {
const ALL: [Texture; 4] = [Texture::Brick, Texture::Burlap, Texture::Canvas, Texture::Sandstone];
/// The texture of `value` (a [`TEXTURES`] value, any case); Canvas otherwise.
pub fn parse(value: &str) -> Self {
pick(&TEXTURES, &Self::ALL, value, Texture::Canvas)
}
/// The surface's height at `p` (points at 100 %), in points: slopes of about 1 at most.
fn height(self, p: Point) -> f64 {
match self {
Texture::Brick => brick(p),
Texture::Burlap => weave(p, 7.0, true),
Texture::Canvas => weave(p, 4.0, false),
Texture::Sandstone => sandstone(p),
}
}
}
/// Texturizer's light directions, in its Light menu's order: (label, parameter value).
pub const LIGHT_DIRECTIONS: [(&str, &str); 8] = [
("Bottom", "bottom"),
("Bottom Left", "bottomLeft"),
("Left", "left"),
("Top Left", "topLeft"),
("Top", "top"),
("Top Right", "topRight"),
("Right", "right"),
("Bottom Right", "bottomRight"),
];
/// Where a surface is lit from ([`LIGHT_DIRECTIONS`]), on the page.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Light {
Bottom,
BottomLeft,
Left,
TopLeft,
Top,
TopRight,
Right,
BottomRight,
}
impl Light {
const ALL: [Light; 8] =
[Light::Bottom, Light::BottomLeft, Light::Left, Light::TopLeft, Light::Top, Light::TopRight, Light::Right, Light::BottomRight];
/// The direction of `value` (a [`LIGHT_DIRECTIONS`] value, any case); Top otherwise.
pub fn parse(value: &str) -> Self {
pick(&LIGHT_DIRECTIONS, &Self::ALL, value, Light::Top)
}
/// The unit vector towards the light (document axes, y down).
fn toward(self) -> Vec2 {
let d = FRAC_1_SQRT_2;
match self {
Light::Bottom => Vec2::new(0.0, 1.0),
Light::BottomLeft => Vec2::new(-d, d),
Light::Left => Vec2::new(-1.0, 0.0),
Light::TopLeft => Vec2::new(-d, -d),
Light::Top => Vec2::new(0.0, -1.0),
Light::TopRight => Vec2::new(d, -d),
Light::Right => Vec2::new(1.0, 0.0),
Light::BottomRight => Vec2::new(d, d),
}
}
}
/// Seeds of the Texture filters' patterns (Pixelate's use smaller salts).
const SALT_CRACKS: u64 = 64;
const SALT_GRAIN: u64 = 72;
const SALT_TILES: u64 = 88;
const SALT_PATCH: u64 = 96;
const SALT_GLASS: u64 = 104;
const SALT_SURFACE: u64 = 112;
/// How soft Craquelure's tones are, as a share of the crack spacing: their contours become cracks.
pub(super) const CONTOUR_SOFTNESS: f64 = 1.0 / 6.0;
/// A pixel's straight colour (0..1).
fn straight(p: [u8; 4]) -> [f32; 3] {
let a = f32::from(p[3]);
[p[0], p[1], p[2]].map(|v| if a > 0.0 { (f32::from(v) / a).min(1.0) } else { 0.0 })
}
/// The pixel of straight colour `c` at coverage `alpha` (0..1), premultiplied.
fn premultiply(c: [f32; 3], alpha: f32) -> [u8; 4] {
let a = (alpha.clamp(0.0, 1.0) * 255.0).round();
let [r, g, b] = c.map(|v| (v.clamp(0.0, 1.0) * a).round().min(a) as u8);
[r, g, b, a as u8]
}
/// Colour `c` lit by `s`: darkened towards black below 0, lightened towards white above.
fn lit(c: [f32; 3], s: f64) -> [f32; 3] {
let s = if s.is_finite() { s.clamp(-1.0, 1.0) as f32 } else { 0.0 };
c.map(|v| if s < 0.0 { v * (1.0 + s) } else { v + (1.0 - v) * s })
}
/// How a surface whose height rises along `slope` (height per length) faces a light from
/// `toward`: 0 flat, positive facing it, negative turned away.
fn facing(slope: Vec2, toward: Vec2) -> f64 {
-slope.dot(toward)
}
/// The share of a pixel `px` wide that lies within `d` of a line (negative `d`: beyond it).
fn cover(d: f64, px: f64) -> f64 {
(d / px + 0.5).clamp(0.0, 1.0)
}
/// The luma of colour `c` (Rec. 601 weights).
fn luma(c: [f32; 3]) -> f32 {
0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]
}
/// The two random points nearest to (`u`, `v`) (in cells), `point(i, j)` being cell (`i`, `j`)'s,
/// found among the cells [`AROUND`] the one holding it.
fn nearest_two(u: f64, v: f64, around: &mut Around<Point>, point: impl Fn(i64, i64) -> Point) -> Option<(Point, Point)> {
let (i, j) = (cell(u)?, cell(v)?);
let mut best: [(f64, Option<Point>); 2] = [(f64::INFINITY, None); 2];
for (k, (di, dj)) in AROUND.iter().enumerate() {
// A cell farther than the second point so far can't hold a nearer one.
if gap(i.wrapping_add(*di), u).hypot(gap(j.wrapping_add(*dj), v)) >= best[1].0 {
continue;
}
let Some(p) = around.get((i, j), k, &point) else { continue };
let d = (p.x - u).hypot(p.y - v);
if d < best[0].0 {
best = [(d, Some(p)), best[0]];
} else if d < best[1].0 {
best[1] = (d, Some(p));
}
}
Some((best[0].1?, best[1].1?))
}
/// How far (`u`, `v`) lies from the border between the cells of `near` and `next` (its nearest
/// points, [`nearest_two`]), and the way away from it (unit), in cells.
fn edge(u: f64, v: f64, near: Point, next: Point) -> (f64, Vec2) {
let q = Point::new(u, v);
let apart = near - next;
let len = apart.hypot();
if !(len.is_finite() && len > 1e-12) {
return (0.0, Vec2::ZERO);
}
(((q - next).hypot2() - (q - near).hypot2()) / (2.0 * len), apart / len)
}
/// The slope of a rise of 1 over `width` from a border at distance `d` (smooth at both ends).
fn bevel(d: f64, width: f64) -> f64 {
if !(width.is_finite() && width > 0.0) {
return 0.0;
}
let t = (d / width).clamp(0.0, 1.0);
6.0 * t * (1.0 - t)
}
/// The object's straight luminance (0..1) softened by a Gaussian of `sigma` pixels, weighted by
/// coverage so the transparency around it doesn't darken its edges.
struct Tones {
v: Vec<f32>,
w: usize,
h: usize,
}
impl Tones {
fn new(px: &[[u8; 4]], w: usize, h: usize, sigma: f64) -> Self {
let mut l: Vec<f32> = px.iter().map(|p| luma([p[0], p[1], p[2]].map(f32::from)) / 255.0).collect();
let mut a: Vec<f32> = px.iter().map(|p| f32::from(p[3]) / 255.0).collect();
blur_plane(&mut l, w, h, sigma);
blur_plane(&mut a, w, h, sigma);
let v = l.iter().zip(&a).map(|(l, a)| if *a > 1e-4 { (l / a).clamp(0.0, 1.0) } else { 0.0 }).collect();
Self { v, w, h }
}
fn get(&self, x: usize, y: usize) -> f64 {
let (x, y) = (x.min(self.w.saturating_sub(1)), y.min(self.h.saturating_sub(1)));
self.v.get(y * self.w + x).map_or(0.0, |v| f64::from(*v))
}
/// The tone at pixel (`x`, `y`) and its slope per document unit along the document's axes.
fn slope(&self, x: usize, y: usize, place: &Place) -> (f64, Vec2) {
let gx = 0.5 * (self.get(x + 1, y) - self.get(x.saturating_sub(1), y));
let gy = 0.5 * (self.get(x, y + 1) - self.get(x, y.saturating_sub(1)));
// Per pixel → per document unit: through the transpose of the document → pixel map.
let [a, b, c, d, _, _] = place.to_px.as_coeffs();
(self.get(x, y), Vec2::new(gx * a + gy * b, gx * c + gy * d))
}
}
/// Craquelure: the object painted on relief plaster that cracks into plates about `spacing` apart
/// and along the contours of its tones. `depth` (0..10) sets how deep and wide the cracks are and
/// how high the plates and the tones stand; `brightness` (0..10) how brightly the surface is lit.
pub(super) fn craquelure(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, spacing: f64, depth: f64, brightness: f64) {
let Some(place) = Place::new(space, w, h) else { return };
if !(spacing.is_finite() && spacing > 0.0) {
return;
}
let depth = (depth / 10.0).clamp(0.0, 1.0);
let light = (0.5 + 0.05 * brightness.clamp(0.0, 10.0)) as f32;
let soft = CONTOUR_SOFTNESS * spacing;
let tones = Tones::new(px, w, h, soft / place.px);
let half = (0.15 + 0.6 * depth).min(0.15 * spacing);
let rise = (3.0 * half).max(0.12 * spacing);
let toward = Light::TopLeft.toward();
let mut around = Around::new();
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let q = place.at(x, y);
let (u, v) = (q.x / spacing, q.y / spacing);
// The cracks between the plates; each plate's edge bevels down into them.
let (mut crack, mut slope) = (0.0, Vec2::ZERO);
if let Some((near, next)) = nearest_two(u, v, &mut around, |i, j| point_in(SALT_CRACKS, i, j)) {
let (e, away) = edge(u, v, near, next);
crack = cover(half - e * spacing, place.px);
slope = away * (0.6 * bevel(e * spacing - half, rise));
}
// The cracks along the tones' contours (quarter, half and three-quarter tone).
let (tone, grad) = tones.slope(x, y, &place);
let steep = grad.hypot();
if steep > 1e-4 {
let level = (tone * 4.0).round().clamp(1.0, 3.0) / 4.0;
crack = crack.max(cover(0.8 * half - (tone - level).abs() / steep, place.px));
}
slope += grad * (0.8 * soft);
let c = lit(straight(*p), 0.5 * depth * facing(slope, toward));
let dark = (1.0 - 0.85 * crack * depth) as f32;
*p = premultiply(c.map(|v| v * light * dark), f32::from(p[3]) / 255.0);
});
}
/// A random value in [-1, 1) for the `size`-point cell holding `q`; `k` picks one of several.
fn cell_noise(salt: u64, q: Point, size: f64, k: u64) -> f64 {
match (cell(q.x / size), cell(q.y / size)) {
(Some(i), Some(j)) => 2.0 * rand(salt, i, j, k) - 1.0,
_ => 0.0,
}
}
/// [`value_noise`] in [-1, 1) over cells `size` points wide.
fn smooth_noise(salt: u64, q: Point, size: f64) -> f64 {
2.0 * value_noise(salt, q.x / size, q.y / size) - 1.0
}
/// Grain: noise of `kind` over the object in 1-point grains (2 for Enlarged, clumps for Clumped),
/// as strong as `intensity` (0..100); `contrast` (0..100, 50 leaves it) sets the image's
/// contrast. Sprinkles and Stippled use the background colour, white.
pub(super) fn grain(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, intensity: f64, contrast: f64, kind: Grain) {
let Some(place) = Place::new(space, w, h) else { return };
let a = (intensity / 100.0).clamp(0.0, 1.0);
let k = (0.5 + contrast / 100.0).clamp(0.5, 1.5) as f32;
let s = SALT_GRAIN;
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let q = place.at(x, y);
let mut c = straight(*p).map(|v| (v - 0.5) * k + 0.5);
let chance = 0.5 * (cell_noise(s, q, 1.0, 3) + 1.0);
match kind {
Grain::Regular => add(&mut c, |ch| cell_noise(s, q, 1.0, ch), 0.5 * a),
Grain::Soft => add(&mut c, |ch| smooth_noise(s + ch, q, 1.0), 0.4 * a),
Grain::Clumped => add(&mut c, |ch| 0.65 * smooth_noise(s + ch, q, 3.0) + 0.35 * cell_noise(s, q, 1.0, ch), 0.6 * a),
Grain::Enlarged => add(&mut c, |ch| 0.5 * (smooth_noise(s + ch, q, 2.0) + cell_noise(s, q, 2.0, ch)), 0.5 * a),
Grain::Contrasty => {
c = c.map(|v| (v - 0.5) * 1.4 + 0.5);
add(&mut c, |_| cell_noise(s, q, 1.0, 4), 0.6 * a);
}
Grain::Horizontal | Grain::Vertical => {
// Streaks one point thick: a value per row (column) drifting along it.
let (along, across) = if kind == Grain::Horizontal { (q.x, q.y) } else { (q.y, q.x) };
let line = cell(across).unwrap_or(0);
let n = 0.6 * (2.0 * rand(s, 0, line, 6) - 1.0) + 0.4 * (2.0 * value_noise(s + 7, along / 12.0, line as f64) - 1.0);
add(&mut c, |_| n, 0.5 * a);
}
Grain::Sprinkles => {
if chance < 0.35 * a {
c = c.map(|v| v + (1.0 - v) * 0.9);
}
}
Grain::Speckle => {
if chance < 0.25 * a {
c = c.map(|v| v * 0.25);
} else {
add(&mut c, |_| cell_noise(s, q, 1.0, 5), 0.1 * a);
}
}
Grain::Stippled => {
// Dots of the colour where it is dark, the background between them.
let dots = f64::from(1.0 - luma(c).clamp(0.0, 1.0)) * (0.35 + 0.65 * a);
c = if chance < dots { c.map(|v| v * 0.6) } else { [1.0; 3] };
}
}
*p = premultiply(c, f32::from(p[3]) / 255.0);
});
}
/// Add noise `n(channel)` (-1..1) times `amount` to colour `c`.
fn add(c: &mut [f32; 3], n: impl Fn(u64) -> f64, amount: f64) {
for (ch, v) in c.iter_mut().enumerate() {
*v += (amount * n(ch as u64)) as f32;
}
}
/// A random point of cell (`i`, `j`) of pattern `salt` within `jitter` (0..1, a share of the
/// cell) of its centre, in cells.
fn jittered(salt: u64, i: i64, j: i64, jitter: f64) -> Point {
Point::new(i as f64 + 0.5 + jitter * (rand(salt, i, j, 0) - 0.5), j as f64 + 0.5 + jitter * (rand(salt, i, j, 1) - 0.5))
}
/// Mosaic Tiles: the object laid in irregular tiles about `tile` across, bevelled at their edges,
/// with sunken grout between them half a point wide per step of `grout` (1..15), as light as
/// `lighten` (0..10) says.
pub(super) fn mosaic_tiles(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, tile: f64, grout: f64, lighten: f64) {
let Some(place) = Place::new(space, w, h) else { return };
if !(tile.is_finite() && tile > 0.0 && grout.is_finite()) {
return;
}
let half = (0.25 * grout).clamp(0.0, 0.225 * tile);
let rise = (1.2 * half).max(0.08 * tile);
let lighten = (0.08 * lighten.clamp(0.0, 10.0)) as f32;
let toward = Light::TopLeft.toward();
let mut around = Around::new();
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let q = place.at(x, y);
let (u, v) = (q.x / tile, q.y / tile);
let Some((near, next)) = nearest_two(u, v, &mut around, |i, j| jittered(SALT_TILES, i, j, 0.5)) else { return };
let (e, away) = edge(u, v, near, next);
let e = e * tile;
let c = straight(*p);
let face = lit(c, 0.35 * facing(away * bevel(e - half, rise), toward));
// Sunk in shade, then lightened.
let sunk = c.map(|v| 0.5 * v + (1.0 - 0.5 * v) * lighten);
let g = cover(half - e, place.px) as f32;
let mut out = face;
for (o, s) in out.iter_mut().zip(sunk) {
*o = *o * (1.0 - g) + s * g;
}
*p = premultiply(out, f32::from(p[3]) / 255.0);
});
}
/// Patchwork: the object in squares `square` points across, each filled with the colour around
/// its centre and raised to a height of its own (brighter squares stand higher, each a little
/// more or less at random), its bevelled sides lit from the top left as strongly as `relief`
/// (0..25) says.
pub(super) fn patchwork(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, square: f64, relief: f64) {
let Some(place) = Place::new(space, w, h) else { return };
let side = square.max(1.0);
if !side.is_finite() {
return;
}
let soft = Soft::new(px, w, side / 3.0 / place.px);
let relief = (relief / 25.0).clamp(0.0, 1.0);
let rise = 0.25 * side;
let toward = Light::TopLeft.toward();
place.each(px, |x, y, p| {
let q = place.at(x, y);
let (Some(i), Some(j)) = (cell(q.x / side), cell(q.y / side)) else { return };
let (x0, y0) = (i as f64 * side, j as f64 * side);
let [r, g, b, alpha] = soft.at(place.index(Point::new(x0 + 0.5 * side, y0 + 0.5 * side)));
if alpha <= 0.0 {
*p = [0; 4];
return;
}
let c = [r, g, b].map(|v| v / alpha);
let height = relief * (0.35 + 0.65 * (0.5 * rand(SALT_PATCH, i, j, 0) + 0.5 * f64::from(luma(c))));
// The bevels rise inwards from each side.
let (fx, fy) = (q.x - x0, q.y - y0);
let ramp = |d: f64| cover(rise - d, place.px);
let slope = Vec2::new(ramp(fx) - ramp(side - fx), ramp(fy) - ramp(side - fy)) * (1.5 * height);
*p = premultiply(lit(c, 0.9 * facing(slope, toward)), alpha);
});
}
/// Stained Glass: the object as panes of single colours around random points about `cell_size`
/// apart, leaded in the foreground colour (black) half a point wide per step of `border` (1..20),
/// lit from behind at its centre as strongly as `light` (0..10) says, fading out towards its
/// corners.
pub(super) fn stained_glass(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, cell_size: f64, border: f64, light: f64) {
let Some(place) = Place::new(space, w, h) else { return };
if !(cell_size.is_finite() && cell_size > 0.0 && border.is_finite()) {
return;
}
let soft = Soft::new(px, w, cell_size / place.px / 4.0);
let half = (0.25 * border).clamp(0.0, 0.25 * cell_size);
let light = (light / 10.0).clamp(0.0, 1.0);
let extent = space.extent;
let mut around = Around::new();
place.each(px, |x, y, p| {
let q = place.at(x, y);
let (u, v) = (q.x / cell_size, q.y / cell_size);
let Some((near, next)) = nearest_two(u, v, &mut around, |i, j| point_in(SALT_GLASS, i, j)) else { return };
let pane = |at: Point| soft.at(place.index(Point::new(at.x * cell_size, at.y * cell_size)));
let (glass, other) = (pane(near), pane(next));
let (e, _) = edge(u, v, near, next);
let lead = cover(half - e * cell_size, place.px) as f32;
let glow = if extent.is_finite() && extent > 0.0 { light * (1.0 - (q.to_vec2().hypot() / extent).powi(2)).max(0.0) } else { 0.0 };
let [r, g, b, a] = glass;
let c = if a > 0.0 { lit([r, g, b].map(|v| v / a), 0.6 * glow) } else { [0.0; 3] };
// The lead holds the panes on both sides of it, as opaque as the more opaque one.
let alpha = a * (1.0 - lead) + a.max(other[3]) * lead;
let rgb = c.map(|v| v * a * (1.0 - lead));
*p = if alpha > 0.0 { premultiply(rgb.map(|v| v / alpha), alpha) } else { [0; 4] };
});
}
/// Brick in stretcher bond (bricks 20 × 8 points, 1.5-point mortar): each brick raised with
/// 1-point bevels and a little higher or lower than the next, rough faced, the mortar sandy.
fn brick(p: Point) -> f64 {
const LENGTH: f64 = 20.0;
const COURSE: f64 = 8.0;
const MORTAR: f64 = 1.5;
let Some(row) = cell(p.y / COURSE) else { return 0.0 };
let x = p.x + if row.rem_euclid(2) == 1 { 0.5 * LENGTH } else { 0.0 };
let Some(col) = cell(x / LENGTH) else { return 0.0 };
let (fx, fy) = (x - col as f64 * LENGTH, p.y - row as f64 * COURSE);
let edge = fx.min(LENGTH - fx).min(fy).min(COURSE - fy) - 0.5 * MORTAR;
if edge <= 0.0 {
return 0.2 * value_noise(SALT_SURFACE, p.x / 0.6, p.y / 0.6);
}
edge.min(1.0) + 0.4 * rand(SALT_SURFACE, col, row, 2) + 0.3 * value_noise(SALT_SURFACE + 1, p.x / 1.5, p.y / 1.5)
}
/// A plain weave of threads `period` points apart, each passing over one thread and under the
/// next; `coarse` threads (burlap) wander and are hairy, fine ones (canvas) are even.
fn weave(p: Point, period: f64, coarse: bool) -> f64 {
let (wander, hair) = if coarse { (0.35, 0.35) } else { (0.08, 0.12) };
let drift = |salt: u64, t: f64| wander * (2.0 * value_noise(salt, t / (4.0 * period), 0.5) - 1.0);
let x = p.x / period + drift(SALT_SURFACE + 3, p.y);
let y = p.y / period + drift(SALT_SURFACE + 4, p.x);
let (Some(i), Some(j)) = (cell(x), cell(y)) else { return 0.0 };
let (fx, fy) = (x - i as f64, y - j as f64);
// Each thread is round across and rises and falls along its length, over one crossing thread
// and under the next; the higher of the two threads shows.
let profile = |t: f64| (PI * t).sin().max(0.0).sqrt();
let over = if i.wrapping_add(j).rem_euclid(2) == 0 { 0.5 } else { -0.5 };
let down = profile(fx) * (0.5 + over * (PI * fy).sin());
let across = profile(fy) * (0.5 - over * (PI * fx).sin());
let thread = down.max(across);
let fibres = hair * value_noise(SALT_SURFACE + 5, p.x / (0.25 * period), p.y / (0.25 * period));
(thread + fibres) * period / PI
}
/// Sandstone: four octaves of smooth noise (8 down to 1 point) over a fine sandy grain.
fn sandstone(p: Point) -> f64 {
let (mut h, mut size, mut amp) = (0.0, 8.0, 1.6);
for octave in 0..4 {
h += amp * value_noise(SALT_SURFACE + 8 + octave, p.x / size, p.y / size);
size *= 0.5;
amp *= 0.5;
}
h + 0.06 * cell_noise(SALT_SURFACE + 12, p, 0.5, 0)
}
/// Texturizer: the object on a `texture` surface scaled by `scaling` (0.5..2), in relief as
/// strong as `relief` (0..50) and lit from `light`; `invert` turns the surface's heights over.
#[allow(clippy::too_many_arguments)] // the effect's five options
pub(super) fn texturizer(
px: &mut [[u8; 4]],
w: usize,
h: usize,
space: &PixelSpace,
texture: Texture,
scaling: f64,
relief: f64,
light: Light,
invert: bool,
) {
let Some(place) = Place::new(space, w, h) else { return };
if !(scaling.is_finite() && scaling > 0.0) {
return;
}
// Slopes over a quarter point of the texture, or half a pixel where pixels are larger.
let step = (0.25f64).max(0.5 * place.px / scaling);
let strength = 0.1 * relief.clamp(0.0, 50.0) * if invert { -1.0 } else { 1.0 };
let toward = light.toward();
place.each(px, |x, y, p| {
if p[3] == 0 {
return;
}
let q = place.at(x, y);
let t = Point::new(q.x / scaling, q.y / scaling);
if !(t.x.is_finite() && t.y.is_finite()) {
return;
}
let at = |dx: f64, dy: f64| texture.height(Point::new(t.x + dx, t.y + dy));
let (left, right, up, down) = (at(-step, 0.0), at(step, 0.0), at(0.0, -step), at(0.0, step));
let slope = Vec2::new(right - left, down - up) / (2.0 * step);
// Ridges catch some light from any side and hollows stay in shade, so the surface shows
// whichever way it runs.
let ridge = (at(0.0, 0.0) - 0.25 * (left + right + up + down)) / step;
*p = premultiply(lit(straight(*p), strength * (facing(slope, toward) + ridge)), f32::from(p[3]) / 255.0);
});
}
+562 -125
View File
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Create Graph",
[],
None,
"{type: column|stackedColumn|bar|stackedBar|line|area|scatter|pie|radar, x, y, width, height, series?: [..], categories?: [..], rows?: [[..]], csv?} → {id}",
"{type: column|stackedColumn|bar|stackedBar|line|area|scatter|pie|radar, x, y, width, height, series?: [..], categories?: [..], rows?: [[number|null]], csv?} → {id}; an empty CSV cell or a null is a blank value, a number in straight quotes is a label",
has_doc,
create
),
@@ -32,7 +32,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Data…",
["Object", "Graph"],
None,
"{id?, series?, categories?, rows?, csv?: first row = series labels (first cell empty), then one row per category: label, values…} replace the graph's data; no data → the current {csv, series, categories, rows}",
"{id?, series?, categories?, rows?, csv?: first row = series labels (first cell empty), then one row per category: label, values…; an empty cell or a null is a blank value, a quoted number a label} replace the graph's data; no data → the current {csv, series, categories, rows}",
has_selection,
set_data
),
@@ -41,7 +41,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Type…",
["Object", "Graph"],
None,
"{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",
"{id?, type?, seriesIndexes?: [index], 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; with `seriesIndexes`, or (no `id`) with only series selected with Group Selection, `type` goes to those series only (Combine different graph types: column, stacked column, line and area mix, and so do bar and stacked bar; a series given the graph's type follows the graph again) (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
),
@@ -50,46 +50,83 @@ pub fn specs() -> Vec<CommandSpec> {
// ---------- data ----------
fn parse_csv(csv: &str) -> (Vec<String>, Vec<String>, Vec<Vec<f64>>) {
let rows: Vec<Vec<String>> = csv
/// One CSV cell: its text, and whether it was in straight quotes (a quoted number is a label, not a value).
struct Cell {
text: String,
quoted: bool,
}
impl Cell {
fn number(&self) -> Option<f64> {
if self.quoted { None } else { self.text.parse::<f64>().ok().filter(|v| v.is_finite()) }
}
}
fn parse_csv(csv: &str) -> (Vec<String>, Vec<String>, Vec<Vec<Option<f64>>>) {
let rows: Vec<Vec<Cell>> = csv
.lines()
.map(|l| l.trim_end_matches('\r'))
.filter(|l| !l.trim().is_empty())
.map(|l| {
let sep = if l.contains('\t') { '\t' } else { ',' };
l.split(sep).map(|c| c.trim().trim_matches('"').to_string()).collect()
l.split(sep)
.map(|c| {
let c = c.trim();
let quoted = c.len() >= 2 && c.starts_with('"') && c.ends_with('"');
Cell { text: c.trim_matches('"').to_string(), quoted }
})
.collect()
})
.collect();
let Some(first) = rows.first() else { return (vec![], vec![], vec![]) };
// A header row has a non-numeric cell after the first one (or an empty first cell).
let header = first.first().is_some_and(|c| c.is_empty()) || first.iter().skip(1).any(|c| c.parse::<f64>().is_err());
let series = if header { first.iter().skip(1).cloned().collect() } else { vec![] };
// A label is quoted or doesn't read as a number; an empty unquoted cell is a blank value.
let label = |c: &Cell| c.quoted || (c.number().is_none() && !c.text.is_empty());
// A header row has an empty first cell or a label after it, or is a label followed only by empty cells (`Year,,`).
// All-empty series names mean no legend.
let empty = |c: &Cell| c.text.is_empty() && !c.quoted;
let header = first.first().is_some_and(empty)
|| first.iter().skip(1).any(label)
|| (first.len() > 1 && first.first().is_some_and(label) && first.iter().skip(1).all(empty));
let mut series: Vec<String> = if header { first.iter().skip(1).map(|c| c.text.clone()).collect() } else { vec![] };
if series.iter().all(String::is_empty) {
series.clear();
}
let data = rows.get(usize::from(header)..).unwrap_or_default();
// The first column holds the category labels if any row starts with one; then it does for every row, so a
// row with an empty label doesn't shift its values one place left.
let labelled = data.iter().any(|r| r.first().is_some_and(label));
let mut categories = vec![];
let mut values = vec![];
for r in rows.iter().skip(header as usize) {
let labelled = r.first().is_some_and(|c| c.parse::<f64>().is_err());
categories.push(if labelled { r[0].clone() } else { String::new() });
values.push(r.iter().skip(labelled as usize).map(|c| c.parse::<f64>().unwrap_or(0.0)).collect());
}
if categories.iter().all(String::is_empty) {
categories.clear();
for r in data {
if labelled {
categories.push(r.first().map(|c| c.text.clone()).unwrap_or_default());
}
values.push(r.iter().skip(labelled as usize).map(Cell::number).collect());
}
(series, categories, values)
}
fn to_csv(g: &GraphSpec) -> String {
let mut out = String::new();
if !g.series.is_empty() {
out.push(',');
out.push_str(&g.series.join(","));
out.push('\n');
}
for (i, r) in g.rows.iter().enumerate() {
if let Some(c) = g.categories.get(i) {
out.push_str(c);
// Always a header row (empty first cell), so a first data row starting with a blank isn't read as one.
let cells = g.cells();
let width = cells.iter().map(Vec::len).max().unwrap_or(0).max(g.series.len());
let mut out = String::from(",");
out.push_str(&(0..width).map(|s| g.series.get(s).cloned().unwrap_or_default()).collect::<Vec<_>>().join(","));
out.push('\n');
let labelled = !g.categories.is_empty();
for (i, r) in cells.iter().enumerate() {
if labelled {
// An empty label, one that reads as a number or one with spaces around it goes in quotes, so it reads back
// as the same label.
let c = g.categories.get(i).map(String::as_str).unwrap_or_default();
if c.trim().is_empty() || c.trim().parse::<f64>().is_ok() || c != c.trim() {
out.push_str(&format!("\"{c}\""));
} else {
out.push_str(c);
}
out.push(',');
}
out.push_str(&r.iter().map(|v| fmt_value(*v)).collect::<Vec<_>>().join(","));
out.push_str(&r.iter().map(|v| v.map(fmt_value).unwrap_or_default()).collect::<Vec<_>>().join(","));
out.push('\n');
}
out
@@ -101,7 +138,7 @@ fn apply_data(g: &mut GraphSpec, p: &Value) -> bool {
let (s, c, r) = parse_csv(csv);
g.series = s;
g.categories = c;
g.rows = r;
g.set_cells(r);
changed = true;
}
let strings =
@@ -115,10 +152,24 @@ fn apply_data(g: &mut GraphSpec, p: &Value) -> bool {
changed = true;
}
if let Some(rows) = p.get("rows").and_then(Value::as_array) {
g.rows = rows.iter().map(|r| r.as_array().map(|a| a.iter().map(|v| v.as_f64().unwrap_or(0.0)).collect()).unwrap_or_default()).collect();
// `null` (or anything that isn't a finite number) is a blank cell.
g.set_cells(
rows.iter().map(|r| r.as_array().map(|a| a.iter().map(|v| v.as_f64().filter(|v| v.is_finite())).collect()).unwrap_or_default()).collect(),
);
changed = true;
}
g.rows.retain(|r| !r.is_empty());
// Drop rows with neither a value nor a label; a label-only row stays, its values blank.
let mut cells = g.cells();
let keep: Vec<bool> = (0..cells.len())
.map(|c| cells.get(c).is_some_and(|r| r.iter().any(Option::is_some)) || g.categories.get(c).is_some_and(|l| !l.is_empty()))
.collect();
if keep.contains(&false) || cells.len() < g.rows.len() {
let mut k = keep.iter();
cells.retain(|_| k.next().copied().unwrap_or(false));
let mut k = keep.iter();
g.categories.retain(|_| k.next().copied().unwrap_or(true));
g.set_cells(cells);
}
changed
}
@@ -234,6 +285,11 @@ impl Gen<'_> {
}
}
/// The stretches of two or more points between blank cells, which a line connects.
fn runs(pts: &[Option<Point>]) -> Vec<Vec<Point>> {
pts.split(Option::is_none).map(|r| r.iter().flatten().copied().collect::<Vec<_>>()).filter(|r| r.len() > 1).collect()
}
fn polyline(pts: &[Point], closed: bool) -> PathData {
let mut bp = BezPath::new();
for (i, p) in pts.iter().enumerate() {
@@ -262,8 +318,14 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
let ncat = g.rows.len().max(1);
let series_label = |i: usize| g.series.get(i).cloned().unwrap_or_default();
let mut series: Vec<Vec<Arc<Node>>> = vec![vec![]; nser];
let val = |c: usize, s: usize| g.rows.get(c).and_then(|r| r.get(s)).copied().unwrap_or(0.0);
let stacked = matches!(g.kind, GraphKind::StackedColumn | GraphKind::StackedBar | GraphKind::Area);
// A blank cell (no value) is `None`: columns, bars and stacked segments leave it out, lines and scatter points
// break around it and the value axis ignores it. Area, pie and radar draw it as 0, which is what it adds to them.
let cells = g.cells();
let cell = |c: usize, s: usize| cells.get(c).and_then(|r| r.get(s)).copied().flatten();
let val = |c: usize, s: usize| cell(c, s).unwrap_or(0.0);
// Each series' own type (Combine different graph types); one type for the whole graph unless a series has its own.
let kinds: Vec<GraphKind> = (0..nser).map(|s| g.series_kind(s)).collect();
let kind = |s: usize| kinds.get(s).copied().unwrap_or(g.kind);
let horizontal = matches!(g.kind, GraphKind::Bar | GraphKind::StackedBar);
match g.kind {
@@ -340,22 +402,26 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
let scatter = g.kind == GraphKind::Scatter;
let (mut xlo, mut xhi) = (f64::MAX, f64::MIN);
for c in 0..ncat {
if stacked {
let pos: f64 = (0..nser).map(|s| val(c, s).max(0.0)).sum();
let neg: f64 = (0..nser).map(|s| val(c, s).min(0.0)).sum();
hi = hi.max(pos);
lo = lo.min(neg);
} else if scatter {
for s in (0..nser).step_by(2) {
hi = hi.max(val(c, s));
lo = lo.min(val(c, s));
xlo = xlo.min(val(c, s + 1));
xhi = xhi.max(val(c, s + 1));
if !scatter {
// Stacked columns (or bars) stack together, and so do areas; other series count one value each.
for stack in [[GraphKind::StackedColumn, GraphKind::StackedBar], [GraphKind::Area, GraphKind::Area]] {
let members = || (0..nser).filter(|s| stack.contains(&kind(*s)));
hi = hi.max(members().map(|s| val(c, s).max(0.0)).sum());
lo = lo.min(members().map(|s| val(c, s).min(0.0)).sum());
}
for v in (0..nser).filter(|s| matches!(kind(*s), GraphKind::Column | GraphKind::Bar | GraphKind::Line)).filter_map(|s| cell(c, s))
{
hi = hi.max(v);
lo = lo.min(v);
}
} else {
for s in 0..nser {
hi = hi.max(val(c, s));
lo = lo.min(val(c, s));
for s in (0..nser).step_by(2) {
if let (Some(y), Some(x)) = (cell(c, s), cell(c, s + 1)) {
hi = hi.max(y);
lo = lo.min(y);
xlo = xlo.min(x);
xhi = xhi.max(x);
}
}
}
}
@@ -388,11 +454,14 @@ 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 };
// 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 }
// Area bands always span the plot; lines only with Edge-to-Edge Lines. Category labels sit where every
// series puts its points: at the edges when all of them span the plot, else at the category centres.
let spans = |k: GraphKind| k == GraphKind::Area || (k == GraphKind::Line && g.edge_to_edge);
let mid = |c: usize, edges: bool| {
if edges && ncat > 1 { r.x0 + r.width() * c as f64 / (ncat - 1) as f64 } else { cat_start(c) + cat_w / 2.0 }
};
let label_edges = kinds.iter().all(|k| spans(*k));
let cat_mid = |c: usize| mid(c, label_edges);
if !scatter {
for c in 0..ncat {
if let Some(cat) = g.categories.get(c).filter(|c| !c.is_empty()) {
@@ -415,14 +484,20 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
x += xs;
}
for (si, s) in (0..nser).step_by(2).enumerate() {
let pts: Vec<Point> =
(0..ncat).map(|c| Point::new(r.x0 + (val(c, s + 1) - xl) / (xh - xl) * r.width(), vpos(val(c, s)))).collect();
if g.connect_points && pts.len() > 1 {
let (fill, stroke, w) = mark(si, false, default_series_paint(si), 1.0);
series[si].push(b.path(polyline(&pts, false), fill, stroke, w));
let pts: Vec<Option<Point>> = (0..ncat)
.map(|c| match (cell(c, s), cell(c, s + 1)) {
(Some(y), Some(x)) => Some(Point::new(r.x0 + (x - xl) / (xh - xl) * r.width(), vpos(y))),
_ => None,
})
.collect();
if g.connect_points {
for run in runs(&pts) {
let (fill, stroke, w) = mark(si, false, default_series_paint(si), 1.0);
series[si].push(b.path(polyline(&run, false), fill, stroke, w));
}
}
if g.mark_points {
for p in &pts {
for p in pts.iter().flatten() {
let (fill, stroke, w) = mark(si, true, Paint::None, 0.0);
series[si].push(b.path(marker(*p, 5.0), fill, stroke, w));
}
@@ -431,75 +506,83 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
}
let ax = b.group("Axes", axes);
b.out.push(ax);
match g.kind {
GraphKind::Column | GraphKind::Bar => {
let cluster = cat_w * (g.cluster_width / 100.0).clamp(0.01, 1.0);
let slot = cluster / nser as f64;
let bar = slot * (g.column_width / 100.0).clamp(0.01, 1.0);
for c in 0..ncat {
let c0 = cat_start(c) + (cat_w - cluster) / 2.0;
for (s, items) in series.iter_mut().enumerate() {
let a = c0 + slot * s as f64 + (slot - bar) / 2.0;
let (v0, v1) = (vpos(0.0f64.clamp(lo, hi)), vpos(val(c, s)));
let rect = if horizontal {
Rect::new(v0.min(v1), a, v0.max(v1), a + bar)
} else {
Rect::new(a, v0.min(v1), a + bar, v0.max(v1))
};
let (fill, stroke, w) = mark(s, true, Paint::None, 0.0);
items.push(b.path(shapes::rectangle(rect), fill, stroke, w));
// Columns (or bars) of one category side by side; stacked columns take one slot of that cluster, or, with
// no plain columns, a width of their own.
let columns: Vec<usize> = (0..nser).filter(|s| matches!(kind(*s), GraphKind::Column | GraphKind::Bar)).collect();
let stacks: Vec<usize> = (0..nser).filter(|s| matches!(kind(*s), GraphKind::StackedColumn | GraphKind::StackedBar)).collect();
let slots = columns.len() + usize::from(!stacks.is_empty());
let cluster = cat_w * (g.cluster_width / 100.0).clamp(0.01, 1.0);
let slot = cluster / slots.max(1) as f64;
let bar = slot * (g.column_width / 100.0).clamp(0.01, 1.0);
let bar_rect = |a: f64, w: f64, v0: f64, v1: f64| {
if horizontal { Rect::new(v0.min(v1), a, v0.max(v1), a + w) } else { Rect::new(a, v0.min(v1), a + w, v0.max(v1)) }
};
for c in 0..ncat {
let c0 = cat_start(c) + (cat_w - cluster) / 2.0;
for (i, &s) in columns.iter().enumerate() {
let Some(v) = cell(c, s) else { continue };
let a = c0 + slot * i as f64 + (slot - bar) / 2.0;
let (fill, stroke, w) = mark(s, true, Paint::None, 0.0);
let n = b.path(shapes::rectangle(bar_rect(a, bar, vpos(0.0f64.clamp(lo, hi)), vpos(v))), fill, stroke, w);
if let Some(items) = series.get_mut(s) {
items.push(n);
}
}
let (a, w) = if columns.is_empty() {
let w = (cat_w * (g.column_width / 100.0).clamp(0.01, 1.0) * (g.cluster_width / 100.0).clamp(0.01, 1.0) * 1.2).min(cat_w);
(cat_start(c) + (cat_w - w) / 2.0, w)
} else {
(c0 + slot * columns.len() as f64 + (slot - bar) / 2.0, bar)
};
let (mut pos, mut neg) = (0.0, 0.0);
for &s in &stacks {
let Some(v) = cell(c, s) else { continue };
let base = if v >= 0.0 { &mut pos } else { &mut neg };
let (v0, v1) = (vpos(*base), vpos(*base + v));
*base += v;
let (fill, stroke, sw) = mark(s, true, Paint::solid(Color::WHITE), 0.25);
let n = b.path(shapes::rectangle(bar_rect(a, w, v0, v1)), fill, stroke, sw);
if let Some(items) = series.get_mut(s) {
items.push(n);
}
}
}
// Cumulative area bands, each from the previous area total up to its own.
let mut below: Vec<f64> = vec![0.0; ncat];
for s in (0..nser).filter(|s| kind(*s) == GraphKind::Area) {
let above: Vec<f64> = below.iter().enumerate().map(|(c, b)| b + val(c, s)).collect();
// Mixed with series at the category centres, areas take their points there too.
let at = |c: usize, v: &[f64]| Point::new(mid(c, label_edges), vpos(v.get(c).copied().unwrap_or(0.0)));
let mut pts: Vec<Point> = (0..ncat).map(|c| at(c, &above)).collect();
pts.extend((0..ncat).rev().map(|c| at(c, &below)));
let (fill, stroke, w) = mark(s, true, Paint::solid(Color::WHITE), 0.25);
let n = b.path(polyline(&pts, true), fill, stroke, w);
if let Some(items) = series.get_mut(s) {
items.push(n);
}
below = above;
}
for s in (0..nser).filter(|s| kind(*s) == GraphKind::Line) {
let pts: Vec<Option<Point>> =
(0..ncat).map(|c| cell(c, s).map(|v| Point::new(mid(c, g.edge_to_edge && label_edges), vpos(v)))).collect();
if g.connect_points {
for run in runs(&pts) {
let (fill, stroke, w) = mark(s, false, default_series_paint(s), 1.0);
let n = b.path(polyline(&run, false), fill, stroke, w);
if let Some(items) = series.get_mut(s) {
items.push(n);
}
}
}
GraphKind::StackedColumn | GraphKind::StackedBar => {
let bar = cat_w * (g.column_width / 100.0).clamp(0.01, 1.0) * (g.cluster_width / 100.0).clamp(0.01, 1.0) * 1.2;
let bar = bar.min(cat_w);
for c in 0..ncat {
let a = cat_start(c) + (cat_w - bar) / 2.0;
let (mut pos, mut neg) = (0.0, 0.0);
for (s, items) in series.iter_mut().enumerate() {
let v = val(c, s);
let base = if v >= 0.0 { &mut pos } else { &mut neg };
let (v0, v1) = (vpos(*base), vpos(*base + v));
*base += v;
let rect = if horizontal {
Rect::new(v0.min(v1), a, v0.max(v1), a + bar)
} else {
Rect::new(a, v0.min(v1), a + bar, v0.max(v1))
};
let (fill, stroke, w) = mark(s, true, Paint::solid(Color::WHITE), 0.25);
items.push(b.path(shapes::rectangle(rect), fill, stroke, w));
if g.mark_points {
for p in pts.iter().flatten() {
let (fill, stroke, w) = mark(s, true, Paint::None, 0.0);
let n = b.path(marker(*p, 5.0), fill, stroke, w);
if let Some(items) = series.get_mut(s) {
items.push(n);
}
}
}
GraphKind::Line => {
for (s, items) in series.iter_mut().enumerate() {
let pts: Vec<Point> = (0..ncat).map(|c| Point::new(cat_mid(c), vpos(val(c, s)))).collect();
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, false), 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, 5.0), fill, stroke, w));
}
}
}
}
GraphKind::Area => {
// Cumulative bands, each from the previous total up to its own.
let mut below: Vec<f64> = vec![0.0; ncat];
for (s, items) in series.iter_mut().enumerate() {
let above: Vec<f64> = (0..ncat).map(|c| below[c] + val(c, s)).collect();
let mut pts: Vec<Point> = (0..ncat).map(|c| Point::new(cat_mid(c), vpos(above[c]))).collect();
pts.extend((0..ncat).rev().map(|c| Point::new(cat_mid(c), vpos(below[c]))));
let (fill, stroke, w) = mark(s, true, Paint::solid(Color::WHITE), 0.25);
items.push(b.path(polyline(&pts, true), fill, stroke, w));
below = above;
}
}
_ => {}
}
}
}
@@ -519,7 +602,15 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
labels.push(b.text(Point::new(x + 12.0, y + 7.5), &label, Justify::Left));
}
}
for (s, items) in series.into_iter().enumerate() {
// Areas at the back, lines and points in front of columns; one graph type keeps the series order.
let depth = |s: usize| match kind(s) {
GraphKind::Area => 0,
GraphKind::Line => 2,
_ => 1,
};
let mut series: Vec<(usize, Vec<Arc<Node>>)> = series.into_iter().enumerate().collect();
series.sort_by_key(|(s, _)| depth(*s));
for (s, items) in series {
if items.is_empty() {
continue;
}
@@ -697,7 +788,8 @@ fn set_data(s: &mut Session, p: &Value) -> Result<Value> {
let id = target(s, p, "graph.setData")?;
let mut spec = spec_of(s, id)?;
if !apply_data(&mut spec, p) {
return Ok(json!({ "csv": to_csv(&spec), "series": spec.series, "categories": spec.categories, "rows": spec.rows }));
// Blank cells come back as null, so the rows can be edited and sent back as they are.
return Ok(json!({ "csv": to_csv(&spec), "series": spec.series, "categories": spec.categories, "rows": spec.cells() }));
}
s.edit("Graph Data", |d, _| regenerate(d, id, spec))?;
Ok(json!({ "id": id.0 }))
@@ -707,16 +799,73 @@ 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", "edgeToEdge", "ticks", "axisMin", "axisMax"];
// The series to retype: `seriesIndexes`, else, with no `id`, a selection made only of this graph's series (Group
// Selection). Anything else selected, the graph itself for one, is the whole graph.
let count = spec.rows.iter().map(Vec::len).max().unwrap_or(0).min(vectorcraft_doc::MAX_GRAPH_SERIES);
let picked: Vec<usize> = match p.get("seriesIndexes") {
Some(v) => {
let a = v.as_array().filter(|a| !a.is_empty()).ok_or_else(|| bad(C, "`seriesIndexes` is a non-empty list of series indexes"))?;
if p.get("type").is_none() {
return Err(bad(C, "`seriesIndexes` needs a `type`"));
}
a.iter()
.map(|i| i.as_u64().and_then(|i| usize::try_from(i).ok()).filter(|i| *i < count).ok_or_else(|| bad(C, format!("no series {i}"))))
.collect::<Result<_>>()?
}
None if p.get("id").is_none() => {
let st = s.doc()?;
let members = series_members(&st.doc);
let picked: Option<Vec<usize>> = st.selection.objects.iter().map(|n| members.get(n).filter(|r| r.graph == id).map(|r| r.index)).collect();
let mut out = picked.unwrap_or_default();
out.sort_unstable();
out.dedup();
out
}
None => vec![],
};
let keys = [
"type",
"seriesIndexes",
"columnWidth",
"clusterWidth",
"legend",
"markPoints",
"connectPoints",
"edgeToEdge",
"ticks",
"axisMin",
"axisMax",
];
if !keys.iter().any(|k| p.get(*k).is_some()) {
// With series picked, the type they share, so the dialog's OK keeps it.
let kinds: Vec<GraphKind> = picked.iter().map(|i| spec.series_kind(*i)).collect();
let kind = kinds.first().copied().filter(|k| kinds.iter().all(|x| x == k)).unwrap_or(spec.kind);
return Ok(json!({
"type": spec.kind.id(), "columnWidth": spec.column_width, "clusterWidth": spec.cluster_width, "legend": spec.legend,
"type": kind.id(), "columnWidth": spec.column_width, "clusterWidth": spec.cluster_width, "legend": spec.legend,
"markPoints": spec.mark_points, "connectPoints": spec.connect_points, "edgeToEdge": spec.edge_to_edge, "ticks": spec.ticks,
"axisMin": spec.axis_min, "axisMax": spec.axis_max,
}));
}
// Series picked with Group Selection stay selected through the regeneration (their groups are new nodes).
let reselect: Vec<u32> = if p.get("seriesIndexes").is_none() { picked.iter().filter_map(|i| u32::try_from(*i).ok()).collect() } else { vec![] };
if let Some(t) = str_param(p, "type") {
spec.kind = GraphKind::parse(t).ok_or_else(|| bad(C, format!("unknown graph type `{t}`")))?;
let kind = GraphKind::parse(t).ok_or_else(|| bad(C, format!("unknown graph type `{t}`")))?;
if picked.is_empty() {
spec.kind = kind;
} else {
if !kind.combines_with(spec.kind) {
return Err(bad(C, format!("a {} series can't go in a {} graph", kind.label(), spec.kind.label())));
}
spec.series_kinds.resize(count, None);
for i in picked {
if let Some(k) = spec.series_kinds.get_mut(i) {
*k = (kind != spec.kind).then_some(kind);
}
}
while spec.series_kinds.last().is_some_and(Option::is_none) {
spec.series_kinds.pop();
}
}
}
spec.column_width = f64_or(p, "columnWidth", spec.column_width).clamp(1.0, 1000.0);
spec.cluster_width = f64_or(p, "clusterWidth", spec.cluster_width).clamp(1.0, 100.0);
@@ -732,8 +881,19 @@ fn set_type(s: &mut Session, p: &Value) -> Result<Value> {
spec.axis_max = v.as_f64();
}
let label = format!("{} Graph", spec.kind.label());
s.edit("Graph Type", |d, _| {
s.edit("Graph Type", |d, sel| {
regenerate(d, id, spec)?;
if !reselect.is_empty() {
let groups: Vec<NodeId> = d
.node(id)
.and_then(Node::children)
.into_iter()
.flatten()
.filter(|c| c.series_index.is_some_and(|i| reselect.contains(&i)))
.map(|c| c.id)
.collect();
sel.set(groups);
}
if let Some(n) = d.node_mut(id)
&& n.name.as_deref().is_some_and(|nm| nm.ends_with(" Graph"))
{
@@ -1133,10 +1293,287 @@ mod tests {
let (s, c, r) = super::parse_csv("\t2024\t2025\nQ1\t1\t2\nQ2\t3\t4\n");
assert_eq!(s, vec!["2024", "2025"]);
assert_eq!(c, vec!["Q1", "Q2"]);
assert_eq!(r, vec![vec![1.0, 2.0], vec![3.0, 4.0]]);
assert_eq!(r, vec![vec![Some(1.0), Some(2.0)], vec![Some(3.0), Some(4.0)]]);
let (s, c, r) = super::parse_csv("1,2\n3,4");
assert!(s.is_empty() && c.is_empty());
assert_eq!(r.len(), 2);
assert_eq!(super::nice_axis(0.0, 7.0, 0), (0.0, 8.0, 2.0));
}
#[test]
fn empty_cells_are_blank_and_quoted_numbers_are_labels() {
// Quoted years label the rows; empty and non-numeric cells are blank values, not zeros.
let (s, c, r) = super::parse_csv(",a,b\n\"2023\",1,\n\"2024\",,x\n\"2025\",3,4");
assert_eq!(s, vec!["a", "b"]);
assert_eq!(c, vec!["2023", "2024", "2025"]);
assert_eq!(r, vec![vec![Some(1.0), None], vec![None, None], vec![Some(3.0), Some(4.0)]]);
// Back to CSV: blanks stay empty and numeric labels keep their quotes, so it reads back the same.
let mut g = GraphSpec { series: s, categories: c, ..GraphSpec::default() };
g.set_cells(r);
assert_eq!(g.blanks, vec![[0, 1], [1, 0], [1, 1]]);
let csv = super::to_csv(&g);
assert!(csv.contains("\"2024\",,"), "{csv}");
let (s2, c2, r2) = super::parse_csv(&csv);
assert_eq!((s2, c2, r2), (g.series.clone(), g.categories.clone(), g.cells()));
}
#[test]
fn a_row_with_an_empty_label_keeps_its_values_in_place() {
// The label column is the first column of every row once any row has a label.
let (_, c, r) = super::parse_csv(",a,b\nQ1,1,2\n,3,4");
assert_eq!(c, vec!["Q1", ""]);
assert_eq!(r, vec![vec![Some(1.0), Some(2.0)], vec![Some(3.0), Some(4.0)]]);
// And a data window round trip keeps it (the empty label is written as "").
let mut g = GraphSpec { series: vec!["a".into(), "b".into()], categories: c, ..GraphSpec::default() };
g.set_cells(r);
assert_eq!(super::parse_csv(&super::to_csv(&g)).2, g.cells());
// Without series or labels, a first row starting with a blank is still data after a round trip.
let mut g = GraphSpec::default();
g.set_cells(vec![vec![None, Some(2.0)], vec![Some(3.0), Some(4.0)]]);
let (s, c, r) = super::parse_csv(&super::to_csv(&g));
assert!(s.is_empty() && c.is_empty());
assert_eq!(r, g.cells());
// A label with spaces around a number stays a label, spaces and all.
let mut g = GraphSpec { categories: vec!["2024 ".into()], ..GraphSpec::default() };
g.set_cells(vec![vec![Some(1.0)]]);
assert_eq!(super::parse_csv(&super::to_csv(&g)).1, vec!["2024 "]);
}
#[test]
fn blanks_read_back_as_null_and_year_header_rows_still_read_as_headers() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let r =
s.execute("graph.create", &json!({"type": "line", "x": 0, "y": 0, "width": 300, "height": 200, "rows": [[1, null], [2, 3]]})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
assert_eq!(s.execute("graph.setData", &json!({"id": id.0})).unwrap()["rows"], json!([[1.0, null], [2.0, 3.0]]));
// A label followed by empty cells is a header with no series names, as before blanks existed.
let (s, c, r) = super::parse_csv("Year,,\n2023,1,2");
assert!(s.is_empty());
assert_eq!(c, Vec::<String>::new());
assert_eq!(r, vec![vec![Some(2023.0), Some(1.0), Some(2.0)]]);
}
#[test]
fn a_huge_ragged_grid_is_capped() {
let g = GraphSpec { rows: vec![vec![0.0; vectorcraft_doc::MAX_GRAPH_SERIES + 10]; 3], ..GraphSpec::default() };
assert!(g.cells().iter().all(|r| r.len() == vectorcraft_doc::MAX_GRAPH_SERIES));
}
#[test]
fn a_label_only_row_stays_with_blank_values() {
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": 0, "y": 0, "width": 300, "height": 200, "csv": ",a\nQ1,1\nQ2\nQ3,3"})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
let spec = s.doc().unwrap().doc.node(id).unwrap().graph.clone().unwrap();
assert_eq!(spec.categories, vec!["Q1", "Q2", "Q3"]);
assert_eq!(spec.cells(), vec![vec![Some(1.0)], vec![None], vec![Some(3.0)]]);
}
#[test]
fn blank_cells_leave_out_their_columns_and_break_lines() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let rows = json!([[3, 1], [null, 2], [5, null], [4, 4]]);
let r = s
.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 400, "height": 200, "series": ["a", "b"], "rows": rows}))
.unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
let count = |s: &Session, index: u32| series(&s.doc().unwrap().doc.node(id).unwrap().clone(), index).children().unwrap().len();
// Three columns each (one blank), plus the legend swatch.
assert_eq!((count(&s, 0), count(&s, 1)), (4, 4));
s.execute("graph.setType", &json!({"type": "stackedColumn"})).unwrap();
assert_eq!((count(&s, 0), count(&s, 1)), (4, 4));
// Line: series a is 3, blank, 5, 4. The lone first point has nothing to connect to, so one line (5 to 4), three
// markers and the legend swatch.
s.execute("graph.setType", &json!({"type": "line"})).unwrap();
assert_eq!(count(&s, 0), 1 + 3 + 1);
// Series b is 1, 2, blank, 4: one line (1 to 2), three markers and the swatch.
assert_eq!(count(&s, 1), 1 + 3 + 1);
// A data window round trip keeps the blanks.
let csv = s.execute("graph.setData", &json!({})).unwrap()["csv"].as_str().unwrap().to_string();
assert_eq!(csv.lines().nth(2), Some(",2"), "{csv}");
let spec = s.doc().unwrap().doc.node(id).unwrap().graph.clone().unwrap();
assert_eq!(spec.cells()[1], vec![None, Some(2.0)]);
// In the file a blank is a 0 placeholder plus its place in `blanks`, so a version without blanks reads
// numbers (as zeros); a graph without blanks writes no `blanks` at all.
let v = serde_json::to_value(&spec).unwrap();
assert_eq!(v["rows"][1], json!([0.0, 2.0]));
assert_eq!(v["blanks"], json!([[1, 0], [2, 1]]));
assert!(serde_json::to_value(GraphSpec::default()).unwrap().get("blanks").is_none());
}
fn spec(s: &Session, id: NodeId) -> GraphSpec {
s.doc().unwrap().doc.node(id).unwrap().graph.as_deref().unwrap().clone()
}
/// Bounds of a series' marks, without its legend swatch (the last child).
fn marks(s: &Session, id: NodeId, index: u32) -> Vec<vectorcraft_geom::Rect> {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let ch = series(&n, index).children().unwrap().to_vec();
ch[..ch.len() - 1].iter().map(|c| c.geometric_bounds().unwrap()).collect()
}
#[test]
fn a_series_given_its_own_type_is_drawn_as_that_type_in_front_of_the_columns() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let one_column = marks(&s, id, 0)[0].width();
s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap();
let g = spec(&s, id);
assert_eq!((g.kind, g.series_kind(0), g.series_kind(1)), (GraphKind::Column, GraphKind::Column, GraphKind::Line));
// The columns left take the whole cluster; the line runs through the category centres, one marker a value.
let cols = marks(&s, id, 0);
assert_eq!(cols.len(), 3);
assert!((cols[0].width() - 2.0 * one_column).abs() < 1e-6, "{} vs {one_column}", cols[0].width());
let line = marks(&s, id, 1);
assert_eq!(line.len(), 4, "a line and three markers");
assert!((line[1].center().x - cols[0].center().x).abs() < 1e-6);
// The line series is drawn after (in front of) the column series.
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let order: Vec<u32> = n.children().unwrap().iter().filter_map(|c| c.series_index).collect();
assert_eq!(order, [0, 1]);
s.execute("graph.setType", &json!({"seriesIndexes": [0], "type": "line"})).unwrap();
s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "column"})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let order: Vec<u32> = n.children().unwrap().iter().filter_map(|c| c.series_index).collect();
assert_eq!(order, [1, 0]);
// Back to the graph's own type: nothing is stored for that series.
assert_eq!(spec(&s, id).series_kinds, [Some(GraphKind::Line)]);
// One undo per change.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(spec(&s, id).series_kinds, [Some(GraphKind::Line), Some(GraphKind::Line)]);
}
#[test]
fn group_selecting_a_series_retypes_only_that_series() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let grp = series(&n, 1).id;
s.execute("select.set", &json!({"ids": [grp.0]})).unwrap();
// The dialog opens on the series' type, and its OK (every option sent back) keeps it.
assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["type"], "column");
s.execute("graph.setType", &json!({"type": "area"})).unwrap();
let g = spec(&s, id);
assert_eq!((g.kind, g.series_kind(1)), (GraphKind::Column, GraphKind::Area));
let mut all = s.execute("graph.setType", &json!({})).unwrap();
assert_eq!(all["type"], "area");
all["ticks"] = json!(4);
s.execute("graph.setType", &all).unwrap();
assert_eq!(spec(&s, id).series_kind(1), GraphKind::Area);
// The series stays selected through each change.
let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 1).id;
assert_eq!(s.doc().unwrap().selection.objects.to_vec(), [grp]);
// A series and something outside it selected, or an explicit `id`: the whole graph.
s.execute("select.set", &json!({"ids": [grp.0, id.0]})).unwrap();
assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["type"], "column");
s.execute("select.set", &json!({"ids": [grp.0]})).unwrap();
assert_eq!(s.execute("graph.setType", &json!({"id": id.0})).unwrap()["type"], "column");
// The whole graph selected: the graph's type changes, the series keeps its own.
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("graph.setType", &json!({"type": "line"})).unwrap();
let g = spec(&s, id);
assert_eq!((g.kind, g.series_kind(0), g.series_kind(1)), (GraphKind::Line, GraphKind::Line, GraphKind::Area));
}
#[test]
fn types_that_do_not_share_a_value_axis_are_refused_or_ignored() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
for ty in ["pie", "radar", "scatter", "bar"] {
assert!(s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": ty})).is_err(), "{ty}");
}
for bad in [json!([2]), json!([]), json!([-1]), json!(["x"]), json!([1e30]), json!({}), json!(1)] {
assert!(s.execute("graph.setType", &json!({"seriesIndexes": bad, "type": "line"})).is_err(), "{bad}");
}
assert!(s.execute("graph.setType", &json!({"seriesIndexes": [1]})).is_err(), "no type");
assert!(spec(&s, id).series_kinds.is_empty());
// A series type the graph's new type can't take is drawn as the graph's type, and kept for when it can.
s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap();
s.execute("graph.setType", &json!({"type": "pie"})).unwrap();
let g = spec(&s, id);
assert_eq!(g.series_kind(1), GraphKind::Pie);
s.execute("graph.setType", &json!({"type": "column"})).unwrap();
assert_eq!(spec(&s, id).series_kind(1), GraphKind::Line);
}
#[test]
fn stacked_columns_take_one_slot_and_the_value_axis_covers_their_total() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let csv = ",a,b,c\nQ1,10,20,30\nQ2,10,20,30";
let id = NodeId(
s.execute("graph.create", &json!({"type": "stackedColumn", "x": 0, "y": 0, "width": 300, "height": 200, "csv": csv})).unwrap()["id"]
.as_u64()
.unwrap(),
);
s.execute("graph.setType", &json!({"seriesIndexes": [2], "type": "line"})).unwrap();
let (a, b) = (marks(&s, id, 0), marks(&s, id, 1));
assert!((a[0].x0 - b[0].x0).abs() < 1e-6 && (a[0].y0 - b[0].y1).abs() < 1e-6, "b sits on a");
// a + b stack to 30, the line's highest value is 30: the axis ends at 30 and the stack reaches the top.
assert!((b[0].y0 - 0.0).abs() < 1e-6, "{:?}", b[0]);
// With a plain column too, the stack is the cluster's last slot, beside it.
s.execute("graph.setType", &json!({"seriesIndexes": [0], "type": "column"})).unwrap();
let (a, b) = (marks(&s, id, 0), marks(&s, id, 1));
assert!((a[0].width() - b[0].width()).abs() < 1e-6 && b[0].x0 > a[0].x1, "{:?} {:?}", a[0], b[0]);
}
#[test]
fn areas_mixed_with_columns_put_their_points_at_the_category_centres() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
s.execute("graph.setType", &json!({"seriesIndexes": [0], "type": "area"})).unwrap();
let (area, cols) = (marks(&s, id, 0)[0], marks(&s, id, 1));
assert!((area.x0 - cols[0].center().x).abs() < 1e-6 && (area.x1 - cols[2].center().x).abs() < 1e-6, "{area:?}");
// The area is drawn behind the columns.
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let order: Vec<u32> = n.children().unwrap().iter().filter_map(|c| c.series_index).collect();
assert_eq!(order, [0, 1]);
// An area graph alone still spans the plot.
s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "area"})).unwrap();
assert!((marks(&s, id, 0)[0].x0 - 100.0).abs() < 1e-6);
}
#[test]
fn bar_graphs_take_stacked_bar_series() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let csv = ",a,b,c\nQ1,10,20,-5\nQ2,10,20,-5";
let id = NodeId(
s.execute("graph.create", &json!({"type": "bar", "x": 0, "y": 0, "width": 300, "height": 200, "csv": csv})).unwrap()["id"]
.as_u64()
.unwrap(),
);
assert!(s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "column"})).is_err());
s.execute("graph.setType", &json!({"seriesIndexes": [1, 2], "type": "stackedBar"})).unwrap();
let (a, b, c) = (marks(&s, id, 0), marks(&s, id, 1), marks(&s, id, 2));
// b and c stack along x in the cluster's second slot, below a's bar; c, negative, runs left of the zero line.
assert!((a[0].height() - b[0].height()).abs() < 1e-6 && b[0].y0 > a[0].y1, "{:?} {:?}", a[0], b[0]);
assert!((b[0].y0 - c[0].y0).abs() < 1e-6 && (c[0].x1 - b[0].x0).abs() < 1e-6, "{:?} {:?}", b[0], c[0]);
}
#[test]
fn series_types_survive_save_and_open_and_older_files_have_none() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = graph(&mut s, "column");
s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap();
let path = vectorcraft_testkit::temp_dir("graph-kinds").join("kinds.vectorcraft");
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
assert_eq!(spec(&s, id).series_kinds, [None, Some(GraphKind::Line)]);
let g: GraphSpec = serde_json::from_value(json!({"kind": "line", "rows": [[1.0, 2.0]]})).unwrap();
assert!(g.series_kinds.is_empty());
assert!(serde_json::to_value(&g).unwrap().get("seriesKinds").is_none());
// A stored type the graph can't take, or past its series, is drawn as the graph's type.
let g: GraphSpec = serde_json::from_value(json!({"kind": "pie", "seriesKinds": ["line", null, "column"], "rows": [[1.0]]})).unwrap();
assert_eq!((g.series_kind(0), g.series_kind(5)), (GraphKind::Pie, GraphKind::Pie));
}
}
+14
View File
@@ -52,6 +52,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
update
),
cmd!(
"links.updateChanged",
"Update Changed Links",
[],
None,
"{} read again the active document's linked files that changed on disk since they were last seen (size or modification time), whatever Preferences → File Handling → Update Links says; the desktop app looks every two seconds and follows that preference. Files seen for the first time are only remembered. One undo step → {updated: [ids], ask: [], modified: []}",
has_doc,
crate::link_watch::update_changed
),
cmd!(
"links.relink",
"Relink",
@@ -158,6 +167,11 @@ fn linked(n: &Node) -> Option<(&LinkInfo, &str, Option<bool>)> {
}
}
/// Every linked file of `d` with the ids of the objects showing it (for [`crate::link_watch`]).
pub(crate) fn linked_groups(d: &Document) -> Vec<(String, Vec<u64>)> {
groups(d, None).into_iter().filter(|g| !g.ids.is_empty()).map(|g| (g.link.path, ids_json(&g.ids))).collect()
}
/// The linked images and placed documents of `d` by file and content: those in the layers (only `ids` when
/// given), then, without `ids`, the ones only symbols and patterns show.
fn groups(d: &Document, ids: Option<&[NodeId]>) -> Vec<Group> {
+10
View File
@@ -10,6 +10,7 @@ pub mod cmd;
pub mod file_access;
pub mod guard;
pub mod inspect;
pub mod link_watch;
pub mod steps;
mod tooling;
pub mod units;
@@ -873,6 +874,11 @@ pub struct Session {
/// The walker threads a folder search starts; `None`: [`cmd::findfiles::threads`]. Tests set
/// it.
pub search_threads: Option<usize>,
/// Each open document's linked files' size and modification time when last seen (linked
/// files changing while the document is open, [`link_watch`]).
pub link_stamps: std::collections::HashMap<(u64, String), link_watch::Stamp>,
/// The stamps being taken on a worker thread ([`Session::start_link_scan`]).
pub link_scan: Option<link_watch::LinkScan>,
}
impl Default for Session {
@@ -928,6 +934,8 @@ impl Session {
font_search: None,
search_rules: None,
search_threads: None,
link_stamps: Default::default(),
link_scan: None,
}
}
@@ -1506,6 +1514,8 @@ mod tests_links;
#[cfg(test)]
mod tests_linkspanel;
#[cfg(test)]
mod tests_linkwatch;
#[cfg(test)]
mod tests_liquify;
#[cfg(test)]
mod tests_live;
+150
View File
@@ -0,0 +1,150 @@
//! Linked files that change while their document is open (another app saved them: PhotoCraft,
//! LightCraft, Photoshop, a placed VectorCraft document): Preferences › File Handling › Update
//! Links decides, as it does when a document opens. Automatically reads them again
//! (`links.update`, an undo step), Ask When Modified offers to, Manually leaves them modified in
//! the Links panel.
//!
//! The active document's linked files are compared by size and modification time with what they
//! were when last seen. The desktop app takes the stamps every two seconds and when its window
//! comes back to the front, on a worker thread so a file on a slow network share never holds up
//! the interface ([`Session::start_link_scan`], [`Session::poll_link_scan`]);
//! `links.updateChanged` takes them on the spot and reads the changed files again (CLI, agents).
//!
//! Stamps are kept per document: one in the background is looked at when it comes to the front.
//! A file seen for the first time is only remembered (one modified while its document was closed
//! is handled when the document opens); one that can't be read yet (another app is still writing
//! it) keeps its old stamp, so the next look tries again. A change is acted on once: undoing an
//! update, or answering No, leaves the link modified until the file changes again.
use std::collections::{BTreeSet, HashMap, HashSet};
use std::sync::{Arc, Mutex, PoisonError};
use serde_json::{Value, json};
use crate::{Result, Session};
/// A file's size and modification time (nanoseconds since 1970).
pub type Stamp = (u64, u128);
/// Stamps of a document's linked files taken on a worker thread: (document uid, [(file,
/// object ids, stamp)]).
pub type LinkScan = Arc<Mutex<Option<(u64, Vec<(String, Vec<u64>, Option<Stamp>)>)>>>;
#[cfg(not(target_arch = "wasm32"))]
fn stamp(path: &str) -> Option<Stamp> {
let meta = std::fs::metadata(path).ok()?;
let modified = meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?;
Some((meta.len(), modified.as_nanos()))
}
#[cfg(target_arch = "wasm32")]
fn stamp(_: &str) -> Option<Stamp> {
None
}
/// The linked files of `d` with the ids of the objects showing them.
pub fn linked_files(d: &vectorcraft_doc::Document) -> Vec<(String, Vec<u64>)> {
crate::cmd::links::linked_groups(d)
}
/// `links.updateChanged`: stamp the active document's linked files now and read the changed
/// ones again, whatever Update Links says.
pub(crate) fn update_changed(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc()?;
let (uid, files) = (st.uid, crate::cmd::links::linked_groups(&st.doc));
let now = files.into_iter().map(|(p, ids)| (p.clone(), ids, stamp(&p))).collect();
s.apply_link_stamps(uid, now, true)
}
impl Session {
/// Start stamping the active document's linked files on a worker thread, unless a look is
/// still running (or the build has no files to look at: the web app).
pub fn start_link_scan(&mut self) {
if self.link_scan.is_some() || cfg!(target_arch = "wasm32") {
return;
}
let Some(st) = self.active() else { return };
let (uid, files) = (st.uid, crate::cmd::links::linked_groups(&st.doc));
if files.is_empty() {
return;
}
let slot: LinkScan = Arc::default();
let out = slot.clone();
let spawned = std::thread::Builder::new().name("link-watch".into()).spawn(move || {
let stamps = files.into_iter().map(|(p, ids)| (p.clone(), ids, stamp(&p))).collect();
*out.lock().unwrap_or_else(PoisonError::into_inner) = Some((uid, stamps));
});
if spawned.is_ok() {
self.link_scan = Some(slot);
}
}
/// Apply a finished look (`None` while there is none, or it is still running): see
/// [`Session::apply_link_stamps`].
pub fn poll_link_scan(&mut self) -> Option<Result<Value>> {
let (uid, stamps) = self.link_scan.as_ref()?.lock().unwrap_or_else(PoisonError::into_inner).take()?;
self.link_scan = None;
Some(self.apply_link_stamps(uid, stamps, false))
}
/// Compare the stamps of document `uid`'s linked files with the ones last seen and act on
/// the changed ones as Update Links says (`update`: read them again regardless). Returns
/// `{updated: [ids], ask: [ids], modified: [ids]}`: read again, to offer (Ask When Modified),
/// or left modified (Manually).
pub fn apply_link_stamps(&mut self, uid: u64, now: Vec<(String, Vec<u64>, Option<Stamp>)>, update: bool) -> Result<Value> {
let none = || json!({"updated": [], "ask": [], "modified": []});
// The document went to the back meanwhile: it's looked at when it's in front again.
if self.active().map(|d| d.uid) != Some(uid) {
return Ok(none());
}
// This document's last stamps; those of closed documents are dropped.
let open: HashSet<u64> = self.documents().iter().map(|d| d.uid).collect();
let mut before: HashMap<String, Stamp> = HashMap::new();
self.link_stamps.retain(|(u, p), s| {
if *u == uid {
before.insert(p.clone(), *s);
false
} else {
open.contains(u)
}
});
let mut changed: Vec<(String, Vec<u64>)> = Vec::new();
for (path, ids, stamp) in now {
match (before.get(&path), stamp) {
// Not there right now (an app saving by delete and rename): keep the last stamp.
(Some(old), None) => {
self.link_stamps.insert((uid, path), *old);
}
(old, Some(new)) => {
if old.is_some_and(|o| *o != new) {
changed.push((path.clone(), ids));
}
self.link_stamps.insert((uid, path), new);
}
(None, None) => {}
}
}
if changed.is_empty() {
return Ok(none());
}
let ids: Vec<u64> = changed.iter().flat_map(|(_, ids)| ids.iter().copied()).collect();
let mode = if update { "automatically" } else { self.prefs.update_links.as_str() };
match mode {
"automatically" => {
let r = self.execute("links.update", &json!({"ids": ids}))?;
// Not readable yet (still being written): the next look tries again.
let unread: BTreeSet<u64> = r["missing"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
for (path, ids) in &changed {
if ids.iter().any(|i| unread.contains(i))
&& let Some(old) = before.get(path)
{
self.link_stamps.insert((uid, path.clone()), *old);
}
}
Ok(json!({"updated": r["updated"], "ask": [], "modified": []}))
}
"askWhenModified" => Ok(json!({"updated": [], "ask": ids, "modified": []})),
_ => Ok(json!({"updated": [], "ask": [], "modified": ids})),
}
}
}
+88
View File
@@ -0,0 +1,88 @@
//! Linked files changing while their document is open (`link_watch`): `links.updateChanged`,
//! and the background look following Preferences → File Handling → Update Links.
use serde_json::{Value, json};
use super::*;
use crate::tests_links::{BLUE, Folder, RED, centre_colour, place, png, session};
/// Write `bytes` to `path` so its modification time moves even on coarse file systems.
fn save(path: &str, bytes: &[u8]) {
std::thread::sleep(std::time::Duration::from_millis(30));
std::fs::write(path, bytes).unwrap();
}
fn colour(s: &Session) -> [u8; 3] {
centre_colour(&s.doc().unwrap().doc)
}
/// One background look, waited for.
fn look(s: &mut Session) -> Value {
s.start_link_scan();
let t0 = std::time::Instant::now();
loop {
if let Some(r) = s.poll_link_scan() {
return r.unwrap();
}
assert!(t0.elapsed().as_secs() < 10, "the look never finished");
std::thread::yield_now();
}
}
#[test]
fn update_changed_reads_changed_files_again_once() {
let dir = Folder::new("watch-update");
let path = dir.file("art.png");
std::fs::write(&path, png(40, 40, RED)).unwrap();
let mut s = session();
let id = place(&mut s, &path);
// First look: remembered, nothing to read.
assert_eq!(s.execute("links.updateChanged", &json!({})).unwrap()["updated"], json!([]));
save(&path, &png(40, 40, BLUE));
assert_eq!(s.execute("links.updateChanged", &json!({})).unwrap()["updated"], json!([id.0]));
assert_eq!(colour(&s), BLUE);
// Undoing it is respected: the link stays modified until the file changes again.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(colour(&s), RED);
assert_eq!(s.execute("links.updateChanged", &json!({})).unwrap()["updated"], json!([]));
assert_eq!(s.execute("links.check", &json!({})).unwrap()["modified"], 1);
}
#[test]
fn a_background_look_follows_update_links() {
for (mode, key) in [("automatically", "updated"), ("askWhenModified", "ask"), ("manually", "modified")] {
let dir = Folder::new(&format!("watch-{mode}"));
let path = dir.file("art.png");
std::fs::write(&path, png(40, 40, RED)).unwrap();
let mut s = session();
s.prefs.update_links = mode.into();
let id = place(&mut s, &path);
assert_eq!(look(&mut s), json!({"updated": [], "ask": [], "modified": []}), "{mode}");
save(&path, &png(40, 40, BLUE));
let r = look(&mut s);
assert_eq!(r[key], json!([id.0]), "{mode}: {r}");
assert_eq!(colour(&s), if mode == "automatically" { BLUE } else { RED }, "{mode}");
// Acted on once: the next look finds nothing new.
assert_eq!(look(&mut s), json!({"updated": [], "ask": [], "modified": []}), "{mode}");
assert!(s.link_scan.is_none());
}
}
#[test]
fn a_file_still_being_written_is_tried_again() {
let dir = Folder::new("watch-partial");
let path = dir.file("art.png");
std::fs::write(&path, png(40, 40, RED)).unwrap();
let mut s = session();
s.prefs.update_links = "automatically".into();
let id = place(&mut s, &path);
look(&mut s);
// Another app has only written the start of the file so far.
let full = png(40, 40, BLUE);
save(&path, &full[..16]);
assert_eq!(look(&mut s)["updated"], json!([]));
assert_eq!(colour(&s), RED, "the last good pixels stay");
save(&path, &full);
assert_eq!(look(&mut s)["updated"], json!([id.0]));
assert_eq!(colour(&s), BLUE);
}
+20 -4
View File
@@ -1,5 +1,6 @@
//! The Photoshop-style raster effects (Effect › Blur › Radial Blur and Smart Blur, Pixelate ›
//! Color Halftone, Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask): applied and
//! Color Halftone, Crystallize, Mezzotint and Pointillize, Sharpen › Unsharp Mask, Texture ›
//! Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer, Video): applied and
//! edited as commands, listed in the catalogue, drawn on the canvas, and written to PDF and SVG
//! as images of the effected object.
@@ -21,6 +22,16 @@ const EFFECTS: [(&str, &str); 3] = [
const PIXELATE: [(&str, &str); 4] =
[("pixelate.colorHalftone", "{}"), ("pixelate.crystallize", "{}"), ("pixelate.mezzotint", "{}"), ("pixelate.pointillize", "{}")];
/// Effect › Texture, at their defaults.
const TEXTURE: [(&str, &str); 6] = [
("texture.craquelure", "{}"),
("texture.grain", "{}"),
("texture.mosaicTiles", "{}"),
("texture.patchwork", "{}"),
("texture.stainedGlass", "{}"),
("texture.texturizer", "{}"),
];
/// Effect › Video (NTSC Colors leaves the striped art's legal colours as they are: see
/// `ntsc_colors_draws_saturated_yellow_safer`).
const VIDEO: [(&str, &str); 1] = [("video.deinterlace", r#"{"create": "interpolation"}"#)];
@@ -61,7 +72,12 @@ fn listed_applied_edited_and_undone() {
let menus: Vec<Value> =
EFFECTS.iter().map(|(id, _)| list["catalog"].as_array().unwrap().iter().find(|e| e["id"] == *id).unwrap()["menu"].clone()).collect();
assert_eq!(menus, [json!(["Effect", "Blur"]), json!(["Effect", "Blur"]), json!(["Effect", "Sharpen"])]);
for (group, menu) in [(PIXELATE.as_slice(), "Pixelate"), (VIDEO.as_slice(), "Video"), ([("video.ntscColors", "{}")].as_slice(), "Video")] {
for (group, menu) in [
(PIXELATE.as_slice(), "Pixelate"),
(TEXTURE.as_slice(), "Texture"),
(VIDEO.as_slice(), "Video"),
([("video.ntscColors", "{}")].as_slice(), "Video"),
] {
for (id, _) in group {
let e = list["catalog"].as_array().unwrap().iter().find(|e| e["id"] == *id).unwrap_or_else(|| panic!("{id} listed"));
assert_eq!((&e["raster"], &e["menu"]), (&json!(true), &json!(["Effect", menu])), "{id}");
@@ -86,7 +102,7 @@ fn listed_applied_edited_and_undone() {
#[test]
fn each_effect_changes_the_canvas_and_draws_the_same_twice() {
for (id, params) in EFFECTS.into_iter().chain(PIXELATE).chain(VIDEO) {
for (id, params) in EFFECTS.into_iter().chain(PIXELATE).chain(TEXTURE).chain(VIDEO) {
let (mut s, g) = striped();
let plain = render(&s);
let params: Value = serde_json::from_str(params).unwrap();
@@ -115,7 +131,7 @@ fn spin_blur_reaches_past_the_corners() {
#[test]
fn pdf_and_svg_write_the_effected_object_as_an_image() {
for (id, params) in EFFECTS.into_iter().chain(PIXELATE).chain(VIDEO) {
for (id, params) in EFFECTS.into_iter().chain(PIXELATE).chain(TEXTURE).chain(VIDEO) {
let (mut s, g) = striped();
let params: Value = serde_json::from_str(params).unwrap();
s.execute("effect.apply", &json!({"effect": id, "ids": [g], "params": params})).unwrap();
+9
View File
@@ -296,6 +296,15 @@ fn structured_junk() {
("effect.apply", json!({"effect": "pixelate.crystallize", "params": {"cellSize": -1e308}})),
("effect.apply", json!({"effect": "pixelate.mezzotint", "params": {"type": 5}})),
("effect.apply", json!({"effect": "pixelate.pointillize", "params": {"cellSize": "NaN"}})),
("effect.apply", json!({"effect": "texture.craquelure", "params": {"crackSpacing": -1e308, "crackDepth": "NaN", "crackBrightness": 1e308}})),
("effect.apply", json!({"effect": "texture.grain", "params": {"intensity": 1e308, "contrast": [1], "grainType": 7}})),
("effect.apply", json!({"effect": "texture.mosaicTiles", "params": {"tileSize": "1e999", "groutWidth": -1, "lightenGrout": null}})),
("effect.apply", json!({"effect": "texture.patchwork", "params": {"squareSize": 1e308, "relief": -1e308}})),
("effect.apply", json!({"effect": "texture.stainedGlass", "params": {"cellSize": 0, "borderThickness": 1e308, "lightIntensity": "x"}})),
(
"effect.apply",
json!({"effect": "texture.texturizer", "params": {"texture": {}, "scaling": -1e308, "relief": 1e308, "lightDirection": 3, "invert": "yes"}}),
),
("effect.apply", json!({"effect": "video.deinterlace", "params": {"eliminate": 7, "create": ["x"]}})),
("effect.apply", json!({"effect": "video.ntscColors", "params": {"junk": 1e308}})),
("effect.apply", json!({"effect": "no.such.effect"})),
+2
View File
@@ -17,6 +17,8 @@ vectorcraft-brush = { workspace = true }
vectorcraft-text = { workspace = true }
# Windows metafile previews, opened when a file's PostScript can't be read.
vectorcraft-metafile = { workspace = true }
# Non-native art in Illustrator editing data (a placed PDF's content, kept as a PDF).
vectorcraft-pdf = { workspace = true }
# The TIFF writer (previews) and PackBits, shared with raster export.
vectorcraft-render = { workspace = true }
kurbo = { workspace = true }
+101 -6
View File
@@ -37,6 +37,7 @@ use vectorcraft_geom::{FillRule, PathData};
use super::lex::{Lexer, Tok};
use super::obj::{self, Obj, V};
use super::paint::{self, GradientDef, Instance, Named};
use crate::ps;
/// Deepest nesting of layers, groups and dictionaries.
const MAX_DEPTH: usize = 256;
@@ -248,6 +249,8 @@ struct Reader<'a> {
hidden_ids: HashSet<NodeId>,
/// CMYK samples converted, by value.
cmyk_cache: HashMap<[u8; 4], [u8; 3]>,
/// A `/ForeignObject`'s `/Data`: its PDF as ASCII85 in comment lines, until the object closes.
foreign_data: Option<Vec<u8>>,
/// The page ended: what follows isn't art.
done: bool,
/// The dictionary closed last was opened on a `%_` line.
@@ -316,8 +319,11 @@ const SKIPPED: &[(&str, &str)] = &[
("Alternate_Content", "End_Versioned_Content"),
];
/// The note for a `/ForeignObject`, art the editor shows but doesn't edit.
pub const NON_NATIVE_ART: &str = "non-native art (a placed PDF's content) is left out: VectorCraft doesn't draw it from the editing data yet";
/// The note for a `/ForeignObject` (non-native art, the content of a placed PDF, which the editor
/// shows but doesn't edit) whose PDF can't be read.
pub const NON_NATIVE_ART_UNREAD: &str = "non-native art (a placed PDF's content) that VectorCraft can't read is left out";
/// The name of the group a `/ForeignObject`'s art comes in as.
pub const NON_NATIVE_ART: &str = "Non-native art";
/// A section comment's marker without its `AI<version>_` prefix and value (`AI14_BeginSymbol` →
/// `BeginSymbol`); DSC comments (`%%BeginProlog`) keep their `%`.
@@ -350,6 +356,7 @@ impl<'a> Reader<'a> {
pending_stroke: None,
spots: Vec::new(),
raster: None,
foreign_data: None,
after_object: false,
nodes: 0,
unsupported: BTreeSet::new(),
@@ -659,8 +666,10 @@ impl<'a> Reader<'a> {
// of it may start with `%_` and read as hidden tokens: a `(` there would open
// a string that swallows the rest of the layer.
let key = o.entries.last().and_then(|(k, _)| k.as_deref());
if (o.ty == "Binary" && key == Some("ASCII85Decode")) || (o.ty == "ForeignObject" && key == Some("Data")) {
if o.ty == "Binary" && key == Some("ASCII85Decode") {
self.lex.skip_past(b"~>");
} else if o.ty == "ForeignObject" && key == Some("Data") {
self.foreign_data = Some(self.lex.skip_past(b"~>").to_vec());
}
}
return Ok(());
@@ -839,9 +848,7 @@ impl<'a> Reader<'a> {
"Document" => self.document(&o),
"AI11Text" => self.text_slot(&o)?,
"SymbolInstance" => self.unreadable("symbols"),
// Art Illustrator shows but doesn't edit (a placed PDF's content): its PDF is kept in
// the dictionary, which nothing draws from yet, so the file opens without it.
"ForeignObject" => self.warn(NON_NATIVE_ART),
"ForeignObject" => self.foreign_art(&o)?,
_ => {}
}
Ok(())
@@ -858,6 +865,94 @@ impl<'a> Reader<'a> {
self.add(n, hidden)
}
/// Non-native art (`/ForeignObject`): the content of a placed PDF, which Illustrator shows but
/// doesn't edit. Its `/Data` is that PDF (ASCII85 in comment lines, kept by the `,` handler),
/// `/Bounds` the art's box in a space measured from `/Origin` with y down, and `/RTransform`
/// maps that space onto the art (both read off files: the box lands on the clipping paths
/// around it). The PDF importer reads the PDF, and its art is fitted to the box, as one
/// group. Without data, or with a PDF that can't be read, nothing is drawn.
fn foreign_art(&mut self, o: &Obj) -> Result<(), String> {
let Some(text) = self.foreign_data.take() else { return Ok(()) };
// One `%` starts each line; the rest, `_` included, is data.
let mut encoded = String::with_capacity(text.len());
for line in text.split(|b| matches!(b, b'\r' | b'\n')) {
encoded.push_str(&String::from_utf8_lossy(line.strip_prefix(b"%").unwrap_or(line)));
}
let Some(pdf) = ps::ascii85_decode(&encoded) else {
self.warn(NON_NATIVE_ART_UNREAD);
return Ok(());
};
let (rt, origin, bounds) = (o.get("RTransform").map(Self::nums), o.get("Origin").map(Self::nums), o.get("Bounds").map(Self::nums));
let (Some([a, b, c, d, tx, ty]), Some([ox, oy]), Some([x0, y0, x1, y1])) = (rt.as_deref(), origin.as_deref(), bounds.as_deref()) else {
self.warn(NON_NATIVE_ART_UNREAD);
return Ok(());
};
let place = Affine::new([*a, *b, *c, *d, *tx, *ty]) * Affine::new([1.0, 0.0, 0.0, -1.0, *ox, *oy]);
let art_box = place.transform_rect_bbox(Rect::new(*x0, *y0, *x1, *y1));
if !sane(art_box) || art_box.width() <= 0.0 || art_box.height() <= 0.0 {
self.warn(NON_NATIVE_ART_UNREAD);
return Ok(());
}
let target = self.ensure_space().transform_rect_bbox(art_box);
let opts = vectorcraft_pdf::ImportOptions { layers: false, ..Default::default() };
let read = match vectorcraft_pdf::import_with_report(&pdf, &opts) {
Ok(r) => r,
Err(e) => {
self.warn(&format!("{NON_NATIVE_ART_UNREAD} ({e})"));
return Ok(());
}
};
let mut art: Vec<Node> = read.document.layers.iter().filter_map(|l| l.children()).flatten().map(|n| n.as_ref().clone()).collect();
let Some(bbox) = art.iter().fold(None, |acc, n| vectorcraft_geom::union_opt(acc, n.geometric_bounds())) else { return Ok(()) };
if bbox.width() <= 0.0 || bbox.height() <= 0.0 {
return Ok(());
}
// The art's own box onto the object's box (its measure of the same art), upside down:
// the object's space has y down, and the PDF's content is kept in its own y-up
// coordinates, so the PDF's bottom is the top of the box.
let fit = Affine::translate((target.x0, target.y1))
* Affine::scale_non_uniform(target.width() / bbox.width(), -target.height() / bbox.height())
* Affine::translate((-bbox.x0, -bbox.y0));
for n in &mut art {
n.transform(fit, true);
// The PDF's swatches stay with it: its colours keep their values, unlinked.
self.reid(n);
}
for (key, blob) in read.document.images {
self.doc.images.entry(key).or_insert(blob);
}
let gs = self.gs.clone();
let mut group = self.new_node(NodeKind::Group { children: art.into_iter().map(Arc::new).collect(), clip: false }, &gs);
group.name = Some(NON_NATIVE_ART.into());
self.add(group, self.obj_hidden)
}
/// Give `n` and its descendants ids of this document, and unlink their swatches.
fn reid(&mut self, n: &mut Node) {
n.id = self.doc.alloc_id();
for it in &mut n.appearance.items {
let p = match it {
AppearanceItem::Fill(f) => &mut f.paint,
AppearanceItem::Stroke(s) => &mut s.paint,
};
if let Paint::Gradient(g) = p {
g.swatch = None;
}
p.map_links(&mut |_, link, _| {
*link = None;
true
});
}
if let Some(m) = &mut n.mask {
self.reid(Arc::make_mut(&mut m.art));
}
if let Some(children) = n.children_mut() {
for c in children {
self.reid(Arc::make_mut(c));
}
}
}
/// Note something the data has that isn't read: an error on a layer that shows, left out of a
/// layer that doesn't.
fn unreadable(&mut self, what: &str) {
+94 -5
View File
@@ -5,7 +5,7 @@ use std::sync::Arc;
use kurbo::{Point, Rect};
use vectorcraft_color::gradient::GradientKind;
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::{ColorMode, Document, Knockout, LayerColor, LineCap, LineJoin, Node, NodeKind};
use vectorcraft_doc::{AppearanceItem, ColorMode, Document, Knockout, LayerColor, LineCap, LineJoin, Node, NodeKind};
use super::*;
@@ -332,13 +332,102 @@ fn sections_it_skips() {
/// Non-native art (a placed PDF's content) keeps its PDF as ASCII85 in comment lines after
/// `/Data ,`. ASCII85 has `_`, so a line may start with `%_`, which otherwise reads as hidden
/// tokens, and a `(` in it would swallow the rest of the layer (a letterhead saved by Illustrator
/// 28 wouldn't open: "it ends inside a layer"). The art is left out with a note.
/// 28 wouldn't open: "it ends inside a layer"). Data that isn't a PDF is left out with a note.
#[test]
fn non_native_art_is_skipped_with_its_ascii85_data() {
let foreign = "/ForeignObject :\n1 /Version ,\n2 0 0 2 10 20 /RTransform ,\n/Data ,\n%,u@!!/MSk8%41#ocdN=10d&\n%_O%*mGn(RduU.]4`[u[.f\n%_;(]<[%\n%1H@<P2`V<S,pbuU7L]\\~>\n;\n0 0 Xd\n6 () XW\n0 Ae\n";
fn non_native_art_that_is_not_a_pdf_is_left_out_with_a_note() {
let foreign = "/ForeignObject :\n1 /Version ,\n2 0 0 2 10 20 /RTransform ,\n10 20 /Origin ,\n1 2 5 8 /Bounds ,\n/Data ,\n%,u@!!/MSk8%41#ocdN=10d&\n%_O%*mGn(RduU.]4`[u[.f\n%_;(]<[%\n%1H@<P2`V<S,pbuU7L]\\~>\n;\n0 0 Xd\n6 () XW\n0 Ae\n";
let s = read_ok(&layer(&format!("{foreign}{}", rect_path(0.0, 0.0, 1.0, 1.0) + "f\n")));
assert_eq!(art(&s.doc).len(), 1);
assert!(s.warnings.iter().any(|w| w == read::NON_NATIVE_ART), "{:?}", s.warnings);
assert!(s.warnings.iter().any(|w| w.starts_with(read::NON_NATIVE_ART_UNREAD)), "{:?}", s.warnings);
}
/// A PDF with one page of `size` points holding `content`.
fn tiny_pdf(size: f64, content: &str) -> Vec<u8> {
let objects = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {size} {size}] /Contents 4 0 R >>"),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len() + 1),
];
let mut pdf = b"%PDF-1.4\n".to_vec();
let mut offsets = vec![];
for (i, o) in objects.iter().enumerate() {
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = pdf.len();
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
for o in offsets {
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
pdf
}
/// `bytes` as ASCII85 in comment lines of 60 characters, ending with `~>`.
fn ascii85_comment_lines(bytes: &[u8]) -> String {
let mut text = String::new();
for chunk in bytes.chunks(4) {
let mut word = [0u8; 4];
word[..chunk.len()].copy_from_slice(chunk);
let mut v = u32::from_be_bytes(word);
let mut digits = [0u8; 5];
for d in digits.iter_mut().rev() {
*d = (v % 85) as u8 + b'!';
v /= 85;
}
text.extend(digits.iter().take(chunk.len() + 1).map(|d| char::from(*d)));
}
let mut lines = String::new();
for line in text.as_bytes().chunks(60) {
lines.push('%');
lines.push_str(std::str::from_utf8(line).unwrap());
lines.push('\n');
}
lines.push_str("%~>\n");
lines
}
/// The PDF of non-native art is drawn by the PDF importer, its art fitted to the object's box:
/// `/Bounds` in a space measured from `/Origin` with y down, then `/RTransform`. Two squares at
/// 1..5 × 2..8 of a 10-point page, blue below red: bounds (1, 2)–(5, 8) → from the origin
/// (10, 20), y down, (11, 18)–(15, 12) → twice that plus (100, 50): x 122..130, y 74..86 in art
/// space, which is y 14..26 in the document (crop marks 100 high). The space has y down, so the
/// PDF's bottom (blue) is the top of the box.
#[test]
fn non_native_art_is_drawn_from_its_pdf_fitted_to_its_bounds() {
let pdf = tiny_pdf(10.0, "0 0 1 rg 1 2 4 3 re f 1 0 0 rg 1 5 4 3 re f");
let foreign = format!(
"q\n/ForeignObject :\n1 /Version ,\n2 0 0 2 100 50 /RTransform ,\n10 20 /Origin ,\n1 2 5 8 /Bounds ,\n/Data ,\n{};\n0 0 Xd\n6 () XW\n0 Ae\n{}h\nW\nn\nQ\n",
ascii85_comment_lines(&pdf),
rect_path(120.0, 70.0, 130.0, 90.0)
);
let s = read_ok(&layer(&foreign));
assert!(s.warnings.is_empty(), "{:?}", s.warnings);
let objects = art(&s.doc);
assert_eq!(objects.len(), 1, "{objects:?}");
// A clipping group: the clip path first, then the non-native art's group.
let NodeKind::Group { children, clip: true } = &objects[0].kind else { panic!("{:?}", objects[0].kind) };
assert_eq!(children.len(), 2);
let group = &children[1];
assert_eq!(group.name.as_deref(), Some(read::NON_NATIVE_ART));
let bounds = group.geometric_bounds().unwrap();
for (got, want) in [(bounds.x0, 122.0), (bounds.y0, 14.0), (bounds.x1, 130.0), (bounds.y1, 26.0)] {
assert!((got - want).abs() < 1e-6, "{bounds:?}");
}
let paths = group.children().unwrap();
assert_eq!(paths.len(), 2);
let fill =
|n: &Node| n.appearance.items.iter().find_map(|it| if let AppearanceItem::Fill(f) = it { Some(f.paint.clone()) } else { None }).unwrap();
let (blue, red) = (fill(&paths[0]), fill(&paths[1]));
assert!(matches!(&blue, Paint::Solid { color, swatch: None, .. } if color.to_rgba8(1.0) == [0, 0, 255, 255]), "{blue:?}");
assert!(matches!(&red, Paint::Solid { color, swatch: None, .. } if color.to_rgba8(1.0) == [255, 0, 0, 255]), "{red:?}");
let (b, r) = (paths[0].geometric_bounds().unwrap(), paths[1].geometric_bounds().unwrap());
assert!((b.y0 - 14.0).abs() < 1e-6 && (b.y1 - 20.0).abs() < 1e-6, "blue {b:?}");
assert!((r.y0 - 20.0).abs() < 1e-6 && (r.y1 - 26.0).abs() < 1e-6, "red {r:?}");
// Its ids are this document's.
assert_ne!(group.id, paths[0].id);
assert_ne!(paths[0].id, paths[1].id);
}
#[test]
+2 -2
View File
@@ -419,7 +419,7 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"create_graph",
"Create graph",
"Create a graph (graph tools) in the plot rectangle. Data as `csv` (first row: empty cell then series names; then one row per category: label, values…) or `series`/`categories`/`rows`. Edit later with run_command graph.setData / graph.setType.",
"Create a graph (graph tools) in the plot rectangle. Data as `csv` (first row: empty cell then series names; then one row per category: label, values…) or `series`/`categories`/`rows`. An empty CSV cell or a null in `rows` is a blank value (no column; lines break around it); a number in straight quotes is a label. Edit later with run_command graph.setData / graph.setType.",
obj(
json!({
"type": {"type": "string", "enum": ["column", "stackedColumn", "bar", "stackedBar", "line", "area", "scatter", "pie", "radar"]},
@@ -430,7 +430,7 @@ pub fn tool_definitions() -> Vec<Value> {
"csv": {"type": "string"},
"series": {"type": "array", "items": {"type": "string"}},
"categories": {"type": "array", "items": {"type": "string"}},
"rows": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}},
"rows": {"type": "array", "items": {"type": "array", "items": {"type": ["number", "null"]}}},
}),
&["x", "y", "width", "height"],
),
+2 -1
View File
@@ -603,7 +603,8 @@ impl Renderer {
};
// De-Interlace's field lines: the rows of the document's raster grid (1 to 2400 ppi).
let ppi = Some(f.doc.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0);
let space = effects::PixelSpace { to_doc, px, center: content.reach.center(), channels, line: 72.0 / ppi };
let (center, extent) = (content.reach.center(), 0.5 * content.reach.width().hypot(content.reach.height()));
let space = effects::PixelSpace { to_doc, px, center, channels, line: 72.0 / ppi, extent };
let (wz, hz) = (wu as usize, hu as usize);
for fx in chain {
match fx {
+42 -7
View File
@@ -367,13 +367,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
painter.add(Shape::closed_line(xf.quad(doc.setup.bleed_rect(ab.rect)), Stroke::new(1.0, t.bleed)));
}
if (artboard_tool || doc.artboards.len() > 1) && tool_labelled != u64::try_from(i).ok() {
painter.text(
r.left_top() - vec2(0.0, 4.0),
egui::Align2::LEFT_BOTTOM,
format!("{:02} - {}", i + 1, ab.name),
egui::FontId::proportional(11.0),
t.text_dim,
);
artboard_label(&painter, r, &format!("{:02} - {}", i + 1, ab.name), t.text_dim);
}
}
if app.ui.view.guides {
@@ -1749,6 +1743,22 @@ fn ime_output(app: &mut VectorcraftApp, ctx: &egui::Context, xf: &Xf) {
});
}
/// The width an artboard needs on screen for its name to show (px): narrower, the name is left
/// out rather than spilling over its neighbours.
const MIN_LABELLED_ARTBOARD: f32 = 24.0;
/// Artboard `name` above the top-left corner of its on-screen rect `r`, no wider than the
/// artboard: a longer name ends in "…", and an artboard too narrow shows none (#949).
fn artboard_label(painter: &egui::Painter, r: egui::Rect, name: &str, color: Color32) {
if r.width() < MIN_LABELLED_ARTBOARD {
return;
}
let mut job = egui::text::LayoutJob::simple_singleline(name.to_owned(), egui::FontId::proportional(11.0), color);
job.wrap = egui::text::TextWrapping { max_width: r.width(), max_rows: 1, break_anywhere: true, overflow_character: Some('…') };
let galley = painter.layout_job(job);
painter.galley(r.left_top() - vec2(0.0, 4.0 + galley.size().y), galley, color);
}
/// Is overlay label `text` the Artboard tool's "01 - <artboard name>"? It holds a name, so it is
/// shown as it is; the tools' other labels ("anchor", "path", Puppet Warp's warning) are ours.
fn names_an_artboard(text: &str) -> bool {
@@ -2219,6 +2229,31 @@ mod tests {
assert!(!names_an_artboard("anchor") && !names_an_artboard("1 - x") && !names_an_artboard("Off the mesh - move closer"));
}
/// #949: an artboard's name stays within the artboard's width on screen, ending in "…" when
/// longer, and an artboard too narrow shows none.
#[test]
fn artboard_labels_fit_their_artboard() {
let ctx = egui::Context::default();
let drawn = |width: f32| {
let r = egui::Rect::from_min_size(pos2(50.0, 50.0), vec2(width, 40.0));
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| artboard_label(ui.painter(), r, "01 - A long artboard name", Color32::WHITE));
out.textures_delta.clear();
out.shapes
.into_iter()
.filter_map(|c| match c.shape {
egui::Shape::Text(t) => Some((t.galley.text().to_string(), t.galley.size().x)),
_ => None,
})
.collect::<Vec<_>>()
};
let full = drawn(400.0);
assert_eq!(full.first().map(|(s, _)| s.as_str()), Some("01 - A long artboard name"));
// Laid out no wider than the artboard (the galley keeps the whole text; it draws "…").
let (_, w) = drawn(30.0).first().cloned().unwrap();
assert!(full[0].1 > 30.0 && w <= 30.0 + 0.5, "{full:?} {w}");
assert!(drawn(10.0).is_empty());
}
fn middle(pos: Pos2, pressed: bool) -> egui::Event {
egui::Event::PointerButton { pos, button: egui::PointerButton::Middle, pressed, modifiers: Default::default() }
}
+5 -2
View File
@@ -12,15 +12,18 @@ use crate::theme::{self, Tokens};
use crate::widgets;
use crate::{VectorcraftApp, icons, menus, titlebar};
/// The application bar's height, in points.
pub const APP_BAR_HEIGHT: f32 = 44.0;
/// The application bar: brand mark, Home, menus, then Discord, search and the workspace switcher
/// at the right. With [`VectorcraftApp::custom_titlebar`] it is also the window's title bar
/// ([`titlebar`]): the caption buttons take the right end and the rest of the bar drags the window.
pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let custom = app.custom_titlebar;
let left = if app.integrated_titlebar { 78 } else { 8 };
let left = app.titlebar_inset.clamp(8.0, f32::from(i8::MAX)) as i8;
let frame = egui::Frame::NONE.fill(t.app_bar).inner_margin(egui::Margin { left, right: if custom { 0 } else { 14 }, top: 0, bottom: 0 });
let bar = egui::Panel::top("app_bar").exact_size(44.0).frame(frame.stroke(Stroke::new(1.0, t.border))).show(ui, |ui| {
let bar = egui::Panel::top("app_bar").exact_size(APP_BAR_HEIGHT).frame(frame.stroke(Stroke::new(1.0, t.border))).show(ui, |ui| {
if custom {
titlebar::drag_area(ui, ui.max_rect());
}
+16 -5
View File
@@ -7,6 +7,8 @@ use std::cmp::Ordering;
use egui::RichText;
use crate::VectorcraftApp;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Contributor {
pub login: &'static str,
@@ -214,7 +216,7 @@ impl Default for View {
}
/// About ▸ Contributors: a name toggle, a sort, and the list as a grab bag or a table.
pub fn contributors_ui(ui: &mut egui::Ui) {
pub fn contributors_ui(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
let id = egui::Id::new("credits_view");
let mut v = ui.data_mut(|d| d.get_temp::<View>(id)).unwrap_or_default();
ui.horizontal_wrapped(|ui| {
@@ -254,14 +256,14 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
if list.is_empty() {
ui.label(tl!("No contributor data was built into this copy."));
} else if v.table {
table(ui, &list, &mut v);
table(app, ui, &list, &mut v);
} else {
ui.horizontal_wrapped(|ui| {
for (i, c) in list.iter().enumerate() {
if i > 0 {
ui.label(RichText::new("·").weak());
}
ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
link(app, ui, c, v.names);
}
});
}
@@ -269,7 +271,16 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
ui.data_mut(|d| d.insert_temp(id, v));
}
fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
fn link(app: &mut VectorcraftApp, ui: &mut egui::Ui, c: &Contributor, names: NameMode) -> egui::Response {
let url = format!("https://github.com/{}", c.login);
let r = ui.add(egui::Link::new(c.name(names))).on_hover_text(c.summary());
if r.clicked() {
app.open_url(&url);
}
r
}
fn table(app: &mut VectorcraftApp, ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| {
for k in SortKey::ALL {
let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" };
@@ -284,7 +295,7 @@ fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
}
ui.end_row();
for c in list {
ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
link(app, ui, c, v.names);
ui.label(group(c.prs));
ui.label(group(c.commits));
ui.label(group(c.lines_added));
+1 -1
View File
@@ -33,7 +33,7 @@ pub(super) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
match tab {
1 => {
ui.set_width(660.0);
crate::credits::contributors_ui(ui);
crate::credits::contributors_ui(app, ui);
}
2 => {
ui.set_width(660.0);
+7 -1
View File
@@ -160,8 +160,12 @@ fn choices(effect: &str, key: &str) -> Option<form::Choices> {
("blur.radial", "quality") => Some(&[("Draft", "draft"), ("Good", "good"), ("Best", "best")]),
("blur.smart", "quality") => Some(&[("Low", "low"), ("Medium", "medium"), ("High", "high")]),
("pixelate.mezzotint", "type") => Some(&vectorcraft_effects::pixel::MEZZOTINT_TYPES),
("texture.grain", "grainType") => Some(&vectorcraft_effects::pixel::GRAIN_TYPES),
("texture.texturizer", "texture") => Some(&vectorcraft_effects::pixel::TEXTURES),
("texture.texturizer", "lightDirection") => Some(&vectorcraft_effects::pixel::LIGHT_DIRECTIONS),
("video.deinterlace", "eliminate") => Some(&[("Odd Fields", "odd"), ("Even Fields", "even")]),
("video.deinterlace", "create") => Some(&[("Duplication", "duplication"), ("Interpolation", "interpolation")]),
("stylize.innerGlow", "source") => Some(&[("Center", "center"), ("Edge", "edge")]),
("path.offsetPath", "joins") => Some(&[("Miter", "miter"), ("Round", "round"), ("Bevel", "bevel")]),
("distort.roughen" | "distort.zigZag", "points") => Some(&[("Smooth", "smooth"), ("Corner", "corner")]),
_ => None,
@@ -415,7 +419,9 @@ mod tests {
#[test]
fn fields_that_pick_a_value_are_dropdowns_of_its_values() {
// The values come from the effect's parameter documentation (`joins: "miter"|"round"|…`).
for (effect, key) in [("path.offsetPath", "joins"), ("distort.zigZag", "points"), ("distort.roughen", "points")] {
for (effect, key) in
[("path.offsetPath", "joins"), ("distort.zigZag", "points"), ("distort.roughen", "points"), ("stylize.innerGlow", "source")]
{
let doc = vectorcraft_effects::effect_info(effect).unwrap().params;
let listed = doc.split(&format!("{key}: ")).nth(1).unwrap().split([',', '}', ' ']).next().unwrap().to_string();
let values: Vec<String> = choices(effect, key).unwrap().iter().map(|(_, v)| format!("\"{v}\"")).collect();
+6 -3
View File
@@ -232,10 +232,13 @@ pub(super) fn param_fields(
continue;
}
if let Some(options) = choices(&k) {
// In the "effect" context: some languages read the plain Soft, Horizontal and
// Vertical (Grain's types) as type settings.
let shown = |label: &'static str| crate::i18n::tr_ctx(crate::i18n::current(), "effect", label);
let cur = v.as_str().unwrap_or_default();
let label = options.iter().find(|(_, value)| *value == cur).map_or(cur, |(l, _)| *l);
let labels: Vec<&str> = options.iter().map(|(l, _)| *l).collect();
if let Some((_, value)) = crate::widgets::dropdown(ui, ("fx-choice", &k), label, &labels, 140.0).and_then(|i| options.get(i)) {
let label = options.iter().find(|(_, value)| *value == cur).map_or(cur, |(l, _)| shown(l));
let labels: Vec<&str> = options.iter().map(|(l, _)| shown(l)).collect();
if let Some((_, value)) = crate::widgets::dropdown_names(ui, ("fx-choice", &k), label, &labels, 140.0).and_then(|i| options.get(i)) {
d.fields.insert(k, json!(value));
changed = true;
}
+13 -93
View File
@@ -2,8 +2,8 @@
//! (`links.relink`), and with Apply to All the other missing files are looked for by name in its
//! folder; Ignore keeps the images showing their saved preview, and with Apply to All ignores the
//! rest. Afterwards, with Preferences › Update Links: Ask When Modified, the modified links are
//! offered for update (`links.update`). Files changed while the app was in the background are
//! checked when its window comes back to the front ([`after_focus`]).
//! offered for update (`links.update`). Files another app changes while the document is open are
//! offered the same way ([`ask_update_changed`], from [`vectorcraft_engine::link_watch`]).
//!
//! Fields: `missing` (`[{name, path, ids}]`, the one asked about first), `modified` (the ids of
//! images whose file was modified), `applyToAll`, `path` (the replacement file; picked when empty)
@@ -61,6 +61,16 @@ fn open(app: &mut VectorcraftApp, missing: Vec<Value>, modified: Vec<Value>) {
/// With Update Links: Ask When Modified, offer to read the modified linked files `ids` again.
fn ask_update(app: &mut VectorcraftApp, ids: Vec<Value>) {
ask(app, ids, tl!("{count} linked image(s) changed since this document was saved. Show the new versions?"));
}
/// The same offer for linked files another app changed while the document is open
/// ([`vectorcraft_engine::link_watch`]).
pub(crate) fn ask_update_changed(app: &mut VectorcraftApp, ids: Vec<Value>) {
ask(app, ids, tl!("{count} linked image(s) changed on disk. Show the new versions?"));
}
fn ask(app: &mut VectorcraftApp, ids: Vec<Value>, question: &str) {
if ids.is_empty() {
return;
}
@@ -68,46 +78,10 @@ fn ask_update(app: &mut VectorcraftApp, ids: Vec<Value>) {
app.status(format!("{} linked image(s) changed on disk: Update Links shows the new versions", ids.len()));
return;
}
let detail = crate::i18n::fmt(
tl!("{count} linked image(s) changed since this document was saved. Show the new versions?"),
&[("count", &ids.len().to_string())],
);
let detail = crate::i18n::fmt(question, &[("count", &ids.len().to_string())]);
super::confirm::ask(app, tl!("Update Modified Links"), &detail, "links.update", json!({ "ids": ids }));
}
/// A linked file as last seen: its path, and its size and modification time when it exists.
pub(crate) type FileStamp = (String, Option<(u64, Option<std::time::SystemTime>)>);
/// The window came back to the front (#925): linked files of the active document changed
/// meanwhile are read again with Update Links: Automatically, offered for update with Ask When
/// Modified (once per change of a file), and left to the Links panel with Manually. Nothing is
/// asked while a dialog is open.
pub fn after_focus(app: &mut VectorcraftApp) {
if cfg!(target_arch = "wasm32") || app.session.prefs.update_links == "manually" || app.ui.dialog.is_some() || app.session.active().is_none() {
return;
}
let Ok(r) = app.session.execute("links.check", &json!({})) else { return };
let rows: Vec<Value> = r["links"].as_array().into_iter().flatten().filter(|l| l["status"] == "modified").cloned().collect();
if rows.is_empty() {
return;
}
if app.session.prefs.update_links == "automatically" {
// A failure shows in the status bar, as the Links panel's Update Link does.
let _ = app.run("links.update", json!({ "ids": ids(&rows) }));
return;
}
// The file each link reads (one found elsewhere after a move), its size and when it was last
// changed (two changes can share a time on file systems that keep it coarsely).
let stamp = |l: &Value| -> FileStamp {
let path = l["found"].as_str().or(l["path"].as_str()).unwrap_or_default().to_string();
let meta = std::fs::metadata(&path).ok();
let at = meta.as_ref().map(|m| (m.len(), m.modified().ok()));
(path, at)
};
let new: Vec<Value> = rows.into_iter().filter(|l| app.links_asked.insert(stamp(l))).collect();
ask_update(app, ids(&new));
}
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx());
let missing = d.fields.get("missing").and_then(Value::as_array).map_or(&[][..], Vec::as_slice);
@@ -296,58 +270,4 @@ mod tests {
assert!(app.ui.dialog.is_none());
let _ = std::fs::remove_dir_all(dir);
}
/// #925: a linked file changed while the app was in the background follows Update Links when
/// the window comes back to the front.
#[test]
fn a_link_changed_in_the_background_follows_update_links_on_focus() {
let dir = std::env::temp_dir().join(format!("vectorcraft-focus-links-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string())));
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
let pic = dir.join("a.png");
// Each version a different size, so no two share a length and modification time.
let mut version = 0;
let mut change = || {
version += 1;
let mut out = vec![];
image::RgbaImage::from_pixel(100 + version, 50, image::Rgba([40 * version as u8, 0, 0, 255]))
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
.unwrap();
std::fs::write(&pic, out).unwrap();
};
change();
app.run("file.place", json!({"path": pic.to_string_lossy()})).unwrap();
let modified = |app: &mut VectorcraftApp| app.run("links.check", json!({})).unwrap()["modified"].as_u64().unwrap();
// The window coming to the front asks for the check.
let mut raw = egui::RawInput { events: vec![egui::Event::WindowFocused(true)], ..Default::default() };
app.raw_input_hook(&mut raw);
assert!(app.links_check);
// Ask When Modified (the default): asked once per change.
change();
after_focus(&mut app);
let d = app.ui.dialog.take().expect("asks");
assert_eq!(d.str("__command"), "links.update");
after_focus(&mut app);
assert!(app.ui.dialog.is_none(), "not twice for the same change");
change();
after_focus(&mut app);
assert!(app.ui.dialog.is_some(), "a new change asks again");
super::super::confirm(&mut app).unwrap();
assert_eq!(modified(&mut app), 0);
// Automatically: read again without asking.
app.run("prefs.set", json!({"key": "updateLinks", "value": "automatically"})).unwrap();
change();
after_focus(&mut app);
assert!(app.ui.dialog.is_none());
assert_eq!(modified(&mut app), 0);
// Manually: left to the Links panel.
app.run("prefs.set", json!({"key": "updateLinks", "value": "manually"})).unwrap();
change();
after_focus(&mut app);
assert!(app.ui.dialog.is_none());
assert_eq!(modified(&mut app), 1);
let _ = std::fs::remove_dir_all(dir);
}
}
+10
View File
@@ -176,6 +176,12 @@ fn raster_filter_dialogs_preview_and_apply() {
("pixelate.crystallize", "cellSize", "20"),
("pixelate.mezzotint", "type", "longLines"),
("pixelate.pointillize", "cellSize", "12"),
("texture.craquelure", "crackDepth", "9"),
("texture.grain", "grainType", "stippled"),
("texture.mosaicTiles", "tileSize", "20"),
("texture.patchwork", "relief", "20"),
("texture.stainedGlass", "borderThickness", "2"),
("texture.texturizer", "lightDirection", "bottomLeft"),
] {
let mut app = app();
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap();
@@ -203,6 +209,10 @@ fn raster_filter_dialogs_preview_and_apply() {
assert_eq!(super::form::humanize("maxRadius"), "Max. Radius:");
assert_eq!(super::form::humanize("channel3"), "Channel 3:");
assert_eq!(super::form::humanize("cellSize"), "Cell Size:");
assert_eq!(super::form::humanize("lightDirection"), "Light Direction:");
let doc = vectorcraft_effects::effect_info("texture.texturizer").unwrap().params;
let ranks: Vec<usize> = ["texture", "scaling", "relief", "lightDirection", "invert"].iter().map(|k| super::effect::doc_rank(doc, k)).collect();
assert!(ranks.windows(2).all(|w| w[0] < w[1]) && ranks[4] < usize::MAX, "{ranks:?}");
}
#[test]
+13
View File
@@ -516,6 +516,9 @@
Layers Vrstvy
Libraries Knihovny
Links Propojení
{count} linked image(s) changed on disk. Show the new versions? Na disku se změnilo {count} propojených obrázků. Zobrazit nové verze?
Update Changed Links Aktualizovat změněná propojení
@msg Updated {_1} linked file(s) changed on disk Aktualizováno propojených souborů změněných na disku: {_1}
Magic Wand Kouzelná hůlka
Navigator Navigátor
Pattern Options Možnosti vzorku
@@ -660,3 +663,13 @@
Video Video
De-Interlace… Odstranit prokládání…
NTSC Colors Barvy NTSC
RGB: RGB:
CMYK: CMYK:
CMYK CMYK
Texture Textura
Craquelure… Krakelování…
Grain… Zrno…
Mosaic Tiles… Mozaikové dlaždice…
Patchwork… Patchwork…
Stained Glass… Vitráž…
Texturizer… Texturovač…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+46
View File
@@ -3003,6 +3003,7 @@
Untitled Sin título
Update Link Actualizar vínculo
Update Links Actualizar vínculos
Update Changed Links Actualizar vínculos cambiados
Update Modified Links Actualizar vínculos modificados
Updated {} image(s) Se actualizaron {} imagen(es)
UPPERCASE MAYÚSCULAS
@@ -3138,6 +3139,7 @@
{count} linked file(s) not found No se encontraron {count} archivo(s) vinculado(s)
fonts not copied: {fonts} fuentes no copiadas: {fonts}
{count} linked image(s) changed since this document was saved. Show the new versions? {count} imagen(es) vinculada(s) cambiaron desde que se guardó este documento. ¿Mostrar las versiones nuevas?
{count} linked image(s) changed on disk. Show the new versions? {count} imagen(es) vinculada(s) cambiaron en el disco. ¿Mostrar las versiones nuevas?
{count} more rules: Export writes every one. {count} reglas más: Exportar las escribe todas.
{count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. Se omitieron {count} objetos sin nombre: ponles nombre o activa Objetos sin nombre en el menú del panel.
{effect} is already applied {effect} ya está aplicado
@@ -3788,6 +3790,7 @@ axis Both Ambos
@msg {_1} is already used by {_2} {_1} ya lo usa {_2}
@msg {_1} linked file(s) can't be read here: their previews show aquí no se pueden leer {_1} archivo(s) vinculado(s): se muestran sus previsualizaciones
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} imagen(es) vinculada(s) cambiaron en el disco: Actualizar vínculos muestra las versiones nuevas
@msg Updated {_1} linked file(s) changed on disk Se actualizaron {_1} archivo(s) vinculado(s) que cambiaron en el disco
@msg {_1}: not saved, so not closed {_1}: no se guardó, así que no se cerró
@msg {bpp}-bit bitmaps can't be read no se pueden leer mapas de bits de {bpp} bits
@msg {command} didn't create an object {command} no creó ningún objeto
@@ -3929,3 +3932,46 @@ axis Both Ambos
Duplication Duplicación
Interpolation Interpolación
@msg Imported library {name} Biblioteca {name} importada
RGB: RGB:
CMYK: CMYK:
Texture Textura
Craquelure… Craquelado…
Grain… Granulado…
Mosaic Tiles… Azulejos de mosaico…
Patchwork… Retazos…
Stained Glass… Vidriera…
Texturizer… Texturizador…
Craquelure Craquelado
Grain Granulado
Mosaic Tiles Azulejos de mosaico
Patchwork Retazos
Stained Glass Vidriera
Texturizer Texturizador
Crack Spacing Espaciado de grietas
Crack Depth Profundidad de grietas
Crack Brightness Brillo de grietas
Intensity Intensidad
Grain Type Tipo de granulado
Tile Size Tamaño de azulejo
Grout Width Anchura de la junta
Lighten Grout Aclarar junta
Square Size Tamaño de cuadrado
Relief Relieve
Border Thickness Grosor del borde
Light Intensity Intensidad de luz
Light Direction Dirección de la luz
Sprinkles Espolvoreado
Clumped Agrupado
Contrasty Contrastado
Enlarged Ampliado
Stippled Punteado
Speckle Moteado
Brick Ladrillo
Burlap Arpillera
Canvas Lienzo
Sandstone Arenisca
Bottom Left Abajo a la izquierda
Top Left Arriba a la izquierda
Top Right Arriba a la derecha
Bottom Right Abajo a la derecha
Edge Borde
Can't render this file because it contains an unexpected character in line 2 and column 73.
+47
View File
@@ -3000,6 +3000,7 @@
Untitled Sans titre
Update Link Mettre à jour le lien
Update Links Mettre à jour les liens
Update Changed Links Mettre à jour les liens modifiés
Update Modified Links Mettre à jour les liens modifiés
Updated {} image(s) {} image(s) mise(s) à jour
UPPERCASE MAJUSCULES
@@ -3135,6 +3136,7 @@
{count} linked file(s) not found {count} fichier(s) lié(s) introuvable(s)
fonts not copied: {fonts} polices non copiées : {fonts}
{count} linked image(s) changed since this document was saved. Show the new versions? {count} image(s) liée(s) modifiée(s) depuis l’enregistrement de ce document. Afficher les nouvelles versions ?
{count} linked image(s) changed on disk. Show the new versions? {count} image(s) liée(s) modifiée(s) sur le disque. Afficher les nouvelles versions ?
{count} more rules: Export writes every one. {count} règles de plus : Exporter les écrit toutes.
{count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. {count} objets sans nom omis : nommez-les ou activez Objets sans nom dans le menu du panneau.
{effect} is already applied {effect} est déjà appliqué
@@ -3785,6 +3787,7 @@ axis Both Les deux
@msg {_1} is already used by {_2} {_1} est déjà utilisé par {_2}
@msg {_1} linked file(s) can't be read here: their previews show {_1} fichier(s) lié(s) illisible(s) ici : leurs aperçus sont affichés
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} image(s) liée(s) modifiée(s) sur le disque : Mettre à jour les liens affiche les nouvelles versions
@msg Updated {_1} linked file(s) changed on disk {_1} fichier(s) lié(s) modifié(s) sur le disque mis à jour
@msg {_1}: not saved, so not closed {_1} : non enregistré, donc non fermé
@msg {bpp}-bit bitmaps can't be read impossible de lire les images bitmap en {bpp} bits
@msg {command} didn't create an object {command} n’a créé aucun objet
@@ -3940,3 +3943,47 @@ axis Both Les deux
Duplication Duplication
Interpolation Interpolation
@msg Imported library {name} Bibliothèque {name} importée
RGB: RGB:
CMYK: CMYK:
Texture Texture
Craquelure… Craquelure…
Grain… Grain…
Mosaic Tiles… Carreaux de mosaïque…
Patchwork… Patchwork…
Stained Glass… Vitrail…
Texturizer… Placage de texture…
Craquelure Craquelure
Grain Grain
Mosaic Tiles Carreaux de mosaïque
Patchwork Patchwork
Stained Glass Vitrail
Texturizer Placage de texture
Crack Spacing Espacement des craquelures
Crack Depth Profondeur des craquelures
Crack Brightness Luminosité des craquelures
Intensity Intensité
Grain Type Type de grain
Tile Size Taille des carreaux
Grout Width Largeur des joints
Lighten Grout Éclaircir les joints
Square Size Taille des carrés
Relief Relief
Border Thickness Épaisseur de la bordure
Light Intensity Intensité de la lumière
Light Direction Direction de la lumière
Sprinkles Saupoudré
Clumped Agglutiné
Contrasty Contrasté
Enlarged Agrandi
Stippled Pointillé
Speckle Moucheté
Brick Brique
Burlap Toile de jute
Canvas Toile
Sandstone Grès
Bottom Left En bas à gauche
Top Left En haut à gauche
Top Right En haut à droite
Bottom Right En bas à droite
effect Soft Doux
Edge Bord
Can't render this file because it contains an unexpected character in line 2 and column 73.
+48
View File
@@ -3003,6 +3003,7 @@
Untitled Senza titolo
Update Link Aggiorna collegamento
Update Links Aggiorna collegamenti
Update Changed Links Aggiorna collegamenti modificati
Update Modified Links Aggiorna collegamenti modificati
Updated {} image(s) Immagini aggiornate: {}
UPPERCASE MAIUSCOLO
@@ -3138,6 +3139,7 @@
{count} linked file(s) not found File collegati non trovati: {count}
fonts not copied: {fonts} font non copiati: {fonts}
{count} linked image(s) changed since this document was saved. Show the new versions? Immagini collegate modificate dopo il salvataggio di questo documento: {count}. Vuoi mostrare le nuove versioni?
{count} linked image(s) changed on disk. Show the new versions? Immagini collegate modificate su disco: {count}. Vuoi mostrare le nuove versioni?
{count} more rules: Export writes every one. Altre {count} regole: Esporta le scrive tutte.
{count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. {count} oggetti senza nome esclusi: assegna loro un nome o attiva Oggetti senza nome nel menu del pannello.
{effect} is already applied L'effetto {effect} è già applicato
@@ -3788,6 +3790,7 @@ axis Both Entrambi
@msg {_1} is already used by {_2} {_1} è già usato da {_2}
@msg {_1} linked file(s) can't be read here: their previews show file collegati non leggibili qui: {_1}; vengono mostrate le loro anteprime
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions immagini collegate modificate su disco: {_1}; Aggiorna collegamenti mostra le nuove versioni
@msg Updated {_1} linked file(s) changed on disk File collegati modificati su disco aggiornati: {_1}
@msg {_1}: not saved, so not closed {_1}: non salvato, quindi non chiuso
@msg {bpp}-bit bitmaps can't be read impossibile leggere bitmap a {bpp} bit
@msg {command} didn't create an object {command} non ha creato alcun oggetto
@@ -3929,3 +3932,48 @@ axis Both Entrambi
Duplication Duplicazione
Interpolation Interpolazione
@msg Imported library {name} Libreria {name} importata
RGB: RGB:
CMYK: CMYK:
Texture Texture
Craquelure… Craquelé…
Grain… Granulosità…
Mosaic Tiles… Tessere di mosaico…
Patchwork… Patchwork…
Stained Glass… Vetro colorato…
Texturizer… Applica texture…
Craquelure Craquelé
Grain Granulosità
Mosaic Tiles Tessere di mosaico
Patchwork Patchwork
Stained Glass Vetro colorato
Texturizer Applica texture
Crack Spacing Spaziatura crepe
Crack Depth Profondità crepe
Crack Brightness Luminosità crepe
Intensity Intensità
Grain Type Tipo di granulosità
Tile Size Dimensione tessere
Grout Width Larghezza stuccatura
Lighten Grout Schiarisci stuccatura
Square Size Dimensione quadrati
Relief Rilievo
Border Thickness Spessore bordo
Light Intensity Intensità luce
Light Direction Direzione luce
Sprinkles Spruzzato
Clumped Raggruppato
Contrasty Contrastato
Enlarged Ingrandito
Stippled Punteggiato
Speckle Maculato
Brick Mattone
Burlap Tela di iuta
Canvas Tela
Sandstone Arenaria
Bottom Left In basso a sinistra
Top Left In alto a sinistra
Top Right In alto a destra
Bottom Right In basso a destra
effect Soft Morbido
effect Regular Normale
Edge Bordo
Can't render this file because it contains an unexpected character in line 2 and column 73.
+49
View File
@@ -2668,6 +2668,7 @@
Unshift シフトを戻す
Update Link リンクを更新
Update Links リンクを更新
Update Changed Links 変更されたリンクを更新
Update Modified Links 変更されたリンクを更新
Use メニュー
Use Artboards アートボードを使用
@@ -2777,6 +2778,7 @@
{count} linked file(s) not found リンクファイルが {count} 個見つかりません
fonts not copied: {fonts} コピーされなかったフォント:{fonts}
{count} linked image(s) changed since this document was saved. Show the new versions? 保存後に {count} 個のリンク画像が変更されています。新しいバージョンを表示しますか?
{count} linked image(s) changed on disk. Show the new versions? {count} 個のリンク画像がディスク上で変更されました。新しいバージョンを表示しますか?
{count} more rules: Export writes every one. ほかに {count} 個のルールがあります。書き出しにはすべて含まれます。
{count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. 名前のないオブジェクト {count} 個を除外しました。名前を付けるか、パネルメニューで「名前のないオブジェクト」をオンにしてください。
{effect} is already applied {effect} はすでに適用されています
@@ -3719,6 +3721,7 @@ theme Appearance 外観
@msg `{tool}` isn't a Liquify tool `{tool}` はゆがみツールではありません
@msg {_1} linked file(s) can't be read here: their previews show この環境では {_1} 個のリンクファイルを読み込めません: 代わりにプレビューを表示しています
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} 個のリンク画像がディスク上で変更されました: 「リンクを更新」で新しいバージョンを表示できます
@msg Updated {_1} linked file(s) changed on disk ディスク上で変更された {_1} 個のリンクファイルを更新しました
@msg pick a colour, gradient or pattern to save as a swatch スウォッチとして保存するカラー、グラデーション、またはパターンを選択してください
@msg Packaged {n} file(s) in {folder}{note} {n} 個のファイルを {folder} にパッケージしました{note}
@msg Packaged {n} file(s){note} {n} 個のファイルをパッケージしました{note}
@@ -3921,3 +3924,49 @@ theme Appearance 外観
Duplication 複製
Interpolation 補間
@msg Imported library {name} ライブラリ {name} を読み込みました
RGB: RGB:
CMYK: CMYK:
Texture テクスチャ
Craquelure… クラッキング…
Grain… 粒状…
Mosaic Tiles… モザイクタイル…
Patchwork… パッチワーク…
Stained Glass… ステンドグラス…
Texturizer… テクスチャライザー…
Craquelure クラッキング
Grain 粒状
Mosaic Tiles モザイクタイル
Patchwork パッチワーク
Stained Glass ステンドグラス
Texturizer テクスチャライザー
Crack Spacing 溝の間隔
Crack Depth 溝の深さ
Crack Brightness 溝の明るさ
Intensity 強さ
Grain Type 粒子の種類
Tile Size タイルのサイズ
Grout Width 目地の幅
Lighten Grout 目地を明るく
Square Size 正方形のサイズ
Relief レリーフ
Border Thickness 境界線の太さ
Light Intensity 光の強さ
Light Direction 光源の方向
Sprinkles 散布
Clumped 集合
Contrasty 高コントラスト
Enlarged 拡大
Stippled スティップル
Speckle 斑点
Brick レンガ
Burlap 麻布
Canvas カンバス
Sandstone 砂岩
Bottom Left 左下
Top Left 左上
Top Right 右上
Bottom Right 右下
effect Soft ソフト
effect Horizontal 水平
effect Vertical 垂直
Edge エッジ
Can't render this file because it contains an unexpected character in line 2 and column 73.
+12
View File
@@ -254,6 +254,13 @@ pub fn lang_from_tag(tag: &str) -> Option<Lang> {
cands.iter().find_map(|c| Lang::from_code(c))
}
/// True when `tag` names the generic `C` / `POSIX` locale (or a variant like `C.UTF-8`), which on
/// macOS means "no particular language" rather than English: the system language list decides.
fn is_c_locale(tag: &str) -> bool {
let base = tag.split(['.', '@']).next().unwrap_or("").to_ascii_lowercase();
matches!(base.as_str(), "c" | "posix")
}
/// Work out the system language on a background thread, so the first frame doesn't wait for the
/// locale probe (which runs `reg.exe` on Windows and `defaults` on macOS). Call it at startup; a
/// frame that needs the language before the probe finishes waits for it. A no-op on wasm.
@@ -295,6 +302,11 @@ fn detect_system_lang() -> Lang {
// variables. `LANGUAGE` is a colon-separated priority list.
for var in ["VECTORCRAFT_LOCALE", "LC_ALL", "LC_MESSAGES", "LANG", "LANGUAGE"] {
let Some(v) = std::env::var(var).ok().filter(|v| !v.is_empty()) else { continue };
// `C` / `POSIX` (and variants like `C.UTF-8`, `POSIX.UTF-8`) mean "no particular locale":
// don't force English, fall through to the OS preferred-languages list below.
if v.split(':').all(is_c_locale) {
continue;
}
if let Some(l) = v.split(':').find_map(lang_from_tag) {
return l;
}
+12
View File
@@ -2483,6 +2483,9 @@
Unshift Desfazer Mudança de Cor
Update Link Atualizar Link
Update Links Atualizar Links
Update Changed Links Atualizar Links alterados
{count} linked image(s) changed on disk. Show the new versions? {count} imagem(ns) vinculada(s) alterada(s) no disco. Mostrar as novas versões?
@msg Updated {_1} linked file(s) changed on disk {_1} arquivo(s) vinculado(s) alterado(s) no disco atualizado(s)
Update Modified Links Atualizar Links Modificados
Use Use
Use Artboards Usar Mesas de Trabalho
@@ -2721,3 +2724,12 @@
Video Vídeo
De-Interlace… Desentrelaçar…
NTSC Colors Cores NTSC
RGB: RGB:
CMYK: CMYK:
Texture Textura
Craquelure… Craquelê…
Grain… Granulação…
Mosaic Tiles… Ladrilhos de mosaico…
Patchwork… Colcha de retalhos…
Stained Glass… Vitral…
Texturizer… Texturizador…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+46
View File
@@ -2999,6 +2999,7 @@
Untitled Без названия
Update Link Обновить связь
Update Links Обновить связи
Update Changed Links Обновить измененные связи
Update Modified Links Обновить изменённые связи
Updated {} image(s) Обновлено изображений: {}
UPPERCASE ПРОПИСНЫЕ
@@ -3134,6 +3135,7 @@
{count} linked file(s) not found Не найдено связанных файлов: {count}
fonts not copied: {fonts} шрифты не скопированы: {fonts}
{count} linked image(s) changed since this document was saved. Show the new versions? Связанных изображений ({count}) изменились с момента сохранения этого документа. Показать новые версии?
{count} linked image(s) changed on disk. Show the new versions? Связанных изображений ({count}) изменились на диске. Показать новые версии?
{count} more rules: Export writes every one. Ещё правил: {count}: экспорт записывает все.
{count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. Пропущено объектов без имени: {count}: назовите их или включите «Объекты без имени» в меню панели.
{effect} is already applied {effect} уже применён
@@ -3784,6 +3786,7 @@ axis Both Оба
@msg {_1} is already used by {_2} {_1} уже используется {_2}
@msg {_1} linked file(s) can't be read here: their previews show здесь невозможно прочитать связанных файлов: {_1}: отображаются их предварительные просмотры
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions связанных изображений ({_1}) изменились на диске: «Обновить связи» покажет новые версии
@msg Updated {_1} linked file(s) changed on disk Обновлено связанных файлов, измененных на диске: {_1}
@msg {_1}: not saved, so not closed {_1}: не сохранён, поэтому не закрыт
@msg {bpp}-bit bitmaps can't be read растровые изображения с глубиной {bpp} бит не читаются
@msg {command} didn't create an object {command} не создал объект
@@ -3927,3 +3930,46 @@ axis Both Оба
Duplication Дублированием
Interpolation Интерполяцией
@msg Imported library {name} Импортирована библиотека {name}
RGB: RGB:
CMYK: CMYK:
Texture Текстура
Craquelure… Кракелюры…
Grain… Зерно…
Mosaic Tiles… Мозаичные фрагменты…
Patchwork… Цветная плитка…
Stained Glass… Витраж…
Texturizer… Текстуризатор…
Craquelure Кракелюры
Grain Зерно
Mosaic Tiles Мозаичные фрагменты
Patchwork Цветная плитка
Stained Glass Витраж
Texturizer Текстуризатор
Crack Spacing Интервал трещин
Crack Depth Глубина трещин
Crack Brightness Яркость трещин
Intensity Интенсивность
Grain Type Тип зерна
Tile Size Размер фрагментов
Grout Width Ширина швов
Lighten Grout Осветление швов
Square Size Размер квадратов
Relief Рельеф
Border Thickness Толщина границ
Light Intensity Интенсивность света
Light Direction Направление света
Sprinkles Брызги
Clumped Комки
Contrasty Контрастное
Enlarged Укрупнённое
Stippled Пунктир
Speckle Крапинки
Brick Кирпич
Burlap Мешковина
Canvas Холст
Sandstone Песчаник
Bottom Left Снизу слева
Top Left Сверху слева
Top Right Сверху справа
Bottom Right Снизу справа
Edge Край
Can't render this file because it contains an unexpected character in line 2 and column 73.
+46
View File
@@ -2995,6 +2995,7 @@
Untitled Без назви
Update Link Оновити зв’язок
Update Links Оновити зв’язки
Update Changed Links Оновити змінені зв’язки
Update Modified Links Оновити змінені зв’язки
Updated {} image(s) Оновлено зображень: {}
UPPERCASE ВЕЛИКІ ЛІТЕРИ
@@ -3130,6 +3131,7 @@
{count} linked file(s) not found не знайдено пов’язаних файлів: {count}
fonts not copied: {fonts} шрифти не скопійовано: {fonts}
{count} linked image(s) changed since this document was saved. Show the new versions? пов’язані зображення ({count}) змінилися з моменту збереження цього документа. Показати нові версії?
{count} linked image(s) changed on disk. Show the new versions? пов’язані зображення ({count}) змінилися на диску. Показати нові версії?
{count} more rules: Export writes every one. ще правил: {count}: експорт записує все.
{count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. пропущено об’єкти без імені: {count}: назвіть їх або увімкніть «Об’єкти без імені» у меню панелі.
{effect} is already applied {effect} вже застосований
@@ -3771,6 +3773,7 @@ axis Both Обидві
@msg {_1} is already used by {_2} {_1} вже використовується {_2}
@msg {_1} linked file(s) can't be read here: their previews show тут неможливо прочитати пов’язані файли: {_1}: відображаються їх попередні перегляди
@msg {_1} linked image(s) changed on disk: Update Links shows the new versions пов’язаних зображень ({_1}) змінилися на диску: «Оновити зв’язки» покаже нові версії
@msg Updated {_1} linked file(s) changed on disk Оновлено пов’язаних файлів, змінених на диску: {_1}
@msg {_1}: not saved, so not closed {_1}: не збережено, тому не закрито
@msg {bpp}-bit bitmaps can't be read растрові зображення з глибиною {bpp} біт не читаються
@msg {command} didn't create an object {command} не створив об’єкт
@@ -3912,3 +3915,46 @@ axis Both Обидві
Duplication Дублюванням
Interpolation Інтерполяцією
@msg Imported library {name} Імпортовано бібліотеку {name}
RGB: RGB:
CMYK: CMYK:
Texture Текстура
Craquelure… Кракелюри…
Grain… Зерно…
Mosaic Tiles… Мозаїчні фрагменти…
Patchwork… Клаптикова плитка…
Stained Glass… Вітраж…
Texturizer… Текстуризатор…
Craquelure Кракелюри
Grain Зерно
Mosaic Tiles Мозаїчні фрагменти
Patchwork Клаптикова плитка
Stained Glass Вітраж
Texturizer Текстуризатор
Crack Spacing Інтервал тріщин
Crack Depth Глибина тріщин
Crack Brightness Яскравість тріщин
Intensity Інтенсивність
Grain Type Тип зерна
Tile Size Розмір фрагментів
Grout Width Ширина швів
Lighten Grout Освітлення швів
Square Size Розмір квадратів
Relief Рельєф
Border Thickness Товщина меж
Light Intensity Інтенсивність світла
Light Direction Напрямок світла
Sprinkles Бризки
Clumped Грудки
Contrasty Контрастне
Enlarged Збільшене
Stippled Пунктир
Speckle Цятки
Brick Цегла
Burlap Мішковина
Canvas Полотно
Sandstone Пісковик
Bottom Left Знизу ліворуч
Top Left Вгорі ліворуч
Top Right Вгорі праворуч
Bottom Right Знизу праворуч
Edge Край
Can't render this file because it contains an unexpected character in line 659 and column 40.
+47
View File
@@ -2893,6 +2893,9 @@
Untitled 未命名
Update Link 更新链接
Update Links 更新链接
Update Changed Links 更新已更改的链接
{count} linked image(s) changed on disk. Show the new versions? 有 {count} 个链接图像已在磁盘上更改。要显示新版本吗?
@msg Updated {_1} linked file(s) changed on disk 已更新 {_1} 个在磁盘上更改的链接文件
Update Modified Links 更新已修改的链接
Updated {} image(s) 已更新 {} 张图像
UPPERCASE 大写
@@ -3559,3 +3562,47 @@ axis Both 两者
Even Fields 偶数场
Duplication 复制
Interpolation 插值
Texture 纹理
Craquelure… 龟裂缝…
Grain… 颗粒…
Mosaic Tiles… 马赛克拼贴…
Patchwork… 拼缀图…
Stained Glass… 染色玻璃…
Texturizer… 纹理化…
Craquelure 龟裂缝
Grain 颗粒
Mosaic Tiles 马赛克拼贴
Patchwork 拼缀图
Stained Glass 染色玻璃
Texturizer 纹理化
Crack Spacing 裂缝间距
Crack Depth 裂缝深度
Crack Brightness 裂缝亮度
Intensity 强度
Grain Type 颗粒类型
Tile Size 拼贴大小
Grout Width 缝隙宽度
Lighten Grout 加亮缝隙
Square Size 方形大小
Relief 凸现
Border Thickness 边框粗细
Light Intensity 光照强度
Light Direction 光照方向
Sprinkles 喷洒
Clumped 结块
Contrasty 强反差
Enlarged 扩大
Stippled 点刻
Speckle 斑点
Brick 砖形
Burlap 粗麻布
Canvas 画布
Sandstone 砂岩
Bottom Left 左下
Top Left 左上
Top Right 右上
Bottom Right 右下
effect Soft 柔和
effect Horizontal 水平
effect Vertical 垂直
Edge 边缘
Can't render this file because it contains an unexpected character in line 2 and column 73.
+47
View File
@@ -2984,6 +2984,9 @@
Untitled 未命名
Update Link 更新連結
Update Links 更新連結
Update Changed Links 更新已變更的連結
{count} linked image(s) changed on disk. Show the new versions? 有 {count} 個連結影像已在磁碟上變更。要顯示新版本嗎?
@msg Updated {_1} linked file(s) changed on disk 已更新 {_1} 個在磁碟上變更的連結檔案
Update Modified Links 更新已修改的連結
Updated {} image(s) 已更新 {} 張影像
UPPERCASE 全部大寫
@@ -3340,3 +3343,47 @@ axis Both 兩者
Even Fields 偶數圖場
Duplication 複製
Interpolation 內插
RGB: RGB:
CMYK: CMYK:
Texture 紋理
Craquelure… 龜裂紋…
Grain… 顆粒…
Mosaic Tiles… 馬賽克拼貼…
Patchwork… 拼綴圖…
Stained Glass… 彩色玻璃…
Texturizer… 紋理化…
Craquelure 龜裂紋
Grain 顆粒
Mosaic Tiles 馬賽克拼貼
Patchwork 拼綴圖
Stained Glass 彩色玻璃
Texturizer 紋理化
Crack Spacing 裂紋間距
Crack Depth 裂紋深度
Crack Brightness 裂紋亮度
Intensity 強度
Grain Type 顆粒類型
Tile Size 拼貼大小
Grout Width 縫隙寬度
Lighten Grout 加亮縫隙
Square Size 方塊大小
Relief 浮雕
Border Thickness 邊框粗細
Light Intensity 光源強度
Light Direction 光源方向
Sprinkles 噴灑
Clumped 結塊
Contrasty 高反差
Enlarged 放大
Stippled 點刻
Speckle 斑點
Brick 磚塊
Burlap 粗麻布
Canvas 畫布
Sandstone 砂岩
Bottom Left 左下
Top Left 左上
Top Right 右上
Bottom Right 右下
effect Soft 柔和
Edge 邊緣
Can't render this file because it contains an unexpected character in line 6 and column 47.
+55 -18
View File
@@ -314,8 +314,9 @@ pub struct VectorcraftApp {
pub services: Services,
pub canvas: CanvasCache,
pub perf: Perf,
/// macOS: draw our own title strip under the traffic lights.
pub integrated_titlebar: bool,
/// macOS: the room the app bar keeps at its left for the window buttons over it, in points
/// (0 where the OS draws no buttons over the app bar).
pub titlebar_inset: f32,
/// Last applied effect (Effect → Apply Last Effect).
pub last_effect: Option<(String, serde_json::Value)>,
/// Commands run through [`Self::run`] so far: the native menu bar reads its rows again when it
@@ -371,12 +372,6 @@ pub struct VectorcraftApp {
/// The look for fonts installed or removed while the app was in the background, running
/// ([`Self::refresh_installed_fonts`]): whether they were.
font_check: Option<std::sync::mpsc::Receiver<bool>>,
/// The window came back to the front: check the active document's linked files next frame
/// ([`dialogs::missing_links::after_focus`]).
pub(crate) links_check: bool,
/// The changed linked files already offered for update (path, size and modification time), so
/// coming back to the window doesn't ask about the same change twice.
pub(crate) links_asked: std::collections::HashSet<dialogs::missing_links::FileStamp>,
/// Keyboard pastes of something other than text (see [`shortcuts::PasteChord`]).
pub(crate) paste_chord: shortcuts::PasteChord,
/// Saves and exports running in the background (Preferences → File Handling).
@@ -446,7 +441,7 @@ impl VectorcraftApp {
cursors: Default::default(),
},
perf: Perf::default(),
integrated_titlebar: false,
titlebar_inset: 0.0,
last_effect: None,
run_count: 0,
clipboard_out: None,
@@ -474,8 +469,6 @@ impl VectorcraftApp {
clipboard_probe: None,
picks: picks::Picks::default(),
font_check: None,
links_check: false,
links_asked: Default::default(),
paste_chord: Default::default(),
background: Default::default(),
recovery: Default::default(),
@@ -757,6 +750,54 @@ impl VectorcraftApp {
std::mem::take(&mut self.ime_discard)
}
/// Linked files another app changed while their document is open: every two seconds, and as
/// soon as the window comes back to the front, stamp the active document's linked files on a
/// worker thread and act on the changed ones as Preferences › File Handling › Update Links
/// says ([`vectorcraft_engine::link_watch`]). No look starts while a dialog is open, so an
/// Ask When Modified question never goes unseen.
#[cfg(not(target_arch = "wasm32"))]
fn tick_link_updates(&mut self, ctx: &egui::Context, now: f64) {
if let Some(r) = self.session.poll_link_scan() {
match r {
Ok(r) => {
let n = |k: &str| r[k].as_array().map_or(0, Vec::len);
if n("updated") > 0 {
self.status(format!("Updated {} linked file(s) changed on disk", n("updated")));
}
if n("ask") > 0 {
dialogs::missing_links::ask_update_changed(self, r["ask"].as_array().cloned().unwrap_or_default());
}
if n("modified") > 0 {
self.status(format!("{} linked image(s) changed on disk: Update Links shows the new versions", n("modified")));
}
}
Err(e) => self.status(e.to_string()),
}
}
let (last_key, focus_key) = (egui::Id::new("linkWatch.lastScan"), egui::Id::new("linkWatch.focused"));
let focused = ctx.input(|i| i.focused);
let was_focused: bool = ctx.data(|d| d.get_temp(focus_key)).unwrap_or(focused);
ctx.data_mut(|d| d.insert_temp(focus_key, focused));
if self.ui.dialog.is_some() || self.session.active().is_none() {
return;
}
let last: f64 = ctx.data(|d| d.get_temp(last_key)).unwrap_or(f64::NEG_INFINITY);
if now - last >= 2.0 || (focused && !was_focused) {
// Walking the document's links costs a pass over it: only when a look is due.
// `start_link_scan` starts none for a document without links, which then doesn't
// wake the app for looks of its own (any frame still looks once one is due).
ctx.data_mut(|d| d.insert_temp(last_key, now));
self.session.start_link_scan();
let links = self.session.link_scan.is_some();
ctx.data_mut(|d| d.insert_temp(egui::Id::new("linkWatch.links"), links));
}
if self.session.link_scan.is_some() {
ctx.request_repaint_after(std::time::Duration::from_millis(100));
} else if ctx.data(|d| d.get_temp::<bool>(egui::Id::new("linkWatch.links"))).unwrap_or(false) {
ctx.request_repaint_after(std::time::Duration::from_millis(2000));
}
}
/// Show a transient status message.
pub fn status(&mut self, s: impl Into<String>) {
self.ui.status = s.into();
@@ -889,11 +930,10 @@ impl VectorcraftApp {
self.system_paste = wanted && self.system_clipboard_pasteable();
}
self.poll_font_check(ctx);
if std::mem::take(&mut self.links_check) {
dialogs::missing_links::after_focus(self);
}
picks::poll(self, ctx);
background::poll(self);
#[cfg(not(target_arch = "wasm32"))]
self.tick_link_updates(ctx, now);
if !self.background.jobs.is_empty() {
// Keep the status bar's progress moving and pick the result up when it arrives.
ctx.request_repaint_after(std::time::Duration::from_millis(100));
@@ -1025,10 +1065,7 @@ impl VectorcraftApp {
match e {
egui::Event::ModifiersChanged(m) => self.host_modifiers = *m,
egui::Event::WindowFocused(false) => self.host_modifiers = egui::Modifiers::NONE,
egui::Event::WindowFocused(true) => {
self.refresh_installed_fonts();
self.links_check = true;
}
egui::Event::WindowFocused(true) => self.refresh_installed_fonts(),
_ => {}
}
}
+7 -2
View File
@@ -3305,8 +3305,13 @@ fn font_items(english_names: bool) -> Vec<Item> {
/// The raster effects' submenus of the Effect menu (the Photoshop-style effects, below the
/// vector effects): (submenu, the catalogue's menu path of its effects).
const RASTER_MENUS: [(&str, &[&str]); 4] =
[("Blur", &["Effect", "Blur"]), ("Pixelate", &["Effect", "Pixelate"]), ("Sharpen", &["Effect", "Sharpen"]), ("Video", &["Effect", "Video"])];
const RASTER_MENUS: [(&str, &[&str]); 5] = [
("Blur", &["Effect", "Blur"]),
("Pixelate", &["Effect", "Pixelate"]),
("Sharpen", &["Effect", "Sharpen"]),
("Texture", &["Effect", "Texture"]),
("Video", &["Effect", "Video"]),
];
/// The Effect menu, built from the effects catalogue (vector effects), plus raster effects.
fn effect_menu() -> Vec<Item> {
+1 -1
View File
@@ -424,7 +424,7 @@ pub(crate) fn label_or_name(s: &str, built_in: bool) -> &str {
/// command feeds); returns the one clicked, for the caller to apply.
pub(crate) fn recent_colors_row(app: &VectorcraftApp, ui: &mut Ui) -> Option<Color> {
let t = Tokens::get(ui.ctx());
crate::widgets::subheader(ui, "Recent Colors");
crate::widgets::subheader(ui, tl!("Recent Colors"));
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::hover());
ui.painter().rect_stroke(r, 0.0, egui::Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside);
let mut chosen = None;
+3 -3
View File
@@ -50,7 +50,7 @@ fn plates_section(app: &mut VectorcraftApp, ui: &mut Ui) {
run(app, "view.separationsPreview", json!({"plates": plates.iter().map(|p| p.name.clone()).collect::<Vec<_>>()}));
}
});
ui.label(egui::RichText::new("CMYK").size(12.0).color(if on { t.text } else { t.text_dim }));
ui.label(egui::RichText::new(tl!("CMYK")).size(12.0).color(if on { t.text } else { t.text_dim }));
});
for p in &plates {
ui.horizontal(|ui| {
@@ -86,13 +86,13 @@ fn settings_section(app: &mut VectorcraftApp, ui: &mut Ui) {
let st = cms::active_settings();
widgets::section_header(ui, tl!("Color Settings"));
ui.horizontal(|ui| {
ui.label("RGB:");
ui.label(tl!("RGB:"));
if let Some(n) = profile_dropdown(ui, "cms-rgb", &st.rgb, ProfileKind::Rgb, None) {
run(app, "edit.colorSettings", json!({"rgb": n}));
}
});
ui.horizontal(|ui| {
ui.label("CMYK:");
ui.label(tl!("CMYK:"));
if let Some(n) = profile_dropdown(ui, "cms-cmyk", &st.cmyk, ProfileKind::Cmyk, None) {
run(app, "edit.colorSettings", json!({"cmyk": n}));
}
+15
View File
@@ -78,6 +78,21 @@ fn effect_menu_lists_the_video_filters_last() {
assert!(at("Sharpen") < at("Video"));
}
/// Effect › Texture, between Sharpen and Video: each filter opens its dialog.
#[test]
fn effect_menu_lists_the_texture_filters_between_sharpen_and_video() {
let app = app();
let entries = menus::menu_entries(&app);
let texture: Vec<_> = entries.iter().filter(|e| e.path == ["Effect", "Texture"]).collect();
let labels: Vec<&str> = texture.iter().map(|e| e.label.as_str()).collect();
assert_eq!(labels, ["Craquelure…", "Grain…", "Mosaic Tiles…", "Patchwork…", "Stained Glass…", "Texturizer…"]);
assert!(texture.iter().all(|e| e.command.as_deref() == Some("effect.dialog")));
let raster: Vec<&str> =
entries.iter().filter_map(|e| e.path.get(1).filter(|_| e.path.len() == 2 && e.path[0] == "Effect")).map(String::as_str).collect();
let at = |name: &str| raster.iter().position(|s| *s == name).unwrap();
assert!(at("Sharpen") < at("Texture") && at("Texture") < at("Video"));
}
#[test]
fn library_submenus_are_disabled_placeholders() {
let app = app();
+5 -5
View File
@@ -598,10 +598,10 @@ pub fn fill_stroke_proxy(ui: &mut Ui, fill: &Paint, stroke: &Paint, mixed: (bool
None
};
ProxyClicks {
fill: fill_resp.on_hover_text("Fill (X), double-click for the Color Picker").clicked(),
stroke: stroke_resp.on_hover_text("Stroke (X), double-click for the Color Picker").clicked(),
swap: swap.on_hover_text("Swap Fill and Stroke (Shift+X)").clicked(),
default: def.on_hover_text("Default Fill and Stroke (D)").clicked(),
fill: fill_resp.on_hover_text(tl!("Fill (X), double-click for the Color Picker")).clicked(),
stroke: stroke_resp.on_hover_text(tl!("Stroke (X), double-click for the Color Picker")).clicked(),
swap: swap.on_hover_text(tl!("Swap Fill and Stroke (Shift+X)")).clicked(),
default: def.on_hover_text(tl!("Default Fill and Stroke (D)")).clicked(),
pick,
}
}
@@ -1668,7 +1668,7 @@ pub fn harmony_wheel(ui: &mut Ui, id: &str, size: f32, colors: &[Color], base: O
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.add_space(((ui.available_width() - size - 76.0) / 2.0).max(0.0));
ui.add_sized([70.0, 22.0], egui::Label::new(egui::RichText::new("Brightness").color(t.text_dim)));
ui.add_sized([70.0, 22.0], egui::Label::new(egui::RichText::new(tl!("Brightness")).color(t.text_dim)));
if let (Some(b), Some([h, s, _])) = (base, base_hsb)
&& let (Some(nv), _) = color_slider(ui, (id, "brightness"), v, size, &|x| crate::panels::c32(&Color::from_hsb(h, s, x)))
{
+5 -4
View File
@@ -282,7 +282,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr
| `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. |
| `pathfinder` | `{operation, ids?}` | `unite`, `minusFront`, `intersect`, `exclude`, `divide`, `trim`, `merge`, `crop`, `outline`, `minusBack`. For a live version, apply the `pathfinder.*` effect to a group (`effect.apply` groups several loose selected objects first). |
| `transform` | `{ids?, dx?, dy?, rotate?, scale?, scaleX?, scaleY?, reflect?, shear?, origin?, copy?}` | Runs move, rotate, scale, reflect, shear in that order. With `copy`, the first step duplicates. |
| `create_graph` | `{type?, x, y, width, height, csv? \| series?, categories?, rows?}` | The nine Illustrator graph types: column, stacked column, bar, stacked bar, line, area, scatter, pie and radar. Edit later with `graph.setData` / `graph.setType` via `run_command`. |
| `create_graph` | `{type?, x, y, width, height, csv? \| series?, categories?, rows?}` | The nine Illustrator graph types: column, stacked column, bar, stacked bar, line, area, scatter, pie and radar. An empty CSV cell or a `null` in `rows` is a blank value; a number in straight quotes is a label. Edit later with `graph.setData` / `graph.setType` via `run_command`. |
| `text_wrap` | `{ids?, offset?, invert?, release?}` | Area type below the objects (same layer) flows around them. |
| `undo` / `redo` | `{}` | |
@@ -521,9 +521,10 @@ CMYK too.
PostScript files (`.eps`, and `.ai` files saved in older formats or without PDF compatibility) open through the EPS
reader (see EPS and PostScript import). An `.ai` saved without PDF compatibility (its PDF part is only a placeholder
page) opens from its editing data alone: its type is made from the file's text document where it can be (point type,
area type and type on a path), and what can't be is left out with a warning; so is non-native art (the content of a
placed PDF, which Illustrator shows but doesn't edit and keeps as a PDF inside the editing data); without editing data
it says it can't be opened.
area type and type on a path), and what can't be is left out with a warning. Non-native art (the content of a placed
PDF, which Illustrator shows but doesn't edit and keeps as a PDF inside the editing data) is drawn by the PDF importer,
as a group named "Non-native art" fitted to the object's box; one whose PDF can't be read is left out with a warning.
Without editing data it says it can't be opened.
What a PDF holds comes in as editable art: soft masks become opacity masks (an alpha mask as a white copy of its art;
the backdrop colour gives Clip, an inverting transfer function Invert), transparency groups keep isolation and knockout,
+2
View File
@@ -75,6 +75,8 @@ pub const INTRA_LAYER_ORDER: &[&[&str]] = &[
&["text", "effects"],
&["render", "eps"],
&["metafile", "eps"],
// Non-native art in Illustrator editing data is a PDF, drawn by the PDF importer.
&["pdf", "eps"],
&["plugins", "effects"],
];