Compare commits

...
310 Commits
Author SHA1 Message Date
Brandon Thomas 90e022b0c0 Merge pull request #102 from storytold/pr/release-0.2.0
Release: VectorCraft v0.2.0
2026-10-05 02:41:45 -04:00
Brandon ThomasandClaude Opus 5.5 8b50a2604d Release: VectorCraft v0.2.0
Bump the workspace version 0.1.1 -> 0.2.0 (cargo xtask version set 0.2.0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:41:32 -04:00
BFlat 819a0cb9b4 Merge pull request #101 from storytold/pr/m4-svg-encoding-tiny
M4.97–M4.98: SVG encoding, Tiny 1.2, font subsets and freeform gradients
2026-10-05 13:35:39 +07:00
bflatasticandClaude Opus 5.5 33023c5a2b M4.98: freeform gradients export to SVG as images clipped to their shapes
SVG has no freeform shading: an object's freeform fill or stroke is now
written as an <image> of the colour field over its painted box, sampled
on the grid the canvas uses (twice the box's size in points), clipped to
the shape or to the stroke's outline, with the fill or stroke opacity and
blend mode on the group. Linked image mode writes the PNG next to the
SVG. Character paints keep the linear approximation (with a warning);
gradients along or across strokes stay vector slices. The grid sizing
moved to the colour crate so the canvas and the export share it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:25:16 +07:00
bflatasticandClaude Opus 5.5 fa3dcab411 M4.97: SVG Options choose the encoding, SVG Tiny 1.2 and embedded font subsets
SVG export writes UTF-8, UTF-16 (big-endian with a byte order mark) or
ISO 8859-1 (other characters as character references) and SVG/SVGZ
open in any of them. The Tiny 1.2 profile (simplified) writes
presentation attributes only and leaves out filters, masks, blend
modes and symbols with warnings. embedFonts embeds the fonts type uses
as @font-face subsets of the characters used, honouring the fonts'
fsType (whole when subsetting is forbidden, left out when embedding
is). New options: encoding, profile, embedFonts (SVG Options dialog,
document.formats); document.serialize also returns dataBase64 for
non-UTF-8 files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:25:15 +07:00
BFlat eacc83e05b Merge pull request #100 from storytold/pr/m4-export-screens
M4.51–M4.52: Export for Screens downloads, zip and options
2026-10-05 13:24:40 +07:00
bflatasticandClaude Opus 5.5 c96bf40d18 M4.52: Export for Screens options: full document, bleed, sub-folders, sizes, presets, format settings
Artboards: All, a range or Full Document (one file per format: a PDF of
every artboard, other formats the bounds of all art), Include Bleed
(artboards grown by the document's bleed). Rows size files as a factor,
100w, 100h or 72ppi (suffixes @2x, @100w, @100h), and choose PNG, PNG 8,
JPG 100/80/50, WebP, GIF, SVG or PDF. Create Sub-folders (by size, by
format, or a row's own folder) and the presets Mobile 1x/2x/3x and
Density buckets (ldpi…xxxhdpi sub-folders). The gear opens Format
Settings: per-format options for every row of that format (the raster
and SVG options dialogs' rows, the PDF preset). Open Location after
Export shows the first file. The dialog has Artboards and Assets tabs.

The params of the last export are saved in the document
(Document.export_settings, not an undo step), read with
document.exportSettings, and the dialog reopens on them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:18:10 +07:00
bflatasticandClaude Opus 5.5 902d95173e M4.51: Export for Screens downloads on the web, picks folders, zips
document.exportForScreens takes zip: true to pack every file into one
store-only .zip (returned as dataBase64 without a folder, written there
with one); without a folder the files come back as base64. The engine's
Export for Screens moves to fileio/screens.rs, with a small ZIP writer
(fileio/zip.rs).

The dialog starts in the user's Desktop (else home, on every OS), has a
folder picker button (the Services.pick_folder the Links panel uses) and
stays open when the export fails. On the web it has no folder: OK reads
"Download" (DialogSpec.ok_label) and the files download, several as one
zip. file.exportForScreens with params goes through io::export_for_screens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:18:10 +07:00
BFlat 8ab7237443 Merge pull request #99 from storytold/pr/m4-pdf-boxes-marks
M4.71–M4.72: PDF bleed and trim boxes, printer's marks
2026-10-05 13:17:15 +07:00
BFlat 522f05c540 Merge pull request #98 from storytold/pr/m4-pdf-import-fidelity
M4.61–M4.62: PDF import fidelity and layers
2026-10-05 13:17:00 +07:00
bflatasticandClaude Opus 5.5 b0e39d0912 M4.72: PDF export draws printer's marks in Registration around each page
document.exportPdf {marks} now draws trim marks (Roman or Japanese, at the
trim mark weight and offset, outside the bleed), a registration target at the
middle of each side, colour bars (process inks, their overprints, the
document's spot inks and black tints) and the page information (title,
artboard, export date in UTC). Marks print in [Registration] (/Separation
/All), the MediaBox grows to hold them and the art is clipped to the
BleedBox. The geometry is the shared vectorcraft_doc::marks::PrinterMarks
(built on TrimMarks) so print can draw the same marks. The "not drawn yet"
warning is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:05:20 +07:00
bflatasticandClaude Opus 5.5 8fb1733f05 M4.71: PDF pages carry the bleed and trim boxes; non-printing layers are left out
document.exportPdf applies the bleed (the document's with bleed.useDocument,
else the custom values): each page's TrimBox is its artboard and its BleedBox
and MediaBox the artboard grown by the bleed, so art in the bleed is kept.
Layers whose Print option is off are left out unless includeNonPrinting (new
setting, Save PDF › General) or createLayers is on. The bleed warning is gone;
the Marks and Bleeds section shows the document's bleed when it is used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:05:19 +07:00
bflatasticandClaude Opus 5.5 8046b9be84 M4.62: PDF/.ai layers from optional content: named layers with visibility, print state and lock; art that is off as hidden layers
Optional content groups become layers (in paint order, shared by every page)
with their name, default visibility, print usage and lock; art outside them
goes to a layer per page. Art that is off isn't lost: an update appended to
the file drops its /OCProperties so the interpreter draws every group, and
the marked-content sequences found walking the content (checked by tag) route
the art to its group's layer, which comes in hidden. A form hidden by its own
/OC keeps the file as it is. document.open layers: false keeps one layer per
page of only what shows (Save then asks for a name).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:05:12 +07:00
bflatasticandClaude Opus 5.5 a51083733e M4.61: PDF import fidelity: opacity masks, isolate/knockout, pattern swatches, editable text, gradient meshes, stop opacity and Extend
Luminosity soft masks become opacity masks (alpha masks as a white copy of
their art; backdrop colour gives Clip, an inverting transfer function Invert;
the PDF export's backdrop and Difference covers read back as the options; a
constant alpha mask is opacity). Transparency groups keep their /I and /K
flags (read by walking the content as the interpreter does). Tiling patterns
become pattern swatches (one per colour for uncoloured ones). Text becomes
point type, one object per line with a run per font and paint, fonts named
from the file (document.open textAs: text | outlines). Patch and triangle
mesh shadings become gradient meshes; gradients drawn through their grey
opacity mask get stop opacity back; shadings that don't extend clip the art.
A rectangular clip around all its art (a form's box) is dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:05:12 +07:00
BFlat 66e92bbd4c Merge pull request #97 from storytold/pr/m4-slices
M4.88–M4.89: slices and the Slice tools
2026-10-05 13:05:06 +07:00
bflatasticandClaude Opus 5.5 fbc7549c31 M4.89: Slice and Slice Selection tools draw, pick, move, resize and delete slices
The Slice tool (Shift+K) drags out a user slice (Shift square, Alt from the
centre, snapping to ruler guides) as one undo step through
object.slice.create. The Slice Selection tool selects slices (Shift
toggles, object.slice.select), moves them (object.slice.move; object
slices move their objects), resizes a user slice by its handles
(object.slice.setRect), opens Slice Options on a double click and deletes
with Delete (object.slice.delete). Hidden or locked slices can't be
picked. Both tools have cursors drawn in code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:52:15 +07:00
bflatasticandClaude Opus 5.5 271b0c6c25 M4.88: Object → Slice makes, edits and lays out user, object and auto slices
Document.slices holds user slices, Node.slice makes an object an object
slice that follows its bounds, and Document.slices_clip_to_artboard picks
the region auto slices fill (the artboards, or the art and the slices).
Object → Slice: Make, Release, Create from Guides, Create from Selection,
Duplicate, Combine, Divide… (dialog), Delete All, Slice Options… (dialog)
and Clip to Artboard; View → Hide/Lock Slices; Select → Object → Slices;
slice.list reads the numbered layout. The canvas draws the slices in the
Slices preferences' line colour with their numbers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:52:15 +07:00
BFlat d767fa22e0 Merge pull request #96 from storytold/pr/m4-links-package
M4.63–M4.64: Links panel, Placement Options and Package
2026-10-05 12:49:30 +07:00
bflatasticandClaude Opus 5.5 8d8ca11e50 M4.64: File > Package collects the document, its linked files and fonts with a report; Document Info categories and Save
File > Package (saved documents; unsaved changes are saved first) writes
<folder>/<name>/<document>.vectorcraft, the linked files in Links/ (relinked in the
packaged copy), the fonts its type uses in Fonts/ (leaving out fonts whose licence
doesn't allow embedding) and <document> Report.txt, then offers Show Package. Without a
folder (the web, agents) the same files come back as a zip.

Document Info's flyout picks one category (document, objects, styles, spot colours,
patterns, gradients, symbols, fonts, font details, linked and embedded images) and Save…
writes the text report Package shares.

Engine: file.package {folder?, name?, copyLinks?, linksFolder?, relink?, copyFonts?,
report?}; document.info gains category, format: text and sections. Text: FontFace
path, fs_type and embeddable. UI: ui.packageDialog (dialog `package`), file.showPackage,
docInfo.save.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:40:05 +07:00
bflatasticandClaude Opus 5.5 2925e7f9b5 M4.63: Links panel with status, Relink, Go To, Update, Edit Original, Embed/Unembed, Link Info and Placement Options
The Links panel lists every image (thumbnail, name, page, missing/modified/embedded
badge) with Show filters, sorting and thumbnail sizes; its buttons and flyout relink
(to a file or a folder), go to, update, edit the original, embed and unembed, show in
folder and open Placement Options. Link Info shows format, colour space, location, ppi
and effective ppi, dimensions, scale, rotation, size and dates. Edit > Edit Original
and the Properties panel's image section reach the same commands.

Engine: links.list, links.goTo, links.embed, links.unembed, links.info and
links.placementOptions; links.relink and links.update now place the new file as the
image's Placement Options say (transforms, bounds, file dimensions, fit, fill, the
alignment point, clip to the old bounds). Model: ImageObject.placement.
UI: links.editOriginal, links.reveal, ui.placementOptionsDialog; Services.open_file
and pick_folder on the desktop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:40:05 +07:00
BFlat 171de62fde Merge pull request #95 from storytold/pr/m4-native-format
M4.35–M4.36: native format v3 and background save
2026-10-05 12:37:53 +07:00
bflatasticandClaude Opus 5.5 db0a7ffd8b M4.36: Background Save and Export write a snapshot off the UI thread with status bar progress
- With Preferences > File Handling > Enable Background Save / Export on, File >
  Save, Save As, Save a Copy and exports encode and write a snapshot of the
  document on a worker thread; the call returns at once ({path, background}).
- The status bar shows a spinner and what is being written; ui.inspect lists
  the running jobs under `background`.
- The document takes the path and counts as saved as of the snapshot once the
  file is written: edits made meanwhile keep it modified.
- Quit and Close wait for running saves; the Save Changes dialog saves at once.
- The host gives a thread-safe writer (Services::write_shared, atomic on the
  desktop); without one (web, tests) everything runs at once as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:27:06 +07:00
bflatasticandClaude Opus 5.5 65cdc45ed9 M4.35: native files compress, save atomically, drop unused images, keep newer keys and save down to v1
- Format v3: optional gzip (Preferences > File Handling > Use Compression, or
  document.save {compress}); compressed files open by their magic bytes, legacy
  ones too.
- Every file write goes to a temporary file renamed over the target (engine
  writes and the desktop app's writer), so a failed save keeps the old file.
- Only the images the document uses are written.
- Document keys from newer versions are kept (Document.extra, flattened).
- Assigned colour profiles are a real field (Document.color_profiles),
  migrated from unknown.colorProfiles on load.
- document.save {version: 1|2} writes files older versions open; {preview}
  embeds a PNG of the first artboard of at most 256 px.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:27:02 +07:00
BFlat 7aee3bd487 Merge pull request #94 from storytold/pr/m4-svg-import-live
M4: SVG import keeps symbols, patterns and filters live; linked images, nested clips, reflected gradients (M4.41, M4.42)
2026-10-05 12:25:30 +07:00
bflatasticandClaude Opus 5.5 303fc3cbca M4.42: SVG import reads linked images from the SVG's folder, intersects nested clips, keeps hidden objects and expands reflected gradients
document.open {path} (and File > Place) find an SVG's relative <image>
links in its folder: raster files stay linked images (LinkInfo with size,
time and hash), SVG files and data: SVGs import as vector art, and a file
that can't be read becomes a placeholder in its box that keeps the link,
with a warning and a missingLinks row (Update Links / Relink read it
later). The SVG library itself reads no file unless given a reader.
Nested clip paths become clip groups in clip groups; undisplayed objects
(display:none) come back hidden; spreadMethod reflect and repeat are
expanded into stops over the painted area (matching resvg). Midpoint stops
and focal points keep folding back as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:12:06 +07:00
bflatasticandClaude Opus 5.5 0d75df4da8 M4.41: SVG import keeps symbols, patterns and blur/shadow filters live
<symbol> with <use> imports as a symbol (named after data-name or its id)
with one instance per <use>; a <use> that shows the symbol differently
(inherited paint, a viewport that cuts it) or that the canvas would paint
differently under its transform stays plain art. feGaussianBlur,
feDropShadow and the usual shadow chains (offset, blur, flood or colour
matrix, composite, merge) become Gaussian Blur, Drop Shadow, Outer/Inner
Glow and Feather effects; other filters are still reported. Export records
the symbol name and the shadow/glow blend mode so export -> import keeps
them. <pattern> keeps importing as a pattern swatch (now tested).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:11:27 +07:00
BFlat 3e8c8d6264 Merge pull request #93 from storytold/pr/m4-dxf-export
M4: DXF export (R12 to 2018) with DXF Options; DWG and PICT hints (M4.77)
2026-10-05 12:10:53 +07:00
bflatasticandClaude Opus 5.5 a17cc428d1 M4.77: DXF export (R12 to 2018) with DXF Options; DWG and PICT say what to use instead
New crates/cad (L3): a hand-written ASCII DXF writer. Layers become DXF
layers (hidden: off, locked, non-printing: not plotted), straight paths
LWPOLYLINE (POLYLINE before R14), curves cubic SPLINEs through every
anchor, fills solid HATCHes (outlines in R12), strokes lines with a
lineweight and a linetype per dash pattern, type TEXT or glyph outlines,
placed images IMAGE entities linked to PNG or JPEG files. Units and
scale ($INSUNITS), scaled lineweights, 8/16/256 indexed or true colour
(420), transparency (440), Preserve Appearance / Maximize Editability,
Alter Paths for Appearance, Outline Text.

document.export {format: dxf} and document.exportDxf; useArtboards
writes one drawing per artboard; selectedOnly (any export) writes the
selection in its layers. Export As lists DXF (then DXF Options,
ui.dxfOptionsDialog for agents, remembered between exports) and DWG
greyed out; document.formats lists DWG and PICT as unsupported with a
hint, and exporting or opening them answers with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:00:17 +07:00
BFlat 448437fb76 Merge pull request #92 from storytold/pr/m4-pdf-fidelity
M4: PDF keeps raster effects everywhere, pattern strokes and freeform gradients; image resampling and compression (M4.58–M4.60)
2026-10-05 11:59:45 +07:00
bflatasticandClaude Opus 5.5 cfed319af4 M4.60: PDF export resamples and compresses images as the Compression settings say
Colour, greyscale and monochrome (black-and-white) images placed above the
threshold resolution are resampled to the target (average, subsample or
bicubic) and compressed with ZIP, JPEG at the chosen quality, or
automatically (JPEGs stay JPEG, the others lossless); images already as
asked are embedded unchanged. JPEG 2000, CCITT, Run Length and no
compression are written as ZIP with a warning when an image needs them;
images with transparency stay lossless. Images no longer ask viewers to
smooth them in PDF/A files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 11:52:35 +07:00
bflatasticandClaude Opus 5.5 ffc60d5ff8 M4.59: pattern strokes and freeform gradients keep their look in PDF
Pattern strokes are written as the pattern's tiles clipped to the stroke's
outline (dashes, caps, profile, arrowheads and alignment included) instead
of mid-grey; freeform gradients on fills, strokes and type as an image of
their colour field at the document raster effects resolution, clipped to
what they paint, instead of their average colour. Both read back within
2% of the canvas at 150 ppi with no warning. Text strokes are covered for
cap, miter limit, dash array and alignment; knockout groups keep their
soft-masked form with a warning. The freeform box and grid sampling are
shared with the renderer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 11:52:35 +07:00
bflatasticandClaude Opus 5.5 e967c31be6 M4.58: raster effects in PDF on every kind of object
PDF export now renders the shadows, glows, blurs and feathers of groups,
layers, type, images, symbol instances, live objects and single fills and
strokes (and those inside opacity masks, symbol definitions and pattern
tiles) as images at the document raster effects resolution, instead of
leaving them out with a warning. Shadows and outer glows go under the
untouched vector art; a path whose fills or strokes carry effects is
written as one piece per fill and stroke. Expand Appearance shares the
image helpers (moved to rasterfx.rs); effect images now cover what the
renderer paints (members' shadows included).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 11:52:34 +07:00
BFlat 7051a65c65 Merge pull request #91 from storytold/pr/flaky-clock-and-port-tests
Tests: journal replays reproduce File Info's dates; the MCP connect test holds its own port
2026-10-05 11:49:35 +07:00
bflatasticandClaude Opus 5.5 7b30dd787b M1.9: the MCP connect-failure test holds its own closed port
remote_connect_fails_fast bound a port and dropped it, so another test or
process could take the port before the connect and make it succeed. It now
holds a socket bound without listening (socket2, a test-only dependency):
connections to it are refused and nothing else can bind it meanwhile. It
also checks the failure comes within 5 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 11:41:26 +07:00
bflatasticandClaude Opus 5.5 851ed15104 M4.55: a journal replay reproduces File Info's dates
File Info's dates come from the clock, so documents made or saved a second
apart differed: journal_replay_reproduces_document and
rich_fixture_satisfies_invariants_and_undoes_to_blank failed at random.
One approach for every clock-derived date: it is a command input that
defaults to now, and the journal records the value used.

- file.new takes created? and document.save / file.saveAs take modified?
  (Unix seconds, or null for none; default now), through date_param /
  clock_date on the one save path (save_with), so Save, Save As and the
  UI's save stamp alike; Save a Copy still leaves the dates.
- Their journal entries always record the date used, so replaying the
  journal makes the same document whenever it runs. Actions recorded from
  the journal (Session::journal_for_action) leave those dates out, so
  playing an action later dates the document then.
- Test fixtures date their documents fixtures::CREATED, and the MCP
  property test dates its local document as the headless one, so
  documents built at different moments compare equal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 11:41:26 +07:00
BFlat 53f42797f3 Merge pull request #90 from storytold/pr/m4-jpeg-png8-gif-webp
M4: JPEG Options with CMYK and ICC, PNG-8 and GIF, WebP options, Export As Text, Save for Office (M4.48–M4.50)
2026-10-05 11:40:06 +07:00
bflatasticandClaude Opus 5.5 da18cf4234 M4.50: WebP options, Export As Text and Save for Office Documents
- WebP takes lossless and quality; there is no permissive pure-Rust lossy encoder yet, so
  lossless: false writes lossless with a warning (the WebP Options dialog says so).
- document.export {format: "txt", encoding: utf8|utf16, lineEndings: lf|crlf,
  selectionOnly}: the stories in stacking order (back to front), a thread once, hidden
  objects and template layers left out; UTF-16 is little-endian with a byte order mark.
  Export As → Text opens Text Export Options.
- File → Save for Office Documents… (document.exportForOffice {ppi, transparent, artboard,
  path}): the artboard as a PNG at 72/150/300 ppi on white or transparent, through a small
  dialog in the app.
document.formats' writable list and the MCP export tool's enum gain txt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 11:27:22 +07:00
bflatasticandClaude Opus 5.5 ac77b52ad3 M4.49: PNG-8 and GIF export with a palette quantizer and dithering
document.export {format: "png8" | "gif"} reduces the art to a palette of at most `colors`
(2–256) entries: median cut over a 15-bit histogram refined by k-means (perceptual,
selective or adaptive weighting), or the web-safe, black-and-white or grey palettes; art
with that many colours or fewer keeps them exactly. Dithering is Floyd–Steinberg diffusion,
an 8×8 ordered pattern or noise, with an amount. Pixels under half opacity become one
transparent entry; partly transparent edges blend over the matte. PNG-8 is an indexed PNG
(PLTE, tRNS, 1–8 bits per pixel, Adam7); GIF is a GIF89a (optionally interlaced). Both are in
document.formats, Export As (PNG-8 / GIF Options dialogs) and Export for Screens.
document.formats' writable list and the MCP export tool's format enum gain gif and png8
(their tests list them).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 11:26:15 +07:00
bflatasticandClaude Opus 5.5 ba4ed2ff93 M4.48: JPEG Options — RGB/CMYK/gray, baseline/optimized/progressive, embedded ICC profiles and image maps
document.export {format: "jpg"} takes colorModel (rgb|cmyk|gray), quality 0–100, method
(baseline|optimized|progressive), scans (3–5), embedIcc and imageMap (none|client|server).
JPEGs are written with jpeg-encoder. CMYK files hold ink amounts drawn as the renderer's ink
planes, so CMYK colours keep their inks (black type stays black ink alone). Profiles are made
in code from the CMS (cms::icc_bytes): the RGB spaces from their primaries, the CMYK spaces as
Lab lookup tables sampled from their model, a grey with sRGB's tone curve, and loaded
profiles as loaded. Image maps write <stem>.html or <stem>.map beside the image for objects
with a URL and an Image Map shape. The JPEG Options dialog gains Color Model, Quality 0–10,
Method and Scans, Image Map and Embed ICC Profile; CMYK documents default to CMYK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 11:25:09 +07:00
BFlat db9d937e1a Merge pull request #89 from storytold/pr/m4-save-pipeline
M4: Save As format chooser, Save keeps its format, Save a Copy, Revert, templates, File menu details (M4.14–M4.17)
2026-10-05 11:19:44 +07:00
bflatasticandClaude Opus 5.5 489bb84755 M4.17: File menu details: converted files, 30 recent files, one colour-mode command, Show in Folder, saved view
Older native files (.drawcraft or an older format version) open as
"<name> [Converted]" and Save asks for a .vectorcraft name instead of
overwriting them (preference appendConverted). Open Recent Files honours the
0-30 preference; empty slots are hidden (the native menu rebuilds when they,
saved views or User Defined libraries change). File > Document Color Mode is
one command, file.documentColorMode, converting through the colour settings;
choosing the current mode is not an edit, and object.convertDocumentColorMode
stays as an alias the command palette leaves out. New File > Show in Folder
(file.reveal; paths with spaces work on Windows). Native files store the view
they were saved with (Document.last_view, written at save time so it never
dirties the document) and reopen there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 11:11:19 +07:00
bflatasticandClaude Opus 5.5 70e2726321 M4.16: Template safety: Templates folder, "<name> template", New from Template
Save as Template suggests "<name> template.vctemplate" in the Templates folder
(the new templatesFolder preference; default Documents/VectorCraft Templates,
created by the desktop save panel) and never the source path. New from
Template is an engine command (file.newFromTemplate {path} or {name,
dataBase64}) that opens any readable file as a new Untitled-N document; in the
app, bytes from an agent go straight to the engine, otherwise the open dialog
starts in the Templates folder with template types first. .ait and template
files keep opening untitled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 11:07:15 +07:00
bflatasticandClaude Opus 5.5 9fae8c56ec M4.15: Revert as an engine command with a confirmation
file.revert reads and decodes the saved file first: if that fails the
document is left as it is, still modified. Otherwise Session::replace_document
swaps the content in the same tab (same place, path, format and uid; history
and selection cleared; saved state), so every tab keeps its zoom. It runs only
for saved, modified documents. In the app, Revert asks first with the shared
confirm dialog, whose OK runs file.revert {confirmed: true} on the engine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 11:07:15 +07:00
bflatasticandClaude Opus 5.5 515ce2ac94 M4.14: Save As format chooser, Save writes back its own format, Save a Copy
Save As offers VectorCraft, VectorCraft Template, PDF, SVG and SVGZ and asks
for the chosen format's options before writing: SVG Options for SVG/SVGZ, the
Save PDF dialog for PDF (its OK finishes the save); on the web a Save As
dialog first names the file and picks the format. A document opened from or
saved as SVG/PDF remembers its format and options in one place
(DocState.format, save_options: the SVG options, or every Save PDF setting),
so Save writes it again; the result's warnings name what the format loses,
then the encoder's notes. Save a Copy writes "<name> copy.<ext>" and leaves
the path, title and modified state alone. Save is off for a clean saved
document, and Save as PDF... is in the File menu.

Engine: document.save, file.saveAs and file.saveCopy take {path?, format?,
options?, svg?} (SVG options also flat) and write linked images beside an
SVG; file.formatOptions reads a format's options. save_plan/save_with are the
one save path of every frontend, on the shared SVG encoder (one SVGZ path).
The template format is writable. Save panels get per-format filters, a folder
and a suggested name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 11:07:15 +07:00
BFlat ba6597b7b5 Merge pull request #88 from storytold/pr/m4-clipboard-flavours
M4: PNG, PDF, SVG and text clipboard formats in and out (M4.57)
2026-10-05 11:00:21 +07:00
bflatasticandClaude Opus 5.5 0968e34e7e M4.57: PNG, PDF and text clipboard flavours in and out
Copy and Cut also offer other apps a PNG of the copied objects, a PDF
with On Copy: PDF, and plain text when every copied object is type
(clipboard.flavours; clipboard.exportPng {scale?}, exportPdf,
exportText). Paste outside text editing takes what another app copied:
SVG, then PDF (clipboard.importPdf), then plain text as point text
(clipboard.importText), then a bitmap embedded at the view centre
(clipboard.importImage {dataBase64, mime?, center?}); pasting our own
copy keeps the lossless internal clipboard, and Cmd+V pastes a bitmap
that comes with no text. Services gains system_clipboard
(SystemClipboard: write, holds_ours, read, has): on Windows every
format at once, on macOS and Linux one (the text, else the PNG); the
web keeps SVG text only. With When pasting text: Keep Plain Text, text
the Type tool copied pastes without its formatting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:52:01 +07:00
BFlat a560f74e17 Merge pull request #79 from storytold/pr/m4-pdf-editing-presets
M4: PDF and .ai reopen editable, Save As .ai; PDF presets and Edit > PDF Presets (M4.43, M4.44)
2026-10-05 10:42:27 +07:00
bflatasticandClaude Opus 5.5 9aa29aa0fd M4.44: PDF presets: built-ins in code, saved presets, Edit > PDF Presets
Built-in presets are generated in code (VectorCraft Default, High
Quality Print, Press Quality, Smallest File Size and PDF/X-1a, X-3,
X-4, the latter listed as not supported yet) and read-only. Saved
presets live with the preferences (Prefs.pdf_presets) and work as
`preset` wherever PDF options are taken: document.exportPdf,
pdfSettings, export/serialize as PDF and .ai saves.

pdf.preset.list / save / delete / export / import manage them
(.vcpdfpresets files; opening one imports it). The Save PDF dialog's
Save Preset… saves its settings under a name; Edit > PDF Presets…
(ui.pdfPresetsDialog) lists the presets with each one's description and
differences from the default, with New…, Edit… (the Save PDF dialog as
a preset editor, ui.pdfPresetDialog), Delete, Import… and Export….
document.pdfSettings now lists what differs from the chosen preset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:40:22 +07:00
bflatasticandClaude Opus 5.5 0ffaa427d1 M4.43: PDFs and .ai files reopen editable; Save As writes PDF-compatible .ai
Preserve Editing (on in the VectorCraft Default preset) embeds the
native document in the PDF as an embedded file with a hash of the pages
it was written with. document.open of a PDF, .ai or .ait restores it
exactly (restored: true) and falls back to importing the artwork, with a
first warning saying why, when the pages were changed elsewhere or the
data can't be read; SVG and PDF share that fallback and its warnings.
The legacy drawcraft payload name is read too. vectorcraft_pdf gains
editing(), ImportReport.native and PdfOptions.native.

document.save / file.saveAs / Save a Copy write .ai (format "ai" or an
.ai path): every artboard as a PDF that always carries the document and
takes the PDF options; a restored .ai keeps its path so Save writes .ai
again, and .ait templates open their document untitled. The desktop
save dialog lists the formats Save writes. Choosing a standard turns
Preserve Editing off (PDF/A refuses it), Export for Screens and PDFs of
some artboards leave it out with a warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:34:15 +07:00
BFlat 4b956422c9 Merge pull request #78 from storytold/pr/m4-links-text-import
M4: linked images with previews, missing and modified links; Text Import Options (M4.53, M4.54)
2026-10-05 10:18:04 +07:00
bflatasticandClaude Opus 5.5 2724263d00 M4.54: Placing a text file asks for Text Import Options and sets it as area type
file.place of a .txt file takes `text: {characterSet: unicode|ansi, platform: windows|mac,
removeLineReturns, removeParagraphReturns, replaceSpaces}`: Unicode reads UTF-8 or UTF-16 by its
byte-order mark (other bytes as the platform's 8-bit set), ANSI reads Windows-1252 or Mac Roman;
CR LF, CR and LF all break lines; joining lines makes each block one paragraph, blank lines can
go, and runs of spaces can become tabs. The text becomes area type filling `rect`, the replaced
object's bounds, or the artboard less a 36 pt margin (type reflows rather than scaling), as one
undo step. The place cursor and file.place.info take text files too, and File → Place lists them.

In the app, placing a text file (the Place dialog, a drop, file.place without options) opens the
Text Import Options dialog. Its dropdown rows share a form helper with Document Setup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:07:23 +07:00
bflatasticandClaude Opus 5.5 2e5ec7b251 M4.53: Linked images remember their file and keep a preview; missing links ask Replace or Ignore, modified ones follow Update Links
A linked image (`ImageObject.link`, now a LinkInfo: absolute path, path relative to the document,
size, modification time, hash, page; old path strings still load) keeps a low-resolution preview
(`ImageBlob.proxy`, at most 256 px a side) that a native save writes in place of the pixels of
images only linked images show. Opening a native document reads the linked files again, looking
for each at its path, then relative to the document and by name in its folder: missing files
show their preview, modified ones are read again only with Update Links: Automatically.
`document.open` reports `missingLinks`, `modifiedLinks` and `updatedLinks`.

links.check reports each link's status, links.update reads modified files again and
links.relink points images at another file or at same-named files in a folder (each keeps its
bounds; one undo step). In the app, a document opened without its linked files asks about each
(Replace…, Ignore, Apply to All), then offers to update modified links when the preference asks.
The renderer caches previews apart from the file's pixels; the Eyedropper samples previews in
the object's pixel space.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:07:10 +07:00
BFlat 0c65fe77f3 Merge pull request #77 from storytold/pr/m4-file-info-raster-settings
M4: File Info in SVG, PDF and PNG; Document Raster Effects Settings (M4.55, M4.56)
2026-10-05 10:05:40 +07:00
bflatasticandClaude Opus 5.5 94f0f1b0d2 M4.56: Document Raster Effects Settings with colour model, background, anti-alias, clipping mask and room around
document.rasterEffectsSettings now also takes and reports colorModel,
background, antiAlias, clippingMask, addAround and preserveSpotColors in one
undo step (Document.raster_effects, RasterEffectsSettings, next to the
existing raster_effects_ppi so old files load unchanged). The raster effect
images of PDF export and Expand Appearance get the extra room and are
finished by the settings (hard edges, grayscale or bitmap, opaque on
white, white only under the art with a clipping mask). Rasterize takes
them as defaults (params override), and its clipping mask puts the image
in a clip group with the art's outline. The settings have their own dialog
(rasterEffectsSettings, ui.rasterEffectsSettingsDialog) and Rasterize…
opens its options prefilled from them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:55:48 +07:00
bflatasticandClaude Opus 5.5 6a446b4fa3 M4.55: File Info with author, description, keyword chips, rating and copyright, written to SVG, PDF and PNG
file.info takes and reports the title, author, author title, description,
keywords, rating, copyright status, notice and URL in one undo step, plus
the read-only created (file.new) and modified (every save to a file) dates;
junk values are refused. Document.metadata (DocMetadata) round-trips
natively. PDF export writes author, subject and keywords to the info
dictionary and XMP, PNG export writes tEXt/iTXt chunks, SVG export writes
<desc> and, with the metadata option, every field as Dublin Core. The File
Info dialog (fileInfo, ui.fileInfoDialog) edits it with keyword chips and
star ratings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:55:47 +07:00
BFlat a908ba0190 Merge pull request #76 from storytold/pr/m4-clipboard-resources
M4: paste carries resources with a Swatch Conflict dialog; paste placement and Paste Remembers Layers (M4.22, M4.23)
2026-10-05 09:52:38 +07:00
BFlat cc03a47b7d Merge pull request #75 from storytold/pr/m4-pdf-import
M4: PDF import pages, Crop To and passwords; CMYK, Gray and spot colours kept (M4.45–M4.47)
2026-10-05 09:51:03 +07:00
bflatasticandClaude Opus 5.5 f346727dd8 M4.23: paste placement, Paste Remembers Layers and the Paste menu's enable rule
Paste (also without formatting) goes to the centre of the view
(edit.paste {center}); Paste in Front/Back with nothing selected go to
the top/bottom of the current layer, in order; Paste on All Artboards
keeps the objects' offset to the artboard they were copied from.
layer.pasteRemembersLayers {on?} (document flag paste_remembers_layers,
saved, document.inspect) pastes objects back into the layers they came
from by name, making missing ones; the Layers panel now has its menu
with the layer commands and the check item. The Paste menu items are
enabled while the system clipboard holds SVG (looked at once per frame
at most, a few times a second).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:45:34 +07:00
bflatasticandClaude Opus 5.5 ee2aa96dcd M4.22: pasting between documents carries the objects' resources
Copy keeps the copied objects with every document resource they use
(Session.clipboard: Clipboard {nodes, images, symbols, patterns,
swatches, graphic_styles, char_styles, para_styles, brushes,
source_artboard, source_layers, source_doc}), following symbols,
patterns, graphic styles and text styles into what they use. Every
edit.paste* brings them in within its undo step: identical resources are
reused, missing ones added, different ones of the same name added
renamed (an earlier paste's copy is reused) and the pasted objects
relinked, graphic style links by id. Global and spot swatches that
conflict ask Merge or Add (swatchConflict; clipboard.conflicts lists
them; the app's Swatch Conflict dialog asks one by one, with Apply to
All). Tint swatches, linked gradient stops, Lab spots and the target's
Spot Colors option are honoured; [Registration] is never copied.
Clipboard SVG exports them too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:45:34 +07:00
bflatasticandClaude Opus 5.5 e0895fd4ba M4.47: PostScript .ai and placeholder-only .ai files say why they can't open
A PostScript file (a .ai saved in an older format or without PDF
compatibility, or an .eps, plain or with a binary header) is refused by any
name with a clear message instead of a parse error (there is no PostScript
importer yet). An .ai whose pages are only placeholder text over the editor's
private data (PieceInfo AIPrivateData) is detected and refused with advice to
save it with PDF compatibility, instead of opening the placeholder.
vectorcraft-pdf: is_postscript(), PdfError::PostScript / PlaceholderOnly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:37:11 +07:00
bflatasticandClaude Opus 5.5 8765d335ec M4.46: PDF import keeps CMYK, Gray and spot colours
DeviceCMYK and CMYK ICC colours import as CMYK and a file painted mostly in
CMYK opens as a CMYK document (with CMYK default swatches); DeviceGray
imports as Gray; Separation and DeviceN inks become global spot swatches
(their 100 % colour from the alternate space: CMYK, RGB, Gray or Lab) that
the art links to at its tint, /All links to Registration and process
colorants make CMYK. Shading stops keep their model and ink links the same
way. Inks mixed in one DeviceN colour, or spaces that can't be told apart,
import as RGB (with a warning for the mix).

document.open takes colorMode (rgb | cmyk) to open any file in that mode,
converted as file.documentColorMode does (its conversion is now shared).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:37:11 +07:00
bflatasticandClaude Opus 5.5 e204341996 M4.45: PDF import and place options — pages, Crop To and password
Opening or placing a PDF or .ai with several pages or a password shows the
Import PDF / Place PDF dialog: a page preview with page navigation, Pages
(All or a range) when opening, Crop To (Bounding Box, Art, Crop, Trim, Bleed,
Media) and a password prompt for encrypted files.

- vectorcraft-pdf: ImportOptions.pages / crop / password, CropTo, info()
  (pages, sizes, rotation, boxes); NeedsPassword / WrongPassword / BadPage
  errors; images keyed by content so placed pages merge safely.
- document.open takes pages, page, cropTo (or crop) and password;
  document.pdfInfo (query) lists pages and boxes, reports needsPassword and
  renders a page thumbnail.
- file.place and file.place.info read only the page placed and take every
  PDF box as crop (art, trim, bleed, media besides crop and bounding) and a
  password; the UI's File > Place asks with the dialog when no page is given,
  keeping Link, Template and the other place params.
- testkit::pdf writes hand-made PDFs with page boxes and RC4 encryption.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:37:11 +07:00
BFlat e8af1f6576 Merge pull request #74 from storytold/pr/shortcut-test-isolation
Tests: shortcut overrides and workspace names no longer leak between parallel tests (M14.6)
2026-10-05 09:36:33 +07:00
bflatasticandClaude Opus 5.5 0e5425544a M14.6: shortcut overrides and workspace names no longer leak between parallel tests
global_override_reaches_shortcut_of_and_tools failed about once in a
hundred full ui-egui runs: the override and workspace-name mirrors
(read by menus and the shortcut dispatcher, which have no app access)
were process-wide, and every frame of any headless test running in
parallel synced its own default UI state into them, wiping the test's
overrides between its sync and its assertions.

- One `ui_mirror!` declares both mirrors: process-wide in the app, one
  per thread under cfg(test), so each test only sees the state of the
  app it drives.
- New test: a sync from another thread leaves this thread's overrides
  and workspace names alone (fails with a shared mirror).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:29:12 +07:00
BFlat d6ebecee39 Merge pull request #73 from storytold/pr/m4-export-as-png
M4: Export As with Use Artboards; PNG/JPEG/WebP resolution, background, anti-aliasing (M4.33, M4.34)
2026-10-05 09:28:45 +07:00
bflatasticandClaude Opus 5.5 950a941003 M4.33: Export As with Use Artboards (All or a range) and art bounds
File > Export > Export As... (file.exportAs) picks any writable format and Use Artboards: All
or a range writes one file per artboard, <file>-<artboard>.<ext> (a PDF keeps them as pages);
off, the export covers the bounds of the visible art (an artless document is refused before the
save dialog). The format's options dialog follows: PNG/JPEG/WebP Options, SVG Options, or Save
PDF for artboards as pages. document.export takes useArtboards for raster formats (true: one
file per chosen artboard, default all) and PDF (false: the art's bounds), and the app's exports
return every file written and the encoder's warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:14:42 +07:00
bflatasticandClaude Opus 5.5 157724279f M4.34: raster export options and PNG Options — resolution, background, anti-aliasing, interlacing
PNG is written by an in-code chunk writer with the resolution in pHYs and optional Adam7
interlacing (JPEG stores the resolution in JFIF). document.export takes ppi (72/150/300/other,
wins over scale), background (transparent, white, black or a colour) and antiAlias (none, art,
type: text snapped to whole pixels); Export for Screens rows take them too (plus one top-level
antiAlias, and a row's ppi names its @Nx suffix), and so does object.rasterize. Hard edges stay
hard in every offscreen pass (groups, masks, filters), whatever the thread count.
RenderOptions.anti_alias and render::encode::RasterExportOptions; PNG/JPEG/WebP Options dialog
(pngOptions/jpgOptions/webpOptions) after File > Export As PNG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:14:11 +07:00
BFlat bba9d2ceb8 Merge pull request #72 from storytold/pr/m4-svg-symbols-editing
M4: SVG symbols as <symbol>/<use>, hidden layers, metadata; Preserve Editing round trip (M4.39, M4.40)
2026-10-05 09:12:13 +07:00
bflatasticandClaude Opus 5.5 74b5a904b7 M4.40: Preserve Editing SVGs reopen exactly, or as plain SVG once edited elsewhere
The editing data now sits in CDATA inside <metadata> with a hash of the
markup around that element (whitespace between tags and line endings
don't count). document.open restores the native document when the hash
matches (data written without a hash is trusted as before); when another
app changed the SVG, or the data can't be read, it imports the SVG and
says why in its warnings. vectorcraft_svg::editing reports the data and
whether it is intact; editing_data stays. Live effects, symbols and
patterns come back exactly; the import fuzz test now mutates saved SVGs
with editing data and hidden layers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:03:52 +07:00
bflatasticandClaude Opus 5.5 370c6e2fc0 M4.39: SVG symbols as <symbol>/<use>, hidden layers on Save, Dublin Core metadata
Symbol instances export as one <symbol> def per symbol and a <use> per
instance. An instance the def can't stand for exactly (stained by its fill,
or a transform under which the canvas paints differently: scaling with
strokes, effects or live objects, turning with effects or brushes, any move
with pattern paints or unlinked masks) is written as its own art, now
stained like the canvas and with its patterns and unlinked masks left on
the page. Symbols nested in themselves stop at a fixed depth.

New SVG option hiddenLayers keeps hidden layers as display:none groups;
document.save (and Save As / Save a Copy) turns it on unless told
otherwise, exports leave hidden layers out. Import shows display:none
layer groups to usvg and marks them hidden, so they reopen hidden
(groups a <use> refers to stay templates). File Info metadata goes through
one Dublin Core list. The symbol stain helper moves to vectorcraft-brush
so the renderer and the SVG writer share it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:03:52 +07:00
BFlat 4cccbc2c6e Merge pull request #71 from storytold/docs/mcp-fences
Docs: close the code blocks that made mcp.md sections render as code
2026-10-05 09:00:00 +07:00
bflatasticandClaude Opus 5.5 950984b3db Docs: close the code blocks that left mcp.md sections rendering as code
Three examples in docs/mcp.md (Flatten Transparency, Flattener Preview,
Object > Expand) had lost their closing fence in earlier merges, so every
section after them rendered inside a code block. The fences are balanced
again and no heading sits inside a block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:51:44 +07:00
BFlat a31f212021 Merge pull request #70 from storytold/pr/m4-place
M4: Place as an engine command, drag and drop, the loaded place cursor (M4.24, M4.25)
2026-10-05 08:50:42 +07:00
bflatasticandClaude Opus 5.5 76b7d960f2 M4.25: Drop files on the canvas to place them; several placed files load the place cursor
Files dropped on the canvas of an open document are placed at the drop point (linked; Shift
embeds); dropped on the tab bar or with no document open they open and join Open Recent Files.
The canvas is outlined while files are dragged over it. On the web, drag events give the drop
point and the files arrive through the inbox.

File > Place picks several files. file.place.queue loads the place cursor (the `place` tool):
a click places the current file at 100% with its top-left corner there, a drag places it at the
dragged size with its aspect kept, the arrow keys cycle the files and Esc discards one; the
previous tool returns after the last. The cursor shows the file's thumbnail and a count badge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:42:30 +07:00
bflatasticandClaude Opus 5.5 20e0935116 M4.24: File > Place as an engine command: images at their physical size, SVG/PDF/native as one group
file.place {path | name+dataBase64, link, template, replace, at, rect, page, crop} places a
file as one undo step without touching the clipboard. Raster images come in at 100% of the
resolution their file declares (PNG pHYs, JFIF/EXIF, TIFF, BMP; 72 ppi otherwise), linked to
their path by default; SVG becomes one group with its embedded images; a PDF/.ai page or a
native document's artboard becomes one clipped group. The images, symbols, patterns and
swatches the art uses join the document (renamed on conflict). Replace keeps the replaced
object's stacking place and transform; Template puts the art on a new locked template layer
below the current one. file.place.info and image.info report size, ppi and colour mode.

File > Place picks a file and opens the Place dialog (Link, Template, Replace); the web picks
through the inbox. The Control bar shows Linked File/Embedded, the file name, colour mode and
ppi of the selected image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:42:30 +07:00
BFlat 26246980e3 Merge pull request #69 from storytold/pr/m4-document-setup-new-document
M4: Document Setup depth; New Document with preset categories, details column and More Settings (M4.20, M4.21)
2026-10-05 08:41:16 +07:00
bflatasticandClaude Opus 5.5 6afcacbd91 M4.21: New Document with preset categories, details column and More Settings
File > New opens a New Document dialog in the reference app's layout:
category tabs (Recent, Saved, Mobile, Web, Print, Film & Video, Art &
Illustration, Branding, Social) over cards of presets generated in code
(page-proportioned thumbnail, neutral name, size with units), and a
Preset Details column: name with Save Preset, width with the units
dropdown, height, portrait/landscape toggles, artboards, linked bleed,
Background Contents with its chip, collapsible Advanced Options (colour
mode, raster effects, preview mode), More Settings, Close and Create.
More Settings is the classic form of the same fields (profile and size
lists, artboard layout buttons, spacing and columns, Templates...).
Choosing a preset fills the fields; editing one makes them custom.

file.new takes every setting (preset, name, orientation, artboards with
artboardLayout {layout, columns, spacing, rightToLeft}, bleed,
backgroundContents, colorMode, rasterEffectsPpi, previewMode; sizes may
carry units). file.newPresets lists the categories; file.newPresets.save
and .delete keep user presets in the preferences; Recent remembers the
last sizes. Print presets and plain sizes start in Preferences > Units >
General, screen presets in pixels. Rearrange All Artboards shares the
grid layout code. The Home screen shows the same preset cards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:34:09 +07:00
bflatasticandClaude Opus 5.5 d527349f5a M4.20: Document Setup with bleed, transparency grid, paper and type options
File > Document Setup is a tabbed dialog over one engine command,
document.setup (no params: a query; any keys: one undo step;
document.setUnits stays as the units alias).

- General: units, bleed (four sides with a link toggle), Edit Artboards,
  Show Images in Outline Mode (greyscale pixels under the frame),
  Highlight Substituted Fonts/Glyphs (pink boxes on screen only).
- Transparency: grid size and colour presets or custom colours, Simulate
  Colored Paper, the flattener preset (built-in or saved) and Discard
  White Overprint (Overprint Preview keeps white overprints visible).
- Type: language with its quotes, Use Typographer's Quotes for the Type
  tool and Smart Punctuation, superscript/subscript size and position and
  small caps size (Character panel position/smallCaps; changing the
  setup restyles the text in the same undo step), text export (SVG text
  as text or outlines by default).
- The canvas draws a red bleed outline and the grid in the document's
  colours and cell size as one textured quad, also in rotated views; a
  white Background Contents exports raster formats opaque.

Model: Document.setup (DocSetup, defaulted, skipped when default) and
CharStyle position/small_caps; old files load unchanged.
tests_transparencygrid's helper now finds the grid by its texture (the
grid is one textured mesh instead of per-cell rectangles).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:28:16 +07:00
BFlat 25df58e722 Merge pull request #68 from storytold/pr/m4-svg-export-options
M4: SVG Options, area type and gradients in SVG export, ids and names, SVGZ (M4.26–M4.30)
2026-10-05 08:04:25 +07:00
bflatasticandClaude Opus 5.5 96aba71759 M4.30: SVGZ read and write
svgz is a writable format with the SVG options (document.export,
serialize, save, Export for Screens): exactly the SVG export, gzipped.
SVGZ is recognised by its extension or the gzip magic bytes and opens,
places and pastes like SVG (clipboard.importSvg takes dataBase64 of SVG
or SVGZ); Export As SVG or Save As to a .svgz name writes it
compressed. Gzip lives in the svg crate (pure-Rust flate2, builds for
wasm) and unpacking stops at 512 MB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:58:10 +07:00
bflatasticandClaude Opus 5.5 b75ed0877d M4.29: object ids and names survive the SVG round trip
Layer Names writes readable ids plus data-name with the original name
whenever the id differs; Minimal writes only the ids definitions need;
Unique prefixes every id and CSS class with a content hash, so SVGs
inlined in one page never collide. Import names objects and layers from
data-name, inkscape:label, serif:id or aria-label.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:57:44 +07:00
bflatasticandClaude Opus 5.5 32252c68ea M4.28: SVG gradient export fidelity
Character gradients on live type export as url(#…) spanning the
laid-out text (point, area, on-path and outlined type, as on the
canvas); character stroke gradients span the text grown by half the
weight. Identical gradients share one def, and a swatch gradient takes
the swatch's name as its id. Midpoints are written as extra stops marked
data-vc-midpoint, which import turns back into midpoints.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:56:54 +07:00
bflatasticandClaude Opus 5.5 1982ecf98d M4.27: area type and rich text in SVG export
Every laid-out line of type is written at its own position (wraps,
indents, tabs, hyphenation and all caps as on the canvas): a positioned
tspan per style run and, on justified lines, per word. Centred and
right-aligned lines are anchored at their centre or right end, so they
stay aligned in a viewer whose font differs. Leading comes from the
layout's baselines, baseline shift is written as dy, horizontal scale as
textLength, vertical scale as the size, tracking and manual kerning as
letter-spacing. The fewerTspans option (engine and dialog) writes one
positioned tspan per line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:56:32 +07:00
bflatasticandClaude Opus 5.5 4a3859c69b M4.26: SVG Options in the engine, a dialog and MCP
document.export, serialize and save, and the Save As / Save a Copy UI
commands, take the SVG options flat or as svg: {…}: styling
(presentation, style, entities, css), outlineText, images (embed or
link; linked files are written next to the SVG), objectIds (layerNames,
minimal, unique), decimals 1–7, minify, responsive, useArtboards and
range "all" (one SVG per artboard), preserveEditing (the SVG reopens as
the full document) and metadata. Unknown keys are rejected and
document.formats lists every option with its default. Results carry the
SVG writer's warnings.

Save writes SVG to an .svg path and Save again reuses its options.
Export As SVG and Save As / Save a Copy to .svg open SVG Options (first
use: Internal CSS and Responsive, then the last-used choices) with a
read-only code view. Save a Copy no longer retargets the document.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:55:59 +07:00
BFlat 2df7edd8c9 Merge pull request #67 from storytold/pr/preferences-units
Preferences ▸ Units drive every length the UI shows and reads (M14.5)
2026-10-05 07:52:11 +07:00
bflatasticandClaude Opus 5.5 3b99df3b9a M14.5: Preferences > Units drive every length the UI shows and reads
The General unit is the active document's units: Preferences > Units >
General sets them (one undo step) and is the units new documents start
in (and the unit shown with no document open); Document Setup changes
only the document. The Transform panel, Properties, the Control bar, the
Info panel, rulers, the status bar, Align spacing, Tabs, paragraph
indents, Pattern Options, dialog distances (Move, shape sizes, path
dialogs, Artboard Options, effect distances, Transform Each, parameter
dialogs), New Document and the canvas measurement labels (smart-guide
sizes, moves, the Measure tool, the Live Corners radius) all follow it,
live. Units > Type drives font size, leading and baseline shift; Units >
Stroke also reaches the Appearance panel and the Magic Wand weight
tolerance. Typed units ("5 mm", "12 pt") work in any field; all ten
units are choices; length preferences show in their unit and accept a
unit; offset/simplify/grid/type-size params read typed units. Typed text
starting with a multi-byte character no longer panics the parser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:43:41 +07:00
BFlat 0e31fc92a2 Merge pull request #66 from storytold/pr/m4-pdf-options
M4: PDF settings, Save PDF dialog, export warnings and Summary (M4.31, M4.32)
2026-10-05 07:30:01 +07:00
bflatasticandClaude Opus 5.5 a40bce615a M4.32: PDF warnings, Summary section, View PDF after Saving and status text
document.export and document.serialize return the encoder's warnings (PDF:
options not applied yet, features approximated or left out, overprint).
document.pdfSettings lists the options that differ from the defaults and
the warnings (includeDocument adds the active document's own from an
in-memory export); the dialog's Summary section shows both. View PDF after
Saving opens the written file as a file:// URL through Services.open_url;
the status line reports the notes. The PDF writer's raster-effects warning
now names what it leaves out (objects of a kind, fills, strokes) instead of
claiming raster effects are never exported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:22:27 +07:00
bflatasticandClaude Opus 5.5 4304a7c0b1 M4.31: Save PDF dialog and document.exportPdf with PDF settings
PdfSettings (serde, camelCase) is the Save PDF model: Standard, Compatibility
PDF 1.4-2.0, General (Preserve editing, Thumbnails, Fast web view, View after
saving, Create layers), Compression, Marks and Bleeds, Output, Advanced and
Security. PdfOptions = settings + pages, title and date.

document.exportPdf {path?, preset?, artboard?|artboards?|range?, standard?,
compatibility?, ...sections} -> {path|dataBase64, bytes, warnings} applies the
options over the preset (null keeps the preset's value); PDF/X, passwords,
PDF/A-2b at 2.0 and out-of-range values are refused; options accepted but not
applied yet come back as warnings. document.export {format: pdf} and
document.formats take and list the same options.

The Save PDF dialog (file.export.pdf, ui.savePdfDialog) has Preset,
Standard and Compatibility on top and a section list; it runs the export
before asking for a path, so bad options never open a save dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:22:27 +07:00
BFlat b0a8d78926 Merge pull request #65 from storytold/pr/m4-svg-import
M4: SVG import units and text fidelity (M4.18, M4.19)
2026-10-05 07:22:06 +07:00
bflatasticandClaude Opus 5.5 5c58ebcb32 M4.19: SVG text import fidelity — text keeps its place, group, clip and mask; textPath, per-character positions, weights, spacing, paints and CSS selectors survive
Each <text> is swapped for a placeholder group before usvg runs, so the text keeps its z-order,
parent group, clip, mask, opacity, link and <use> instances, and usvg resolves its gradient and
pattern paints (in text space). <textPath> becomes type on a path (startOffset, side, anchor),
vertical writing-mode type on a vertical path. Per-character x/y/dx/dy/rotate lists become line
breaks (bounded), kerning (absolute x measured against the layout), baseline shift and rotation;
baseline-shift, numeric and relative weights (style names), letter/word spacing, kerning and the
character stroke's cap, join, miter and dashes are kept. The style cascade supports type, #id,
multiple-class, attribute, descendant and child selectors with !important and specificity.
<pattern> paints become pattern swatches. The import fuzz covers the new attributes and CSS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:20:10 +07:00
bflatasticandClaude Opus 5.5 bc17c1e635 M4.18: SVG import units — absolute lengths keep their physical size, px map 1:1 to pt, document units follow the file
A pixel (and a unitless user unit) is a point and absolute lengths convert at 72 pt per inch
(`font-size="12pt"` is 12 pt, `1in` is 72 pt); a root size in absolute units keeps the physical
size main already gave it (`210mm` opens as a 595.28 pt artboard, user units being CSS pixels of
it). The document's units follow the unit of the root width (pixels when it has none). Text font
sizes honour their units (em/ex/% relative to the parent size, CSS size keywords), as do text
positions, letter spacing, stroke widths and dashes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:09:06 +07:00
BFlat 39ce7b0e01 Merge pull request #30 from storytold/harden/no-panics
Robustness: VectorCraft never crashes — no panics in shipped code
2026-10-05 06:50:04 +07:00
bflatasticandClaude Opus 5.5 8ac84dde20 M14.4: robustness docs name the library fuzzing and main's brands gate
docs/development.md lists the library files import_fuzz now covers, and the
PR template's CI checklist includes `cargo xtask brands`, which main added
after the no-panics branch was cut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 06:40:34 +07:00
bflatasticandClaude Opus 5.5 0744347065 M14.4: tests main gained follow the no-panic lint rules
As the branch does for older tests: `panic!("…")` instead of
`unreachable!()` in test code (clippy.toml only allows unwrap, expect and
panic there), and the integration tests main added (CLI file I/O, render
gradient text and stroke geometry, SVG export suites) opt out of the
unwrap/expect/panic lints. No test logic changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 06:40:34 +07:00
bflatasticandClaude Opus 5.5 8d0fae610d M14.4: fuzz the library loaders and main's new SVG import paths
import_fuzz now also feeds garbage and mutated `.vcswatches`, `.gpl`,
`.vcstyles` and `.vcflattener` files through `swatch.library.load` /
`graphicStyle.loadLibrary` / `flattener.presets.import`, uses what loads
(adds and applies the swatches and styles, previews and flattens with the
presets) and renders and exports the result. The SVG generator gains `<a>`,
Lab colours, mask options (`mask-type`, `data-vectorcraft-mask`) and paint
attributes. `PROPTEST_CASES` now raises the case count as the file's docs
say (an explicit `cases: 64` used to override it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 06:40:34 +07:00
bflatasticandClaude Opus 5.5 7fb07bb3db M14.4: as_chunks for the pixel loops main gained since the no-panics branch
Same treatment as the branch's `as_chunks` commit (Rust 1.99's
`chunks_exact_to_as_chunks` lint) for the flattener's coverage pass, the
CMYK ink compositor, the Flattener Preview highlight and four test helpers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 06:40:34 +07:00
bflatasticandClaude Opus 5.5 1a5f7f6441 M14.4: no panics in the shipped code main gained since the no-panics branch
The M3/M4 work added ten panic sites the new workspace lints deny:
- doc: the lens/taper width profiles fall back to a uniform profile
  instead of `expect` (the presets are a constant table, covered by tests).
- engine: `FlattenOptions::default` builds the medium preset directly
  (`builtin`), `apply` skips an empty group instead of `expect`, the
  writable-format fallback reports an error instead of `expect`, and the
  new-art template's stroke comes back as an `Option` (it always has one)
  instead of `expect`.
- tools: the freeform gradient drag returns no actions for a grab it never
  starts (was `unreachable!`), and the gradient tool's release takes the
  gesture with `if let` instead of `expect`.

Behaviour on the success paths is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 06:39:18 +07:00
Brandon ThomasandClaude Opus 5.5 3e323bfa2a Robustness: VectorCraft never crashes — no panics in shipped code
Shipped code no longer unwraps, expects or panics: every one of the 124
unwrap()/expect()/panic!/unreachable! sites outside tests now handles the
failure (Result/Option, ?, let-else, defaults) and reports errors to the user.

- Lints: workspace clippy denies unwrap_used, expect_used, panic, todo,
  unimplemented and unreachable; clippy.toml allows them in tests, and
  integration tests, examples and testkit opt out explicitly.
- Safety net (vectorcraft_engine::guard): a panic in a top-level command rolls
  the document back and returns EngineError::Internal; a panicking tool is
  reset; the MCP server answers with an internal error and keeps serving; the
  UI frame and each control request are guarded.
- APIs that hid failures now return them: Rendered::to_png/to_jpeg/to_webp
  return Result (JPEG/WebP errors used to write empty files),
  FontDb::face returns Option, Region::top returns Option.
- Fuzzing: engine/tests/import_fuzz.rs feeds garbage, hostile and mutated SVG
  and PDF through import → render → export (30k cases locally, no panics).
- Docs: AGENTS.md/CLAUDE.md rule, docs/development.md "Robustness" section,
  README pointer and a PR template checklist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 06:28:59 +07:00
Brandon ThomasandClaude Opus 5.5 0f241aac9f clippy: as_chunks for fixed-size pixel and pair chunks (Rust 1.99 lint was red on main)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 06:24:06 +07:00
BFlat c4236e1bb2 Merge pull request #64 from storytold/pr/scale-strokes-off-by-default
M3: Scale Strokes & Effects is off by default
2026-10-05 06:17:50 +07:00
bflatasticandClaude Opus 5.5 a0b694dcd8 M3.66: Scale Strokes & Effects is off by default
New preferences start with Scale Strokes & Effects off, as in Illustrator:
scaling an object keeps its stroke weights, dashes and effect distances unless
the option (or `strokes: true`) is on. Preference files that already store the
option keep their value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 06:17:36 +07:00
BFlat 5750786e63 Merge pull request #63 from storytold/docs/roadmap-m3-complete
Roadmap: M3 Paint & appearance complete
2026-10-05 05:57:37 +07:00
bflatasticandClaude Opus 5.5 b9fa2d27fd Roadmap: M3 complete (M3.7–M3.98)
M3 is done: the M3 row, the shipped list (swatches, colour, gradients, strokes,
appearance, masks, transparency, graphic styles, and the title bar) and the
colour, stroke and appearance parity rows cover everything that landed through
#62, with the few known gaps named. M6 notes the Boolean precision fix and M14
the custom title bar and content-sized dialogs. The totals are summed from the
rows again (they had drifted), and the method note records M3's measured pace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:57:21 +07:00
BFlat bd7ed58376 Merge pull request #62 from storytold/pr/m3-gradient-on-strokes
M3: gradient within, along or across a stroke (M3.83)
2026-10-05 05:51:11 +07:00
bflatasticandClaude Opus 5.5 025d758abe M3.83: gradients within, along or across a stroke
The Gradient panel's Stroke buttons now work: with the Stroke proxy on a
linear or radial gradient they lay it within the stroke (placed on the
page, as before), along the path (start to end of each subpath) or across
it (left edge to right edge; inside/outside strokes span their visible
side). `paint.editGradient {stroke: true, strokeMode}` sets it (new art
too with nothing selected); `document.inspect` reports
strokeOptions.gradientMode. Model: StrokeLayer.gradient_mode (default
Within, not written).

Shared geometry in effects::stroke::gradient: ribs on the bisectors of
the flattened path (fans round sharp corners, widened under arrowheads,
following width profiles) cut slices each painted with a linear piece of
the gradient (Gradient::span). The canvas and raster export clip them to
the stroke's outline and arrowheads, grown a pixel and composited with
copy so there are no seams and translucent stops paint once. SVG and PDF
write the same slices clipped to the outline, with a warning (new
vectorcraft_svg::export_with_report). Outline Stroke and Expand make
gradient meshes clipped to the outline. Mesh quads now grow along their
edges so thin quads show no seams. Dashes, profiles and arrowheads keep
working in both modes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:47:54 +07:00
BFlat ccdb1b30f6 Merge pull request #61 from storytold/pr/eyedropper-test-flake
Tests: the eyedropper image-sampling test no longer flakes (colour settings changed by parallel tests)
2026-10-05 05:37:48 +07:00
bflatasticandClaude Opus 5.5 5a0e6be852 M3.52: stop the eyedropper image-sampling test from flaking
The engine unit tests share one process, and two colour-management tests
switched the process-wide Color Settings (the working RGB space to the wide
gamut profile, Black Point Compensation off) for a moment. Any test that
showed or rendered a colour at that moment saw it through the wrong profile:
clicking_an_image_samples_its_pixel read #008181 instead of #008080.

- Move the two tests that change the Color Settings to their own test
  binary (crates/engine/tests/color_settings.rs), so no unit test can see
  non-default settings.
- The image-sampling test compares the stored colour, not its display hex.
- every_command_survives_empty_params toggles the process-wide proof view
  (Proof Colors, Overprint Preview, Separations), so it now takes the
  colour-management test lock like the tests that read that view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:36:57 +07:00
BFlat 0038e00382 Merge pull request #60 from storytold/pr/m3-color-guide-limit-themes
M3: Color Guide Limit to Library, Edit or Apply without art, Color Themes panel (M3.95, M3.96)
2026-10-05 05:11:50 +07:00
bflatasticandClaude Opus 5.5 be648089be M3.96: Color Themes panel with a local theme library
Window → Color Themes: the Create tab makes a five-colour theme on a
harmony wheel (a harmony rule from the selected colour, colours moving
together; or Custom, each on its own), with a brightness slider, hex
field, current-colour picker and name; the My Themes tab lists saved
themes to edit, add to Swatches as a colour group, or delete. Themes are
kept in the preferences (Prefs::color_themes; no online service).

Commands: colorTheme.list, colorTheme.save {colors | color + rule, name?,
replace?}, colorTheme.delete, colorTheme.addToSwatches. Harmony::theme
and move_on_wheel live in the colour crate; the wheel is now a shared
widget (widgets::harmony_wheel) used by Recolor Artwork's Edit tab too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:08:28 +07:00
bflatasticandClaude Opus 5.5 81582c8d4f M3.95: Color Guide Limit to Library and Edit or Apply Colors without art
The Color Guide's library button opens a Limit to Library popup (None,
Document Swatches, the colour libraries): every harmony colour and
variation snaps to the nearest colour there (CIEDE2000), cached per
inputs so the grid isn't re-snapped every frame. `color.harmony` takes
`limitTo`, `ui.colorGuideLimit` sets the panel's limit, and Recolor
Artwork's Limit to Library also offers the document's swatches (one
shared engine helper, swatchlib::limit_palette).

Edit or Apply Colors opens Recolor with the harmony colours limited to
the same library; with no art selected the colours are the rows and OK
saves them as a new colour group.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:08:15 +07:00
BFlat 96a1a02436 Merge pull request #59 from storytold/pr/m3-freeform-canvas-focal
M3: freeform editing on the canvas; radial extent, aspect and focal point (M3.97, M3.98)
2026-10-05 04:48:55 +07:00
bflatasticandClaude Opus 5.5 d8eff17b9f M3.98: radial annotator extent ellipse, aspect handle and off-centre focal point
Radial gradients show a dashed extent ellipse: its handle across the bar
sets the aspect ratio, the ellipse elsewhere rotates it, and the dot inside
the centre ring drags the focal point (GradientGeom.focal) for an off-centre
radial. paint.setGradientGeom takes aspect (%) and focal ([x,y] | null), and
start/end may be left out; the focal point keeps its place in the ellipse as
the vector, aspect and object transform. It renders as a two-point radial,
exports and imports as SVG fx/fy and PDF two-point radial shadings, expands
to off-centre rings, and travels in gradient params and the native format.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 04:43:43 +07:00
bflatasticandClaude Opus 5.5 b8793eeaef M3.97: freeform points drag out to delete, spread ring drags, Lines mode draws lines
With the Gradient tool on a freeform gradient the selected point shows its
spread as a dashed ring with a handle; dragging either sets the spread. A
point dragged out of the object is removed on release, and double-clicking a
point opens the gradientStop popover, which now edits the selected freeform
point (colour, opacity, spread). In Lines mode successive clicks draw one
smooth line (with a rubber band to the pointer); clicking an existing point
continues the line from it, and Escape or a click off the art ends it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 04:43:43 +07:00
BFlat 37acaebf95 Merge pull request #58 from storytold/pr/m3-width-tool-new-art
M3: Width tool depth and the new-art appearance template (M3.64, M3.65)
2026-10-05 04:43:28 +07:00
bflatasticandClaude Opus 5.5 faf006e135 M3.65: new-art appearance template (stroke options and graphic styles with nothing selected, New Art Has Basic Appearance)
With nothing selected, Stroke panel edits (stroke.set / stroke.setAdvanced: cap, join, dashes,
arrowheads, profile...) and a graphic style clicked in the Graphic Styles panel
(graphicStyle.apply, add too) set up the next object drawn: its stroke options, fills, strokes,
effects, opacity and blend mode, linked to the style. The Appearance panel's New Art Has Basic
Appearance (appearance.setNewArtBasic, preference newArtBasic, on by default) now works: off,
new art takes the whole appearance of the last selection. paint.default resets the template,
appearance.newArt reports it, and with nothing selected the Stroke panel shows its stroke and
the Appearance panel its rows. PaintDefaults gains the template appearance, opacity, blend
mode and style.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 04:38:09 +07:00
bflatasticandClaude Opus 5.5 b79262d4f4 M3.64: Width tool depth: Width Point Edit, multi-select, Alt-copy and discontinuous points
Double-clicking a width point opens Width Point Edit (Side 1, Side 2, Total Width, a link
keeping the sides equal, Adjust Adjoining Width Points, Delete; dialog `widthPoint`, also
`ui.widthPointEdit`). Shift-click selects several width points, which drag and widen together
as one undo step; Alt-dragging a point's centre copies it (`stroke.widthPoint.copy`); dropping a
point onto another makes a discontinuous point (two points at the same t), and the stroke's
width steps there. `stroke.widthPoint.set` takes `adjustAdjoining`, keeps a discontinuous point
when only its widths change, and `stroke.widthPoint.remove` takes `indices`. Flipping a profile
along the path swaps the sides of its steps; `document.inspect` reports `widthPoints`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 04:38:09 +07:00
BFlat d9740931a5 Merge pull request #57 from storytold/pr/m3-cmyk-blending
M3: blend modes and opacity composite inks in CMYK documents (M3.90)
2026-10-05 04:37:52 +07:00
bflatasticandClaude Opus 5.5 9537eca5f7 M3.90: blend modes and opacity composite inks in CMYK documents
A CMYK document that shows transparency is drawn once per ink plane
(complemented C, M, Y; complemented K as a grey) so every blend mode,
opacity, group, knockout and effect composites inks, PDF-style; the
planes' inks are shown through the working CMYK profile (cached 4-D XYZ
table). Cyan multiplied over magenta now shows C100 M100. Opacity masks
keep screen luminance; RGB colours and placed images are separated
first. RGB documents and opaque CMYK documents keep the screen-colour
path.

PDF export of CMYK documents writes its transparency groups with
/CS /DeviceCMYK (luminosity mask groups stay RGB) and puts top-level
transparency in a non-isolated page-wide group so it blends in CMYK too.
Flatten Transparency composites CMYK documents ink by ink and makes CMYK
regions. Non-separable modes no longer give NaN for greys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 04:30:06 +07:00
BFlat 6ee994239d Merge pull request #56 from storytold/pr/m3-recolor
M3: Recolor Artwork reduction, methods, presets and Edit tab; Edit Color Group (M3.78, M3.79)
2026-10-05 04:28:14 +07:00
bflatasticandClaude Opus 5.5 fcc0f4ca0f M3.79: Edit or Apply Color Group opens Recolor Artwork on a colour group
Double-clicking a colour group's folder in the Swatches panel, or the options
button (Edit or Apply Color Group) while a group is selected, opens Recolor
Artwork on the group: without art the rows are the group's colours and OK
rewrites the group; with art selected the art is reduced to the group's size,
takes its colours, and art and group change in one undo step. New
swatch.editGroup {group, colors, rename?} rewrites a group in order (art linked
to its global swatches follows); recolor.apply takes group, groupColors and
rename.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 04:20:27 +07:00
bflatasticandClaude Opus 5.5 fca068a0a7 M3.78: Recolor Artwork with colour reduction, methods, presets and Limit to Library
Recolor Artwork keys colours by identity (model and exact values), so CMYK
colours stay CMYK and lookalikes stay apart. recolor.reduce groups the
selection's colours into rows (k-means in Lab, weighted by use; tints of a
global swatch share its row) with Preserve White/Black/Grays and Limit to
Library; recolor.apply maps rows by Exact, Preserve Tints, Scale Tints, Tints
and Shades or Hue Shift; recolor.randomize shuffles or varies new colours.
The dialog has Assign (rows with merge, separate, exclude, randomize, method,
library) and Edit (hue/saturation wheel with linked or unlinked handles,
brightness, harmony rules) tabs, and Edit Colors gains Recolor with Preset
(1/2/3 Color Job, Color Library). Colour parameters accept colour keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 04:20:27 +07:00
BFlat 0c950aae0e Merge pull request #55 from storytold/pr/ui-dialog-stretch-test
UI: test that no dialog stretches with the screen
2026-10-05 04:13:34 +07:00
bflatasticandClaude Opus 5.5 1aac138b4f M14.1: a test that no shared-frame dialog stretches with the screen
Fourteen dialog openers (swatch, colour, style, flattener, expand, effect and
parameter dialogs) are drawn on a 1000 pt and a 2000 pt tall screen; each
dialog must be the same height on both, so nothing inside one fills the
available height again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 04:10:34 +07:00
BFlat 321d8e78cf Merge pull request #54 from storytold/pr/m3-scale-strokes-preview-bounds
M3: Scale Strokes & Effects complete, Scale Corners, Use Preview Bounds (M3.66, M3.67)
2026-10-05 04:10:17 +07:00
bflatasticandClaude Opus 5.5 65f0f34cd3 M3.67: Use Preview Bounds measures strokes in transforms, the bounding box and Align
With Use Preview Bounds on, the Transform panel, Properties and Control bar
X/Y/W/H, the Selection tool's bounding box, Align, Distribute and
Distribute Spacing measure visual bounds (the whole stroke). setBounds sets
the visual box, scaling the path so that kept or scaled strokes land on
the typed size. object.align/distribute/distributeSpacing take
bounds?: "preview"|"geometric"; the Align flyout item shows and toggles the
preference through prefs.set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:59:37 +07:00
bflatasticandClaude Opus 5.5 5afc78a100 M3.66: Scale Strokes & Effects scales dashes and effect distances; Scale Corners works
Scaling with Scale Strokes & Effects on now scales stroke weights, dash
lengths and dash offsets and every distance parameter of object, fill and
stroke effects (groups and layers too), from new length metadata in the
effects catalogue. Off, nothing painted changes size and type keeps its
character strokes' weight. Scale Corners off keeps live corner radii.
object.scale/transform/transformEach take strokes?/corners? (default: the
preferences) and journal the values they used. The Scale dialog and a new
Transform Each dialog get both checkboxes, kept as the preferences on OK;
the panels' toggles go through prefs.set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:59:37 +07:00
BFlat f3397fadc6 Merge pull request #53 from storytold/pr/m3-lab-spots
M3: spot colours defined in Lab, Spot Colors options (M3.85)
2026-10-05 03:55:29 +07:00
BFlat b32284aeae Merge pull request #52 from storytold/pr/m3-attributes-registration
M3: Attributes panel; Registration colour, trim marks and live Crop Marks (M3.76, M3.77)
2026-10-05 03:54:53 +07:00
bflatasticandClaude Opus 5.5 ea61d34a93 M3.85: spot colours defined in Lab, with Spot Colors options for Lab values or CMYK equivalents
Colours gain a CIE Lab model (`Color::Lab {l,a,b}`, serde tag `lab`), handled through the
CMS (display, separation, conversions, gamut), tints (toward paper white), keep_model, harmonies,
recolouring, blends, Info and the Color panel. Swatch Options gets a Lab mode
(`swatch.edit {mode: "lab"}` / `{color: {l,a,b}}`), and colour params accept `{l,a,b}`.

Swatches panel → Spot Colors… (`ui.spotColors`, dialog `spotColors`) runs `swatch.spotOptions
{useLab}` (Document.spot_use_lab, default on): Lab spot colours show and separate from their Lab
values, or art linked to them takes their working-CMYK equivalents, as one undo step. PDF export
writes Lab spot inks as Separation spaces with a Lab alternate (tint function from paper white),
or a DeviceCMYK one with CMYK equivalents.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:46:57 +07:00
bflatasticandClaude Opus 5.5 c1df6f0621 M3.77: [Registration] swatch on every plate, trim marks on every artboard, Japanese marks and live Crop Marks
[Registration] is a built-in swatch that prints 100% on every plate: Separations
Preview shows it on each process and spot plate and PDF export writes it as
/Separation /All. It can't be edited, moved, duplicated, merged or deleted;
swatch.list shows it after None and the Swatches panel applies it as a swatch.
Object > Create Trim Marks strokes the marks in Registration and marks every
artboard when nothing is selected ({style?, allArtboards?}). The preference
japaneseCropMarks (General) switches to Japanese marks: double lines at the
trim and bleed edges plus centre marks. Effect > Crop Marks (cropMarks) is a
live effect whose marks follow the object; rendering, SVG/PDF export and
Expand Appearance draw them. The mark geometry lives in doc::marks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:46:04 +07:00
bflatasticandClaude Opus 5.5 4591c85845 M3.76: Attributes panel with overprint, centre point, path direction, fill rule, image map, URL and note
Window > Attributes (Cmd+F11) shows Overprint Fill/Stroke, Show/Don't Show
Center, Reverse Path Direction Off/On, the non-zero / even-odd fill rule,
Image Map, a URL field with recent URLs and a Browser button, and a note.
New commands attributes.set and path.setFillRule; path.reverse takes ids and
reversed; attributes.info reports every value. Objects keep the attributes in
Node.attrs. SVG export wraps linked objects in <a>, and import reads links
back. The canvas draws the centre point of selected objects that show it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:46:04 +07:00
BFlat 9fcefde3f9 Merge pull request #51 from storytold/pr/pathops-near-horizontal-edges
Pathops: Booleans keep their corners on almost-horizontal edges; deterministic property tests (M6.10)
2026-10-05 03:45:49 +07:00
bflatasticandClaude Opus 5.5 be7006ccf5 M6.10: Boolean operations hold their area identities on almost-horizontal edges
The sweep compares a curve with another segment over a y-span shorter than
its tolerance by bounding boxes only, so an edge rising by less than that
tolerance (e.g. a rectangle edge cut through a circle's cap, whose computed
intersection end sits ~1e-12 off the horizontal) was mis-ordered and the
result lost a corner: xor/difference/area disagreed by up to 10%. Every
sweep input now snaps on-curve y values within the tolerance to one level
(handles follow and are levelled with their anchor), so such edges become
exact horizontals, which the sweep handles exactly.

- tests/regressions.rs: the six minimal failures found by the properties,
  the root cause in isolation, and the self-crossing "star" polygon.
- The star-polygon strategy rejected no angular step of half a turn or
  more, so it sometimes drew self-crossing polygons (the shoelace failure).
- Property suites run from a fixed seed (PROPTEST_CASES / PROPTEST_RNG_SEED
  still override), so CI cannot fail at random.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:36:30 +07:00
BFlat bc59c53b5a Merge pull request #50 from storytold/pr/m3-object-expand
M3: Object > Expand dialog; gradient fills expand to objects or a mesh (M3.84)
2026-10-05 03:32:51 +07:00
bflatasticandClaude Opus 5.5 705976fd96 M3.84: Object > Expand dialog; gradient fills expand to a mesh or N objects
Object > Expand… opens a purpose-built dialog (Object, Fill and Stroke
checkboxes, disabled when the selection has nothing for them; Expand
Gradient To: Gradient Mesh or Specify N Objects, default 255). Every
option now does something:
- Object: type to outlines, live shapes to paths, effects baked.
- Stroke: strokes outlined into filled paths.
- Fill: linear/radial gradient fills become N solid rectangles or
  concentric ellipses, or a gradient mesh with lines where the colour
  changes, inside a clip group shaped like the object; freeform
  gradients become a mesh shaped like the object. Colours keep their
  stops' colour model. With Fill off gradient fills stay live.
object.expand {} defaults match the dialog (stroke now defaults on);
new query object.expand.info and UI command ui.expandDialog.
Create Gradient Mesh on a gradient-filled object takes the colours the
gradient paints at each point (GradientMesh::for_path_with, from_grid,
highlight).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:25:33 +07:00
BFlat 4757dd265c Merge pull request #49 from storytold/pr/m3-mask-view-clip-paint
M3: view only the mask, lossless SVG mask options, per-document transparency grid, paint on clipping paths (M3.86–M3.89)
2026-10-05 03:24:19 +07:00
bflatasticandClaude Opus 5.5 3b2f46137a M3.89: a clipping path's fill paints behind the clipped art, its stroke over it
Paint given to a clipping path (paint commands with its id) now shows: its
fill paints inside the clip behind the clipped art and its stroke over the
art, unclipped, on the canvas and in SVG and PDF. Node::clip_paint splits the
clipping path into its fills and its strokes (type: its characters' too) for
all three outputs; the renderer caches the parts per clipping path. Clip group
visual bounds take in the stroke. Make Clipping Mask still leaves the clipping
path unpainted, as the reference app does; Ungroup no longer wipes a painted
clipping path's appearance (Release already kept it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:11:04 +07:00
bflatasticandClaude Opus 5.5 799b507564 M3.88: Show Transparency Grid is per-document engine view state
view.transparencyGrid {on?} -> {on} is now an engine command (DocState.
transparency_grid; not undoable, doesn't dirty the document), so agents can
turn the grid on headless and each open document keeps its own setting. The
menu item, its Show/Hide label, the shortcut and the Properties panel button
use the same id; the canvas draws the grid for the active document only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:11:04 +07:00
bflatasticandClaude Opus 5.5 1603729ec3 M3.87: SVG opacity masks keep their Clip and Invert options
Exported masks carry data-vectorcraft-mask="noclip invert" (the options that
differ from clipping, not inverted). Import restores the flags and the original
mask art, dropping the backdrop rectangle and inverting filter group that draw
the options in SVG; a single mask object comes back as itself, not wrapped in a
group. Masks from other apps import as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:11:04 +07:00
bflatasticandClaude Opus 5.5 03571affbd M3.86: Alt-click the mask thumbnail to view only the mask
transparency.viewOpacityMask {on?, id?} shows only the opacity mask on the
canvas as greyscale coverage (luminance, clip and invert applied; the rest of
the art hidden) and enters mask editing; called again it shows the artwork and
keeps editing. The Transparency panel's mask thumbnail runs it on Alt-click and
is outlined in the accent colour while the mask is viewed. The state is
DocState.mask_view (shown while that mask is edited: DocState::shown_mask) and
reaches the renderer as RenderOptions.mask_view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:11:04 +07:00
BFlat cbdef4ea1f Merge pull request #48 from storytold/pr/m3-graphic-style-libraries
M3: graphic style libraries with .vcstyles save and load (M3.80)
2026-10-05 03:07:34 +07:00
bflatasticandClaude Opus 5.5 874a287958 M3.80: graphic style libraries in the library panel, with .vcstyles save and load
Window > Graphic Style Libraries and the Graphic Styles panel's library button open
read-only libraries of original styles generated in code (Shadows and Glows, Outlines
and Rules, Hand-Drawn, Gradient Finishes, Shape Effects, Blends and Transparency) in
the shared library panel. Clicking a style adds it and applies it to the selection as
one undo step (Alt: on top). Save Graphic Style Library writes .vcstyles JSON (styles
unlinked from swatches, with the patterns they paint with) to the user library folder
(User Defined) or a file; Other Library loads one, or another document's styles.

Commands: graphicStyle.libraries, graphicStyle.library, graphicStyle.addFromLibrary,
graphicStyle.saveLibrary, graphicStyle.loadLibrary; UI: window.graphicStyleLibrary
(.other), ui.saveGraphicStyleLibrary, window.userGraphicStyleLibrary1-10. The user and
loaded library bookkeeping is shared with swatch libraries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:06:13 +07:00
BFlat 2b07aa2ac9 Merge pull request #47 from storytold/pr/m3-stroke-depth
M3: width profile library, thirty more arrowheads, whole-stroke bounds and hit-testing (M3.61–M3.63)
2026-10-05 02:59:27 +07:00
bflatasticandClaude Opus 5.5 c1dc860f1d M3.63: visual bounds and clicks take in the whole stroke
Paths and compound paths now measure their visual bounds along their shape:
the body (inside/outside alignment, the width profile's widest point), the
tips of the miter spikes the limit keeps, projecting caps' corners and the
arrowheads' reach. A rectangle's right-angle miters stay within half the
weight (Fit to Selected Art, Rasterize and all-art SVG export get tighter,
exact boxes); Appearance::outset is the conservative reach for anything else.
Clicks hit what the strokes paint: outside strokes outside the path (0.8 x the
weight out), inside ones inside, the profile's width where clicked, caps,
spikes and heads. Click culling keeps a cheap box (hit test 0.34 ms per click
on the 50k-path budget). The subpath/tangent helpers move from the stroke
geometry into vectorcraft_geom::bez so both share them; a cross-check test
holds every painted point of the real stroke geometry inside the bounds and
hittable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:55:23 +07:00
bflatasticandClaude Opus 5.5 fdad11a99c M3.62: thirty more generated arrowheads with rendered previews
Arrowhead gains 30 original shapes generated in code, appended after the first
ten so saved names stay stable: Barbed, Half Arrow (left/right), Concave,
Double Arrow, Chevron, Feather, Swallowtail, Reverse Triangle, Triangle to Bar,
Kite, Leaf, Drop, Half Circle, Oval, Target, Tag (+ open), Diamond (open),
Hexagon (+ open), Star, Cross, Plus, Double Bar, Dot on Bar, Slash, Double
Slash, Bracket and Fork. Each is drawn in local units of the head's weight
inside the usual 4 x 4 box, with its own stop for the stroke under it.
Arrowhead::label() names them in the menus. The Stroke panel's arrowhead
dropdowns render every head as the canvas draws it (cached textures, a shared
widgets::doc_preview that the Brushes panel now uses too) in a scrolling list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:55:23 +07:00
bflatasticandClaude Opus 5.5 3e73ad8581 M3.61: width profile library: Add to Profiles, Delete Profile, Reset Profiles
The Stroke panel's Profile list now holds saved profiles next to the built-ins,
kept with the preferences (Prefs.width_profiles). Add to Profiles… names the
selected stroke's variable width, Delete Profile removes the selected stroke's
saved one (built-ins can't be deleted), Reset Profiles removes them all.
Three more generated built-ins (Pinch, Teardrop, Wave). The dropdown (Stroke
panel and Control bar) lists saved profiles and draws a custom profile's own
silhouette. Commands: stroke.widthProfile.add/delete/reset/list; stroke.set
{profile} takes a built-in id or a saved name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:55:21 +07:00
BFlat c73aa380e1 Merge pull request #46 from storytold/pr/m3-flattener-ui
M3: Flatten Transparency dialog, flattener presets, Flattener Preview panel (M3.92–M3.94)
2026-10-05 02:42:33 +07:00
bflatasticandClaude Opus 5.5 953024cc35 M3.94: Window > Flattener Preview shows what flattening would rasterize, outline and change
flattener.preview {highlight, overprints?, preset?|options?, ids?} reports, without changing the
document, the transparent and affected objects, patterns, outlined strokes and type, rasterized
complex regions, all rasterized areas and flat-colour regions (FlattenReport, worked out by the
flattener's own plan without rendering images). The panel previews a snapshot taken on Refresh,
greyed with the chosen highlight in red, zoomable (click, Alt-click, drag, double-click); its
Overprints, preset and options (Show Options) set how it flattens, and its menu saves them as a
preset. ui.flattenerPreview drives it; the settings persist with the UI state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:36:43 +07:00
bflatasticandClaude Opus 5.5 1dc158cafe M3.93: Edit > Transparency Flattener Presets… manages built-in and saved flattener presets
Saved presets live in Prefs.flattener_presets (persisted with the preferences) beside the
built-in High, Medium and Low Resolution ones, which stay read-only. New commands:
flattener.presets.list / save / delete / import / export (a .vcflattener JSON file; opening one
imports it). Any saved preset name works as `preset` wherever flattener options are taken
(Session::flatten_options). The manager dialog lists the presets and edits the selected saved
one in place; the Flatten Transparency dialog lists saved presets and gains Save Preset….

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:36:28 +07:00
bflatasticandClaude Opus 5.5 936721fbf5 M3.92: Object > Flatten Transparency… opens a dialog with presets, options and a live preview
The dialog offers the built-in presets, the raster/vector balance, both resolutions and the
outline and preserve switches; Preview flattens on the canvas through the interaction and OK
keeps it as one undo step (object.flattenTransparency). The options editor is shared for the
preset manager and the Flattener Preview panel. Drivable as dialog `flattenTransparency`
through ui.flattenTransparencyDialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:35:27 +07:00
BFlat 3675156c4e Merge pull request #45 from storytold/pr/m3-tints-linked-stops
M3: tints of global and spot colours, gradient stops linked to swatches (M3.59, M3.60)
2026-10-05 02:34:12 +07:00
bflatasticandClaude Opus 5.5 7227e295ea M3.60: gradient swatches and stops linked to global and spot swatches
Applying a gradient swatch records it as the gradient's swatch (the Swatches panel highlights
it and the Gradient panel's swatch menu marks it). A stop given a global or spot swatch (and a
tint) in paint.editGradient / a gradient paint's stops, dropped from the Swatches panel or
picked in the stop popover stays linked: editing the swatch recolours it, in art and in
gradient swatches, deleting it unlinks it. Spot stops separate on their plate, and a gradient
whose stops are all tints of one spot ink (or paper white) exports to PDF as a Separation
shading; gradients mixing models are written in one colour space (the PDF writer requires it;
mixed models used to abort the export).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:29:19 +07:00
bflatasticandClaude Opus 5.5 b2ececc7b4 M3.59: tints of global and spot colours, tint swatches and Global colour balance
A colour linked to a global or spot swatch keeps a tint (Paint::Solid.tint, 1 by default and
left out of files at 100 %): paint.setFill/setStroke {swatch, tint} apply the swatch at that
percentage, swatch edits recolour every linked colour at its own tint, and Color::tinted
resolves it (CMYK and Gray inks scale, RGB mixes toward white). swatch.new saves a tint as a
tint swatch ("Ink 40%") linked to its base. Spot tints separate as that percentage of their
plate (Separations Preview, PDF Separation value) instead of an ink-ratio guess. Adjust Color
Balance's Global mode (tint) shifts the tints of linked colours. The Color panel shows Tint
mode (the swatch chip, its name and a T slider) for linked colours; the Swatches panel
highlights the tint swatch in use. Gradient stops get the same link and tint fields (the
swatch walks cover them), so GradientStop is no longer Copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:29:17 +07:00
BFlat e94748dc17 Merge pull request #43 from storytold/pr/m3-targeting-expand-appearance
M3: target circles and full Expand Appearance (M3.69, M3.70)
2026-10-05 02:20:06 +07:00
bflatasticandClaude Opus 5.5 b4ba4ecf0a M3.70: Expand Appearance turns fills, strokes, effects and brushes into objects
effect.expandAppearance now expands the whole appearance: each visible fill becomes a
path of its own and each stroke its outline (a brushed stroke its brush art), grouped
in paint order under the object's id and transparency; geometry effects are baked and
raster effects become an embedded image (shadows below the art, blur/feather/inner glow
replacing it). Type with effects or its own fills is outlined, and a group's or layer's
own fills and strokes are expanded among its members. The command is enabled whenever
an appearance isn't basic. Builds on bake_leaf (effects::expand_leaf / expand_art) and
shares the raster-effect image code with PDF export.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:16:02 +07:00
bflatasticandClaude Opus 5.5 b768b4a53b M3.69: target circles target layers, groups and objects; drag them to move appearances
Clicking a Layers panel target circle runs `layer.target {id}`: a layer gets its art
selected and becomes the target (`Selection.target`) of the appearance, effect,
transparency and opacity-mask commands, which then edit the layer itself; the
Appearance panel lists it. Dragging a circle onto another runs
`appearance.transfer {source, target, copy?}` (Alt copies), dropping it on the trash
clears it. Filled dots also mark groups and layers with fills of their own, masked
objects get a dashed underline, and while an opacity mask is edited the panel lists
only an `<Opacity Mask>` entry and the tab says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:15:41 +07:00
BFlat a2adf729a7 Merge pull request #42 from storytold/pr/m3-proxies-eyedropper
M3: Fill/Stroke chip popovers, panel shortcuts, Eyedropper Options and modes (M3.51, M3.52)
2026-10-05 02:15:06 +07:00
BFlat 6a9b658443 Merge pull request #41 from storytold/pr/custom-titlebar-dragon-icon
UI: custom title bar on Windows and Linux; the dragon icon as the brand mark
2026-10-05 02:14:15 +07:00
bflatasticandClaude Opus 5.5 303689c8f4 M3.52: Eyedropper Options with Picks Up / Applies trees, Alt and Shift+Alt modes, image sampling
Double-clicking the Eyedropper opens Eyedropper Options (dialog
eyedropperOptions): raster sample size (point, 3x3, 5x5) and Picks Up /
Applies trees (transparency; focal fill colour, transparency, overprint;
focal stroke colour, transparency, overprint, weight, cap, join, miter,
dash; character and paragraph attributes). The options live in the
preferences (Prefs::eyedropper, eyedropper.setOptions, prefs key
eyedropper) and replace the earlier appearance/transparency switches.

appearance.copyFrom takes pickUp/apply overrides, reverse (Alt-click:
the selection's attributes onto the clicked object) and append
(Shift+Alt-click: add the source's fills and strokes). Type copies font,
size and paragraph attributes to type. Clicking an image samples its
pixels; sampled colours keep their colour model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:08:10 +07:00
bflatasticandClaude Opus 5.5 2e6662c93b M3.51: Fill/Stroke chips open swatch and mixer popovers; panel shortcuts; Recolor quick action
The Control bar and Properties Fill/Stroke chips show the proxies' paints
("?" for mixed selections) and open a popover with the Swatches panel body
(Shift-click or the toggle: the Color panel's mixer); a click brings the
chip's proxy forward through paint.toggleActive {fill}. The Properties
panel offers Recolor for multi-colour selections. Window panel shortcuts
(Color F6, Color Guide Shift+F3, Appearance Shift+F6, Graphic Styles
Shift+F5, Stroke Cmd+F10, Gradient Cmd+F9, Transparency Shift+Cmd+F10)
run window.panel {panel}, show in the Window menu and are editable.

paint.invert / paint.complement and the "?" state honour the Appearance
panel's active item; objects without bounds still show their paints.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:05:33 +07:00
bflatasticandClaude Opus 5.5 512e96e7c8 M14.3: the dragon app icon is the brand mark
The mark left of Home (and in the About box) is now the VectorCraft app icon,
the engraved dragon from assets/app-icon/ (128 px PNG, include_bytes!), decoded
once per context into a mipmapped texture and drawn at 22 pt with the icon's own
rounded corners. The old code-drawn gradient square with a pen nib is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:00:05 +07:00
bflatasticandClaude Opus 5.5 3c856caa65 M14.2: custom title bar on Windows and Linux
The window is undecorated off macOS and the app bar is the title bar: Minimize,
Maximize/Restore and Close caption buttons (46 pt, drawn in code, Close hover red)
at the far right, empty bar space drags the window and double-click toggles
maximize, and 5 pt invisible edge/corner zones resize it (off while maximized or
fullscreen). Close runs app.quit, so modified documents are asked about first.
The right-side group now fits the space after the menus: Discord drops out and
the search box becomes an icon at narrow widths, so nothing overlaps at 800 pt.
macOS and the web build are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:00:04 +07:00
BFlat 8dc6396971 Merge pull request #38 from storytold/pr/m3-graphic-styles-panel
M3: Graphic Styles panel depth, Tile Edge Color, Show Swatch Bounds (M3.71, M3.72)
2026-10-05 01:59:14 +07:00
bflatasticandClaude Opus 5.5 b36fd67bf4 M3.72: Tile Edge Color dialog and Show Swatch Bounds in pattern editing
Object > Pattern > Tile Edge Color... picks the colour of the tile edge
(a layer-colour preset or a custom colour), stored in the preference
patternTileEdgeColor and passed to the renderer. Pattern Options gains
Show Swatch Bounds: a dashed outline of the part of the tiling the swatch
repeats (the period of brick and hex tilings).

Commands: prefs.set {patternTileEdgeColor}, pattern.options
{showSwatchBounds}, ui.tileEdgeColor. Model: PatternDef.show_swatch_bounds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:50:25 +07:00
bflatasticandClaude Opus 5.5 d8b942c43d M3.71: Graphic Styles panel views, previews, merge, reorder and drag and drop
Thumbnail, Small List and Large List views; thumbnails rendered on a
square or on type and cached by a hash of each style's look. Merge
Graphic Styles (named in Graphic Style Options), drag to reorder, delete
asks first (Alt-click does not), the right button held on a style shows
it large on the selected object. Styles dragged onto art apply to the
object hit; art dragged off the canvas or the Appearance thumbnail dropped
on the panel becomes a new style. Override Character Color (on by
default) clears type's character fill and stroke when a style with fills
or strokes is applied. Control bar Style picker.

Commands: graphicStyle.merge, graphicStyle.move, graphicStyle.setOptions,
ui.mergeGraphicStyles. Prefs: overrideCharColor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:50:13 +07:00
BFlat 1c18851728 Merge pull request #35 from storytold/pr/m3-flatten-transparency
M3: Flatten Transparency engine (M3.91)
2026-10-05 01:39:03 +07:00
bflatasticandClaude Opus 5.5 f75d7628c7 M3.91: Object > Flatten Transparency turns transparent art into opaque art that looks the same
object.flattenTransparency {preset?|options, ids?} splits the targets into groups of overlapping
objects. Groups with transparency become atomic regions (faces of the arrangement of every fill,
outlined stroke and clipping path) filled with the colour the renderer composites there: blend
modes, group opacity, isolated and non-isolated groups, knockout groups and knockout shapes, over
white or (Preserve Alpha) over nothing. Regions reached by gradients, patterns, images, opacity
masks or raster effects are rendered into one image per group, under the regions, clipped to
them with Clip Complex Regions. The raster/vector balance rasterizes whole groups past its region
budget (all at 0). Options and presets live in FlattenOptions; single visible paints keep their
colour and overprint; text and strokes can be outlined everywhere. One undo step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:36:18 +07:00
BFlat cc514957f5 Merge pull request #32 from storytold/pr/m3-freeform-gradients
M3: freeform gradients — model, rendering, commands, tool and panel (M3.81, M3.82)
2026-10-05 01:29:29 +07:00
bflatasticandClaude Opus 5.5 ed308d61f7 M3.82: freeform point and line commands, Gradient tool and Gradient panel freeform section
paint.freeform.addPoint/setPoint/deletePoint/addLine/splitLine/selectPoint
and the paint.freeform.get query edit the freeform gradient behind the active
proxy (one undo step each; errors on other paints). The session keeps the
selected point. The Gradient tool on a freeform gradient selects and drags
points, adds points on the art (joined in Lines mode) and on lines, and
deletes the selected point. The Gradient panel shows the Draw toggle and the
selected point's colour, opacity, spread and Delete Point; the Color panel
recolours the selected point.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:21:39 +07:00
bflatasticandClaude Opus 5.5 2877407111 M3.81: freeform gradients: colour points with spread, smooth lines, first points placed inside the shape
Freeform gradients carry their own points (colour, opacity, spread) and lines
(Catmull-Rom curves through points), blended by inverse-distance weighting
from each disc of pure colour. paint.editGradient {kind: "freeform", mode?}
places 4+ points inside the shape, coloured along the stops (which follow the
points' colours). The renderer samples the field on a cached colour grid
drawn as an image paint clipped by the fill or stroke. Moves, transforms,
box rebases and warps move the points; old files load unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:21:38 +07:00
BFlat 0d145986e8 Merge pull request #28 from storytold/pr/m3-stroke-on-type
M3: stroke on type, Stroke panel units, mixed values and Control bar (M3.40, M3.41)
2026-10-05 01:21:10 +07:00
BFlat 09778f236c Merge pull request #27 from storytold/pr/m3-swatch-libraries
M3: swatch libraries — library panel, generated libraries, save and load, gradient libraries (M3.56–M3.58)
2026-10-05 01:11:53 +07:00
BFlat 1ea13b54d3 Merge pull request #26 from storytold/pr/m3-container-appearance
M3: group, layer and type appearance with Contents and Characters rows (M3.68)
2026-10-05 01:10:47 +07:00
bflatasticandClaude Opus 5.5 64d366e2ae M3.41: Stroke panel follows Units > Stroke, shows mixed values blank and reaches the Control bar
- Weights and dash lengths show in Units > Stroke in the Stroke panel,
  the Control bar and the Properties panel (Session::stroke_unit).
- Values the selected objects don't share show blank (weight, miter
  limit, dashes) or light no button (cap, join, align), from a
  per-revision cached DocState::stroke_mixed.
- Align Stroke Inside/Outside are disabled for open paths and type.
- The Control bar weight is a preset spinner with a width profile
  dropdown, and its Stroke link (and the Properties panel's) opens the
  Stroke panel as an anchored popover instead of docking it.
- The panels show a group's first painted object's stroke, as the
  Stroke proxy does (Session::shown_stroke).
- stroke.set on a group skips images and symbol instances.
- document.inspect reports each object's strokeOptions (cap, join,
  miterLimit, align, dash, dashOffset, alignDashes, arrows, arrowAlign,
  profile) in stroke.set terms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:10:14 +07:00
bflatasticandClaude Opus 5.5 323b178ea7 M3.40: stroke on type — the Stroke panel edits the characters' stroke
With type selected, stroke.set (without an item) gives every run the
weight, cap, join, miter limit and dashes instead of adding an object
stroke; while the Type tool edits text, the Stroke panel styles the
selected characters through text.setRangeStyle `strokeOptions`, and the
panel reads the first run (or the selection's style).

CharStyle gains stroke_cap, stroke_join, stroke_miter_limit and
stroke_dash (defaulted, skipped when default). Character strokes and
object strokes on type now draw through the shared stroke geometry on
the canvas, in PDF and in SVG (live text writes cap/join/miter/dash and
tspans reset what the first run sets; object strokes are drawn on the
glyph outlines), so every stroke option, opacity and blend applies.
Outlines, the Eyedropper and graphic styles keep the options.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:09:55 +07:00
bflatasticandClaude Opus 5.5 3a79e6d922 M3.58: gradient libraries; Swatch Options renames gradient swatches; Alt-drop replaces a swatch
Window > Swatch Libraries > Gradients lists five generated gradient
libraries (Spectrum, Sky, Neutral incl. fades to transparent, Duotone,
Radial Glows); the library panel's menu lists them too.
Swatch Options on a gradient swatch shows its name and the gradient; OK
renames it. Alt-dropping an edited gradient (or a colour) on a swatch of its
kind in the Swatches panel replaces it, via swatch.edit {paint}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:00:00 +07:00
bflatasticandClaude Opus 5.5 786f2c05d3 M3.57: save swatch libraries as .vcswatches, .gpl or CSS and load them back
Save Swatch Library… (Swatches panel menu, libraries menu, Window menu)
opens a dialog: name, format and destination. .vcswatches is lossless JSON
(colour models, global, spot, gradients, groups), .gpl an RGB palette with
groups as comment headers, CSS custom properties with escaped names.
Libraries saved to the user library folder show under User Defined.
Other Library… (and opening a .vcswatches/.gpl file) loads a library or
another document's swatches into the library panel.

Commands: swatch.library.save {path?, format?, names?, name?, user?}
(no path returns {data}), swatch.library.load {path?|data?|dataBase64?,
name?}; UI: window.swatchLibrary.other, ui.saveSwatchLibrary (dialog
saveSwatchLibrary), window.userSwatchLibrary1..10.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:59:59 +07:00
bflatasticandClaude Opus 5.5 e7f6328188 M3.56: swatch library panel with code-generated swatch libraries
Window > Swatch Libraries and the Swatches panel's library button open a
read-only, floating library panel (thumbnail and list views, find field,
libraries menu, previous/next arrows). Clicking a swatch adds it to the
document and applies it as one undo step; Shift/Cmd-clicks select swatches
and groups for Add to Swatches. Default Swatches restores missing defaults.

Libraries are computed in code (OKLCH, rounded to 8-bit sRGB): Web Safe 216,
Grays and Neutrals, Earth Tones, Skin Tone Ramps, Pastels, Brights,
Metallic Gradients, Perceptual Scales, Harmony Sets.

Commands: swatch.library.list, swatch.library.get, swatch.library.add
{library, names?, apply?, focus?}, swatch.resetDefaults {replace?};
UI command window.swatchLibrary {library}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:59:59 +07:00
bflatasticandClaude Opus 5.5 8a837fd4b9 M3.68: groups, layers and type have their own fills, strokes and effects, with Contents and Characters rows
A group's or layer's own fills and strokes paint every member's geometry, below or above the
members as the new contents slot (Appearance.contents_index) says; its geometry effects reshape
the members as one piece and its raster effects apply to the composite (one combined shadow),
respecting knockout groups. Type's own fills and strokes paint under or over its characters.
Render, SVG/PDF (via bake) and Expand Appearance evaluate them alike.

Commands: appearance.* / effect.* take target: "object"|"contents" and layer ids;
appearance.moveItem moves the Contents slot (from: "contents") or an item across it
(contents); appearance.targetContents selects a group's members. Graphic styles applied to a
group now style the group itself. The Appearance panel shows Contents/Characters rows that
drag, and double-click to target the members or edit the characters' paint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:59:34 +07:00
BFlat 25c8f32fdd Merge pull request #23 from storytold/pr/ui-dialog-menu-sizing
UI: dialogs and menus size to their content
2026-10-05 00:58:01 +07:00
bflatasticandClaude Opus 5.5 d3df1bcea2 M14.1: dialogs and menus size to their content
The shared dialog frame's button row took all the height left in the window
(a right-to-left layout fills the available height), and every dialog shared
one window id, so a dialog never shrank and inherited the size of the one
before it: the save prompt showed a tall empty band around its buttons. The
row is now as tall as the buttons and each dialog kind has its own window.

Menu items forced themselves to the popup's full available width, so every
menu opened at egui's maximum menu width. The menu layout already justifies
items, so popups are now as wide as their widest label and shortcut (at least
230 pt).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:54:03 +07:00
BFlat e21ff60b6a Merge pull request #22 from storytold/pr/m3-blend-isolation
M3: blend accuracy, non-isolated groups, layer clipping masks (M3.73–M3.75)
2026-10-05 00:34:36 +07:00
bflatasticandClaude Opus 5.5 45ff33e144 M3.75: layer clipping masks from the Layers panel button
layer.clippingMask.toggle {id?} -> {clip}: the top object of the current
layer (or of the one selected group, or of `id`) becomes the clipping path,
unpainted and moved to the bottom so art added later is clipped too; pressing
the button again releases the mask and leaves the path unpainted. One undo
step. The Layers panel button runs it and underlines clipping-path names.

Model: NodeKind::Layer.clip (skipped when false). Clipped layers render, hit
test, bound, and export to SVG and PDF like clip groups (Node::clips /
set_clips); the clip-path helpers are shared with Make/Release Clipping Mask.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:27:31 +07:00
bflatasticandClaude Opus 5.5 54427d7a92 M3.74: blending inside a group reaches the art below unless the group isolates it
On screen, a group (or object) with opacity, a blend mode, an opacity mask or
knockout whose content blends is drawn offscreen over a copy of its backdrop
and mixed back in by its opacity, mask and clip (a group blend mode then
applies to the backdrop-free result, as PDF defines it). Isolate Blending keeps
the content starting from transparency. Clip groups no longer isolate their
children (blending content is clipped per drawing), and non-isolated knockout
groups composite their elements against the art below the group. Groups
without blending inside still render as plain layers.

In PDF, such groups are written as non-isolated transparency groups (a
constant-alpha soft mask instead of the writer's isolated opacity group),
knockout groups included.

doc: Node::blends_through / children_blend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:27:31 +07:00
bflatasticandClaude Opus 5.5 a1a5c79b03 M3.73: blend modes follow the reference formulas on screen, in SVG and in PDF
color::blend gains blend_rgb (the 16 blend functions, with the 0.3/0.59/0.11
luminance of the non-separable modes), composite (source-over with blending)
and the shared opacity-mask luminance weights. Exported SVG masks take
luminance from sRGB values (color-interpolation="sRGB"), as the canvas does.

Tests: a per-mode table on the canvas (object, fill and group blend modes,
within 1/255; Exclusion 2/255 in the rasteriser's 8-bit pipeline), the same
table through SVG export rendered by resvg, clip and invert masks against the
canvas, and every mode's PDF name read back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:27:31 +07:00
BFlat bcc30ded5a Merge pull request #21 from storytold/pr/m3-color-guide
M3: Color Guide rules and options, Edit Colors dialogs, recolouring meshes, images and patterns (M3.53–M3.55)
2026-10-05 00:21:57 +07:00
bflatasticandClaude Opus 5.5 2edac7e317 M3.55: Edit Colors and Recolor Artwork also recolour gradient meshes, embedded images and pattern fills
One shared colour visitor (`colorcmds::Recolor`, used by Edit Colors, Recolor Artwork, the Blend
commands, the proxy Invert/Complement and Document Color Mode) maps solid colours, gradient stops
and text runs as before, and now also:
- gradient-mesh points (with fills); Blend grades meshes by their average and keeps their shading;
- embedded images' pixels (`includeImages?`, default on): the image becomes a recoloured copy under
  a content key, the original stays for undo and other objects; linked images are left alone;
- the tiles of pattern fills and strokes (`includePatterns?`, default on): one recoloured copy per
  pattern saved as a new pattern swatch ("Dots 2"), the original untouched.
Everything stays one undo step; only nodes that change are copied, in one walk of the tree.

- doc: `ImageBlob::map_rgb` (decode, map each distinct colour once, keep alpha, re-encode PNG),
  `ImageBlob::content_key` (now also used by image import) and `ImageBlob::png`.
- `recolor.colors` lists mesh points and pattern tile colours too.
- patterncmds: `add_pattern` adds a pattern and its swatch (shared by Make Pattern and Save a Copy).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:16:08 +07:00
bflatasticandClaude Opus 5.5 1de1c6446f M3.54: Adjust Color Balance and Saturate open live-preview dialogs, committed as one undo step
- Edit > Edit Colors > Adjust Color Balance... opens Adjust Colors (`ui.colorBalanceDialog`, kind
  `colorBalance`): colour mode Grayscale, RGB, CMYK or Global, a slider and field per channel
  (-100..100 %), Convert, Fill and Stroke, and Preview.
- Saturate... opens Saturate (`ui.saturateDialog`, kind `saturate`): Intensity -100..100 % and
  Preview.
- Both preview through the session interaction (fields changed by `ui.dialog.set` preview on the
  next frame too); OK keeps the result as one undo step, Cancel rolls it back.
- `edit.colors.adjustBalance` takes `mode` (rgb|cmyk|gray|global; default from the channels
  given). Global mode needs tints of global and spot colours and answers with a clear error until
  they exist; the dialog stays open and the colours keep their links.
- Shared dialog helpers: `form::slider`, `form::preview`, `form::commit_preview`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:14:46 +07:00
bflatasticandClaude Opus 5.5 bfebf66404 M3.53: Color Guide has 21 harmony rules, Steps and Variation options, a fixed base colour and Save Colors as Swatches
The harmony rules (Complementary 1/2, Split Complementary, Left/Right Complement, Analogous 1/2,
Monochromatic, Shades, Triad 1-3, Tetrad 1-3, Compound 1/2, High Contrast 1-3, Pentagram) live in
the color crate with our own math, base colour first. The variation grid moves there too
(`harmony::Guide`): a row per harmony colour with the colour in the centre column, shades/cool/muted
to the left and tints/warm/vivid to the right, 1-20 steps and a variation amount.

- `color.harmony {color, rule, steps?, variation?, amount?}` query returns {rule, colors, grid}.
- The panel keeps its base colour until "Set base color" takes the current one; Shift/Cmd-click
  selects several cells and Save Colors as Swatches saves them (`swatch.new {colors}`, one step).
- Color Guide Options dialog (`ui.colorGuideOptions`, kind `colorGuideOptions`) with a preview row;
  the options persist in `UiState::color_guide`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:13:36 +07:00
BFlat 6c25e54914 Merge pull request #20 from storytold/pr/m3-overprint
M3: overprint per fill and stroke, Overprint Black options (M3.49, M3.50)
2026-10-05 00:07:34 +07:00
bflatasticandClaude Opus 5.5 cb881b082f M3.50: Overprint Black dialog with fill/stroke, rich and spot black options
Edit > Edit Colors > Overprint Black... opens a parameter dialog (Remove,
Percentage, Fill, Stroke, Include Blacks with CMY, Include Spot Blacks).
edit.colors.overprintBlack takes fill?, stroke?, includeCmyBlacks? and
includeSpotBlacks?; it covers type characters and gradients whose every stop
is black, and records no undo step when nothing changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:01:02 +07:00
bflatasticandClaude Opus 5.5 0c82a3f5c5 M3.49: each fill and stroke can overprint
Fill and stroke layers (and type characters' fill and stroke) carry an
overprint flag. object.setOverprint {fill?, stroke?, item?, ids?} sets it
(groups: their contents; type: its characters too; one undo step, none when
nothing changes) and attributes.info reads it back (null where mixed).
Overprint Preview and Separations Preview draw overprinting fills and strokes
with Multiply. The old Document.unknown["overprintBlack"] id list migrates to
the new flags on load; Overprint Black now sets the flags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:01:02 +07:00
BFlat 6808564486 Merge pull request #19 from storytold/m3-paint-stage2
M3 stage 2: swatch and gradient panel depth, stroke export parity, dash alignment, appearance editing, knockout, graphic style links (M3.30–M3.48)
2026-10-04 23:55:10 +07:00
bflatasticandClaude Opus 5.5 edeb1cb548 Roadmap: M3 stage 2 (M3.30–M3.39, M3.42–M3.48)
The M3 row, the shipped list and the colour, stroke and appearance parity rows
cover Swatches panel depth, Gradient panel depth and rebased gradients, stroke
export parity and Outline Stroke fidelity, corner-fitted dashes, appearance
editing depth and effects on every object kind, knockout and page groups, and
graphic style links. Remaining estimates are recalibrated on the measured
throughput of the first two M3 waves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:54:21 +07:00
bflatastic ca709871f2 Merge main into m3-paint-appearance (PR #15, #16) 2026-10-04 23:51:50 +07:00
bflatastic 3981acdb06 Merge wp/C3: dash alignment and variable-width dashes (M3.38, M3.39)
# Conflicts:
#	crates/pdf/src/lib.rs
#	crates/ui-egui/src/panels/mod.rs
2026-10-04 23:48:47 +07:00
bflatastic 43d7ac420d Merge wp/D2
# Conflicts:
#	crates/pdf/src/export.rs
#	crates/render/src/fx.rs
#	crates/render/src/lib.rs
#	crates/svg/src/export.rs
#	crates/ui-egui/src/canvas.rs
#	crates/ui-egui/src/dialogs/mod.rs
#	crates/ui-egui/src/panels/appearance.rs
#	docs/control-protocol.md
#	docs/mcp.md
2026-10-04 23:46:38 +07:00
BFlat deaeef047c Merge pull request #16 from storytold/docs/name-reference-app
Docs: name the reference app again in ROADMAP and docs/mcp.md
2026-10-04 23:41:42 +07:00
bflatasticandClaude Opus 5.5 454c5f8620 M3.39: width profiles follow the path through dashes, and their corners take the join
Each dash and dot of a profile stroke is as wide as the profile where it sits
along the whole path (before, dashes dropped the profile). Corners of profile
strokes take the stroke's miter (within the miter limit), round or bevel join on
the outer side and stay clean on the inner side, instead of a miter clamped at
4x; smooth points of curves bend round. Canvas, SVG/PDF outlines and Outline
Stroke share the geometry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:40:52 +07:00
bflatasticandClaude Opus 5.5 42ad4540b7 M3.38: dashes fitted to corners and path ends, a flag of their own that keeps the offset
With alignDashes every run between corners and path ends holds a whole number of
periods, scaled to fit, with a dash (or dot) centred on each corner and on both
ends of an open path. It lives in the shared dash generator, so the canvas, SVG
and PDF export (as outlines) and Outline Stroke all draw it.

stroke.set: alignDashes and dashOffset edit the current pattern on their own, and
a new dash array keeps the offset and alignment it replaces. The Stroke panel
turns dashes on fitted to corners (or as last turned off), and its Exact/Fit
buttons send only the flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:37:58 +07:00
bflatastic 0f169b5c5d Merge wp/B2
# Conflicts:
#	crates/doc/src/lib.rs
#	crates/engine/src/cmd/style.rs
#	crates/engine/src/lib.rs
#	crates/ui-egui/src/canvas.rs
#	crates/ui-egui/src/menus.rs
#	crates/ui-egui/src/panels/swatches.rs
#	docs/control-protocol.md
2026-10-04 23:37:27 +07:00
bflatastic 818980a789 Merge wp/A2
# Conflicts:
#	crates/engine/src/lib.rs
2026-10-04 23:20:10 +07:00
bflatasticandClaude Opus 5.5 cb68229bda Docs: name the reference app again in ROADMAP and docs/mcp.md
The ROADMAP and the MCP docs say Illustrator/Adobe again where they compare
VectorCraft with it (the rewording in #15 and the create_graph row). The
vendor-name gate keeps checking what users and agents read in the app (Rust
string literals, Cargo descriptions, packaging) and the non-Markdown files
under docs/, and no longer scans Markdown pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:19:45 +07:00
bflatasticandClaude Opus 5.5 0358c4df95 M3.35: gradient handles snap and honour Constrain Angle; double-clicking a tool button opens its options
Gradient tool handles (a drawn vector's start and end, the end handle and the moved start) snap to anchors, edges
and smart guides, with the guide overlays; Shift constrains to 45° steps from the Constrain Angle preference
(`ToolContext::constrain_angle`, `constrain_angle_from` in geom). Double-clicking a toolbar button runs the new UI
command `tool.options {tool}`: the Gradient tool opens the Gradient panel, the Eyedropper opens Eyedropper Options
(`eyedropper.setOptions {appearance?, transparency?}`, which `appearance.copyFrom` honours and its params override).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:18:34 +07:00
bflatasticandClaude Opus 5.5 0f1565aed2 M3.43: effects render on type, placed images, symbol instances, blends, envelopes, meshes and repeats; a fill or stroke's own raster effects render and export as SVG filters on that element
Geometry effects reach objects that aren't paths through their outlines (vectorcraft_effects::
reshape: glyph outlines, evaluated live art, the symbol's art, an image clipped by its reshaped
frame), applied to the whole art as one piece; the renderer caches that art per object and
paints raster effects around it in the same filter layers paths use (shared Renderer::raster_fx,
also used per fill/stroke). Exports bake the reshaped art; SVG writes per-item filters; PDF
warns about raster effects on strokes as well as fills and objects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:15:13 +07:00
bflatastic abd77d58b0 Merge wp/F2: knockout groups, knockout shape, page groups (M3.46-M3.48)
# Conflicts:
#	crates/pdf/src/export.rs
#	crates/svg/src/export.rs
2026-10-04 23:15:10 +07:00
bflatasticandClaude Opus 5.5 4e60eabc00 M3.30: swatches dropped back on themselves stay put
A drag released on one of the dragged swatches no longer falls through to
the list's drop zone (which moved them to the end).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:12:24 +07:00
BFlat 8008e16f57 Merge pull request #15 from storytold/m3-paint-stage1
M3 stage 1: swatches, colour picker, gradients, strokes, appearance targeting, masks (M3.7–M3.29)
2026-10-04 23:10:47 +07:00
bflatasticandClaude Opus 5.5 f48ba26380 M3.32: the document colour mode governs new colours
A CMYK document (file.new {colorMode: "cmyk"}, Document::new_with_mode)
starts with CMYK default swatches (default_swatches(model)), and RGB
colours and gradient stops applied to it are stored as CMYK (Gray stays
Gray) unless paint.setFill/setStroke get keepModel; the Color panel keeps
the model picked on its sliders. RGB documents keep colours as given.
Harmonies and Color Guide variations keep the base colour's model, Recolor
Artwork the replaced colour's, and Edit Colors blends between models take
the document's, through a shared Color::in_model / keep_model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:10:15 +07:00
bflatasticandClaude Opus 5.5 039c0cbef7 M3.48: Page Isolated Blending and Page Knockout Group
New Document.page_isolate / page_knockout, set by
transparency.togglePageIsolatedBlending / togglePageKnockoutGroup {value?}
(one undo step each, saved with the document, reported by
transparency.info) and the Transparency panel menu's check items. The page
renders as an isolated group (top-level blend modes no longer blend with
what lies under the page) and/or a knockout group whose elements are the
layers (neutral layers pass their objects through). PDF and SVG wrap the
page content in an isolated group and write page knockout as soft masks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:10:06 +07:00
bflatastic b85ef3de5a Merge wp/C2: stroke export parity and Outline Stroke fidelity (M3.36, M3.37) 2026-10-04 23:08:27 +07:00
bflatastic 22581f5b51 Merge wp/E1: graphic style fidelity, links and management (M3.44, M3.45) 2026-10-04 23:07:55 +07:00
bflatasticandClaude Opus 5.5 93640efb36 M3.34: copied appearances place their gradients on the target's own bounds
The eyedropper (`appearance.copyFrom`), `graphicStyle.new` and `graphicStyle.apply` move placed gradients so they
sit at the same place relative to each target's bounds (type: its layout box in text space) instead of where the
source had them; the paint defaults take the gradient unplaced, so new art fits it. Model: `GradientGeom::rebase`
/ `GradientPaint::rebase` (color), `Appearance::rebase_gradients` (strokes relative to their inflated boxes) and
`GraphicStyle::unit_box`: new styles store placed gradients in unit-box space; styles from older files keep
document coordinates and apply as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:07:51 +07:00
bflatasticandClaude Opus 5.5 f363cdb4ee M3.36: SVG and PDF export draw arrowheads, width profiles, brushes and aligned strokes as the canvas does
Both writers now ask the shared stroke geometry how to write each stroke
(effects::stroke::for_writer): a plain stroke where that is exact, else the
canvas's filled outlines (arrowheads, width profiles, dashes fitted to corners,
dots with round or projecting caps), grouped under the stroke opacity when the
line and its heads overlap. Brushed strokes are written as their brush art.
Inside/outside alignment only applies to closed paths, so open paths export as
centred strokes. The PDF outside-stroke clip frame and the SVG outside mask now
cover the whole stroke reach (miter spikes, projecting caps). SVG stroke
gradients use the same paint box as the canvas. The svg crate docs no longer
claim arrowheads, profiles, brushes and effects are dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:07:33 +07:00
bflatasticandClaude Opus 5.5 c95098d15a M3.47: opacity and opacity mask can define an object's knockout shape
New Node.knockout_shape (transparency.set / object.setProps knockoutShape,
reported by transparency.info). In a knockout group such an object knocks
out only as much as its opacity and mask let through (a 50% object knocks
out 50%); otherwise its full coverage knocks out. The renderer, PDF and
SVG masks honour it, and the panel's checkbox is now live.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:07:01 +07:00
bflatasticandClaude Opus 5.5 b1d41d156a Roadmap and README: M3.7–M3.29 (stage 1 of M3)
ROADMAP: the M3 row lists what landed (swatches, colour picker and panel,
proxy commands, gradient transforms and annotator, stroke geometry, dotted
lines, arrowheads, appearance targeting, mask reach, clipping by any shape)
and what is pending; a "Paint and appearance" block in Shipped so far; the
colour, stroke and appearance parity rows and the totals re-derived from the
detailed M3 plan, calibrated on its first wave; the reference app is no longer
named. README: the open/export formats from the shared loader and the vendor
name step of cargo xtask ci.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:06:52 +07:00
bflatasticandClaude Opus 5.5 6e0af835c6 M3.46: three-state knockout groups (on, neutral, off) render, export and edit
Knockout Group is now Knockout {Neutral, Off, On} (old files' true loads as
On, false as Neutral). In a knockout group each child erases what the
children below it drew where it paints, then adds itself, so it composites
against the group's backdrop; neutral plain groups pass their children
through to the enclosing knockout group. transparency.set / object.setProps
take knockout "on"|"off"|"neutral"|bool; transparency.info reports the
state. PDF and SVG write knockout groups as nested soft masks with the same
look (PDF warns); the Transparency panel has a tri-state checkbox.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:04:20 +07:00
bflatasticandClaude Opus 5.5 bdf8f704af M3.31: Swatches panel menu adds used colours, selects unused, merges, ungroups and sorts by kind
New commands: swatch.addUsedColors {selection?, global?} adds a swatch for
each colour the art uses that no solid swatch has (optionally global, the
art linking to them); swatch.unused lists the swatches nothing uses (links,
colours, gradients and patterns in art, symbols, pattern tiles and graphic
styles count); swatch.merge keeps the first colour and relinks the others'
art; swatch.ungroup and swatch.sortByKind (process, spot, gradients,
patterns). Each edit is one undo step; the panel menu items are enabled.
Document::visit_paints walks every paint of a document.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:03:56 +07:00
bflatasticandClaude Opus 5.5 321ab52aea M3.45: graphic style links: redefine, break link, options, unused, sort, Select > Same
The Appearance panel names the style a linked object uses and its menu offers Redefine
Graphic Style "name", which updates every object still linked (edited objects keep their
look and are unlinked). The Graphic Styles panel breaks links (button and menu), selects
all unused styles, sorts by name, deletes several styles, and opens Graphic Style Options
(rename; links follow) on double-click; New Graphic Style from the menu or with Alt asks
for a name. Select > Same > Graphic Style and Appearance Attribute work, and Document Info
lists the style names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:03:45 +07:00
bflatasticandClaude Opus 5.5 10ea4d5daf M3.42: Appearance editing depth — drag effect rows to reorder them or move them into/out of fills and strokes (Alt copies), click an effect name to edit it in its prefilled dialog, choosing an applied effect asks to edit it or add another, Show All Hidden Attributes, multi-select and Alt-drag duplicate of fills/strokes, drag the thumbnail onto art to copy the appearance, stroke rows note brush/Dashed/profile, Shift-click a chip for the mixer, Properties fx opens the effect menu
Engine: effect.move {from, to, fromItem?, toItem?, copy?}; appearance.showAllHidden;
appearance.duplicateItem takes to? and indices?; appearance.removeItem takes indices?;
Appearance::has_hidden/show_all. UI: effect.dialog takes index? (edit in place via
effect.setParams) and opens the effectExists question for an effect already in the list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:01:34 +07:00
bflatasticandClaude Opus 5.5 eb83efdb19 M3.44: graphic styles keep opacity and blend, capture groups and type, add on Alt-click
Styles now store the object's opacity, blend mode, isolate and knockout and apply them.
New Graphic Style from a group without its own paint captures its topmost object's
appearance, and from type its characters' fill and stroke. Names are made unique.
graphicStyle.apply takes ids and add (Alt-click in the panel adds the style on top of the
existing appearance). graphicStyle.list reports every style. Applying links objects to a
stable style id (Node.graphic_style), which the management commands build on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:01:10 +07:00
bflatasticandClaude Opus 5.5 059f623410 M3.33: Gradient panel: proxy, swatch menu, diamond location, stop popover, Alt-drag copies and swaps, swatch drops, stop eyedropper
The Gradient panel gains the Fill/Stroke proxy, a thumbnail menu of the document's gradient swatches with Save to
Swatches, Angle and Aspect Ratio fields with preset menus (the shared spin field), Show/Hide Options, and a stop
colour row whose eyedropper samples the next colour clicked on art into the selected stop (Eyedropper tool option
`stop`, `paint.sampleColor {stop}`). On the slider a selected midpoint diamond shows and sets its midpoint in
Location, double-clicking a stop opens the shared stop popover, Alt-drag copies a stop and Alt-dropping it on
another swaps their colours (`swap_stop_colors`), and a dropped colour swatch adds or recolours a stop. Clicking a
stop no longer falls through to the add-stop strip. Swatch tiles and the gradient thumbnail drag onto art, which
paints the object under the pointer through `paint.setFill`/`paint.setStroke {ids}`. The panel, the popover and
the Color panel share one stop-writing helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:00:38 +07:00
bflatasticandClaude Opus 5.5 fe258915e3 M3.30: Swatches panel multi-selection, find field and drag and drop
Clicking a colour group's folder selects the whole group. Show Find Field
filters the panel by name. Tiles and list rows drag to reorder swatches,
move them into or out of colour groups or reorder groups (new command
swatch.move, one undo step); swatches and Fill/Stroke proxies dropped on
art paint the object under the pointer (paint.setFill/setStroke with its
id), and a proxy dropped on the panel becomes a swatch. List rows show
kind and colour-mode icons and take double-clicks; spot colours get a dot
in the grid. The search field widget is shared with the Layers panel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 22:54:56 +07:00
bflatasticandClaude Opus 5.5 18d870c7df M3.37: Outline Stroke and the live Outline Stroke effect outline strokes as the canvas paints them
Outline Stroke (and the stroke part of Expand) now builds each stroke from the
shared canvas geometry: dash shapes, arrowheads, width profiles and inside/outside
alignment, united into one clean path (pathops::stroke_region). Every stroke item
is outlined in paint order (runs of fills stay on copies of the path), stroke
opacity, blend mode and effects move to the new fill, and brushed strokes become
their brush art. The live Effect > Path > Outline Stroke outlines the stroke it
sits on (on a fill or the object: the top stroke) with that stroke's cap, join,
dashes, alignment, profile, arrowheads and weight: GeomContext now carries the
stroke and the fill rule, built per item by the renderer, export baking and
Expand Appearance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 22:53:58 +07:00
bflatastic 6bd27a05da Merge wp/I1
# Conflicts:
#	crates/engine/src/cmd/gradient.rs
#	crates/engine/src/cmd/paint.rs
#	crates/engine/src/lib.rs
#	crates/ui-egui/src/dialogs/mod.rs
#	crates/ui-egui/src/menus.rs
#	crates/ui-egui/src/panels/color.rs
#	crates/ui-egui/src/panels/mod.rs
#	crates/ui-egui/src/toolbar.rs
#	docs/control-protocol.md
2026-10-04 22:14:37 +07:00
bflatastic 96f1b6852a Merge wp/F1
# Conflicts:
#	crates/doc/src/node.rs
#	crates/engine/src/cmd/appearance.rs
#	crates/engine/src/cmd/paint.rs
#	crates/ui-egui/src/chrome.rs
#	crates/ui-egui/src/panels/transparency.rs
2026-10-04 22:07:49 +07:00
bflatasticandClaude Opus 5.5 546559d8a1 M3.17: dialog modules in rustfmt order
The swatch and gradient merges left the module list unsorted, which cargo fmt --check rejects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 22:02:27 +07:00
bflatasticandClaude Opus 5.5 0b547ec597 M4.13: raster exports check the format's size limit first and share the renderer's pixel budget
The encoder kept its own copy of the renderer's pixel budget and checked it after
render::raster_size, so a WebP too wide for its 14-bit sizes reported the generic limit
(fileio test oversized_raster_exports_are_refused). Only the WebP side limit stays local.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 22:02:26 +07:00
bflatasticandClaude Opus 5.5 62569f8a9d M3.19: a negative dash entry makes the pattern invalid, so the line draws solid
The merged dasher clamped negative entries to zero, which turned [-5, 3] into dots instead of the
solid line SVG and PDF draw for an invalid pattern (render test invalid_dash_pattern_strokes_solid).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 22:02:26 +07:00
bflatastic 721bb483c3 Merge wp/D1
# Conflicts:
#	crates/engine/src/cmd/appearance.rs
#	crates/engine/src/cmd/gradient.rs
#	crates/engine/src/cmd/paint.rs
#	crates/engine/src/cmd/stroke.rs
#	crates/engine/src/lib.rs
#	crates/engine/src/tooling.rs
#	crates/render/src/lib.rs
#	crates/tools/src/lib.rs
#	crates/tools/src/xform/gradient.rs
#	crates/ui-egui/src/panels/stroke.rs
2026-10-04 22:01:05 +07:00
bflatastic 41f2af932c Merge wp/C1
# Conflicts:
#	crates/doc/src/appearance.rs
#	crates/effects/src/stroke/width.rs
#	crates/engine/src/lib.rs
#	crates/pdf/src/export.rs
#	crates/render/src/lib.rs
2026-10-04 21:47:58 +07:00
bflatastic efc0ac31d9 Merge wp/B1
# Conflicts:
#	crates/engine/src/cmd/swatch.rs
#	crates/ui-egui/src/dialogs/mod.rs
#	crates/ui-egui/src/panels/mod.rs
#	docs/control-protocol.md
2026-10-04 21:47:07 +07:00
bflatastic 1ae0c22c26 Merge wp/A1 2026-10-04 21:46:02 +07:00
bflatastic 0af6734b70 Merge M3/M4 wave 0: neutral labels and brands gate, per-area paint modules, one engine loader/encoder (M3.7, M3.8, M4.10-M4.13)
# Conflicts:
#	crates/engine/src/cmd/file.rs
#	crates/mcp/src/headless.rs
#	crates/mcp/src/tools.rs
#	crates/ui-egui/src/io.rs
2026-10-04 21:46:01 +07:00
Brandon Thomas ead8f72208 Merge pull request #11 from storybored379/pr/svg-import-physical-units
SVG import: root sizes in mm/cm/in/pt open at their physical size
2026-10-04 10:16:45 -04:00
Brandon Thomas 3326b1aaac Merge pull request #10 from storybored379/pr/svg-export-area-type-lines
SVG export: area type keeps its wrapped lines and alignment
2026-10-04 10:16:42 -04:00
Brandon Thomas 6b93a3dded Merge pull request #9 from storybored379/pr/raster-export-size-limit
Raster export: refuse sizes the renderer can't produce instead of crashing
2026-10-04 10:16:39 -04:00
Brandon Thomas 4be1e009ec Merge pull request #8 from storybored379/pr/svg-import-tspan-line-gap
SVG import: tspan line breaks follow the baseline gap (none on the same baseline, bounded far gaps)
2026-10-04 10:16:35 -04:00
Brandon Thomas a4696edbe8 Merge pull request #7 from storybored379/pr/invalid-dash-pattern
Stroke dashes: an invalid dash pattern draws a solid stroke instead of hanging the renderer
2026-10-04 10:16:32 -04:00
Brandon Thomas cbf5dc99bb Merge pull request #6 from storybored379/pr/svg-import-text-on-path
SVG import: <textPath> comes back as type on a path
2026-10-04 10:16:28 -04:00
Brandon Thomas 8164d7c3fb Merge pull request #5 from storybored379/pr/mcp-headless-open-pdf-ai
MCP: headless open_file reads .pdf and PDF-compatible .ai, as File ▸ Open does
2026-10-04 10:16:24 -04:00
Brandon ThomasandClaude Opus 5.5 439f99e491 xtask: rustfmt version.rs (fmt gate was red on main)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:16:06 -04:00
Brandon Thomas a1430cfc1e Merge pull request #12 from storytold/pr/license-standard
Licensing: MIT OR Apache-2.0 files, NOTICE, ArtCraft brand terms
2026-10-04 10:08:55 -04:00
Brandon ThomasandClaude Opus 5.5 ef5beece81 Licensing: MIT OR Apache-2.0 files, NOTICE, ArtCraft brand terms
Adds LICENSE-MIT, LICENSE-APACHE and docs/brand/LICENSE-brand.txt from the Crafting Apps
licensing standard. NOTICE follows the standard template (copyright, licence, brand carve-out)
and keeps the third-party list, now noting the Feather-derived Lucide icons (also MIT) and the
egui default fonts compiled into the app. ASSETS.md gives the docs/brand/ files their trademark
terms and marks the 21 Feather-derived Lucide icons. README uses the standard License and credits
section; AGENTS.md/CLAUDE.md point to the licence and brand files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:05:13 -04:00
bflatasticandClaude Opus 5.5 ad82e6e9a9 M3.27: Color Picker reads colours with the engine's color_value (one parser; CMYK and grey in percent as the paint commands take them) instead of its own copy
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:24:22 +07:00
bflatasticandClaude Opus 5.5 c071dcd4d0 M3.28: proxies look through groups like the paint commands (a group shows its contents' paint, and contents that differ make a "?" proxy), the toolbar and panels share the engine's Session::proxy_paints, Magic Wand and Select Text by Font bump the revision so the cached "?" state follows them; double-click proxy test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:22:05 +07:00
bflatasticandClaude Opus 5.5 ff9af2628a M3.29: Live Paint Bucket and Appearance item colours feed the recent colours; the last-colour/gradient commands read "Apply Last Color" / "Apply Last Gradient" (no clash with Window > Gradient); , and . keyboard test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:20:19 +07:00
bflatasticandClaude Opus 5.5 6d2dc7cbbc M3.28: Color panel behaviour — the mode follows the colour's own model (the last mode picked returns for colours in its model), Alt-clicking the spectrum, None/Black/White, swatches, recent colours or Color Guide chips paints the inactive proxy, Hide Options leaves the proxy and spectrum, RGB/HSB show the out-of-gamut warning (click to correct), Shift-clicking the spectrum cycles modes, Shift-dragging a slider moves the others in tandem, and a mixed selection draws "?" proxies (paint.proxies reports fillMixed/strokeMixed)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:01:58 +07:00
bflatasticandClaude Opus 5.5 f21161ce8c M3.27: Color Picker dialog — double-clicking a Fill/Stroke proxy (toolbar, Color, Swatches, Properties) or ui.colorPicker {stroke?, color?} opens a colour field with an H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields and hex, Only Web Colors, out-of-gamut (corrected through color.convert) and out-of-web warnings, New/Original chips and a Color Swatches list; OK runs paint.setFill / paint.setStroke (CMYK in CMYK documents)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:01:58 +07:00
bflatasticandClaude Opus 5.5 8a871d98a9 M3.29: proxy paint commands — paint.invert / paint.complement recolour the active proxy of each selected object keeping its colour model (the defaults when nothing is selected), paint.lastColor (,) and paint.lastGradient (.) re-apply the last solid colour or gradient (the toolbar chips draw and run them), swap and default work on type, and recent colours live in the Session, fed by every paint command (MCP, eyedropper, live previews on commit) and listed by paint.recent
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:01:58 +07:00
bflatasticandClaude Opus 5.5 2ed962e7d0 M3.16: the eyedropper samples type gradients in text space
Shift-sampling a type object's run gradient projected the document point onto a vector kept in
text space, so placed gradients on moved or turned type sampled the wrong colour. The eyedropper
now reads the paint through Node::proxy_paint and samples in the paint's own space and box (text
space for runs, the stroke-inflated box for strokes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:59:03 +07:00
bflatasticandClaude Opus 5.5 cca1d11032 M3.14: gradient swatches and new art fit a placed gradient to themselves
Lossless gradient params carried a saved gradient's vector (document coordinates of the art it
came from) into swatch applications and the default paint, so a swatch applied elsewhere, or the
next shape drawn, got a gradient placed off the object. Applying a gradient swatch now fits it to
each object (keeping its aspect, on type in text space), and the default for new art keeps all
but the placement. Saving a swatch stays lossless.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:58:05 +07:00
bflatasticandClaude Opus 5.5 6ebffb9156 M3.24: Clear Appearance and Reduce to Basic add no fill or stroke rows to a group
A group's own stack now stays empty after Clear (and keeps only the rows it had after Reduce to
Basic), so the proxies keep showing the defaults for it. The Layers target-dot helper no longer
takes over the thumbnail function's doc comment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:52:46 +07:00
bflatasticandClaude Opus 5.5 d3dc4a2af3 M3.17: the stop popover survives its opening double-click, and the selected stop belongs to its gradient
The double-click that opens the popover lands outside it in the same frame, so the popover
closed as soon as it opened; only a later click elsewhere closes it now. The selected stop
remembers the document, first selected object and proxy it was picked on (Session::selected_stop),
so selecting other art or toggling Fill/Stroke no longer lets Delete or the arrows edit a stop
on a different gradient.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:52:28 +07:00
bflatasticandClaude Opus 5.5 1b3e7110a3 M3.21: active-row edits of the other kind stay on the painted leaves, item transparency is one undo step
- A fill row no longer turns stroke edits (stroke.set, setAdvanced, paint.setStroke, gradients) into
  a new stroke on a selected group's own stack: an active row only stands for edits of its kind.
- Removing, duplicating or moving items of other objects (`ids`) no longer re-points the active row.
- transparency.set with an item touches the item only for opacity/blend (isolate or knockout alone
  is one undo step) and rejects unknown blend modes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:50:46 +07:00
bflatasticandClaude Opus 5.5 8910f8fd1e M3.12: the dialog Cancel button reuses dialogs::cancel; the Swatches panel no longer clones its selection every frame
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:45:50 +07:00
bflatasticandClaude Opus 5.5 d1d133bf09 M3.13: swatch.newGroup falls back to "Color Group" for a blank name, never gives a swatch its own group's name, and takes mesh point colours from the artwork
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:45:49 +07:00
bflatasticandClaude Opus 5.5 e9e4b94ed0 M3.10: default swatch names never read "-0" (components are clamped first); one numbering helper for free and new swatch names
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:45:47 +07:00
bflatasticandClaude Opus 5.5 984eb7f10c M3.26: effects crate docs list the shared clip_outline helper
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:45:04 +07:00
bflatasticandClaude Opus 5.5 745d50f76e M3.25: appearance.setItem takes ids without a selection; undo/redo through mask editing tested
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:45:04 +07:00
bflatasticandClaude Opus 5.5 014ee0382f M3.20: visual bounds cover arrowheads so overhanging heads are not culled; one arrowhead-weight helper
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:40:41 +07:00
bflatasticandClaude Opus 5.5 4283820a30 M3.19: dash patterns too fine to advance draw solid instead of hanging on zero-length paths
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:40:39 +07:00
bflatasticandClaude Opus 5.5 3786cfaf36 M3.17: interactive gradient annotator — drag the start handle or the bar to move the gradient, the end handle to change length and angle, just past the end to rotate (Shift snaps to 45°); a bar click adds a stop, stops drag along the bar, off it to delete (never below two), Alt-drag copies, diamonds move midpoints; a click inside selected art applies the gradient from that point; double-clicking a stop opens a popover (Color/Swatches, Opacity, Location); the selected stop lives in the session (gradient.selectStop), shared by the tool, the Gradient and Color panels and agents, and takes Delete/Backspace and ←/→ ahead of their shortcuts (Tool::claims_key, also headless); stop chips draw as Overlay::Swatch with generated add/remove-stop cursors; the stop math moves into the color crate
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:39:36 +07:00
bflatasticandClaude Opus 5.5 3692b9d233 M3.24: basic appearance, Layers target dots, Mixed Appearances and Clear resets opacity
An appearance is basic with at most one fill and one stroke, none hidden or
with its own opacity, blend mode or effects, and no object effects; Reduce
to Basic keeps the topmost visible fill and stroke. The Layers panel fills
an object's target circle for any non-default transparency (blend mode,
isolation, knockout or an opacity mask: Node::has_default_transparency).
A multi-selection whose objects differ shows "Mixed Appearances" with no
rows. The Appearance panel's object row and the Control bar name the
selection from one source, and type objects are "Type". Expand Appearance
is only in the Object menu. Clear Appearance also resets the object's
opacity and blend mode. Tests cover addFill/addStroke, setItem/removeItem,
clear, reduceToBasic and graphicStyle.apply.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:30:03 +07:00
bflatasticandClaude Opus 5.5 7da0bfb1c6 M3.13: New Swatch and New Color Group dialogs — New Swatch reuses the Swatch Options fields prefilled from the active colour (its default name follows the colour), goes into the selected colour group, Alt-click skips it and Cmd/Ctrl makes a spot colour; New Color Group makes a group from the selected swatches or the selected artwork's colours, optionally as global swatches the art links to and with swatches for tints
swatch.new takes mode and group; swatch.newGroup takes fromArtwork, toGlobal and includeTints and reports the group's swatches and the paints it linked. Adds a shared radio widget and the ui.newSwatch / ui.newColorGroup openers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:26:57 +07:00
bflatasticandClaude Opus 5.5 4f685be57a M3.26: clipping sets with any clip shape
Clip groups clip by compound paths (with their holes), even-odd paths, text
outlines and groups (the union of their members) on screen, in raster
export, in SVG and in PDF alike, through one shared region:
Node::clip_outline (the model walk) and effects::clip_outline (text
outlined, several shapes united), cached per clipping path in the
renderer. Nothing to clip by hides the clipped art everywhere, and clicks
hit a clip group only inside its clipping region. Make Clipping Mask takes
a compound path or text object on top (it loses its paint) and Release
gives it back unchanged in kind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:23:57 +07:00
bflatasticandClaude Opus 5.5 b6b87ee9b6 M3.23: per-item Opacity popups and one grouped blend-mode widget
Clicking an Opacity row in the Appearance panel (a fill's or stroke's, or
the object's) opens a popup with the same blend-mode dropdown, opacity
field and slider as the Transparency panel (widgets::opacity_blend), and
edits that item through transparency.set. The blend-mode dropdown draws a
separator between the BlendMode::group() groups, also for the blend-mode
parameters of effect dialogs and inline effect editors. Fills and strokes
of type objects composite with their own opacity and blend mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:21:28 +07:00
bflatasticandClaude Opus 5.5 5ae7bfb8ca M3.20: arrowheads: hollow outlines, tip/extend alignment, one opacity for line and head
Hollow heads draw as rings (the open arrow as a chevron) and closed paths get
no heads. The stroke stops under each head, so it never shows through a hollow
head or past the tip; stroke.set {arrowAlign: "extend"|"tip"} chooses whether
the tip extends past the end point (default) or sits on it with the stroke
shortened. Line and heads share one paint box (a gradient runs on into the
head) and take the stroke and object opacity once, as one layer on the canvas
and one group in PDF. StrokeLayer.arrow_align is a new defaulted field, so
older files load unchanged. The Stroke panel's Align buttons get two generated
icons and are disabled when the stroke has no head.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:20:40 +07:00
bflatasticandClaude Opus 5.5 23f19a861b M3.19: dotted lines: zero-length dashes draw discs or squares
The shared stroke geometry now has its own arc-length dash generator: a dash
of length 0 becomes a dot that a round cap paints as a disc and a projecting
cap as a square turned along the path (a butt cap paints nothing). Dashes
restart per subpath, join across the start of closed subpaths, and a pattern
too fine for the path draws solid. The canvas and raster export use it through
line_outline; the stroke.set params doc says how to get dots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:18:13 +07:00
bflatasticandClaude Opus 5.5 09d7aaa7f4 M3.18: shared stroke-geometry module for arrowheads, width profiles and the profile preset catalogue
Arrowheads, width-profile outlines and dashed line outlines now come from
vectorcraft_effects::stroke (stroke_pieces, line_outline, width_outline), used by
the canvas and PDF export alike; the renderer caches profile outlines too. Width
profiles are a doc-level catalogue (WidthProfile::PRESETS, preset, preset_id,
id_of) shared by stroke.set and the Stroke panel; stroke.set rejects unknown
profile ids and its params doc lists the arrowhead names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:16:17 +07:00
bflatasticandClaude Opus 5.5 aa1b592935 M3.16: the Gradient tool follows the active proxy and type objects — the tool and annotator edit the fill or the stroke by the focused proxy (ToolContext.fill_active); paint.setGradientGeom takes stroke? (default: the active proxy) and index? (one appearance item), sets type objects' vectors on their runs in text space and keeps the aspect the document shows; paint.setFill/setStroke vectors on type move into text space; aspect edits place run gradients on the layout bounds; run strokes (and type appearance strokes) fit the stroke-inflated box on screen and in PDF
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:16:12 +07:00
bflatasticandClaude Opus 5.5 3082be6ecf M3.12: delete swatches and colour groups — Shift/Cmd-click selects several swatches and groups in the panel, Delete asks first (Alt-click skips), swatch.delete {names} removes them in one undo step and art using a deleted global swatch keeps its colour, unlinked
Adds a shared confirmation dialog (`confirm`) that runs a command on OK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:15:46 +07:00
bflatasticandClaude Opus 5.5 543d09985d M3.22: effects apply to the active fill or stroke and item effect rows are editable
effect.apply, effect.remove, effect.setParams and the new effect.duplicate
take item (omitted: the Appearance panel's active item, else the object;
null: the object's own effects), so the fx button, the Effect menu, Apply
Last Effect and the effect dialogs (effect.dialog {item}) put the effect on
the selected fill or stroke. effect.list reports each object's item effects
and the active item. appearance.addEffect is an alias of effect.apply.
In the Appearance panel, item effect rows sit under their fill or stroke
with working eyes and inline editors, and can be selected for Duplicate
and Delete; a selected effect row is remembered per object.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:15:40 +07:00
bflatasticandClaude Opus 5.5 b117566fc2 M3.25: opacity-mask editing and transparency command reach
While a mask is edited, the Transparency panel and its commands act on the
masked object (Release leaves editing first). Mask commands and
transparency.set take ids and work without a selection; Make Opacity Mask
with one object adds an empty mask and enters editing. Opacity is 0..100 in
every command. transparency.info reports the panel's values with null for
mixed ones, shown blank. Saves and exports never include the editing layer
(Document::without_edit_modes; mask_edit is not serialized).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:10:21 +07:00
storybored379andClaude Opus 5.5 6b159467a2 SVG import: tspan line breaks follow the baseline gap (none on the same baseline, bounded far gaps)
Text import turns the gap between an absolutely positioned <tspan y> and the previous line into
that many line breaks, with two problems:

- At least one break was added even when the tspan sits on the current baseline. Styled or
  kerned runs written with x and y on every tspan (as Illustrator writes them) were split into one
  line per run: "Hello <tspan y=same>world</tspan>" came back as "Hello \nworld".
- The count had no upper bound. A tspan with y="1e15" (or any y far below its text) asked for
  ~10^14 '\n' characters, and opening the file aborted the app, the MCP server or
  `vectorcraft-cli` with "memory allocation failed" after eating all memory.

A tspan less than half a line from the current baseline now adds no break, the gap is capped at
10 000 line breaks, and a non-finite gap counts as one; other gaps are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:32:48 -07:00
storybored379andClaude Opus 5.5 ce5fdfd373 SVG import: root sizes in mm/cm/in/pt open at their physical size
VectorCraft writes and reads SVG at 1 user unit (CSS px) = 1 pt, but a root width/height in
absolute units was taken in CSS pixels (96 per inch). An A4 document from Inkscape
(width="210mm" height="297mm" viewBox="0 0 210 297") opened on a 793.7 × 1122.5 pt artboard,
i.e. 280 × 396 mm, with all art 4/3 too large, and its PDF export came out on that page size;
width="8.5in" gave an 11.3 inch artboard and width="300pt" a 400 pt one.

When the root width or height has an absolute unit (in, cm, mm, pt, pc) the import now scales by
72/96 along that axis, so the artboard and the art keep their physical size (595.3 × 841.9 pt for
A4); geometry, stroke widths and text follow through the same transform. Unitless, px, % and
viewBox-only files are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:25:18 -07:00
storybored379andClaude Opus 5.5 cab5dffbcf SVG export: area type keeps its wrapped lines and alignment
Area type was written as one <tspan> per paragraph at the frame's top-left: SVG text never wraps,
so every SVG viewer (and VectorCraft's own import) showed each paragraph as a single line running
out of the frame. Centred and right-aligned area type also got text-anchor="middle"/"end" at the
frame's left edge, so it was centred on (or ended at) that edge instead of inside the frame.

The exporter now asks the text layout for the lines: each line is a <tspan x y> at its baseline,
at the line's left edge, centre or right edge to match the text-anchor, with hyphenated breaks
written with their hyphen and the spaces at soft breaks dropped. The <text> carries the first
line's position too, so importers that place text by its own x/y (ours included) start at the
first line. Re-importing an exported file now reproduces the area type pixel for pixel as point
type with the same line breaks (justified lines lose their extra word spacing).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:25:18 -07:00
storybored379andClaude Opus 5.5 152228d76d Raster export: refuse sizes the renderer can't produce instead of crashing
PNG/JPEG/WebP export renders the artboard at the requested scale with no size check. The CPU
rasteriser addresses at most 65535 pixels a side (and panics at that edge), so a large artboard at
a print resolution, e.g. the maximum 16383 pt artboard at 300 ppi (~68 000 px), or a screenshot at
scale 16, panicked inside vello_common: the desktop app or the MCP server exited and unsaved work
was lost.

`vectorcraft_render::raster_size` checks the pixel size first (at most 32 768 px a side and 268
megapixels, like Rasterize already refuses oversized images) and the export paths return an error
that names the size: `document.export` / `exportSelection` / `serialize`, the MCP `export` and
`screenshot` tools, File ▸ Export and the control protocol's `ui.render`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:25:18 -07:00
storybored379andClaude Opus 5.5 c20b7deec5 Stroke dashes: an invalid dash pattern draws a solid stroke instead of hanging the renderer
A dash pattern with a negative value (for example [-5, 3], from `stroke.set` or a PDF `d`
operator) was handed to the dasher whenever any value was positive. When the values sum to zero
or less the dasher never advances, so rendering the document hung forever (canvas, screenshot,
PNG/JPEG export, `vectorcraft-cli convert`).

PDF (32000-1 §8.4.3.6) and SVG (`stroke-dasharray`) both define such a pattern as invalid and the
stroke as solid. `Dash::is_dashed` now says whether a pattern draws dashes (finite, non-negative,
not all zero), and the renderer, the width-profile outline, the SVG and PDF exporters and the PDF
importer use it, so an invalid pattern is drawn, written and read back as a solid stroke everywhere
(the PDF exporter used to clamp negatives to 0, writing zero-length dashes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:25:17 -07:00
storybored379andClaude Opus 5.5 4ac7095601 SVG import: <textPath> comes back as type on a path
VectorCraft exports type on a path as <text><textPath href="#text-path-N"
startOffset="…%"> with the path in <defs>, but the XML text fallback read
every <text> as point type at the text element's x/y. Text on a path
therefore re-imported as a straight line at (0, 0), above the artboard
and mostly out of view, and lost its path.

Resolve the textPath's href (SVG 2 href or xlink:href) to the <path>,
bake the text's user-space transform (and the path's own transform) into
it, and read startOffset (a percentage, or a length along the path) into
TextKind::OnPath.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:17:34 -07:00
storybored379andClaude Opus 5.5 1fd9b79a9d MCP: headless open_file reads .pdf and PDF-compatible .ai, as File ▸ Open does
Headless `open_file` (and `file.open` through run_command) had its own
loader that stopped at .vectorcraft and .svg, so an agent could not open
a PDF or an .ai file without the desktop app, although the engine and
`vectorcraft-cli` already import them through vectorcraft-pdf.

The headless loader now imports .pdf, .ai and any file starting with
%PDF through vectorcraft_pdf::import_with_report (default options, as
File ▸ Open uses), and returns the import warnings like the SVG path.
The open_file tool description lists the new types.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:17:34 -07:00
bflatasticandClaude Opus 5.5 d272d32be3 M3.11: Swatch Options — double-click, the options button or the menu item opens a colour swatch's editor (name, process/spot, Global, Gray/RGB/HSB/CMYK/Web sliders, hex) with a live canvas preview; gradients open the Gradient panel and patterns pattern editing; swatch.edit recolours and renames every linked fill, stroke and text run in one undo step, spot forces Global, grouped swatches edit too; swatch.list query
ui.dialog.cancel over the control channel now rolls back a dialog's live preview.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:32:17 +07:00
bflatasticandClaude Opus 5.5 81f86a9acc M3.15: gradients follow every transform — rotate, reflect, shear, non-uniform scale and Free Distort/warps pin an unplaced gradient to its fit first (fills on the bounds, strokes on the stroke-inflated box; moves and uniform scales keep refitting); linear vectors keep their isolines and radial gradients map their ellipse (principal axes give the new end and aspect); the radial angle places the vector before the gradient is placed; the eyedropper samples radials honouring aspect; SVG import shares the mapping; warped text, images and symbols no longer map their gradients twice
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:31:54 +07:00
bflatasticandClaude Opus 5.5 018bed29db M3.21: the Appearance panel's active item drives paint, stroke, gradient and transparency edits
Clicking a fill or stroke row runs appearance.setActiveItem; the toolbar,
Control bar and Properties proxies and the Color, Swatches, Gradient,
Stroke and Transparency panels show and edit that item, and the gradient
tool's annotator follows it. paint.setFill/setStroke, stroke.set/
setAdvanced, paint.editGradient/setGradientGeom and transparency.set take
item (omitted: the active item, null: the top one). Item edits and the
appearance.* commands address the selected objects' own stacks, so a
group's own fills no longer leak into its contents. Any selection change
clears the active item.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:28:05 +07:00
bflatasticandClaude Opus 5.5 61ddc1ce34 M3.14: lossless, validated gradient params — paint.setFill/setStroke, swatch.new and appearance.setItem keep kind (freeform too), midpoints, stop opacity, aspect, vector and swatch link; a shared parse_stops rejects fewer than two or malformed stops, clamps offsets and normalises 0..100 opacity; an aspect without a vector places the gradient on each object's (stroke-inflated) bounds; panels write gradients through one params writer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:22:49 +07:00
bflatasticandClaude Opus 5.5 2f21fc3a66 M3.10: swatch names follow the colour model (C=10 M=20 Y=30 K=0, R=.. G=.. B=.., Gray K=..) and are unique document-wide; New Swatch saves the active gradient or pattern, Ctrl/Cmd-click makes a spot colour; colour groups take solid colours only
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:20:52 +07:00
bflatasticandClaude Opus 5.5 00a0874d4d M3.9: swatches in colour groups are first-class — Document Color Mode converts them, they can be spot colours with their own plate and PDF Separation, and Document Info counts them
Document::swatches_iter/_mut, swatch_mut, swatch_group_of, remove_swatch and a copy-on-write
walk over linked solid paints (map_solid_paints) cover every colour group.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:15:10 +07:00
bflatasticandClaude Opus 5.5 8f9d6ef2bd Roadmap: M3/M4 wave 0 (one loader/encoder for every frontend, MCP export options, per-area paint modules, brands gate)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:02:52 +07:00
bflatasticandClaude Opus 5.5 fb8c41d8e3 Merge wp/M1: one engine loader/encoder for every frontend and export correctness (M4.12, M4.13)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:58:07 +07:00
bflatasticandClaude Opus 5.5 115255a7e4 Merge wp/WP-00: neutral labels, packaging metadata and vendor-name CI gate (M3.7, M4.10, M4.11)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:57:34 +07:00
bflatasticandClaude Opus 5.5 29d43d2a84 Merge wp/WP-01: split paint/panel command modules and dialogs into per-area files (M3.8)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:57:28 +07:00
bflatasticandClaude Opus 5.5 879295dd1a M4.10: shortcut-set alias test reads the legacy name from the alias table
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:56:39 +07:00
bflatasticandClaude Opus 5.5 7b410ecf2c M3.7: proof setup looks profiles up through the alias table; alias tests read the legacy name from it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:56:39 +07:00
bflatasticandClaude Opus 5.5 7bc50901e4 M4.13: review fixes — template sublayers are left out of raster exports too (the check moves into draw_arc), Export Selection skips art under a template layer at any depth, Export for Screens drops @Nx suffixes on SVG/PDF rows, names files without case clashes and calls an unnamed artboard Artboard-N, oversized raster exports are refused instead of writing empty files, a named MCP artboard/range replaces the option picks, and control app.export without a path returns dataBase64 instead of opening a save dialog
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:49:45 +07:00
bflatasticandClaude Opus 5.5 5d9b97c3bd M4.10: neutral file, preference and MCP wording; vendor-name CI gate
Edit -> "PDF Presets…"; the clipboard preferences read "Legacy vector
clipboard (no transparency)" / "Legacy vector clipboard" (keys copyAicb and
aicbMode unchanged); the second keyboard shortcut set is "Classic Defaults",
and the old set name is still accepted from saved UI preferences, imported
sets and shortcuts.preset. The MCP server instructions, the apply_effect and
create_graph tool descriptions, the text.createInPath params doc,
docs/mcp.md, the ui-egui crate description and the PDF compatibility docs
no longer name another vendor.

New `cargo xtask brands` (run by `cargo xtask ci`) fails on vendor product
or company names in Rust string literals (labels, params docs, menus,
panels, MCP definitions; comments and test code skipped), Cargo.toml
descriptions and text under docs/ and packaging/; a required legacy alias
is marked `brand-ok`. xtask shares one git file listing between the asset
and brand checks. docs/development.md describes the gate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:47:28 +07:00
bflatasticandClaude Opus 5.5 dd0bb0eefb M3.7: colour tests share the settings lock
Every colour-management test reads or changes the process-wide colour
settings or proof view, so all of them now hold the file's lock; the
legacy-profile-name test switching the RGB working space could otherwise
shift a parallel RGB/CMYK round-trip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:45:32 +07:00
bflatasticandClaude Opus 5.5 45a7302601 M4.13: export correctness and MCP export parity — document.export/serialize honour artboard, artboards or range ("1-3, 5") for PDF (one page per artboard) and return dataBase64 without a path; Export for Screens writes one page per PDF, ignores scale and @Nx suffixes for vector formats, names duplicate artboards -2/-3 and returns bytes without a folder; raster exports, thumbnails and Export Selection leave template layers out (RenderOptions.skip_templates); file.exportForScreens with params runs the engine; the MCP export tool takes artboard/range/options, lists formats from the engine and never retargets the document; CLI convert --range
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:38:23 +07:00
bflatasticandClaude Opus 5.5 4d503c0327 M4.12: one loader and encoder for every frontend — engine fileio (load/encode/export/save, append-only FORMATS table, OPEN_EXTS, document.formats query, typed per-format options, parse_range); document.open reads native/legacy, SVG/SVGZ, PDF/.ai, .ait (opens untitled like native templates) and PNG/JPEG/GIF/WebP/TIFF/BMP; desktop, web, control channel, headless MCP and the CLI open and encode through it; open dialogs list "All readable files" plus one filter per format
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:36:51 +07:00
bflatasticandClaude Opus 5.5 9cabeea68b M3.8: register the split paint modules next to paint so the command palette keeps listing them (Stroke Options for "stroke")
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:32:11 +07:00
bflatasticandClaude Opus 5.5 b7969a6a5d M3.8: split the paint/panel command modules and the dialogs into per-area files
No behaviour change: every command keeps its id, label, menu, shortcut and
params doc.

- Engine: swatch.* -> cmd/swatch.rs, graphicStyle.* -> cmd/style.rs,
  appearance.* (incl. copyFrom) -> cmd/appearance.rs, stroke.set/setAdvanced
  -> cmd/stroke.rs, paint.editGradient/setGradientGeom -> cmd/gradient.rs.
  Shared helpers in cmd/mod.rs: leaf_targets/paint_targets (replacing three
  copies) and unique_name (swatch and graphic style naming).
- UI: dialogs.rs -> dialogs/ with one file per dialog and a DialogSpec
  registry (one DialogKind row per dialog); shared field widgets in
  dialogs/form.rs. Headless frame tests draw and confirm every dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:22:23 +07:00
bflatasticandClaude Opus 5.5 0af38a31fd M4.11: packaging metadata describes a vector illustration app
Drop the file types and wording carried over from the sibling raster
editor: the macOS exported type now declares .vectorcraft/.drawcraft
(was .pcraft) and the raster imported types (large raster documents, QOI)
are gone; the dev bundle's PDF type is "PDF Document"; Linux categories and
keywords say vector graphics (no trademark keyword), the AppStream
description and screenshot show VectorCraft, and the deb/rpm, MSI and web
embedding descriptions say vector illustration app. File associations stay
as they are. An xtask test rejects raster-editor wording in packaging/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:19:13 +07:00
bflatasticandClaude Opus 5.5 016fcbf34e M3.7: neutral paint, effect and stroke labels
The wide-gamut RGB working space is now "Wide Gamut RGB (1998 primaries)";
its old name still resolves everywhere (cms::profile / canonical_name,
Color Settings, Assign Profile, documents and settings saved earlier).
The Effect menu section is "Vector Effects", stroke width profiles are
Lens / Taper Start / Taper End, dash, arrowhead-tip and gradient-on-stroke
tooltips are reworded, the Window library submenus hold neutral disabled
placeholders until the generated libraries land, and the Swatches menu
offers "Save Swatch Library…" (disabled).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:17:50 +07:00
bflatasticandClaude Opus 5.5 d99853ed2a M4.10: neutral New Document preset name
Rename the 'iPhone' New Document preset to 'Phone 390×844'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:01:59 +07:00
bflatasticandClaude Opus 5.5 5fab94753c CI: cargo fmt xtask/src/version.rs (the fmt gate failed on main)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:23:06 +07:00
BFlat 604c20ed90 Merge pull request #3 from storytold/revert-readme-habits
Docs: revert the README habits sentence
2026-10-04 01:16:35 +07:00
bflatastic e08ced735e Docs: drop the README's list of specific Illustrator habits (too detailed for the overview); the control-protocol note on the save prompt stays 2026-10-04 01:16:12 +07:00
BFlat b538b0a84a Merge pull request #2 from storytold/docs-workflow-updates
Docs: workflow fixes from #1 (README, control protocol)
2026-10-04 00:57:41 +07:00
bflatastic 62681d2b80 Docs: README names the Illustrator habits that now work (corner widget dragging, Alt-click pivot on an anchor, middle-mouse pan, save prompt before closing); control protocol: file.close / closeAll / app.quit open a saveChanges dialog per modified document and how agents answer it 2026-10-04 00:55:55 +07:00
Brandon ThomasandClaude Opus 5.5 4ddc422e51 Release 0.1.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:55:30 -04:00
642 changed files with 110168 additions and 9094 deletions

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
## What and why
<!-- What does this change, and why? Link the issue if there is one. -->
## How it was tested
<!-- Tests added, and how you checked the behaviour (CLI, MCP, screenshots). -->
## Checklist
- [ ] `cargo xtask ci` passes (fmt, clippy, tests, assets, brands, layers, wasm).
- [ ] No panics in shipped code: no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!` or
`unimplemented!` outside tests, and no unchecked indexing on data from files, parameters or
messages. Errors are returned as `Result` and shown to the user
([why and how](https://github.com/storytold/vectorcraft/blob/main/docs/development.md#robustness-vectorcraft-never-crashes)).
- [ ] Clean-room: nothing copied from Adobe products or GPL/AGPL code ([AGENTS.md](https://github.com/storytold/vectorcraft/blob/main/AGENTS.md)).
- [ ] Every new asset has a row in `ASSETS.md`.
+10 -1
View File
@@ -21,13 +21,22 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
- Creative Commons with redistribution allowed.
- **Every asset file must have a row in [`ASSETS.md`](ASSETS.md)** (path, author, source URL, licence, notes). Put licence texts next to the assets (e.g. `assets/fonts/OFL-*.txt`) and summarize them in `NOTICE`.
- `cargo xtask assets` (run by `cargo xtask ci`) fails on any unattributed asset.
- Code and original assets are MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`). The ArtCraft logos in `docs/brand/` are trademarks, not open source, under `docs/brand/LICENSE-brand.txt`: never modify them or use them outside VectorCraft.
- Prefer art generated in code for defaults (swatches, brushes, symbols, patterns, cursors). If the provenance of an asset is unclear, don't add it.
- Reference screenshots of Illustrator stay local under the gitignored `plan/` and are never committed, published or used as assets.
- **Never crash: no panics in shipped code.** A crash loses the user's unsaved work. Files, pasted data, MCP and control messages, and command params are all untrusted input.
- Fallible work returns `Result` (or `Option`); callers handle it or propagate it with `?`. Failures reach the user as an error message (`EngineError` for commands, the crate's own error type or `String` below the engine), never as an abort.
- Workspace clippy lints ban these outside tests, so `cargo xtask ci` fails on them: `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`. Use instead: `?`, `.ok_or(…)?`, `let … else { return … }`, `if let`, `unwrap_or` / `unwrap_or_default` / `unwrap_or_else`, `.first()` / `.last()` / `.split_last()`, and a real error for a match arm you believe is impossible.
- Don't panic implicitly either: use `.get(i)` instead of `v[i]` or `&s[a..b]` when the index comes from data. Don't divide integers by something that can be zero, cap sizes and counts read from input (allocations, images, loops), and check that floats are finite before casting them or looping on them.
- Don't silence errors just to satisfy the lint: use `let _ =` or `.ok()` only where ignoring the failure is the right behaviour, with a comment saying why.
- Tests may unwrap and panic (`#[test]`, `#[cfg(test)]`, `tests/`, `testkit`). Integration-test files start with an `#![allow(…)]` for that.
- `vectorcraft_engine::guard` is the last line of defence. Every entry point (commands, tool events, MCP requests, the UI frame, control requests) catches a panic, rolls the document back and reports an internal error. It exists for bugs and doesn't replace `Result`: wasm aborts on panic, so the web app has no safety net.
- Untrusted input is fuzzed (`engine/tests/import_fuzz.rs`, `engine/tests/command_sweep.rs`, `format/tests/prop_format.rs`, `mcp/tests/protocol_props.rs`). Extend these when you add an importer, a parser or a protocol method.
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, menu path, shortcut, params doc, `enabled`, `run`) + tests. Tools emit commands (Begin/Preview/Commit). UI-only commands live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The control channel and MCP reach all of them.
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd.
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, wasm). One task id per commit (`M2.1: pen tool`).
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings including the no-panic lints, tests, layers, wasm). One task id per commit (`M2.1: pen tool`).
## Running and looking at the app
- `cargo run --release -p vectorcraft -- --control 7979 [file.svg|file.vectorcraft]` (sibling apps' agents use the same default port: if the log says it failed to bind, pick another port — otherwise your requests reach a different app).
+41 -30
View File
@@ -2,20 +2,20 @@
Every non-code asset in this repository (icons, images, fonts, example art, colour profiles, presets) is listed here with its author, source and licence. `cargo xtask assets` (part of `cargo xtask ci`) fails if an asset file is missing from this table.
**Policy (mandatory):** VectorCraft contains **no Adobe iconography, images, artwork, presets, swatch/brush/symbol/pattern libraries or ICC profiles**. Every asset is either original work by VectorCraft contributors, or third-party material under an open licence (OSI open source, public domain / CC0, or Creative Commons that allows redistribution), and it is attributed below. Screenshots of Adobe software are never committed.
**Policy (mandatory):** VectorCraft contains **no Adobe iconography, images, artwork, presets, swatch/brush/symbol/pattern libraries or ICC profiles**. Every asset is either original work by VectorCraft contributors, or third-party material under an open licence (OSI open source, public domain / CC0, or Creative Commons that allows redistribution), and it is attributed below. Screenshots of Adobe software are never committed. The one exception is the ArtCraft name, wordmark and logos in `docs/brand/`: ArtCraft trademarks, not open source, usable only under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
Generated-in-code art is original and has no file to list. This covers the default swatches, brushes, symbols, patterns, graphic styles, image-trace presets and the vector tool cursors (`crates/ui-egui/src/cursors.rs`).
| Asset | Author | Source | Licence | Notes |
|---|---|---|---|---|
| `docs/brand/artcraft-logo-white.png` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft brand mark, owned by the project (licence/trademark terms to be confirmed by the owner) | Used in README only; not shipped in the app |
| `docs/brand/artcraft-logo-white.svg` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft brand mark, owned by the project (licence/trademark terms to be confirmed by the owner) | Used in README only; not shipped in the app |
| `docs/brand/artcraft-logo.png` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft brand mark, owned by the project (licence/trademark terms to be confirmed by the owner) | Used in README only; not shipped in the app |
| `docs/brand/artcraft-logo.svg` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft brand mark, owned by the project (licence/trademark terms to be confirmed by the owner) | Used in README only; not shipped in the app |
| `docs/brand/artcraft-mark-black.png` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft brand mark, owned by the project (licence/trademark terms to be confirmed by the owner) | Used in README only; not shipped in the app |
| `docs/brand/artcraft-mark-black.svg` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft brand mark, owned by the project (licence/trademark terms to be confirmed by the owner) | Used in README only; not shipped in the app |
| `docs/brand/artcraft-mark.png` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft brand mark, owned by the project (licence/trademark terms to be confirmed by the owner) | Used in README only; not shipped in the app |
| `docs/brand/artcraft-mark.svg` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft brand mark, owned by the project (licence/trademark terms to be confirmed by the owner) | Used in README only; not shipped in the app |
| `docs/brand/artcraft-logo-white.png` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Used in README only; not shipped in the app |
| `docs/brand/artcraft-logo-white.svg` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Used in README only; not shipped in the app |
| `docs/brand/artcraft-logo.png` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Used in README only; not shipped in the app |
| `docs/brand/artcraft-logo.svg` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Used in README only; not shipped in the app |
| `docs/brand/artcraft-mark-black.png` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Used in README only; not shipped in the app |
| `docs/brand/artcraft-mark-black.svg` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Used in README only; not shipped in the app |
| `docs/brand/artcraft-mark.png` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Used in README only; not shipped in the app |
| `docs/brand/artcraft-mark.svg` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Used in README only; not shipped in the app |
| `assets/app-icon/LICENSE.txt` | (licence/readme text) | VectorCraft | MIT OR Apache-2.0 | |
| `assets/app-icon/README.md` | (licence/readme text) | VectorCraft | MIT OR Apache-2.0 | |
| `assets/app-icon/hicolor/128x128/apps/ai.storyteller.vectorcraft.png` | Project owner (ArtCraft team) | Original artwork drawn in ArtCraft for VectorCraft, vectorised and rendered by `packaging/icons.sh` | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | App icon (engraved dragon) |
@@ -64,12 +64,12 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/blend.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/brush.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/chart-column.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/chevron-down.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/chevron-left.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/chevron-right.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/chevrons-left.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/chevrons-right.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/circle.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/chevron-down.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC; derived from Feather, also MIT (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/chevron-left.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC; derived from Feather, also MIT (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/chevron-right.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC; derived from Feather, also MIT (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/chevrons-left.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC; derived from Feather, also MIT (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/chevrons-right.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC; derived from Feather, also MIT (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/circle.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC; derived from Feather, also MIT (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/clipboard-paste.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/cloud.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/combine.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
@@ -89,6 +89,8 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/dc-appearance.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-arc.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-arrow-down.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-arrow-extend.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-arrow-tip.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-arrow-up.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-artboard-options.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-artboards.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
@@ -97,12 +99,16 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/dc-cap-butt.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-cap-round.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-cap-square.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-center-hide.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-center-show.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-clear.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-color-guide.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-crystallize.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-cube.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-dash-align.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-dash-exact.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-dir-off.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-dir-on.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-direct.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-dist-bottom.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-dist-hcenter.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
@@ -125,6 +131,9 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/dc-grad-freeform.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-grad-linear.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-grad-radial.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-grad-stroke-across.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-grad-stroke-along.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-grad-stroke-within.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-gradient.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-graphic-styles.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-grid-view.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
@@ -180,6 +189,8 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/dc-reverse.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-rotate-view.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-rounded-rect.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-rule-evenodd.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-rule-nonzero.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-scallop.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-screen-mode.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-selection.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
@@ -221,26 +232,26 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/history.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/house.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/image.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/info.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/info.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC; derived from Feather, also MIT (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/lasso.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/layers.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/layout-grid.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/library.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/link-2-off.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/link.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/external-link.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | Help → app page link |
| `assets/icons/link.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC; derived from Feather, also MIT (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/external-link.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC; derived from Feather, also MIT (`assets/icons/LICENSE-lucide.txt`) | Help → app page link |
| `assets/icons/git-branch.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | Help → GitHub link |
| `assets/icons/globe.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | Help → website link |
| `assets/icons/message-circle.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | Discord button and Help → Discord link |
| `assets/icons/lock-open.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/lock.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/lock.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC; derived from Feather, also MIT (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/map.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/maximize-2.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/menu.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/minus.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/monitor.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/minus.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC; derived from Feather, also MIT (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/monitor.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC; derived from Feather, also MIT (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/move-diagonal-2.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/move.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/move.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC; derived from Feather, also MIT (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/paint-bucket.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/paintbrush.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/palette.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
@@ -249,7 +260,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/pilcrow.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/pin.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/pipette.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/plus.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/plus.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC; derived from Feather, also MIT (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/printer.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/redo-2.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/rotate-3d.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
@@ -259,7 +270,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/save.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/scaling.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/scissors.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/search.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/search.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC; derived from Feather, also MIT (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/settings.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/shapes.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/share-2.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
@@ -269,7 +280,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/spline.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/spray-can.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/square-dashed.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/square.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/square.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC; derived from Feather, also MIT (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/squares-exclude.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/squares-intersect.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/squares-subtract.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
@@ -280,15 +291,15 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/swatch-book.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/text-cursor-input.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/tornado.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/trash-2.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/triangle.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/type.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/trash-2.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC; derived from Feather, also MIT (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/triangle.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC; derived from Feather, also MIT (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/type.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC; derived from Feather, also MIT (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/undo-2.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/ungroup.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/wand-sparkles.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/waves.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/x.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/zoom-in.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/x.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC; derived from Feather, also MIT (`assets/icons/LICENSE-lucide.txt`) | |
| `assets/icons/zoom-in.svg` | Lucide Icons and Contributors | https://lucide.dev (lucide-static v1.49.0; header kept in file) | ISC; derived from Feather, also MIT (`assets/icons/LICENSE-lucide.txt`) | |
| `docs/images/dusk-poster.png` | VectorCraft contributors | Rendered by VectorCraft from `examples/dusk-poster.vectorcraft` | MIT OR Apache-2.0 | |
| `examples/dusk-poster.vectorcraft` | VectorCraft contributors | Original artwork built through the VectorCraft command API | MIT OR Apache-2.0 | |
| `examples/dusk-poster.svg` | VectorCraft contributors | SVG export of the above | MIT OR Apache-2.0 | |
+10 -1
View File
@@ -21,13 +21,22 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
- Creative Commons with redistribution allowed.
- **Every asset file must have a row in [`ASSETS.md`](ASSETS.md)** (path, author, source URL, licence, notes). Put licence texts next to the assets (e.g. `assets/fonts/OFL-*.txt`) and summarize them in `NOTICE`.
- `cargo xtask assets` (run by `cargo xtask ci`) fails on any unattributed asset.
- Code and original assets are MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`). The ArtCraft logos in `docs/brand/` are trademarks, not open source, under `docs/brand/LICENSE-brand.txt`: never modify them or use them outside VectorCraft.
- Prefer art generated in code for defaults (swatches, brushes, symbols, patterns, cursors). If the provenance of an asset is unclear, don't add it.
- Reference screenshots of Illustrator stay local under the gitignored `plan/` and are never committed, published or used as assets.
- **Never crash: no panics in shipped code.** A crash loses the user's unsaved work. Files, pasted data, MCP and control messages, and command params are all untrusted input.
- Fallible work returns `Result` (or `Option`); callers handle it or propagate it with `?`. Failures reach the user as an error message (`EngineError` for commands, the crate's own error type or `String` below the engine), never as an abort.
- Workspace clippy lints ban these outside tests, so `cargo xtask ci` fails on them: `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`. Use instead: `?`, `.ok_or(…)?`, `let … else { return … }`, `if let`, `unwrap_or` / `unwrap_or_default` / `unwrap_or_else`, `.first()` / `.last()` / `.split_last()`, and a real error for a match arm you believe is impossible.
- Don't panic implicitly either: use `.get(i)` instead of `v[i]` or `&s[a..b]` when the index comes from data. Don't divide integers by something that can be zero, cap sizes and counts read from input (allocations, images, loops), and check that floats are finite before casting them or looping on them.
- Don't silence errors just to satisfy the lint: use `let _ =` or `.ok()` only where ignoring the failure is the right behaviour, with a comment saying why.
- Tests may unwrap and panic (`#[test]`, `#[cfg(test)]`, `tests/`, `testkit`). Integration-test files start with an `#![allow(…)]` for that.
- `vectorcraft_engine::guard` is the last line of defence. Every entry point (commands, tool events, MCP requests, the UI frame, control requests) catches a panic, rolls the document back and reports an internal error. It exists for bugs and doesn't replace `Result`: wasm aborts on panic, so the web app has no safety net.
- Untrusted input is fuzzed (`engine/tests/import_fuzz.rs`, `engine/tests/command_sweep.rs`, `format/tests/prop_format.rs`, `mcp/tests/protocol_props.rs`). Extend these when you add an importer, a parser or a protocol method.
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, menu path, shortcut, params doc, `enabled`, `run`) + tests. Tools emit commands (Begin/Preview/Commit). UI-only commands live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The control channel and MCP reach all of them.
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd.
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, wasm). One task id per commit (`M2.1: pen tool`).
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings including the no-panic lints, tests, layers, wasm). One task id per commit (`M2.1: pen tool`).
## Running and looking at the app
- `cargo run --release -p vectorcraft -- --control 7979 [file.svg|file.vectorcraft]` (sibling apps' agents use the same default port: if the log says it failed to bind, pick another port — otherwise your requests reach a different app).
Generated
+80 -21
View File
@@ -1885,6 +1885,12 @@ dependencies = [
"libc",
]
[[package]]
name = "jpeg-encoder"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b454d911ac55068f53495488d8ccd0646eaa540c033a28ee15b07838afafb01f"
[[package]]
name = "js-sys"
version = "0.3.106"
@@ -3543,6 +3549,16 @@ dependencies = [
"serde",
]
[[package]]
name = "socket2"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "spirv"
version = "0.4.0+sdk-1.4.341.0"
@@ -4044,11 +4060,13 @@ dependencies = [
[[package]]
name = "vectorcraft"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"arboard",
"clipboard-win",
"eframe",
"egui",
"image",
"log",
"muda",
"rfd",
@@ -4061,7 +4079,7 @@ dependencies = [
[[package]]
name = "vectorcraft-brush"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"kurbo 0.13.1",
"serde",
@@ -4071,9 +4089,25 @@ dependencies = [
"vectorcraft-geom",
]
[[package]]
name = "vectorcraft-cad"
version = "0.2.0"
dependencies = [
"image",
"kurbo 0.13.1",
"serde_json",
"vectorcraft-brush",
"vectorcraft-color",
"vectorcraft-doc",
"vectorcraft-effects",
"vectorcraft-geom",
"vectorcraft-pathops",
"vectorcraft-text",
]
[[package]]
name = "vectorcraft-cli"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"serde_json",
"vectorcraft-color",
@@ -4089,7 +4123,7 @@ dependencies = [
[[package]]
name = "vectorcraft-color"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"kurbo 0.13.1",
"moxcms",
@@ -4101,8 +4135,9 @@ dependencies = [
[[package]]
name = "vectorcraft-doc"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"image",
"kurbo 0.13.1",
"proptest",
"serde",
@@ -4114,7 +4149,7 @@ dependencies = [
[[package]]
name = "vectorcraft-effects"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"kurbo 0.13.1",
"serde_json",
@@ -4122,12 +4157,15 @@ dependencies = [
"vectorcraft-doc",
"vectorcraft-geom",
"vectorcraft-pathops",
"vectorcraft-text",
]
[[package]]
name = "vectorcraft-engine"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"flate2",
"image",
"kurbo 0.13.1",
"log",
"proptest",
@@ -4135,6 +4173,7 @@ dependencies = [
"serde_json",
"thiserror 2.0.21",
"vectorcraft-brush",
"vectorcraft-cad",
"vectorcraft-color",
"vectorcraft-doc",
"vectorcraft-format",
@@ -4151,8 +4190,9 @@ dependencies = [
[[package]]
name = "vectorcraft-format"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"flate2",
"proptest",
"serde",
"serde_json",
@@ -4164,7 +4204,7 @@ dependencies = [
[[package]]
name = "vectorcraft-geom"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"kurbo 0.13.1",
"proptest",
@@ -4176,11 +4216,13 @@ dependencies = [
[[package]]
name = "vectorcraft-mcp"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"image",
"log",
"proptest",
"serde_json",
"socket2",
"vectorcraft-doc",
"vectorcraft-engine",
"vectorcraft-format",
@@ -4193,7 +4235,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pathops"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"kurbo 0.13.1",
"linesweeper",
@@ -4205,7 +4247,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pdf"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"hayro-interpret",
"hayro-syntax",
@@ -4213,20 +4255,27 @@ dependencies = [
"krilla",
"kurbo 0.13.1",
"log",
"serde",
"serde_json",
"smallvec",
"thiserror 2.0.21",
"vectorcraft-brush",
"vectorcraft-color",
"vectorcraft-doc",
"vectorcraft-effects",
"vectorcraft-geom",
"vectorcraft-testkit",
"vectorcraft-text",
]
[[package]]
name = "vectorcraft-render"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"flate2",
"gif",
"image",
"jpeg-encoder",
"log",
"proptest",
"serde_json",
@@ -4243,15 +4292,20 @@ dependencies = [
[[package]]
name = "vectorcraft-svg"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"flate2",
"image",
"kurbo 0.13.1",
"proptest",
"resvg",
"serde",
"serde_json",
"skrifa 0.47.0",
"svgtypes 0.16.1",
"thiserror 2.0.21",
"usvg 0.48.1",
"vectorcraft-brush",
"vectorcraft-color",
"vectorcraft-doc",
"vectorcraft-effects",
@@ -4262,7 +4316,7 @@ dependencies = [
[[package]]
name = "vectorcraft-testkit"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4279,20 +4333,22 @@ dependencies = [
[[package]]
name = "vectorcraft-text"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"harfrust 0.13.3",
"kurbo 0.13.1",
"log",
"skrifa 0.47.0",
"subsetter",
"vectorcraft-doc",
"vectorcraft-geom",
]
[[package]]
name = "vectorcraft-tools"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"image",
"kurbo 0.13.1",
"serde",
"serde_json",
@@ -4305,7 +4361,7 @@ dependencies = [
[[package]]
name = "vectorcraft-trace"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4318,13 +4374,15 @@ dependencies = [
[[package]]
name = "vectorcraft-ui-egui"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"egui",
"egui_extras",
"image",
"log",
"serde",
"serde_json",
"vectorcraft-cad",
"vectorcraft-color",
"vectorcraft-doc",
"vectorcraft-effects",
@@ -4334,13 +4392,14 @@ dependencies = [
"vectorcraft-pdf",
"vectorcraft-render",
"vectorcraft-svg",
"vectorcraft-testkit",
"vectorcraft-text",
"vectorcraft-tools",
]
[[package]]
name = "vectorcraft-web"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"eframe",
"egui",
@@ -5320,7 +5379,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"serde_json",
]
+17 -3
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
[workspace.package]
version = "0.1.0"
version = "0.2.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@@ -29,6 +29,7 @@ vectorcraft-engine = { path = "crates/engine" }
vectorcraft-ui-egui = { path = "crates/ui-egui" }
vectorcraft-mcp = { path = "crates/mcp" }
vectorcraft-testkit = { path = "crates/testkit" }
vectorcraft-cad = { path = "crates/cad" }
# foundation
kurbo = { version = "0.13", features = ["serde"] }
@@ -38,15 +39,20 @@ serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4"
proptest = "1"
# tests: a TCP port bound without listening (connections to it are refused)
socket2 = "0.6"
# rendering / formats
vello_cpu = "0.2"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
usvg = "0.48"
krilla = "0.8"
skrifa = "0.47"
harfrust = "0.13"
linesweeper = "0.4"
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
jpeg-encoder = { version = "0.6", default-features = false, features = ["std"] }
gif = { version = "0.14", default-features = false, features = ["std", "raii_no_panic"] }
# ui
egui = "0.36"
@@ -55,6 +61,7 @@ eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"
rfd = "0.17"
arboard = { version = "3.6", default-features = false }
clipboard-win = "5.4"
webbrowser = "1"
[workspace.lints.rust]
@@ -62,7 +69,14 @@ unsafe_code = "deny"
[workspace.lints.clippy]
dbg_macro = "warn"
todo = "warn"
# VectorCraft never crashes: shipped code returns `Result`/`Option` instead of panicking (see
# AGENTS.md › Robustness). Tests may unwrap (clippy.toml).
unwrap_used = "deny"
expect_used = "deny"
panic = "deny"
todo = "deny"
unimplemented = "deny"
unreachable = "deny"
[profile.dev.package."*"]
opt-level = 2
+177
View File
@@ -0,0 +1,177 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 ArtCraft Team and the VectorCraft contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+20 -6
View File
@@ -1,14 +1,28 @@
VectorCraft
Copyright the VectorCraft contributors. Licensed under MIT OR Apache-2.0.
Copyright (c) 2026 ArtCraft Team and the VectorCraft contributors.
Licensed under the MIT License or the Apache License, Version 2.0, at your option
(LICENSE-MIT, LICENSE-APACHE).
This product bundles third-party material under the following licences. Per-file attribution
(author, source, licence) for every asset is in ASSETS.md. No Adobe icons, images or artwork are included.
The ArtCraft name, wordmark and logos in docs/brand/ are trademarks of the ArtCraft Team. They are
not open source and are not covered by the license above; their terms are in
docs/brand/LICENSE-brand.txt.
- Lucide icons (assets/icons/*.svg except dc-*.svg) — ISC License. See assets/icons/LICENSE-lucide.txt.
- VectorCraft icons (assets/icons/dc-*.svg) — original work, MIT OR Apache-2.0.
Third-party material
This product bundles third-party material under its own licenses, listed below. The per-file
list (author, source, license) is in ASSETS.md.
- Lucide icons (assets/icons/*.svg except dc-*.svg) — ISC License; the icons Lucide derived from Feather are also under the MIT License (Cole Bemis). See assets/icons/LICENSE-lucide.txt.
- Source Sans 3 and Source Serif 4 (assets/fonts/Source*) — SIL Open Font License 1.1. See assets/fonts/OFL-SourceSans3.txt, OFL-SourceSerif4.txt.
- Inter (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt.
- JetBrains Mono (assets/fonts/JetBrainsMono-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-JetBrainsMono.txt.
- egui's default fonts, compiled into the app through the epaint_default_fonts crate — Ubuntu Light
(Ubuntu Font Licence 1.0), Hack (MIT), Noto Emoji (SIL Open Font License 1.1) and emoji-icon-font
(MIT). Their license texts ship with that crate.
Rust dependencies (vello_cpu, kurbo, peniko, skrifa, harfrust, usvg, linesweeper, egui, eframe, …) are
permissively licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io.
fetched through crates.io under their own permissive licenses (mostly MIT and/or Apache-2.0; also
BSD, ISC, Zlib, BSL-1.0 and Unicode-3.0).
JPEG export uses the jpeg-encoder crate (MIT OR Apache-2.0, and the IJG License): this software is
based in part on the work of the Independent JPEG Group.
+17 -6
View File
@@ -133,7 +133,8 @@ exposes to agents, and exported by VectorCraft's own renderer. The source files
- **Robust.** Exact curve booleans (no "cannot perform operation"), unlimited undo via structural
sharing, and property-tested file round trips.
- **Open.** A documented native format (`.vectorcraft`, JSON), first-class SVG, PDF (and
PDF-compatible `.ai`) import and export, PNG/JPEG/WebP, and Export for Screens.
PDF-compatible `.ai`) import and export, PNG/JPEG/WebP export, Export for Screens, and SVGZ,
templates, GIF, TIFF and BMP on open.
- **Agent-native.** Every menu item, tool gesture, panel and dialog can be driven over a JSON
control channel and an **MCP server**, so Claude and other agents can draw, edit and export the
way a person does.
@@ -150,7 +151,7 @@ cargo run --release -p vectorcraft-cli -- run --in examples/ribbons.vectorcraft
cargo run --release -p vectorcraft-cli -- bench examples/neon-drive.vectorcraft # render timing
cargo xtask bundle # dist/VectorCraft.app (macOS)
cd apps/vectorcraft-web && trunk build --release # web build → dist/web
cargo xtask ci # fmt, clippy, tests, layering, wasm
cargo xtask ci # fmt, clippy, tests, layering, wasm, vendor names
```
### Use it from Claude Code and other agents
@@ -172,8 +173,9 @@ milestones, and honest time-to-parity estimates.
and `apps/{vectorcraft, vectorcraft-cli, vectorcraft-web}`. The egui frontend is its own crate, so
the UI can be swapped without touching the engine.
Agent and contributor rules (clean-room, the asset policy, quality gates) are in
[`AGENTS.md`](AGENTS.md). Every bundled asset is listed with its licence in [`ASSETS.md`](ASSETS.md).
Agent and contributor rules (clean-room, the asset policy, no panics in shipped code, quality gates) are in
[`AGENTS.md`](AGENTS.md); [`docs/development.md`](docs/development.md#robustness-vectorcraft-never-crashes)
explains how VectorCraft avoids crashing. Every bundled asset is listed with its licence in [`ASSETS.md`](ASSETS.md).
## The Crafting Apps
@@ -217,11 +219,20 @@ And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video stud
## License and credits
MIT OR Apache-2.0. Bundled fonts are OFL, Lucide icons are ISC, and all other icons and art are
original. Per-asset attribution is in [`ASSETS.md`](ASSETS.md) (see also [`NOTICE`](NOTICE)).
VectorCraft is dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), at your option.
Copyright (c) 2026 ArtCraft Team and the VectorCraft contributors. Required notices are in [NOTICE](NOTICE).
Bundled fonts, icons, images and other assets keep their own open licenses; each one is listed
with its author, source and license in [ASSETS.md](ASSETS.md).
The app icon (an engraved dragon on VectorCraft red, `#e8573f`) is the owner's original artwork; its
palette and files are in [`assets/app-icon/`](assets/app-icon/README.md).
The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the
ArtCraft Team and are not covered by this license. They may be used only unmodified, and only as
part of this repository and VectorCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
Forks and modified versions must remove them.
<sub>Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. VectorCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
<p align="center">
+33 -20
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-02._
_Last updated: 2026-10-05._
## Where we are
@@ -12,10 +12,10 @@ _Last updated: 2026-10-02._
|---|---|
| Infrastructure (engine, command registry, history, render, formats, MCP, web, packaging, tests) | **~90%** |
| Look & feel vs Illustrator 2026 default workspace (measured) | **~75–80%** |
| Feature surface vs full Illustrator (weighted, see below) | **~65%** |
| Feature surface vs full Illustrator (weighted, see below) | **~68%** |
| Parity including interaction fidelity and hardening ("a power user can't tell the difference, but faster") | **~50%** |
| Time to **feature parity** (every menu item, tool, panel, effect and dialog functional) | **~330–500 h** one agent · **~85–140 h** with 4–6 agents |
| Time to **full parity** (feature parity + interaction-fidelity pass + hardening) | **~440–670 h** one agent · **~115–190 h** with 4–6 agents |
| Time to **feature parity** (every menu item, tool, panel, effect and dialog functional) | **~300–450 h** one agent · **~75–130 h** with 4–6 agents |
| Time to **full parity** (feature parity + interaction-fidelity pass + hardening) | **~425–650 h** one agent · **~105–185 h** with 4–6 agents |
### Shipped so far
- **Architecture:** 19+ crates with enforced layering (`cargo xtask layers`). Every action is a command (~400 engine + ~50 UI). Undo is unlimited via structural sharing. `command.batch` runs several commands as one transaction.
@@ -31,6 +31,7 @@ _Last updated: 2026-10-02._
- Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool.
- Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close).
- Four brightness themes, persistent preferences.
- On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content.
- **Tools:**
- **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso.
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join.
@@ -47,12 +48,22 @@ _Last updated: 2026-10-02._
- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity), Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline.
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks.
- **Advanced art:** live Blends (steps/distance/smooth colour, spine), Envelope Distort (warp/mesh/top object), Gradient Mesh, Shape Builder, Live Paint, Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
- **Paint and appearance (M3):**
- **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved and loaded as `.vcswatches`, `.gpl` or CSS. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate.
- **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel follows each colour's own model and Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers, panels have their shortcuts, and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening.
- **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles snap and honour Constrain Angle. Gradient stops stay linked to global and spot swatches. Freeform gradients (points and lines) are edited on the canvas; radial gradients have an extent ellipse, an aspect handle and an off-centre focal point (SVG `fx`/`fy`, PDF two-point radials). A stroke's gradient runs within, along or across it. Object ▸ Expand turns gradients into N objects or a gradient mesh.
- **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units, shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance.
- **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups; Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export. Groups, layers and type have their own fills, strokes and effects (Contents and Characters rows); the Layers panel's target circles target layers, groups and objects; Expand Appearance turns fills, strokes, effects and brushes into objects.
- **Masks and clipping:** opacity-mask controls work while the mask is being edited; transparency commands take ids and work without a selection; clipping sets clip by compound paths, even-odd paths, text and groups on screen and in raster export, matching SVG/PDF. Alt-click shows only the mask; SVG masks keep Clip and Invert; the transparency grid is per-document state; a clipping path's own fill and stroke paint behind and over the clipped art; layers can be clipping masks.
- **Transparency:** three-state Knockout Group (on, neutral, off), Opacity & Mask Define Knockout Shape, Page Isolated Blending and Page Knockout Group, on screen and in SVG/PDF. Blend modes match the standard formulas on screen, in SVG and in PDF, and non-isolated groups blend with the art below. Flatten Transparency with presets, a dialog and a Flattener Preview panel.
- **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; six generated style libraries save and load as `.vcstyles`.
- **Neutral wording:** labels, MCP tool text, docs and packaging use neutral names, and `cargo xtask brands` (part of `cargo xtask ci`) fails on vendor names.
- **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize.
- **Formats:**
- `.vectorcraft` (lossless JSON), SVG import/export, PDF export/import (including PDF-compatible `.ai`).
- PNG, JPEG and WebP export, Export for Screens, Place.
- **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.
- **Tests:** ~700 automated tests: model-based property tests, a junk-parameter sweep over every command, golden renders, and MCP end-to-end tests over stdio.
- **Tests:** ~1,900 automated tests: model-based property tests, a junk-parameter sweep over every command, golden renders, and MCP end-to-end tests over stdio.
## Milestones and estimates
@@ -61,10 +72,10 @@ _Last updated: 2026-10-02._
| M0 | Skeleton + vertical slice | ✅ done | — |
| M1 | Selection, transform, layers, MCP | ✅ mostly done (transform reference point snaps to anchors/centres; rotated persistent bbox pending) | 4–6 |
| M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging landed; Shaper, Pen modifier nuances) | 15–20 |
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | 🟡 panels done; opacity masks done (make/release, clip, invert, disable, link; render + SVG `<mask>` in/out + PDF soft mask); mask-editing mode (click the mask thumbnail; live update) done; freeform gradients pending | 18–27 |
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | 🟡 Export for Screens (PNG/JPG/WebP/SVG/PDF × scales) done; headless CLI/MCP export every format; system clipboard: copy puts SVG markup on it, paste takes SVG from other apps (Ctrl/Cmd+C/X/V now also work off macOS); live effects now survive SVG/PDF/clipboard export (geometry baked, SVG filters for shadows/glows/blur/feather); Save / Don't Save / Cancel before closing modified documents; PDF raster effects, PNG/PDF clipboard flavours, EPS/DXF pending | 52–73 |
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool, Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; flattener presets not yet used by PDF/print; confirm the Scale Strokes & Effects default | 4–8 |
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | 🟡 one engine loader/encoder for every frontend (desktop, web, CLI, control channel, headless MCP open native/SVG/SVGZ/PDF/.ai/.ait/PNG/JPEG/GIF/WebP/TIFF/BMP; `document.formats`; MCP `export` takes artboard/range/options; one PDF page per artboard in Export for Screens; template layers left out of raster exports); Export for Screens (PNG/JPG/WebP/SVG/PDF × scales) done; headless CLI/MCP export every format; system clipboard: copy puts SVG markup on it, paste takes SVG from other apps (Ctrl/Cmd+C/X/V now also work off macOS); live effects now survive SVG/PDF/clipboard export (geometry baked, SVG filters for shadows/glows/blur/feather); Save / Don't Save / Cancel before closing modified documents; PDF raster effects, PNG/PDF clipboard flavours, EPS/DXF pending | 52–73 |
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Shape Builder edge erase, large-offset bug open) | 3–6 |
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder edge erase, large-offset bug open) | 3–6 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 |
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass pending | 8–12 |
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
@@ -72,29 +83,31 @@ _Last updated: 2026-10-02._
| M11 | Artboards & views (artboard panel/tool done, Trim View, middle-button pan; print tiling, multiple windows, presentation polish) | 🟡 | 20–33 |
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (12 presets, 18 ms/1k² image), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, regenerate in place) done; graph Design/Column/Marker designs pending | 6–10 |
| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 |
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor, workspaces done; accessibility, Windows/Linux packaging pending | 20–30 |
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor, workspaces, a custom title bar on Windows/Linux and content-sized dialogs and menus done; accessibility, Windows/Linux packaging pending | 18–28 |
| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance) | ⬜ | 60–90 |
| — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 | 50–80 |
| | **Total to full parity** (one agent; sum of the rows above — matches [Parity estimate](#parity-estimate)) | | **~440–670** |
| | **Total to full parity** (one agent; sum of the rows above — matches [Parity estimate](#parity-estimate)) | | **~425–650** |
## Parity estimate
_Method (2026-10-02):_ Illustrator's feature surface is split into 22 areas, weighted by how much of the app (and of
_Method (2026-10-02, M3 rows re-derived 2026-10-04):_ Illustrator's feature surface is split into 22 areas, weighted by how much of the app (and of
real users' work) each represents. Each area is scored by depth of behaviour, not by presence of a menu item: an area
is 100% only when every feature in it behaves like Illustrator. Feature parity = Σ weight × score / Σ weight. Remaining
time is counted per area from what is missing, calibrated on measured throughput: in the last session one agent landed
about 20 medium features (Text Wrap, Graphs, Effect → Pathfinder, export baking…) in ~16 h wall clock including builds
and CI on a heavily loaded machine — about 0.8 h per medium feature; large subsystems (3D, raster filters, vertical type)
are counted bottom-up.
are counted bottom-up. The M3 rows (colour, strokes, appearance) come from a detailed 92-task M3 plan whose review
found more missing depth than the first estimate; all 92 tasks landed on 2026-10-04 and 2026-10-05 with 6–8 agents in parallel; its packages, planned at 5–11 h each, took
about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) are likely high.
| Area | Weight | Done | Missing (main items) | One agent (h) |
|---|---:|---:|---|---:|
| Selection, transform & align tools | 6 | 85% | rotated persistent bounding box, Start Global Edit, transform nuances | 4–6 |
| Drawing tools | 7 | 85% | Shaper Groups (merge/punch overlapping shapes), pen/pencil modifier nuances, Touch Type | 10–15 |
| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 85% | Live Paint gap options, Shape Builder edge cases | 3–6 |
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 75% | freeform gradients, swatch libraries (original), Tile Edge Color | 10–15 |
| Strokes, brushes, width profiles | 5 | 65% | brush options depth, brush libraries (generated in code) | 15–25 |
| Appearance, transparency, graphic styles, masks | 5 | 85% | Flatten Transparency, style libraries, mask view (Alt-click) | 6–10 |
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform and mixed spot/process gradients in SVG/PDF (exported as stops or process colours), pattern fills in Expand | 2–4 |
| Strokes, brushes, width profiles | 5 | 88% | brush options depth, brush libraries (generated in code) | 6–10 |
| Appearance, transparency, graphic styles, masks | 5 | 97% | flattener presets in PDF/EPS/print, raster effect reach in preview bounds | 2–4 |
| Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters | 6–10 |
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 15% | ~55 filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Texture…), resolution setting, raster effects in PDF | 30–45 |
| 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 |
@@ -111,18 +124,18 @@ are counted bottom-up.
| Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 |
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 75% | ~14 panels (Tabs, Links, Attributes, Asset Export, SVG Interactivity, Variables, CSS Properties…), Properties per context | 20–30 |
| Libraries, Links, Package | 2 | 15% | Links panel, Package, local libraries | 10–15 |
| **Feature parity** | **103** | **~65%** | | **~330–500** |
| **Feature parity** | **103** | **~68%** | | **~300–450** |
| Interaction-fidelity pass (side by side with Illustrator: every tool modifier, cursor, dialog, Properties context) | | | | 60–90 |
| Hardening (big-file corpus, fuzzing, cross-platform and browser QA, accessibility, packaging) | | | | 50–80 |
| **Full parity** | | **~50%** | | **~440–670** |
| **Full parity** | | **~52%** | | **~425–650** |
With 4–6 agents working on disjoint crates (as the layering allows) the wall-clock time divides by roughly 3.5–4
(integration, review and shared files such as `menus.rs` serialize some work): **~85–140 h** to feature parity,
**~115–190 h** to full parity.
(integration, review and shared files such as `menus.rs` serialize some work): **~75–130 h** to feature parity,
**~105–185 h** to full parity.
_Inventories (2026-10-02):_ 426 engine commands + ~60 UI commands; 316 menu items wired, ~70 still disabled; 69 of 79
tools implemented (missing: Slice ×2, Touch Type, vertical type ×3, Print Tiling); 32 panels of ~45; 44 live
effects of ~110 (Illustrator effects 44/54, Photoshop-style raster effects 1/56); ~1,050 tests.
effects of ~110 (Illustrator effects 44/54, Photoshop-style raster effects 1/56); ~1,050 tests (~1,900 on 2026-10-05).
_Where we already beat Illustrator:_ exact curve booleans, off-thread multithreaded rendering, undo that never runs out,
lossless SVG/PDF export of live effects with SVG filters, a documented JSON format, the same app on the web, and every
+20 -12
View File
@@ -2,9 +2,9 @@
//!
//! ```text
//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless]
//! vectorcraft-cli run [--in file.vectorcraft|file.svg] [--cmd id [--params '{json}']]... [--export out.svg|.png|.pdf|.jpg|.webp|.vectorcraft]... [--scale 2]
//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}']]... [--export out.svg|.png|.pdf|.jpg|.webp|.vectorcraft]... [--scale 2]
//! vectorcraft-cli commands
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0] [--outline-text]
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text]
//! vectorcraft-cli info FILE
//! vectorcraft-cli bench FILE [--size 2880x1800] [--iters 5]
//! vectorcraft-cli perf [--paths 50000]
@@ -28,21 +28,23 @@ USAGE:
(vectorcraft --control 7979), falling back to a headless in-process session.
vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON]]... [--export FILE]... [--scale N]
Headless batch: open FILE (.vectorcraft/.svg) or start a new document, run commands in
Headless batch: open FILE (any readable format) or start a new document, run commands in
order, export (.svg, .png, .pdf, .jpg, .webp, .vectorcraft by extension). Prints one JSON result per step.
vectorcraft-cli commands
Print the command catalogue as JSON.
vectorcraft-cli convert IN OUT [--scale N] [--artboard I] [--outline-text]
Open IN (.vectorcraft, .svg, .pdf/.ai, images) and export OUT by extension (.svg, .pdf, .png,
.jpg, .webp, .vectorcraft). Live effects are kept; --outline-text writes SVG text as paths.
vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text]
Open IN (any readable format) and export OUT by extension (.svg, .pdf, .png, .jpg, .webp,
.vectorcraft). --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard
unless one of them is given, the other formats the first. Live effects are kept;
--outline-text writes SVG text as paths.
vectorcraft-cli info FILE
Print a JSON summary: title, colour mode, units, artboards, object counts by kind, fonts.
vectorcraft-cli bench FILE [--size WxH] [--iters N]
Render FILE (.vectorcraft/.svg) fitted to WxH (default 2880x1800) and print ms per frame
Render FILE (any readable format) fitted to WxH (default 2880x1800) and print ms per frame
(warm), multithreaded and single-threaded.
vectorcraft-cli perf [--paths N]
@@ -50,6 +52,11 @@ USAGE:
synthetic N-path document (default 50000). Exits non-zero if a budget is exceeded.
";
/// The usage text plus the formats `document.open` reads.
fn usage() -> String {
format!("{USAGE}\nReadable formats: .{}\n", vectorcraft_engine::cmd::fileio::OPEN_EXTS.join(", ."))
}
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let r = match args.first().map(String::as_str) {
@@ -61,14 +68,14 @@ fn main() -> ExitCode {
Some("bench") => bench(&args[1..]),
Some("perf") => perf::run(&args[1..]),
Some("-h" | "--help" | "help") | None => {
print!("{USAGE}");
print!("{}", usage());
Ok(())
}
Some("-V" | "--version") => {
println!("vectorcraft-cli {}", env!("CARGO_PKG_VERSION"));
Ok(())
}
Some(other) => Err(format!("unknown subcommand `{other}`\n\n{USAGE}")),
Some(other) => Err(format!("unknown subcommand `{other}`\n\n{}", usage())),
};
match r {
Ok(()) => ExitCode::SUCCESS,
@@ -119,12 +126,13 @@ fn commands() -> Result<(), String> {
fn convert(args: &[String]) -> Result<(), String> {
let mut files = vec![];
let (mut scale, mut artboard, mut outline_text) = (1.0f64, 0u64, false);
let (mut scale, mut artboard, mut range, mut outline_text) = (1.0f64, None::<u64>, None::<String>, false);
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
"--scale" | "-s" => scale = it.next().and_then(|v| v.parse().ok()).ok_or("--scale needs a number")?,
"--artboard" | "-a" => artboard = it.next().and_then(|v| v.parse().ok()).ok_or("--artboard needs an index")?,
"--artboard" | "-a" => artboard = Some(it.next().and_then(|v| v.parse().ok()).ok_or("--artboard needs an index")?),
"--range" | "-r" => range = Some(it.next().cloned().ok_or("--range needs artboards such as 1-3,5")?),
"--outline-text" => outline_text = true,
f => files.push(f.to_string()),
}
@@ -135,7 +143,7 @@ fn convert(args: &[String]) -> Result<(), String> {
let r = h
.call(
"engine.execute",
json!({"command": "document.export", "params": {"path": output, "scale": scale, "artboard": artboard, "outlineText": outline_text}}),
json!({"command": "document.export", "params": {"path": output, "scale": scale, "artboard": artboard, "range": range, "outlineText": outline_text}}),
)
.map_err(|e| format!("export {output}: {e}"))?;
println!("{r}");
+23 -10
View File
@@ -23,9 +23,9 @@ impl Rng {
}
/// `n` mixed paths (ellipses with strokes, translucent rectangles, stroked stars) on a 1600×1200 page.
pub fn synthetic(n: usize) -> Document {
pub fn synthetic(n: usize) -> Result<Document, String> {
let mut d = Document::new(1600.0, 1200.0);
let l = d.layers[0].id;
let l = d.layers.first().map(|l| l.id);
let mut r = Rng(42);
for i in 0..n {
let (x, y, s) = (r.next() * 1600.0, r.next() * 1200.0, 3.0 + r.next() * 22.0);
@@ -43,9 +43,9 @@ pub fn synthetic(n: usize) -> Document {
if i % 3 == 1 {
node.opacity = 0.8;
}
d.insert(Some(l), usize::MAX, node).expect("insert");
d.insert(l, usize::MAX, node).map_err(|e| format!("building the synthetic document: {e}"))?;
}
d
Ok(d)
}
fn median_ms(runs: usize, mut f: impl FnMut()) -> f64 {
@@ -57,7 +57,7 @@ fn median_ms(runs: usize, mut f: impl FnMut()) -> f64 {
})
.collect();
v.sort_by(f64::total_cmp);
v[v.len() / 2]
v.get(v.len() / 2).copied().unwrap_or(0.0)
}
/// 1-minute load average (macOS / Linux), if available.
@@ -87,7 +87,7 @@ pub fn run(args: &[String]) -> Result<(), String> {
println!("WARNING: the machine is busy; wall-clock timings below are not trustworthy.");
}
let t = Instant::now();
let doc = synthetic(n);
let doc = synthetic(n)?;
println!(" (built in {:.0} ms)", t.elapsed().as_secs_f64() * 1000.0);
let mut rows: Vec<(&str, f64, f64)> = vec![];
@@ -123,18 +123,31 @@ pub fn run(args: &[String]) -> Result<(), String> {
let mut bytes = vec![];
rows.push(("save .vectorcraft", median_ms(3, || bytes = vectorcraft_format::save_file(&doc)), 300.0));
rows.push(("open .vectorcraft", median_ms(3, || drop(vectorcraft_format::load(&bytes).expect("load"))), 300.0));
let mut failed = None;
let open = median_ms(3, || {
if let Err(e) = vectorcraft_format::load(&bytes) {
failed = Some(format!("open .vectorcraft: {e}"));
}
});
rows.push(("open .vectorcraft", open, 300.0));
let svg_opts = vectorcraft_svg::ExportOptions::default();
rows.push(("export SVG", median_ms(3, || drop(vectorcraft_svg::export(&doc, &svg_opts))), 500.0));
// Pathfinder Unite on 1,000 overlapping paths.
let unite = median_ms(3, || {
let mut s = vectorcraft_engine::Session::new();
s.add_document(synthetic(1000), None);
s.execute("select.all", &json!({})).expect("select");
s.execute("object.pathfinder.unite", &json!({})).expect("unite");
let r = synthetic(1000).and_then(|d| {
s.add_document(d, None);
s.execute("select.all", &json!({})).and_then(|_| s.execute("object.pathfinder.unite", &json!({}))).map_err(|e| e.to_string())
});
if let Err(e) = r {
failed = Some(format!("Pathfinder Unite: {e}"));
}
});
rows.push(("Pathfinder Unite, 1,000 paths", unite, 150.0));
if let Some(e) = failed {
return Err(e);
}
let mut over = 0;
println!(" {:<40} {:>10} {:>10}", "", "measured", "budget");
+2
View File
@@ -1,3 +1,5 @@
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::io::Write;
use std::process::{Command, Stdio};
+84
View File
@@ -0,0 +1,84 @@
//! The CLI opens every readable format (PDF, .ai, images) through the engine's `document.open`.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::process::Command;
use serde_json::Value;
const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft-cli");
fn tmp(name: &str) -> String {
let dir = std::env::temp_dir().join(format!("vectorcraft-cli-fileio-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir.join(name).to_string_lossy().to_string()
}
fn ok(args: &[&str]) -> String {
let out = Command::new(BIN).args(args).output().unwrap();
assert!(out.status.success(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
String::from_utf8(out.stdout).unwrap()
}
/// `run` a new `width`×`height` document with a rectangle and export it to `out`.
fn make(out: &str, width: u32, height: u32) {
ok(&[
"run",
"--cmd",
"file.new",
"--params",
&format!(r#"{{"width":{width},"height":{height},"artboards":2}}"#),
"--cmd",
"shape.rectangle",
"--params",
r#"{"x":1,"y":1,"width":2,"height":1}"#,
"--export",
out,
]);
}
fn info(file: &str) -> Value {
serde_json::from_str(&ok(&["info", file])).unwrap()
}
#[test]
fn convert_pdf_to_svg_and_info_on_ai() {
let pdf = tmp("in.pdf");
make(&pdf, 120, 80);
let svg = tmp("out.svg");
ok(&["convert", &pdf, &svg]);
assert!(std::fs::read_to_string(&svg).unwrap().contains("<svg"));
let ai = tmp("in.ai");
std::fs::copy(&pdf, &ai).unwrap();
let v = info(&ai);
assert_eq!(v["artboards"].as_array().unwrap().len(), 2, "{v}");
assert_eq!(v["artboards"][0]["rect"][2], 120.0);
// `run --in` takes it too.
ok(&["run", "--in", &ai, "--cmd", "document.inspect"]);
// A PDF keeps every artboard unless --artboard / --range names some.
let all = tmp("all.pdf");
ok(&["convert", &ai, &all]);
assert_eq!(info(&all)["artboards"].as_array().unwrap().len(), 2);
let second = tmp("second.pdf");
ok(&["convert", &ai, &second, "--range", "2"]);
assert_eq!(info(&second)["artboards"].as_array().unwrap().len(), 1);
}
#[test]
fn images_open_at_their_pixel_size() {
let webp = tmp("tiny.webp");
make(&webp, 3, 2);
let v = info(&webp);
assert_eq!(v["artboards"][0]["rect"], serde_json::json!([0.0, 0.0, 3.0, 2.0]), "{v}");
assert_eq!(v["kinds"]["Image"], 1, "{v}");
let png = tmp("tiny.png");
ok(&["convert", &webp, &png, "--scale", "2"]);
assert_eq!(&std::fs::read(&png).unwrap()[16..24], [0, 0, 0, 6, 0, 0, 0, 4], "IHDR 6×4");
}
#[test]
fn help_lists_readable_formats() {
let out = ok(&["--help"]);
assert!(out.contains("Readable formats: .vectorcraft") && out.contains(".ait") && out.contains(".webp"), "{out}");
}
+2
View File
@@ -1,4 +1,6 @@
//! End-to-end MCP over stdio against the built `vectorcraft-cli mcp --headless` binary.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::io::{BufRead, BufReader, Write};
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
+1 -1
View File
@@ -26,4 +26,4 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "DragEvent"] }
+78 -30
View File
@@ -1,14 +1,21 @@
//! The browser shell: web `Services`, drag-and-drop, and the eframe web runner.
use std::cell::Cell;
use std::rc::Rc;
use std::sync::{Arc, Mutex};
use vectorcraft_engine::Session;
use vectorcraft_engine::cmd::fileio;
use vectorcraft_ui_egui::place::{DropTarget, PlaceArrival, PlaceInbox};
use vectorcraft_ui_egui::{Services, VectorcraftApp};
use wasm_bindgen::JsCast as _;
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
const OPEN_EXTS: &[&str] = &["vectorcraft", "drawcraft", "svg", "png", "jpg", "jpeg", "gif", "webp"];
/// Where the pointer was over the canvas during the last file drag (CSS px from its top-left) and
/// whether Shift was held: where dropped files land.
type DragPos = Rc<Cell<Option<(f32, f32, bool)>>>;
const CANVAS_ID: &str = "vectorcraft_canvas";
const LOADING_ID: &str = "vectorcraft_loading";
@@ -23,6 +30,7 @@ pub fn start() {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return;
};
let drag = track_drag(&canvas);
let mut options = eframe::WebOptions::default();
if query().contains("webgl")
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
@@ -38,8 +46,9 @@ pub fn start() {
log::info!("vectorcraft-web: wgpu backend {:?}", rs.adapter.get_info().backend);
}
let inbox: Inbox = Arc::default();
let app = VectorcraftApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone()));
Ok(Box::new(WebShell { app, inbox }))
let place_inbox: PlaceInbox = Arc::default();
let app = VectorcraftApp::new(Session::new(), services(inbox.clone(), place_inbox.clone(), cc.egui_ctx.clone()));
Ok(Box::new(WebShell { app, inbox, place_inbox, drag }))
}),
)
.await;
@@ -56,29 +65,58 @@ fn query() -> String {
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
/// Follow file drags over the canvas: drag events carry the pointer position, which egui doesn't
/// get during a drag.
fn track_drag(canvas: &web_sys::HtmlCanvasElement) -> DragPos {
let pos = DragPos::default();
let p = pos.clone();
let on_drag = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::DragEvent)>::new(move |e: web_sys::DragEvent| {
p.set(Some((e.offset_x() as f32, e.offset_y() as f32, e.shift_key())));
});
for kind in ["dragover", "drop"] {
if let Err(e) = canvas.add_event_listener_with_callback(kind, on_drag.as_ref().unchecked_ref()) {
log::error!("couldn't follow {kind} events: {e:?}");
}
}
// The listener lives as long as the page.
on_drag.forget();
pos
}
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously)
/// and feed them through the inbox.
/// and feed them through the inboxes: placed where they were dropped on the canvas, else opened.
struct WebShell {
app: VectorcraftApp,
inbox: Inbox,
place_inbox: PlaceInbox,
drag: DragPos,
}
impl eframe::App for WebShell {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
for f in dropped {
let inbox = self.inbox.clone();
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
match f.bytes_async().await {
Ok(bytes) => {
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
ctx.request_repaint();
if !dropped.is_empty() {
let at = self.drag.take();
let z = ctx.zoom_factor();
let target = self.app.drop_target(at.map(|(x, y, _)| egui::pos2(x / z, y / z)), at.is_some_and(|a| a.2));
for f in dropped {
let (inbox, place_inbox, ctx) = (self.inbox.clone(), self.place_inbox.clone(), ctx.clone());
wasm_bindgen_futures::spawn_local(async move {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
match f.bytes_async().await {
Ok(bytes) => {
match target {
DropTarget::Place { at, embed } => {
place_inbox.lock().unwrap_or_else(|e| e.into_inner()).push(PlaceArrival { name, bytes, drop: Some((at, embed)) })
}
DropTarget::Open => inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes)),
}
ctx.request_repaint();
}
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
}
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
}
});
});
}
}
self.app.logic(ctx);
}
@@ -92,14 +130,35 @@ impl eframe::App for WebShell {
}
}
fn services(inbox: Inbox, ctx: egui::Context) -> Services {
fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context) -> Services {
let open_inbox = inbox.clone();
let picked = place_inbox.clone();
let place_ctx = ctx.clone();
Services {
// File → Place…: the picked files go to the Place dialog.
place_async: Some(Box::new(move || {
let inbox = picked.clone();
let ctx = place_ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let dialog = fileio::place_filters().fold(rfd::AsyncFileDialog::new().set_title("Place"), |d, (name, exts)| d.add_filter(name, exts));
let Some(files) = dialog.pick_files().await else {
return;
};
let mut arrived = vec![];
for f in files {
arrived.push(PlaceArrival { name: f.file_name(), bytes: f.read().await, drop: None });
}
inbox.lock().unwrap_or_else(|e| e.into_inner()).extend(arrived);
ctx.request_repaint();
});
})),
place_inbox: Some(place_inbox),
open_async: Some(Box::new(move || {
let inbox = open_inbox.clone();
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let Some(file) = rfd::AsyncFileDialog::new().add_filter("All supported", OPEN_EXTS).pick_file().await else {
let dialog = fileio::open_filters().fold(rfd::AsyncFileDialog::new(), |d, (name, exts)| d.add_filter(name, exts));
let Some(file) = dialog.pick_file().await else {
return;
};
let bytes = file.read().await;
@@ -125,7 +184,7 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
let document = window.document().ok_or("no document")?;
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
let opts = web_sys::BlobPropertyBag::new();
opts.set_type(mime_for(&name));
opts.set_type(fileio::format_for_name(&name).map_or("application/octet-stream", |f| f.mime));
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?;
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?;
let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor")?;
@@ -143,14 +202,3 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000).map_err(js)?;
Ok(())
}
fn mime_for(name: &str) -> &'static str {
match name.rsplit('.').next().map(str::to_ascii_lowercase).as_deref() {
Some("svg") => "image/svg+xml",
Some("png") => "image/png",
Some("jpg" | "jpeg") => "image/jpeg",
Some("pdf") => "application/pdf",
Some("vectorcraft" | "drawcraft") => "application/json",
_ => "application/octet-stream",
}
}
+5 -1
View File
@@ -19,9 +19,13 @@ log = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd = { workspace = true }
arboard = { workspace = true }
arboard = { workspace = true, features = ["image-data"] }
image = { workspace = true }
webbrowser = { workspace = true }
[target.'cfg(windows)'.dependencies]
clipboard-win = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
muda = { version = "0.21", default-features = false }
+4 -3
View File
@@ -4,12 +4,12 @@
//! cross-compile from macOS or Linux still links, unless `VECTORCRAFT_REQUIRE_WINRES=1` turns it into
//! an error (for release builds).
fn main() {
fn main() -> Result<(), String> {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=../../assets/app-icon/vectorcraft.ico");
println!("cargo:rerun-if-env-changed=VECTORCRAFT_REQUIRE_WINRES");
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
return;
return Ok(());
}
let mut res = winresource::WindowsResource::new();
res.set_icon("../../assets/app-icon/vectorcraft.ico")
@@ -21,8 +21,9 @@ fn main() {
.set("InternalName", "vectorcraft");
if let Err(e) = res.compile() {
if std::env::var_os("VECTORCRAFT_REQUIRE_WINRES").is_some() {
panic!("embedding Windows resources failed: {e}");
return Err(format!("embedding Windows resources failed: {e}"));
}
println!("cargo:warning=vectorcraft.exe built without icon/version resources: {e}");
}
Ok(())
}
+264
View File
@@ -0,0 +1,264 @@
//! The system clipboard with every format Copy offers and Paste reads
//! (`Services::system_clipboard`).
//!
//! - Windows: text, SVG, PDF, PNG and an opaque bitmap (for apps that don't read PNG) in one go;
//! Paste reads each of them.
//! - macOS and Linux: one format at a time, the text (a type-only copy's text or the SVG markup),
//! else the PNG as a bitmap; Paste reads text and bitmaps.
use std::io::Cursor;
use vectorcraft_engine::cmd::clipboard::{BITMAP, Flavour, PNG, TEXT};
use vectorcraft_ui_egui::SystemClipboard;
/// This platform's system clipboard.
pub fn system_clipboard() -> Box<dyn SystemClipboard> {
#[cfg(windows)]
let cb = Box::<win::Clipboard>::default();
#[cfg(not(windows))]
let cb = Box::<portable::Clipboard>::default();
cb
}
/// The text flavour among `flavours`.
fn text_of(flavours: &[Flavour]) -> Option<String> {
flavours.iter().find(|f| f.mime == TEXT).map(|f| String::from_utf8_lossy(&f.data).into_owned())
}
fn decode_png(png: &[u8]) -> Option<image::RgbaImage> {
Some(image::load_from_memory_with_format(png, image::ImageFormat::Png).ok()?.to_rgba8())
}
#[cfg(windows)]
mod win {
use std::num::NonZeroU32;
use clipboard_win::{formats, options::NoClear, raw};
use vectorcraft_engine::cmd::clipboard::{PDF, SVG};
use super::*;
/// The registered clipboard formats a flavour is written under (and read from, first held
/// first).
fn names(mime: &str) -> &'static [&'static str] {
match mime {
SVG => &["image/svg+xml"],
PDF => &["Portable Document Format", "application/pdf"],
PNG => &["PNG"],
_ => &[],
}
}
fn formats_of(mime: &str) -> impl Iterator<Item = u32> {
names(mime).iter().filter_map(|n| raw::register_format(n)).map(NonZeroU32::get)
}
fn err(e: impl std::fmt::Display) -> String {
format!("{e}")
}
/// `png` over white, as a BMP file (what the bitmap format takes).
fn opaque_bmp(png: &[u8]) -> Option<Vec<u8>> {
let img = decode_png(png)?;
let over = |c: u8, a: u8| ((u32::from(c) * u32::from(a) + 255 * (255 - u32::from(a)) + 127) / 255) as u8;
let mut rgb = Vec::with_capacity(img.as_raw().len() / 4 * 3);
for p in img.pixels() {
let [r, g, b, a] = p.0;
rgb.extend([over(r, a), over(g, a), over(b, a)]);
}
let rgb = image::RgbImage::from_raw(img.width(), img.height(), rgb)?;
let mut bmp = vec![];
rgb.write_to(&mut Cursor::new(&mut bmp), image::ImageFormat::Bmp).ok()?;
Some(bmp)
}
#[derive(Default)]
pub struct Clipboard {
/// The clipboard's sequence number right after our last write.
seq: Option<NonZeroU32>,
/// The text of our last write.
text: Option<String>,
}
impl SystemClipboard for Clipboard {
/// Writes every flavour it can; the first failure is reported.
fn write(&mut self, flavours: &[Flavour]) -> Result<(), String> {
let mut result = Ok(());
{
let _open = clipboard_win::Clipboard::new_attempts(10).map_err(err)?;
raw::empty().map_err(err)?;
let mut set = |r: clipboard_win::SysResult<()>| {
if result.is_ok() {
result = r.map_err(err);
}
};
for f in flavours {
if f.mime == TEXT {
set(raw::set_string_with(&String::from_utf8_lossy(&f.data), NoClear));
continue;
}
for id in formats_of(f.mime) {
set(raw::set_without_clear(id, &f.data));
}
if f.mime == PNG
&& let Some(bmp) = opaque_bmp(&f.data)
{
set(raw::set_bitmap_with(&bmp, NoClear));
}
}
}
self.seq = raw::seq_num();
self.text = text_of(flavours);
result
}
fn holds_ours(&mut self) -> bool {
if self.seq.is_some() && raw::seq_num() == self.seq {
return true;
}
// A clipboard manager may have copied our data again as its own.
let Some(ours) = self.text.as_deref() else { return false };
let Ok(_open) = clipboard_win::Clipboard::new_attempts(10) else { return false };
read_one(TEXT).is_some_and(|f| f.data == ours.as_bytes())
}
fn read(&mut self, mimes: &[&'static str]) -> Option<Flavour> {
let _open = clipboard_win::Clipboard::new_attempts(10).ok()?;
mimes.iter().find_map(|m| read_one(m))
}
fn has(&mut self, mimes: &[&'static str]) -> bool {
mimes.iter().any(|m| match *m {
TEXT => raw::is_format_avail(formats::CF_UNICODETEXT),
BITMAP => raw::is_format_avail(formats::CF_BITMAP) || formats_of(PNG).any(raw::is_format_avail),
m => formats_of(m).any(raw::is_format_avail),
})
}
}
/// Read the first registered format of `mime` the clipboard holds into `data`.
fn read_registered(mime: &str, data: &mut Vec<u8>) -> bool {
formats_of(mime).filter(|id| raw::is_format_avail(*id)).any(|id| {
data.clear();
raw::get_vec(id, data).is_ok() && !data.is_empty()
})
}
/// One flavour, while the clipboard is open.
fn read_one(mime: &'static str) -> Option<Flavour> {
let mut data = vec![];
let mime = match mime {
BITMAP if read_registered(PNG, &mut data) => PNG,
BITMAP => {
data.clear();
raw::get_bitmap(&mut data).ok()?;
"image/bmp"
}
TEXT => {
raw::get_string(&mut data).ok()?;
TEXT
}
m if read_registered(m, &mut data) => m,
_ => return None,
};
// Memory blocks can be longer than the data: SVG text ends at its first NUL.
if mime == SVG
&& let Some(end) = data.iter().position(|b| *b == 0)
{
data.truncate(end);
}
(!data.is_empty()).then_some(Flavour { mime, data })
}
}
/// arboard: one format at a time. Built everywhere so it is checked everywhere.
#[cfg_attr(windows, allow(dead_code))]
mod portable {
use std::borrow::Cow;
use super::*;
/// `img` as PNG bytes.
fn encode_png(img: &image::RgbaImage) -> Option<Vec<u8>> {
let mut out = vec![];
img.write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png).ok()?;
Some(out)
}
/// What our last write put on the clipboard.
enum Ours {
Text(String),
Image(usize, usize),
}
#[derive(Default)]
pub struct Clipboard {
/// Kept open: on Linux the copied data lives as long as the handle does.
cb: Option<arboard::Clipboard>,
ours: Option<Ours>,
}
impl Clipboard {
fn cb(&mut self) -> Result<&mut arboard::Clipboard, String> {
if self.cb.is_none() {
self.cb = Some(arboard::Clipboard::new().map_err(|e| e.to_string())?);
}
self.cb.as_mut().ok_or_else(|| "no system clipboard".to_string())
}
}
impl SystemClipboard for Clipboard {
fn write(&mut self, flavours: &[Flavour]) -> Result<(), String> {
let text = text_of(flavours);
let image = match &text {
Some(_) => None,
None => flavours.iter().find(|f| f.mime == PNG).and_then(|f| decode_png(&f.data)),
};
let cb = self.cb()?;
let ours = match (text, image) {
(Some(t), _) => {
cb.set_text(t.as_str()).map_err(|e| e.to_string())?;
Some(Ours::Text(t))
}
(None, Some(img)) => {
let (width, height) = (img.width() as usize, img.height() as usize);
cb.set_image(arboard::ImageData { width, height, bytes: Cow::Owned(img.into_raw()) }).map_err(|e| e.to_string())?;
Some(Ours::Image(width, height))
}
(None, None) => {
cb.clear().map_err(|e| e.to_string())?;
None
}
};
self.ours = ours;
Ok(())
}
fn holds_ours(&mut self) -> bool {
let Some(cb) = self.cb.as_mut() else { return false };
match &self.ours {
Some(Ours::Text(t)) => cb.get_text().is_ok_and(|now| now == *t),
Some(Ours::Image(w, h)) => cb.get_image().is_ok_and(|now| (now.width, now.height) == (*w, *h)),
None => false,
}
}
fn read(&mut self, mimes: &[&'static str]) -> Option<Flavour> {
let cb = self.cb().ok()?;
mimes.iter().find_map(|m| match *m {
TEXT => cb.get_text().ok().filter(|t| !t.is_empty()).map(|t| Flavour { mime: TEXT, data: t.into_bytes() }),
BITMAP => {
let img = cb.get_image().ok()?;
let rgba = image::RgbaImage::from_raw(u32::try_from(img.width).ok()?, u32::try_from(img.height).ok()?, img.bytes.into_owned())?;
encode_png(&rgba).map(|data| Flavour { mime: PNG, data })
}
_ => None,
})
}
/// Text only: reading a bitmap costs as much as pasting it.
fn has(&mut self, mimes: &[&'static str]) -> bool {
mimes.contains(&TEXT) && self.cb().is_ok_and(|cb| cb.get_text().is_ok_and(|t| !t.is_empty()))
}
}
}
+103 -10
View File
@@ -6,12 +6,14 @@
//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
//! See `vectorcraft_ui_egui::control` for the methods.
mod clipboard;
mod control_server;
#[cfg(target_os = "macos")]
mod native_menu;
use vectorcraft_engine::Session;
use vectorcraft_ui_egui::{Services, VectorcraftApp};
use vectorcraft_engine::cmd::fileio;
use vectorcraft_ui_egui::{FilePick, Services, VectorcraftApp};
struct App(VectorcraftApp, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>);
@@ -90,32 +92,112 @@ fn save_prefs(app: &VectorcraftApp) {
let mut ui = app.ui.clone();
ui.engine_prefs = app.session.prefs.to_json();
if let Ok(bytes) = serde_json::to_vec_pretty(&ui) {
let _ = std::fs::write(p, bytes);
// Preferences are best effort: a failed write keeps the previous file.
let _ = fileio::write_atomic(&p, &bytes);
}
}
}
/// A native file dialog showing `pick`'s file types, folder and suggested name.
fn file_dialog(pick: &FilePick) -> rfd::FileDialog {
let d = pick.filters.iter().fold(rfd::FileDialog::new(), |d, (name, exts)| d.add_filter(*name, exts));
let d = match &pick.folder {
Some(folder) => d.set_directory(folder),
None => d,
};
if pick.name.is_empty() { d } else { d.set_file_name(&pick.name) }
}
/// File → Show in Folder: select `path` in Finder / Explorer, or open its folder elsewhere.
fn reveal(path: &str) -> Result<(), String> {
reveal_command(path).spawn().map(|_| ()).map_err(|e| format!("can't show {path}: {e}"))
}
#[cfg(target_os = "macos")]
fn reveal_command(path: &str) -> std::process::Command {
let mut c = std::process::Command::new("open");
c.args(["-R", path]);
c
}
#[cfg(windows)]
fn reveal_command(path: &str) -> std::process::Command {
use std::os::windows::process::CommandExt as _;
// Explorer reads `/select,"path"` itself (the usual argument quoting breaks paths with spaces)
// and needs backslashes.
let mut c = std::process::Command::new("explorer");
c.raw_arg(format!("/select,\"{}\"", path.replace('/', "\\")));
c
}
#[cfg(not(any(target_os = "macos", windows)))]
fn reveal_command(path: &str) -> std::process::Command {
let folder = std::path::Path::new(path).parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(std::path::Path::new("."));
let mut c = std::process::Command::new("xdg-open");
c.arg(folder);
c
}
/// Write a file the safe way: a failed write keeps the old file ([`fileio::write_atomic`]).
fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
fileio::write_atomic(std::path::Path::new(path), bytes).map_err(|e| e.to_string())
}
fn services() -> Services {
Services {
pick_open: Some(Box::new(|| {
rfd::FileDialog::new()
.add_filter("All supported", &["vectorcraft", "drawcraft", "svg", "png", "jpg", "jpeg", "gif", "webp"])
.pick_file()
pick_open: Some(Box::new(|pick: &FilePick| file_dialog(pick).pick_file().map(|p| p.to_string_lossy().to_string()))),
pick_open_multi: Some(Box::new(|| {
fileio::place_filters()
.fold(rfd::FileDialog::new().set_title("Place"), |d, (name, exts)| d.add_filter(name, exts))
.pick_files()
.unwrap_or_default()
.into_iter()
.map(|p| p.to_string_lossy().to_string())
.collect()
})),
pick_save: Some(Box::new(|pick: &FilePick| {
// The Templates folder may not exist yet.
if let Some(folder) = &pick.folder {
let _ = std::fs::create_dir_all(folder);
}
file_dialog(pick).save_file().map(|p| p.to_string_lossy().to_string())
})),
pick_save: Some(Box::new(|name: &str| rfd::FileDialog::new().set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()))),
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))),
// Menu-bar Paste never sees egui's Paste event, so read the clipboard directly.
clipboard_read: Some(Box::new(|| arboard::Clipboard::new().ok()?.get_text().ok())),
write: Some(Box::new(write_file)),
// Background Save and Export write from a worker thread.
write_shared: Some(std::sync::Arc::new(write_file)),
// Every format Copy offers and Paste reads (menu-bar Paste never sees egui's Paste event).
system_clipboard: Some(clipboard::system_clipboard()),
// Help → Discord / website / GitHub, the Discord button, About and Home links.
open_url: Some(Box::new(|url: &str| {
let _ = webbrowser::open(url);
})),
reveal: Some(Box::new(reveal)),
// Links panel: Edit Original; Package: Show Package. Relink to Folder and Package pick folders.
open_file: Some(Box::new(open_file)),
pick_folder: Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string()))),
..Default::default()
}
}
/// Edit Original, Show Package: open `path` (a file or a folder) in the system's default app for it.
fn open_file(path: &str) -> Result<(), String> {
#[cfg(windows)]
let mut c = {
use std::os::windows::process::CommandExt as _;
let mut c = std::process::Command::new("explorer");
c.raw_arg(format!("\"{}\"", path.replace('/', "\\")));
c
};
#[cfg(target_os = "macos")]
let mut c = std::process::Command::new("open");
#[cfg(not(any(target_os = "macos", windows)))]
let mut c = std::process::Command::new("xdg-open");
#[cfg(not(windows))]
c.arg(path);
c.spawn().map(|_| ()).map_err(|e| format!("can't open {path}: {e}"))
}
/// The window, Dock, taskbar and app-switcher icon (`assets/app-icon/`, see its README). macOS gets
/// the version with Apple's transparent margin; elsewhere the full-bleed tile. The app ID matches
/// `packaging/linux/ai.storyteller.vectorcraft.desktop` so Wayland docks find the launcher icon.
@@ -127,6 +209,10 @@ fn app_icon() -> egui::IconData {
eframe::icon_data::from_png_bytes(png).unwrap_or_default()
}
/// Windows and Linux: no OS title bar; the app bar is the title bar (`vectorcraft_ui_egui::titlebar`).
/// macOS keeps its traffic lights over the integrated title strip.
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
fn main() -> eframe::Result {
let mut control_port: Option<u16> = std::env::var("VECTORCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut files = Vec::new();
@@ -147,6 +233,7 @@ fn main() -> eframe::Result {
.with_inner_size([1440.0, 900.0])
.with_min_inner_size([800.0, 500.0])
.with_drag_and_drop(true)
.with_decorations(!CUSTOM_TITLEBAR)
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
.with_title_shown(false)
@@ -160,7 +247,13 @@ fn main() -> eframe::Result {
Box::new(move |cc| {
let mut app = VectorcraftApp::new(Session::new(), services());
load_prefs(&mut app);
// User Defined swatch and graphic style libraries live next to the preferences.
let swatches = prefs_path().and_then(|p| Some(p.parent()?.join("Swatches").to_string_lossy().to_string()));
app.session.swatch_libraries.set_user_dir(swatches);
let styles = prefs_path().and_then(|p| Some(p.parent()?.join("Graphic Styles").to_string_lossy().to_string()));
app.session.style_libraries.set_user_dir(styles);
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());
app = app.with_control(rx);
+12 -4
View File
@@ -1,6 +1,7 @@
//! macOS: VectorCraft's menu tree as the native system menu bar (like Illustrator on the Mac).
//! Items dispatch through the same command path as the in-window menus; enablement, check marks
//! and dynamic labels ("Undo Move") are refreshed a few times per second.
//! and dynamic labels ("Undo Move") are refreshed a few times per second, and the menu is rebuilt
//! when saved views or recent files come and go.
use std::collections::HashMap;
use std::str::FromStr;
@@ -22,6 +23,8 @@ pub struct NativeMenu {
last_refresh: f64,
/// Shortcut-override / workspace-list generation the menu was built for.
generation: u64,
/// Saved views and recent files listed (empty slots are left out, so a change rebuilds).
slots: usize,
}
/// Accelerator for a shortcut like "Cmd+Shift+]" (modifier-less shortcuts stay in the app so they
@@ -53,7 +56,7 @@ impl NativeMenu {
.filter(|(_, p, _, c)| (p.is_null() || p.as_object().is_some_and(|o| o.is_empty())) && menus::shortcut_of(c).and_then(accel).is_some())
.map(|(_, _, _, c)| c.clone())
.collect();
Self { _menu: menu, items, last_refresh: 0.0, generation }
Self { _menu: menu, items, last_refresh: 0.0, generation, slots: menus::listed_slots(app) }
}
/// Dispatch clicked items and refresh state.
@@ -69,8 +72,11 @@ impl NativeMenu {
return;
}
self.last_refresh = now;
// Shortcuts edited or workspaces added: rebuild so accelerators and lists are current.
if vectorcraft_ui_egui::shortcut_editor::GENERATION.load(std::sync::atomic::Ordering::Relaxed) != self.generation {
// Shortcuts edited, workspaces added, saved views or recent files changed: rebuild so
// accelerators and lists are current.
if vectorcraft_ui_egui::shortcut_editor::GENERATION.load(std::sync::atomic::Ordering::Relaxed) != self.generation
|| menus::listed_slots(app) != self.slots
{
*self = NativeMenu::install(app);
return;
}
@@ -137,6 +143,8 @@ fn build(
build(app, &sub, children, items, counter);
let _ = parent.append(&sub);
}
// Unused saved-view and recent-file slots are left out, as in the in-window menus.
Item::Cmd(_, cmd, _) if menus::hidden_slot(app, cmd) => {}
Item::Cmd(label, cmd, params) => {
*counter += 1;
let id = format!("dc{counter}");
+1 -1
View File
@@ -31,7 +31,7 @@ artwork. Licence: see `LICENSE.txt`.
| `vectorcraft-macos-512.png` | tile with Apple margin, 512 px | runtime Dock icon (`apps/vectorcraft/src/main.rs`) |
| `vectorcraft.icns` | macOS icon set, 16 to 1024 px | `cargo xtask bundle` (`CFBundleIconFile`) |
| `vectorcraft.ico` | Windows icon, 16 to 256 px | `apps/vectorcraft/build.rs` (embedded in the `.exe`) |
| `hicolor/<size>/apps/ai.storyteller.vectorcraft.png` | Linux theme icons, 16 to 512 px; the 256 one is also the runtime icon on Windows and Linux | `packaging/linux/ai.storyteller.vectorcraft.desktop` |
| `hicolor/<size>/apps/ai.storyteller.vectorcraft.png` | Linux theme icons, 16 to 512 px; the 256 one is also the runtime icon on Windows and Linux, the 128 one the brand mark in the app bar and About box (`crates/ui-egui/src/brand.rs`) | `packaging/linux/ai.storyteller.vectorcraft.desktop` |
| `hicolor/scalable/apps/ai.storyteller.vectorcraft.svg` | Linux scalable icon | as above |
## How it reaches each OS
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M2 12H14"/><path d="M13 7.5L21.5 12L13 16.5Z" fill="currentColor" stroke="none"/><path d="M14 4V20" stroke-width="1" stroke-dasharray="2 1.5"/></svg>

After

Width:  |  Height:  |  Size: 341 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M2 12H10"/><path d="M9.5 7.5L18 12L9.5 16.5Z" fill="currentColor" stroke="none"/><path d="M18 4V20" stroke-width="1" stroke-dasharray="2 1.5"/></svg>

After

Width:  |  Height:  |  Size: 341 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><rect x="4.75" y="4.75" width="14.5" height="14.5"/></svg>

After

Width:  |  Height:  |  Size: 241 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><rect x="4.75" y="4.75" width="14.5" height="14.5"/><rect x="10" y="10" width="4" height="4" fill="currentColor" stroke="none"/></svg>

After

Width:  |  Height:  |  Size: 317 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M12 5.5A6.5 6.5 0 1 1 5.5 12"/><path d="M3 14.5L5.5 11.5L8 14.5"/></svg>

After

Width:  |  Height:  |  Size: 264 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M12 5.5A6.5 6.5 0 1 0 18.5 12"/><path d="M16 14.5L18.5 11.5L21 14.5"/></svg>

After

Width:  |  Height:  |  Size: 268 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><g stroke-width="1.34"><path d="M3.66 20V9a5.34 5.34 0 0 1 5.34-5.34h11" stroke-opacity="0.2"/><path d="M5 20V9a4 4 0 0 1 4-4h11" stroke-opacity="0.6"/><path d="M6.34 20V9a2.66 2.66 0 0 1 2.66-2.66h11"/></g></svg>

After

Width:  |  Height:  |  Size: 396 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><g stroke-width="4"><path d="M5 20v-5" stroke-opacity="0.2"/><path d="M5 15v-6" stroke-opacity="0.45"/><path d="M5 9a4 4 0 0 1 4-4h2" stroke-opacity="0.7"/><path d="M11 5h9"/></g></svg>

After

Width:  |  Height:  |  Size: 368 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><defs><linearGradient id="g" gradientUnits="userSpaceOnUse" x1="3" y1="0" x2="21" y2="0"><stop offset="0" stop-color="white" stop-opacity="0.15"/><stop offset="1" stop-color="white"/></linearGradient></defs><path d="M5 20V9a4 4 0 0 1 4-4h11" stroke="url(#g)" stroke-width="4"/></svg>

After

Width:  |  Height:  |  Size: 466 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M4 4H20V20H4Z M8.5 8.5H15.5V15.5H8.5Z" fill="currentColor" fill-opacity="0.55" fill-rule="evenodd" stroke-width="1"/></svg>

After

Width:  |  Height:  |  Size: 315 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M4 4H20V20H4Z M8.5 8.5H15.5V15.5H8.5Z" fill="currentColor" fill-opacity="0.55" fill-rule="nonzero" stroke-width="1"/></svg>

After

Width:  |  Height:  |  Size: 315 B

+5
View File
@@ -0,0 +1,5 @@
# Tests may unwrap, expect and panic: a failing assertion is the point of a test. Shipped code may
# not (workspace lints in Cargo.toml; AGENTS.md › Robustness).
allow-unwrap-in-tests = true
allow-expect-in-tests = true
allow-panic-in-tests = true
+1 -2
View File
@@ -83,10 +83,9 @@ pub(crate) fn sweep(t: &Track, nib: &Nib) -> Vec<SubPath> {
};
let half = std::f64::consts::FRAC_PI_2;
let mut poly = left.clone();
let (pe, te) = *s.last().unwrap();
let (Some(&(pe, te)), Some(&(ps, ts))) = (s.last(), s.first()) else { return Vec::new() };
cap(pe, te, half, -half, &mut poly);
poly.extend(right.iter().rev());
let (ps, ts) = s[0];
cap(ps, ts, -half, -3.0 * half, &mut poly);
vec![SubPath::polyline(&poly, true)]
}
+20 -6
View File
@@ -37,9 +37,8 @@ fn map_color(c: &Color, mode: Colorization, key: &Color) -> Color {
}
fn map_paint(p: &mut Paint, mode: Colorization, key: &Color) {
if let Paint::Solid { color, swatch } = p {
*color = map_color(color, mode, key);
*swatch = None;
if let Paint::Solid { color, .. } = p {
*p = Paint::solid(map_color(color, mode, key));
}
}
@@ -83,9 +82,8 @@ pub fn tint_node(n: &mut Node, color: &Color, amount: f32) {
AppearanceItem::Fill(f) => &mut f.paint,
AppearanceItem::Stroke(s) => &mut s.paint,
};
if let Paint::Solid { color: c, swatch } = p {
*c = c.lerp(color, amount);
*swatch = None;
if let Paint::Solid { color: c, .. } = p {
*p = Paint::solid(c.lerp(color, amount));
}
}
if let vectorcraft_doc::NodeKind::Text(t) = &mut n.kind {
@@ -101,3 +99,19 @@ pub fn tint_node(n: &mut Node, color: &Color, amount: f32) {
}
}
}
/// The stain instance `inst` puts on its symbol's art: its visible fill colour and that fill's
/// opacity (how far the art mixes towards it). `None` for an unstained instance.
pub fn stain(inst: &Node) -> Option<(Color, f32)> {
let f = inst.appearance.fill().filter(|f| f.visible)?;
Some((f.paint.color()?, f.opacity))
}
/// A symbol's art for instance `inst`, stained by the instance's fill ([`stain`]).
pub fn instance_art(art: &Node, inst: &Node) -> Node {
let mut a = art.clone();
if let Some((c, amount)) = stain(inst) {
tint_node(&mut a, &c, amount);
}
a
}
+1 -1
View File
@@ -11,7 +11,7 @@ use vectorcraft_geom::{Anchor, FillRule, PathData, Point, Rect, SubPath, shapes}
use crate::{ArtBrush, ArtScale, Bristle, BristleShape, Brush, BrushKind, Calligraphic, Colorization, Direction, PatternBrush, PatternFit, Scatter};
fn fill(c: Color) -> Appearance {
Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(Paint::solid(c)))], effects: vec![] }
Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(Paint::solid(c)))], ..Default::default() }
}
fn path(p: PathData, c: Color) -> Node {
+7 -3
View File
@@ -23,7 +23,7 @@ use vectorcraft_color::Paint;
use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, StrokeLayer};
use vectorcraft_geom::{BezPath, Rect};
pub use colorize::{colorize, tint_node};
pub use colorize::{colorize, instance_art, stain, tint_node};
pub use defaults::defaults;
pub use serde_json;
@@ -325,7 +325,11 @@ pub fn unique_name(lib: &[Brush], base: &str) -> String {
if !lib.iter().any(|b| b.name == base) {
return base.to_string();
}
(2..).map(|i| format!("{base} {i}")).find(|n| !lib.iter().any(|b| &b.name == n)).unwrap()
// At most `lib.len()` names are taken, so one of the first `lib.len() + 1` candidates is free.
(2..=lib.len() + 2)
.map(|i| format!("{base} {i}"))
.find(|n| !lib.iter().any(|b| &b.name == n))
.unwrap_or_else(|| format!("{base} {}", lib.len() + 2))
}
// ---------- geometry ----------
@@ -432,7 +436,7 @@ pub fn expand(doc: &Document, n: &Node) -> Option<Node> {
/// A filled path node painted with `paint` (brush output).
pub(crate) fn filled(path: vectorcraft_geom::PathData, paint: &Paint) -> Node {
Node::path(NodeId(0), path, Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(paint.clone()))], effects: vec![] })
Node::path(NodeId(0), path, Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(paint.clone()))], ..Default::default() })
}
/// A sample stroke for previews (a gentle S-curve in a `w`×`h` box).
+1 -1
View File
@@ -267,7 +267,7 @@ fn bristle_makes_translucent_strands() {
#[test]
fn tints_colorization_uses_stroke_colour() {
let b = art_brush(rect_art(10.0, 4.0), ArtScale::Stretch);
let BrushKind::Art(mut a) = b.kind else { unreachable!() };
let BrushKind::Art(mut a) = b.kind else { panic!("not an art brush") };
a.colorization = Colorization::Tints;
let b = Brush { name: "t".into(), kind: BrushKind::Art(a) };
let out = stroke_pieces(&b, &line(0.0, 0.0, 50.0, 0.0), &stroke(1.0));
+3 -3
View File
@@ -76,11 +76,11 @@ impl Track {
corner.push(a.dot(b) < cos_limit);
tangents.push(unit(a + b));
}
(Some(a), None) | (None, Some(a)) => {
(a, b) => {
// An end of an open track (both `None` only for a track without segments).
corner.push(false);
tangents.push(a);
tangents.push(a.or(b).unwrap_or(Vec2::new(1.0, 0.0)));
}
(None, None) => unreachable!(),
}
}
Some(Track { verts: pts, cum, closed, dirs, tangents, corner })
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "vectorcraft-cad"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "VectorCraft CAD interchange: a hand-written ASCII DXF writer (R12 to 2018)"
[lints]
workspace = true
[dependencies]
vectorcraft-geom = { workspace = true }
vectorcraft-color = { workspace = true }
vectorcraft-doc = { workspace = true }
vectorcraft-effects = { workspace = true }
vectorcraft-brush = { workspace = true }
vectorcraft-pathops = { workspace = true }
vectorcraft-text = { workspace = true }
kurbo = { workspace = true }
image = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
+105
View File
@@ -0,0 +1,105 @@
//! The 256-colour index palette CAD apps share, generated rather than tabulated: indices 1–9 are
//! the standard colours, 10–249 run through 24 hues at five brightnesses each in a full and a
//! half-saturated tone, and 250–255 are greys.
use crate::ColorDepth;
/// The nine standard colours (index 7 shows white on a dark background and black on paper).
const STANDARD: [[u8; 3]; 9] =
[[255, 0, 0], [255, 255, 0], [0, 255, 0], [0, 255, 255], [0, 0, 255], [255, 0, 255], [255, 255, 255], [128, 128, 128], [192, 192, 192]];
/// The brightness of the five shades of each hue.
const SHADES: [f64; 5] = [1.0, 0.65, 0.5, 0.3, 0.15];
/// The RGB of colour index `i` (1–255; anything else is index 7's white).
pub fn aci_rgb(i: u8) -> [u8; 3] {
match i {
1..=9 => STANDARD[usize::from(i - 1)],
10..=249 => {
let hue = f64::from(i / 10 - 1) * 15.0;
let k = i % 10;
let v = SHADES[usize::from(k / 2)];
let pastel = k % 2 == 1;
hue_rgb(hue).map(|f| {
let f = if pastel { f + (1.0 - f) / 2.0 } else { f };
(255.0 * v * f).floor() as u8
})
}
250..=255 => {
let g = (51.0 + 204.0 * f64::from(i - 250) / 5.0).floor() as u8;
[g; 3]
}
_ => STANDARD[6],
}
}
/// The fully saturated, full-brightness colour of `hue` degrees as 0–1 channels.
fn hue_rgb(hue: f64) -> [f64; 3] {
let h = hue.rem_euclid(360.0) / 60.0;
let x = 1.0 - (h % 2.0 - 1.0).abs();
match h as u8 {
0 => [1.0, x, 0.0],
1 => [x, 1.0, 0.0],
2 => [0.0, 1.0, x],
3 => [0.0, x, 1.0],
4 => [x, 0.0, 1.0],
_ => [1.0, 0.0, x],
}
}
/// The indices a colour depth may use (true colour: all of them, for the fallback index).
fn candidates(depth: ColorDepth) -> Box<dyn Iterator<Item = u8>> {
match depth {
ColorDepth::Aci8 => Box::new(1..=8),
ColorDepth::Aci16 => Box::new((1..=9).chain([30]).chain(250..=255)),
ColorDepth::Aci256 | ColorDepth::True => Box::new(1..=255),
}
}
/// The index of `depth` nearest to `rgb` (squared RGB distance; ties go to the lower index).
/// Index 7 stands for both white and black, as CAD apps draw it in the background's contrast.
pub fn nearest_aci(rgb: [u8; 3], depth: ColorDepth) -> u8 {
let dist = |c: [u8; 3]| rgb.iter().zip(c).map(|(a, b)| (i32::from(*a) - i32::from(b)).pow(2)).sum::<i32>();
candidates(depth).min_by_key(|i| if *i == 7 { dist([255; 3]).min(dist([0; 3])) } else { dist(aci_rgb(*i)) }).unwrap_or(7)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_palette_matches_its_known_entries() {
let known: [(u8, [u8; 3]); 14] = [
(1, [255, 0, 0]),
(10, [255, 0, 0]),
(11, [255, 127, 127]),
(12, [165, 0, 0]),
(13, [165, 82, 82]),
(17, [76, 38, 38]),
(19, [38, 19, 19]),
(20, [255, 63, 0]),
(21, [255, 159, 127]),
(30, [255, 127, 0]),
(140, [0, 191, 255]),
(250, [51, 51, 51]),
(252, [132, 132, 132]),
(255, [255, 255, 255]),
];
for (i, rgb) in known {
assert_eq!(aci_rgb(i), rgb, "index {i}");
}
}
#[test]
fn nearest_index_by_depth() {
assert_eq!(nearest_aci([255, 0, 0], ColorDepth::Aci8), 1);
assert_eq!(nearest_aci([0, 0, 0], ColorDepth::Aci256), 7, "black is index 7");
assert_eq!(nearest_aci([255, 255, 255], ColorDepth::Aci16), 7);
assert_eq!(nearest_aci([255, 127, 0], ColorDepth::Aci256), 30);
assert_eq!(nearest_aci([255, 127, 0], ColorDepth::Aci16), 30);
assert_eq!(nearest_aci([255, 127, 0], ColorDepth::Aci8), 1, "8 colours: red is nearest");
assert_eq!(nearest_aci([60, 60, 60], ColorDepth::Aci16), 250);
assert_eq!(nearest_aci([100, 100, 100], ColorDepth::Aci8), 8);
assert_eq!(nearest_aci([60, 60, 60], ColorDepth::Aci8), 7, "dark greys go to black");
}
}
+325
View File
@@ -0,0 +1,325 @@
//! CAD interchange: a hand-written ASCII DXF writer, versions R12 to 2018.
//!
//! [`export`] writes the visible art of a document into one DXF drawing: each layer becomes a DXF
//! layer (hidden ones switched off, locked ones locked, non-printing ones not plotted), paths
//! become polylines (straight segments) or cubic splines (curves), fills solid hatches, type text
//! entities or glyph outlines, and placed images image entities linked to PNG or JPEG files the
//! caller writes next to the drawing. Coordinates are y-up, in drawing units: `scale` units per
//! `unit` of the art, with the origin at the bottom-left corner of [`DxfOptions::region`].
//!
//! What DXF can't hold (blending modes, opacity masks, raster effects, gradients, patterns,
//! clipping) is approximated or left out, and each such loss comes back as a warning.
//!
//! - `aci`: the 256-colour index palette and the nearest index of a colour.
//! - `writer`: group codes, handles and the file's sections, tables and objects.
//! - `scene`: the document walk that turns objects into entities.
mod aci;
mod scene;
mod writer;
use vectorcraft_doc::{Document, Unit};
use vectorcraft_geom::Rect;
pub use aci::{aci_rgb, nearest_aci};
/// The DXF version a file is written for (the `$ACADVER` it declares).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
pub enum DxfVersion {
R12,
R13,
R14,
R2000,
R2004,
R2007,
R2010,
R2013,
#[default]
R2018,
}
impl DxfVersion {
/// Newest first, as version menus list them.
pub const ALL: [Self; 9] = [Self::R2018, Self::R2013, Self::R2010, Self::R2007, Self::R2004, Self::R2000, Self::R14, Self::R13, Self::R12];
/// The `version` param value.
pub fn id(self) -> &'static str {
match self {
Self::R12 => "R12",
Self::R13 => "R13",
Self::R14 => "R14",
Self::R2000 => "2000",
Self::R2004 => "2004",
Self::R2007 => "2007",
Self::R2010 => "2010",
Self::R2013 => "2013",
Self::R2018 => "2018",
}
}
/// The releases that write this version (menus).
pub fn label(self) -> &'static str {
match self {
Self::R12 => "R12",
Self::R13 => "R13",
Self::R14 => "R14",
Self::R2000 => "2000–2002",
Self::R2004 => "2004–2006",
Self::R2007 => "2007–2009",
Self::R2010 => "2010–2012",
Self::R2013 => "2013–2017",
Self::R2018 => "2018",
}
}
/// The `$ACADVER` header value.
pub fn acadver(self) -> &'static str {
match self {
Self::R12 => "AC1009",
Self::R13 => "AC1012",
Self::R14 => "AC1014",
Self::R2000 => "AC1015",
Self::R2004 => "AC1018",
Self::R2007 => "AC1021",
Self::R2010 => "AC1024",
Self::R2013 => "AC1027",
Self::R2018 => "AC1032",
}
}
/// A version by id (`2018`, `R2018`, `r12`) or `$ACADVER` (`AC1032`), any case.
pub fn from_id(s: &str) -> Option<Self> {
let s = s.trim();
Self::ALL.into_iter().find(|v| {
let id = v.id();
s.eq_ignore_ascii_case(id) || s.eq_ignore_ascii_case(v.acadver()) || s.strip_prefix(['R', 'r']).is_some_and(|y| y == id)
})
}
/// Objects carry handles and subclass markers; splines, hatches and images exist.
fn handles(self) -> bool {
self >= Self::R13
}
/// Lightweight polylines.
fn lwpolyline(self) -> bool {
self >= Self::R14
}
/// Lineweights, layouts, plot styles and long symbol names.
fn r2000(self) -> bool {
self >= Self::R2000
}
/// True colour and transparency.
fn true_color(self) -> bool {
self >= Self::R2004
}
/// Text is UTF-8 (earlier versions escape what isn't ASCII as `\U+XXXX`).
fn utf8(self) -> bool {
self >= Self::R2007
}
}
/// How many colours entities may use: indexed colours of the 256-colour palette, or true colour
/// (with the nearest index for apps that read only the index).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ColorDepth {
/// Indices 1–8: the seven standard colours and dark grey.
Aci8,
/// Indices 1–9, orange (30) and the six greys (250–255).
Aci16,
/// Every index 1–255.
Aci256,
/// 24-bit colour (DXF 2004 and later).
#[default]
True,
}
impl ColorDepth {
pub const ALL: [Self; 4] = [Self::Aci8, Self::Aci16, Self::Aci256, Self::True];
/// The `colors` param value.
pub fn id(self) -> &'static str {
match self {
Self::Aci8 => "8",
Self::Aci16 => "16",
Self::Aci256 => "256",
Self::True => "true",
}
}
pub fn label(self) -> &'static str {
match self {
Self::Aci8 => "8 Colors",
Self::Aci16 => "16 Colors",
Self::Aci256 => "256 Colors",
Self::True => "True Colors",
}
}
/// A depth by id (`8`, `16`, `256`, `true`; `truecolor` and `24` also read).
pub fn from_id(s: &str) -> Option<Self> {
let s = s.trim().to_ascii_lowercase();
match s.as_str() {
"truecolor" | "truecolors" | "true colors" | "24" => Some(Self::True),
_ => Self::ALL.into_iter().find(|d| d.id() == s),
}
}
}
/// The file format placed images are written in.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum RasterFormat {
#[default]
Png,
/// No transparency: transparent pixels show white.
Jpeg,
}
impl RasterFormat {
pub const ALL: [Self; 2] = [Self::Png, Self::Jpeg];
/// The `rasterFormat` param value.
pub fn id(self) -> &'static str {
match self {
Self::Png => "png",
Self::Jpeg => "jpeg",
}
}
pub fn label(self) -> &'static str {
match self {
Self::Png => "PNG",
Self::Jpeg => "JPEG",
}
}
/// The extension the image files get.
pub fn extension(self) -> &'static str {
match self {
Self::Png => "png",
Self::Jpeg => "jpg",
}
}
/// A format by id (`png`, `jpeg`, `jpg`), any case.
pub fn from_id(s: &str) -> Option<Self> {
let s = s.trim();
if s.eq_ignore_ascii_case("jpg") {
return Some(Self::Jpeg);
}
Self::ALL.into_iter().find(|f| s.eq_ignore_ascii_case(f.id()))
}
}
/// What the export favours when DXF can't hold an object's look as it is.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Preserve {
/// Type becomes glyph outlines; strokes a plain CAD line can't draw (aligned inside or
/// outside, width profiles, arrowheads, fitted dashes) become their filled outlines; brush
/// strokes become their brush art.
#[default]
Appearance,
/// Type stays text; every stroke is a line with its weight and dashes.
Editability,
}
impl Preserve {
pub const ALL: [Self; 2] = [Self::Appearance, Self::Editability];
/// The `preserve` param value.
pub fn id(self) -> &'static str {
match self {
Self::Appearance => "appearance",
Self::Editability => "editability",
}
}
pub fn label(self) -> &'static str {
match self {
Self::Appearance => "Preserve Appearance",
Self::Editability => "Maximize Editability",
}
}
pub fn from_id(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|p| s.trim().eq_ignore_ascii_case(p.id()))
}
}
/// The DXF export settings.
#[derive(Clone, Debug)]
pub struct DxfOptions {
pub version: DxfVersion,
/// The unit of the art that `scale` drawing units stand for.
pub unit: Unit,
/// Drawing units per `unit` (1 mm = `scale` units).
pub scale: f64,
/// Lineweights grow with `scale` (else they keep their printed width).
pub scale_lineweights: bool,
pub colors: ColorDepth,
pub raster: RasterFormat,
pub preserve: Preserve,
/// Alter Paths for Appearance: every stroke is written as the filled area it paints.
pub alter_paths: bool,
/// Type is written as glyph outlines (always with [`Preserve::Appearance`]).
pub outline_text: bool,
/// The drawing's origin is this region's bottom-left corner (document space).
pub region: Rect,
/// Leave out objects entirely outside `region` (one drawing per artboard).
pub crop: bool,
}
impl Default for DxfOptions {
fn default() -> Self {
Self {
version: DxfVersion::default(),
unit: Unit::Millimeters,
scale: 1.0,
scale_lineweights: false,
colors: ColorDepth::default(),
raster: RasterFormat::default(),
preserve: Preserve::default(),
alter_paths: false,
outline_text: false,
region: Rect::ZERO,
crop: false,
}
}
}
/// An image file a drawing links to, to be written next to it under `name`.
#[derive(Clone, Debug)]
pub struct DxfImage {
pub name: String,
pub bytes: Vec<u8>,
}
/// A written drawing.
#[derive(Clone, Debug, Default)]
pub struct DxfOutput {
pub bytes: Vec<u8>,
/// The image files the drawing links to (by name, next to it).
pub images: Vec<DxfImage>,
/// What was approximated or left out.
pub warnings: Vec<String>,
}
/// The smallest and largest `scale` (drawing units per unit).
pub const SCALE_RANGE: std::ops::RangeInclusive<f64> = 1e-6..=1e6;
/// Write `doc` as a DXF drawing with `opts`. Live geometry effects are applied first; template
/// layers, guides and hidden objects are left out.
pub fn export(doc: &Document, opts: &DxfOptions) -> Result<DxfOutput, String> {
if !(opts.scale.is_finite() && SCALE_RANGE.contains(&opts.scale)) {
return Err(format!("scale must be a number from {} to {}, not {}", SCALE_RANGE.start(), SCALE_RANGE.end(), opts.scale));
}
let r = opts.region;
if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) {
return Err("the export region is not finite".into());
}
let baked = vectorcraft_effects::bake_document(doc);
let doc = baked.as_ref().unwrap_or(doc);
Ok(scene::Scene::new(doc, opts).run())
}
#[cfg(test)]
mod tests;
+854
View File
@@ -0,0 +1,854 @@
//! The document walk: each visible object becomes DXF entities on its layer's DXF layer, in paint
//! order. Fills are solid hatches (outlines in R12), strokes polylines and cubic splines with a
//! lineweight and dashes, or filled outlines where the look needs them.
use std::collections::HashMap;
use std::io::Cursor;
use kurbo::PathEl;
use vectorcraft_color::Paint;
use vectorcraft_doc::{AppearanceItem, Dash, Document, ImageObject, Node, NodeKind, StrokeLayer, TextKind, TextObject, Unit};
use vectorcraft_effects::stroke;
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect};
use crate::aci::nearest_aci;
use crate::writer::{Codes, Drawing, EntityStyle, Handle, ImageDef, Layer, LineType, fixed};
use crate::{ColorDepth, DxfImage, DxfOptions, DxfOutput, Preserve, RasterFormat};
/// Curves are flattened to within this distance (points) where DXF needs straight segments
/// (hatch boundaries, R12 polylines).
const FLATNESS: f64 = 0.05;
/// How deep symbols and brush art may nest in one another (deeper art is left out).
const MAX_NEST: u32 = 8;
/// Cap height as a fraction of the type size: the height of CAD text is that of its capitals.
const CAP_HEIGHT: f64 = 0.7;
/// Longest text value older DXF versions read.
const MAX_TEXT: usize = 250;
/// The lineweights DXF knows (hundredths of a millimetre).
const LINEWEIGHTS: [i32; 24] = [0, 5, 9, 13, 15, 18, 20, 25, 30, 35, 40, 50, 53, 60, 70, 80, 90, 100, 106, 120, 140, 158, 200, 211];
const NO_TRUE_COLOR: &str = "true colour needs DXF 2004 or later: colours are written as the nearest of the 256 indexed colours";
const NO_TRANSPARENCY: &str = "transparency needs DXF 2004 or later: transparent objects are written opaque";
const NO_FILLS: &str = "DXF R12 has no fills: filled areas are written as their outlines";
const NO_IMAGES: &str = "DXF R12 has no images: placed images are left out";
const WIDE_LINES: &str =
"lines wider than 2.11 mm (the widest lineweight) are written at 2.11 mm: Alter Paths for Appearance writes their true width";
const STROKE_EXTRAS: &str = "stroke alignment, width profiles, arrowheads and brushes are left out (Maximize Editability writes strokes as lines)";
const CLIPPING: &str = "clipping masks are not applied: the art they clip is written whole";
const TYPE_ITEMS: &str = "fills and strokes added to whole type objects are left out of text entities (outline the text to keep them)";
/// One segment of a subpath, cubics for every curve.
#[derive(Clone, Copy)]
enum Seg {
Line(Point),
Cubic(Point, Point, Point),
}
impl Seg {
fn end(self) -> Point {
match self {
Seg::Line(p) | Seg::Cubic(_, _, p) => p,
}
}
}
/// A subpath: its start, its segments and whether it closes.
struct Sub {
start: Point,
segs: Vec<Seg>,
closed: bool,
}
/// The subpaths of `bp` (quadratics raised to cubics).
fn subpaths(bp: &BezPath) -> Vec<Sub> {
let mut out = vec![];
let mut cur: Option<Sub> = None;
let mut last = Point::ZERO;
for el in bp.elements() {
let seg = match *el {
PathEl::MoveTo(p) => {
out.extend(cur.replace(Sub { start: p, segs: vec![], closed: false }));
last = p;
continue;
}
PathEl::ClosePath => {
if let Some(mut s) = cur.take() {
s.closed = true;
last = s.start;
out.push(s);
}
continue;
}
PathEl::LineTo(p) => Seg::Line(p),
PathEl::QuadTo(q, p) => Seg::Cubic(last + (q - last) * (2.0 / 3.0), p + (q - p) * (2.0 / 3.0), p),
PathEl::CurveTo(a, b, p) => Seg::Cubic(a, b, p),
};
last = seg.end();
cur.get_or_insert_with(|| Sub { start: last, segs: vec![], closed: false }).segs.push(seg);
}
out.extend(cur);
out
}
/// `bp` flattened into polygons (each closing implicitly), consecutive repeats dropped.
fn polygons(bp: &BezPath) -> Vec<Vec<Point>> {
let mut out: Vec<Vec<Point>> = vec![];
let mut cur: Vec<Point> = vec![];
let push = |p: Point, cur: &mut Vec<Point>| {
if cur.last().is_none_or(|q| q.distance(p) > 1e-9) {
cur.push(p);
}
};
kurbo::flatten(bp.iter(), FLATNESS, |el| match el {
PathEl::MoveTo(p) => {
out.push(std::mem::take(&mut cur));
push(p, &mut cur);
}
PathEl::LineTo(p) => push(p, &mut cur),
PathEl::ClosePath => out.push(std::mem::take(&mut cur)),
_ => {}
});
out.push(cur);
for poly in &mut out {
if poly.len() > 1 && poly.first().zip(poly.last()).is_some_and(|(a, b)| a.distance(*b) <= 1e-9) {
poly.pop();
}
}
out.retain(|p| p.len() >= 2);
out
}
/// A DXF symbol name (layer, text style) from `name`: from DXF 2000 up to 255 characters
/// without `<>/\":;?*|=`` ` ``; before, up to 31 letters, digits, `$`, `-` and `_`, in capitals.
fn symbol_name(name: &str, long: bool) -> String {
let s: String = if long {
let bad = |c: char| c.is_control() || "<>/\\\":;?*|=`".contains(c);
name.trim().chars().map(|c| if bad(c) { '_' } else { c }).take(255).collect()
} else {
name.trim().chars().map(|c| if c.is_ascii_alphanumeric() || "$-_".contains(c) { c.to_ascii_uppercase() } else { '_' }).take(31).collect()
};
s.trim().to_string()
}
/// `base` made unique among `taken` (any case) by `_2`, `_3`…, within `max` characters.
fn unique(base: &str, taken: &[&str], max: usize) -> String {
let base = if base.is_empty() { "Layer".to_string() } else { base.to_string() };
let free = |n: &str| !taken.iter().any(|t| t.eq_ignore_ascii_case(n));
if free(&base) {
return base;
}
(2..)
.map(|i| {
let suffix = format!("_{i}");
let keep: String = base.chars().take(max.saturating_sub(suffix.len())).collect();
format!("{keep}{suffix}")
})
.find(|n| free(n))
.unwrap_or(base)
}
fn overlaps(a: Rect, b: Rect) -> bool {
a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1
}
/// The layer flags art inherits from the layers around it.
#[derive(Clone, Copy)]
struct LayerFlags {
off: bool,
locked: bool,
plot: bool,
}
pub(crate) struct Scene<'a> {
doc: &'a Document,
o: &'a DxfOptions,
/// Document space → drawing units (y up, origin at the region's bottom-left).
to_dxf: Affine,
/// Drawing units per point.
k: f64,
/// The entities written so far.
e: Codes,
next: u32,
layers: Vec<Layer>,
/// The DXF layer art goes on, and its flags.
layer: String,
flags: LayerFlags,
linetypes: Vec<LineType>,
/// (style name, font family).
styles: Vec<(String, String)>,
images: Vec<ImageDef>,
files: Vec<DxfImage>,
/// Image keys → index into `images` (`None`: left out).
by_key: HashMap<String, Option<usize>>,
warnings: Vec<String>,
brushes: Option<Vec<vectorcraft_brush::Brush>>,
extents: Option<[f64; 4]>,
/// The opacity of the containers around the current object.
opacity: f32,
/// Symbols and brush art being written inside one another.
nest: u32,
}
impl<'a> Scene<'a> {
pub fn new(doc: &'a Document, o: &'a DxfOptions) -> Self {
let k = o.scale / o.unit.points();
let r = o.region;
Self {
doc,
o,
to_dxf: Affine::new([k, 0.0, 0.0, -k, -r.x0 * k, r.y1 * k]),
k,
e: Codes::new(o.version),
next: fixed::FIRST_FREE,
layers: vec![],
layer: "0".into(),
flags: LayerFlags { off: false, locked: false, plot: true },
linetypes: vec![],
styles: vec![],
images: vec![],
files: vec![],
by_key: HashMap::new(),
warnings: vec![],
brushes: None,
extents: None,
opacity: 1.0,
nest: 0,
}
}
pub fn run(mut self) -> DxfOutput {
if self.o.colors == ColorDepth::True && !self.o.version.true_color() {
self.warn(NO_TRUE_COLOR);
}
let doc = self.doc;
for l in &doc.layers {
self.node(l, false);
}
let r = self.o.region;
let limits = [0.0, 0.0, r.width() * self.k, r.height() * self.k];
let one = (self.o.scale - 1.0).abs() < 1e-12;
let insunits = match self.o.unit {
Unit::Inches if one => 1,
Unit::Feet | Unit::FeetInches if one => 2,
Unit::Millimeters if one => 4,
Unit::Centimeters if one => 5,
Unit::Meters if one => 6,
Unit::Yards if one => 10,
_ => 0,
};
let drawing = Drawing {
version: self.o.version,
layers: self.layers,
linetypes: self.linetypes,
styles: self.styles,
images: self.images,
entities: self.e.into_string(),
extents: self.extents.unwrap_or(limits),
limits,
insunits,
metric: matches!(self.o.unit, Unit::Millimeters | Unit::Centimeters | Unit::Meters),
next: self.next,
};
DxfOutput { bytes: drawing.write().into_bytes(), images: self.files, warnings: self.warnings }
}
fn warn(&mut self, w: &str) {
if !self.warnings.iter().any(|x| x == w) {
self.warnings.push(w.to_string());
}
}
fn handle(&mut self) -> Handle {
let h = Handle(self.next);
self.next = self.next.saturating_add(1);
h
}
/// A document point in drawing units, counted in the extents.
fn pt(&mut self, p: Point) -> Point {
let q = self.to_dxf * p;
let e = self.extents.get_or_insert([q.x, q.y, q.x, q.y]);
*e = [e[0].min(q.x), e[1].min(q.y), e[2].max(q.x), e[3].max(q.y)];
q
}
fn node(&mut self, n: &Node, force: bool) {
if let NodeKind::Layer { template: true, .. } = n.kind {
return;
}
// Hidden layers are written, switched off.
let layer = n.is_layer();
if !force && !n.visible && !layer {
return;
}
if self.o.crop && !layer {
match n.visual_bounds() {
Some(b) if !overlaps(b, self.o.region) => return,
None if !n.is_container() => return,
_ => {}
}
}
self.note_losses(n);
let opacity = self.opacity;
self.opacity *= n.opacity.clamp(0.0, 1.0);
match &n.kind {
NodeKind::Layer { children, printable, clip, .. } => {
let outer = (self.layer.clone(), self.flags);
self.flags =
LayerFlags { off: self.flags.off || !n.visible, locked: self.flags.locked || n.locked, plot: self.flags.plot && *printable };
self.layer = self.add_layer(n);
self.children(children, *clip);
(self.layer, self.flags) = outer;
}
NodeKind::Group { children, clip } => self.children(children, *clip),
NodeKind::Path { path, rule, guide, .. } => {
if !*guide {
self.shape(n, &path.to_bezpath(), *rule);
}
}
NodeKind::Compound { children, rule } => {
let mut bp = BezPath::new();
for p in children.iter().filter_map(|c| c.path_data()) {
bp.extend(p.to_bezpath());
}
self.shape(n, &bp, *rule);
}
NodeKind::Text(t) => self.text(n, t),
NodeKind::Image(im) => self.image(im),
NodeKind::SymbolInstance { symbol, xf } => {
if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol)
&& self.nest < MAX_NEST
{
let mut art = (*sym.art).clone();
art.transform(*xf, false);
self.nest += 1;
self.node(&art, true);
self.nest -= 1;
}
}
// Live blends, envelopes, meshes and repeats are written as their evaluated art.
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => {
let g = vectorcraft_doc::live::expand_deep(n, None);
for c in g.children().into_iter().flatten() {
self.node(c, false);
}
}
}
self.opacity = opacity;
}
/// A container's children; a clipping container's first child is its clipping path, which
/// paints its fills below the others and its strokes over them.
fn children(&mut self, children: &[std::sync::Arc<Node>], clip: bool) {
let Some((first, rest)) = children.split_first().filter(|_| clip) else {
children.iter().for_each(|c| self.node(c, false));
return;
};
self.warn(CLIPPING);
let paint = first.clip_paint();
if let Some(f) = &paint.fill {
self.node(f, false);
}
rest.iter().for_each(|c| self.node(c, false));
if let Some(s) = &paint.stroke {
self.node(s, false);
}
}
/// Warn about what DXF can't hold in `n`'s look.
fn note_losses(&mut self, n: &Node) {
if n.blend != vectorcraft_color::BlendMode::Normal {
self.warn("blending modes are left out");
}
if n.mask.as_ref().is_some_and(|m| !m.disabled) {
self.warn("opacity masks are left out");
}
let raster = |fx: &[vectorcraft_doc::Effect]| fx.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id));
if raster(&n.appearance.effects) || n.appearance.items.iter().any(|i| raster(i.effects())) {
self.warn("raster effects (shadows, glows, blurs, feathering) are left out");
}
}
/// A DXF layer for document layer `n`, with a name no other layer has.
fn add_layer(&mut self, n: &Node) -> String {
let long = self.o.version.r2000();
let base = symbol_name(n.name.as_deref().unwrap_or("Layer"), long);
let mut taken: Vec<&str> = self.layers.iter().map(|l| l.name.as_str()).collect();
taken.extend(["0", "Defpoints"]);
let name = unique(&base, &taken, if long { 255 } else { 31 });
let aci = match n.kind {
NodeKind::Layer { color, .. } => nearest_aci(color.rgb(), ColorDepth::Aci256),
_ => 7,
};
let f = self.flags;
self.layers.push(Layer { name: name.clone(), aci, off: f.off, locked: f.locked, plot: f.plot });
name
}
/// One colour for a paint: a solid colour, the average of a gradient's stops, or a pattern's
/// first solid fill.
fn paint_rgb(&mut self, p: &Paint) -> Option<[u8; 3]> {
let rgb = |c: &vectorcraft_color::Color| {
let [r, g, b, _] = c.to_rgba8(1.0);
[r, g, b]
};
match p {
Paint::None => None,
Paint::Solid { color, .. } => Some(rgb(color)),
Paint::Gradient(g) => {
self.warn("gradients are written as one colour, the average of their stops");
let stops = &g.gradient.stops;
let n = stops.len().max(1) as f64;
let sum = stops.iter().map(|s| rgb(&s.color)).fold([0.0; 3], |a, c| [0, 1, 2].map(|i| a[i] + f64::from(c[i])));
Some(sum.map(|v| (v / n).round().clamp(0.0, 255.0) as u8))
}
Paint::Pattern { pattern, .. } => {
self.warn("pattern fills are written as one colour");
let first = self.doc.pattern(pattern).and_then(|def| def.art.iter().find_map(|a| first_solid(a)));
Some(first.map_or([128; 3], |c| rgb(&c)))
}
}
}
/// The entity style of colour `rgb` at `opacity` (times the containers'), with an optional
/// stroke weight (points) and linetype.
fn style(&mut self, rgb: [u8; 3], opacity: f32, weight: Option<f64>, linetype: Option<String>) -> EntityStyle {
let v = self.o.version;
let opacity = (self.opacity * opacity).clamp(0.0, 1.0);
let alpha = if opacity >= 0.998 {
None
} else if v.true_color() {
Some((opacity * 255.0).round() as u8)
} else {
self.warn(NO_TRANSPARENCY);
None
};
EntityStyle {
aci: nearest_aci(rgb, self.o.colors),
rgb: (self.o.colors == ColorDepth::True).then(|| u32::from_be_bytes([0, rgb[0], rgb[1], rgb[2]])),
lineweight: weight.filter(|_| v.r2000()).map(|w| self.lineweight(w)),
linetype,
alpha,
}
}
/// The DXF lineweight nearest to `width` points (scaled with the drawing when asked).
fn lineweight(&mut self, width: f64) -> i32 {
let scale = if self.o.scale_lineweights { self.o.scale } else { 1.0 };
let hundredths = width * 2540.0 / 72.0 * scale;
let max = LINEWEIGHTS[LINEWEIGHTS.len() - 1];
if hundredths > f64::from(max) + 5.0 {
self.warn(WIDE_LINES);
}
LINEWEIGHTS.into_iter().min_by(|a, b| (f64::from(*a) - hundredths).abs().total_cmp(&(f64::from(*b) - hundredths).abs())).unwrap_or(max)
}
/// A linetype for dash pattern `d` (one per distinct pattern).
fn linetype(&mut self, d: &Dash) -> String {
let mut lengths: Vec<f64> = d.pattern.iter().map(|v| v * self.k).collect();
if lengths.len() % 2 == 1 {
lengths.extend(lengths.clone());
}
let pattern: Vec<f64> = lengths.iter().enumerate().map(|(i, v)| if i % 2 == 0 { *v } else { -v }).collect();
let same = |l: &LineType| l.pattern.len() == pattern.len() && l.pattern.iter().zip(&pattern).all(|(a, b)| (a - b).abs() < 1e-9);
if let Some(l) = self.linetypes.iter().find(|l| same(l)) {
return l.name.clone();
}
let name = format!("DASHED{}", self.linetypes.len() + 1);
self.linetypes.push(LineType { name: name.clone(), pattern });
name
}
/// A path's fills and strokes, in paint order.
fn shape(&mut self, n: &Node, bp: &BezPath, rule: FillRule) {
if bp.elements().is_empty() {
return;
}
for item in &n.appearance.items {
match item {
AppearanceItem::Fill(f) if f.visible => {
if let Some(rgb) = self.paint_rgb(&f.paint) {
let style = self.style(rgb, f.opacity, None, None);
self.area(bp, rule, &style, false);
}
}
AppearanceItem::Stroke(st) if st.visible && st.width > 0.0 && st.width.is_finite() => self.stroke(bp, rule, st),
_ => {}
}
}
}
fn stroke(&mut self, bp: &BezPath, rule: FillRule, st: &StrokeLayer) {
let appearance = self.o.preserve == Preserve::Appearance;
if appearance
&& st.brush.is_some()
&& self.nest < MAX_NEST
&& let Some(art) = self.brush_art(bp, st)
{
let opacity = self.opacity;
self.opacity *= st.opacity.clamp(0.0, 1.0);
self.nest += 1;
art.iter().for_each(|piece| self.node(piece, true));
self.nest -= 1;
self.opacity = opacity;
return;
}
let Some(rgb) = self.paint_rgb(&st.paint) else { return };
let plain = stroke::is_plain(st);
if self.o.alter_paths || (appearance && !plain) {
let region = stroke::outline_region(&PathData::from_bezpath(bp), rule, st);
let style = self.style(rgb, st.opacity, None, None);
self.area(&region.to_bezpath(), FillRule::NonZero, &style, true);
return;
}
if !plain {
self.warn(STROKE_EXTRAS);
}
let linetype = st.dash.as_ref().filter(|d| d.is_dashed()).map(|d| self.linetype(d));
let style = self.style(rgb, st.opacity, Some(st.width), linetype);
self.lines(bp, &style, st.width);
}
fn brush_art(&mut self, bp: &BezPath, st: &StrokeLayer) -> Option<Vec<Node>> {
let doc = self.doc;
let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc));
let b = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name))?;
Some(vectorcraft_brush::stroke_pieces(b, bp, st))
}
/// A filled area as a solid hatch (R12: its outlines). `clean`: the path has no overlaps, so
/// the hatch's even-odd rule reads it as it is.
fn area(&mut self, bp: &BezPath, rule: FillRule, style: &EntityStyle, clean: bool) {
if !self.o.version.handles() {
self.warn(NO_FILLS);
self.lines(bp, style, 0.0);
return;
}
let several = bp.elements().iter().filter(|e| matches!(e, PathEl::MoveTo(_))).count() > 1;
let normalized = (rule == FillRule::NonZero && several && !clean)
.then(|| vectorcraft_pathops::try_normalize(&PathData::from_bezpath(bp), rule).ok())
.flatten()
.map(|p| p.to_bezpath());
let loops: Vec<Vec<Point>> = polygons(normalized.as_ref().unwrap_or(bp)).into_iter().filter(|p| p.len() >= 3).collect();
if loops.is_empty() {
return;
}
let h = self.handle();
let layer = self.layer.clone();
self.e.entity("HATCH", h, fixed::MODEL_RECORD, &layer, style, "AcDbHatch");
self.e.xyz(10, 0.0, 0.0);
self.e.normal();
self.e.raw(2, "SOLID");
self.e.int(70, 1);
self.e.int(71, 0);
self.e.int(91, loops.len() as i64);
for l in &loops {
// An external polyline boundary, closed, without bulges.
self.e.int(92, 3);
self.e.int(72, 0);
self.e.int(73, 1);
self.e.int(93, l.len() as i64);
for p in l {
let q = self.pt(*p);
self.e.xy(10, q.x, q.y);
}
self.e.int(97, 0);
}
self.e.int(75, 0);
self.e.int(76, 1);
self.e.int(98, 0);
}
/// Path `bp` as lines: polylines for straight subpaths (and for curves in R12, flattened),
/// cubic splines for curved ones. `width`: the stroke weight, which DXF before 2000 gives
/// polylines as a width.
fn lines(&mut self, bp: &BezPath, style: &EntityStyle, width: f64) {
let v = self.o.version;
let width = (!v.r2000() && width > 0.0).then_some(width * self.k);
for sub in subpaths(bp) {
if sub.segs.is_empty() {
continue;
}
if sub.segs.iter().all(|s| matches!(s, Seg::Line(_))) {
let mut pts: Vec<Point> = std::iter::once(sub.start).chain(sub.segs.iter().map(|s| s.end())).collect();
if sub.closed && pts.len() > 2 && pts.last().is_some_and(|p| p.distance(sub.start) <= 1e-9) {
pts.pop();
}
self.polyline(&pts, sub.closed, style, width);
} else if v.handles() {
self.spline(&sub, style);
} else {
let mut path = BezPath::new();
path.move_to(sub.start);
for s in &sub.segs {
match *s {
Seg::Line(p) => path.line_to(p),
Seg::Cubic(a, b, p) => path.curve_to(a, b, p),
}
}
for pts in polygons(&path) {
self.polyline(&pts, sub.closed, style, width);
}
}
}
}
/// A polyline through `pts` (document space): lightweight from R14, else a polyline with a
/// vertex entity per point.
fn polyline(&mut self, pts: &[Point], closed: bool, style: &EntityStyle, width: Option<f64>) {
if pts.len() < 2 {
return;
}
let pts: Vec<Point> = pts.iter().map(|p| self.pt(*p)).collect();
let layer = self.layer.clone();
let h = self.handle();
let flags = i64::from(closed);
if self.o.version.lwpolyline() {
self.e.entity("LWPOLYLINE", h, fixed::MODEL_RECORD, &layer, style, "AcDbPolyline");
self.e.int(90, pts.len() as i64);
self.e.int(70, flags);
if let Some(w) = width {
self.e.num(43, w);
}
for p in &pts {
self.e.xy(10, p.x, p.y);
}
return;
}
self.e.entity("POLYLINE", h, fixed::MODEL_RECORD, &layer, style, "AcDb2dPolyline");
self.e.int(66, 1);
self.e.xyz(10, 0.0, 0.0);
self.e.int(70, flags);
if let Some(w) = width {
self.e.num(40, w);
self.e.num(41, w);
}
for p in &pts {
let vh = self.handle();
self.e.entity("VERTEX", vh, h, &layer, style, "AcDbVertex");
if self.o.version.handles() {
self.e.raw(100, "AcDb2dVertex");
}
self.e.xyz(10, p.x, p.y);
}
let end = self.handle();
self.e.entity("SEQEND", end, h, &layer, style, "");
}
/// A curved subpath as one cubic B-spline whose clamped knots repeat at every joint, so it
/// passes through every anchor (straight segments become straight cubics).
fn spline(&mut self, sub: &Sub, style: &EntityStyle) {
let end = sub.segs.last().map_or(sub.start, |s| s.end());
let closing = (sub.closed && end.distance(sub.start) > 1e-9).then_some(Seg::Line(sub.start));
let mut ctrl = vec![sub.start];
let mut last = sub.start;
for seg in sub.segs.iter().copied().chain(closing) {
match seg {
Seg::Line(p) => ctrl.extend([last.lerp(p, 1.0 / 3.0), last.lerp(p, 2.0 / 3.0), p]),
Seg::Cubic(a, b, p) => ctrl.extend([a, b, p]),
}
last = seg.end();
}
let n = (ctrl.len() - 1) / 3;
let mut knots = vec![0.0; 4];
for i in 1..n {
knots.extend([i as f64; 3]);
}
knots.extend([n as f64; 4]);
let ctrl: Vec<Point> = ctrl.iter().map(|p| self.pt(*p)).collect();
let layer = self.layer.clone();
let h = self.handle();
self.e.entity("SPLINE", h, fixed::MODEL_RECORD, &layer, style, "AcDbSpline");
self.e.normal();
// Planar.
self.e.int(70, 8);
self.e.int(71, 3);
self.e.int(72, knots.len() as i64);
self.e.int(73, ctrl.len() as i64);
self.e.int(74, 0);
for k in knots {
self.e.num(40, k);
}
for p in ctrl {
self.e.xyz(10, p.x, p.y);
}
}
/// Type: glyph outlines (Preserve Appearance, Outline Text, type on a path), else one text
/// entity per run of each line.
fn text(&mut self, n: &Node, t: &TextObject) {
let outline = self.o.preserve == Preserve::Appearance || self.o.outline_text || matches!(t.kind, TextKind::OnPath { .. });
if outline {
if let Some(o) = vectorcraft_effects::outline_text(n) {
self.node(&o, true);
}
return;
}
if !n.appearance.items.is_empty() {
self.warn(TYPE_ITEMS);
}
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let plain = t.plain_text();
let xf = self.to_dxf * t.xf;
let [a, b, c, d, _, _] = xf.as_coeffs();
// Along and across the baseline, in drawing units per text-space unit.
let along = a.hypot(b);
if along <= 1e-12 {
return;
}
let across = (a * d - b * c).abs() / along;
let angle = b.atan2(a).to_degrees();
for line in &layout.lines {
let Some(glyphs) = layout.glyphs.get(line.glyph_start..line.glyph_end) else { continue };
for run in glyphs.chunk_by(|x, y| x.run == y.run) {
let Some(first) = run.first() else { continue };
let lo = run.iter().map(|g| g.byte).min().unwrap_or(first.byte);
let hi = run.iter().map(|g| g.byte + g.len).max().unwrap_or(lo);
let Some(s) = plain.get(lo..hi).map(str::trim_end).filter(|s| !s.trim().is_empty()) else { continue };
let Some(cs) = t.runs.get(first.run).map(|r| &r.style) else { continue };
let paint = if cs.fill.is_none() { &cs.stroke } else { &cs.fill };
let Some(rgb) = self.paint_rgb(paint) else { continue };
let style = self.style(rgb, 1.0, None, None);
let text_style = self.text_style(&cs.font_family);
let height = cs.size * cs.v_scale / 100.0 * CAP_HEIGHT * across;
let width = if cs.v_scale > 0.0 { cs.h_scale / cs.v_scale } else { 1.0 };
let origin = run.iter().map(|g| g.origin).min_by(|p, q| p.x.total_cmp(&q.x)).unwrap_or(first.origin);
let p = self.pt(t.xf * origin);
let layer = self.layer.clone();
let h = self.handle();
self.e.entity("TEXT", h, fixed::MODEL_RECORD, &layer, &style, "AcDbText");
self.e.xyz(10, p.x, p.y);
self.e.num(40, height);
let s: String = s.chars().take(MAX_TEXT).collect();
self.e.str(1, &s);
if angle.abs() > 1e-9 {
self.e.num(50, angle);
}
if (width - 1.0).abs() > 1e-9 {
self.e.num(41, width);
}
self.e.str(7, &text_style);
if self.o.version.handles() {
self.e.raw(100, "AcDbText");
}
}
}
}
/// The text style for font `family` (one per family).
fn text_style(&mut self, family: &str) -> String {
if let Some((name, _)) = self.styles.iter().find(|(_, f)| f == family) {
return name.clone();
}
let long = self.o.version.r2000();
let mut taken: Vec<&str> = self.styles.iter().map(|(n, _)| n.as_str()).collect();
taken.push(crate::writer::STANDARD_STYLE);
let name = unique(&symbol_name(family, long), &taken, if long { 255 } else { 31 });
self.styles.push((name.clone(), family.to_string()));
name
}
/// A placed image, linked to its file (written once per image).
fn image(&mut self, im: &ImageObject) {
if !self.o.version.handles() {
self.warn(NO_IMAGES);
return;
}
let Some(i) = self.image_def(&im.key) else { return };
let Some((dw, dh)) = self.images.get(i).map(|d| (f64::from(d.size.0), f64::from(d.size.1))) else { return };
let (w, h) = (f64::from(im.width.max(1)), f64::from(im.height.max(1)));
let map = self.to_dxf * im.xf;
let lin = |v: kurbo::Vec2| (map * v.to_point()) - (map * Point::ZERO);
// Insertion at the bottom-left corner; u along one file pixel, v one pixel up.
let u = lin(kurbo::Vec2::new(w / dw, 0.0));
let v = lin(kurbo::Vec2::new(0.0, -h / dh));
for corner in [Point::new(0.0, 0.0), Point::new(w, 0.0), Point::new(0.0, h), Point::new(w, h)] {
self.pt(im.xf * corner);
}
let at = map * Point::new(0.0, h);
let handle = self.handle();
let reactor = self.handle();
let Some(def) = self.images.get_mut(i) else { return };
def.reactors.push((reactor, handle));
let def = def.handle;
let layer = self.layer.clone();
let style = EntityStyle { aci: 7, ..EntityStyle::default() };
self.e.entity("IMAGE", handle, fixed::MODEL_RECORD, &layer, &style, "AcDbRasterImage");
self.e.int(90, 0);
self.e.xyz(10, at.x, at.y);
self.e.xyz(11, u.x, u.y);
self.e.xyz(12, v.x, v.y);
self.e.xy(13, dw, dh);
self.e.handle(340, def);
// Shown, also when not aligned with the screen; PNG with its transparency.
self.e.int(70, if self.o.raster == RasterFormat::Png { 11 } else { 3 });
self.e.int(280, 0);
self.e.int(281, 50);
self.e.int(282, 50);
self.e.int(283, 0);
self.e.handle(360, reactor);
self.e.int(71, 1);
self.e.int(91, 2);
self.e.xy(14, -0.5, -0.5);
self.e.xy(14, dw - 0.5, dh - 0.5);
if self.o.version >= crate::DxfVersion::R2010 {
self.e.int(290, 0);
}
}
/// The image file for image `key`, encoded once in the chosen format (`None`: left out).
fn image_def(&mut self, key: &str) -> Option<usize> {
if let Some(i) = self.by_key.get(key) {
return *i;
}
let made = self.encode_image(key);
let i = match made {
Ok((file, size)) => {
let handle = self.handle();
self.images.push(ImageDef { name: file.name.clone(), size, handle, reactors: vec![] });
self.files.push(file);
Some(self.images.len() - 1)
}
Err(e) => {
self.warn(&e);
None
}
};
self.by_key.insert(key.to_string(), i);
i
}
/// Image `key` as a file in the chosen format, and its size in pixels.
fn encode_image(&self, key: &str) -> Result<(DxfImage, (u32, u32)), String> {
let blob = self.doc.images.get(key).filter(|b| !b.bytes.is_empty()).ok_or_else(|| format!("image '{key}' has no pixels and was left out"))?;
let img = image::load_from_memory(&blob.bytes).map_err(|_| format!("image '{key}' could not be decoded and was left out"))?;
let size = (img.width(), img.height());
let mut bytes = vec![];
let written = match self.o.raster {
RasterFormat::Png => img.to_rgba8().write_to(&mut Cursor::new(&mut bytes), image::ImageFormat::Png),
RasterFormat::Jpeg => {
// No alpha in JPEG: transparent pixels over white.
let mut rgba = img.to_rgba8();
for p in rgba.pixels_mut() {
let a = u32::from(p[3]);
for c in 0..3 {
p[c] = ((u32::from(p[c]) * a + 255 * (255 - a)) / 255) as u8;
}
}
let rgb = image::DynamicImage::ImageRgba8(rgba).to_rgb8();
rgb.write_with_encoder(image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, 90))
}
};
written.map_err(|e| format!("image '{key}' could not be written: {e}"))?;
Ok((DxfImage { name: format!("{}.{}", blob.content_key(), self.o.raster.extension()), bytes }, size))
}
}
/// The first visible solid fill colour in `n` (pattern art).
fn first_solid(n: &Node) -> Option<vectorcraft_color::Color> {
let own = n.appearance.items.iter().find_map(|i| match i {
AppearanceItem::Fill(f) if f.visible => f.paint.color(),
_ => None,
});
own.or_else(|| n.children().into_iter().flatten().find_map(|c| first_solid(c)))
}
+371
View File
@@ -0,0 +1,371 @@
//! DXF output read back as group codes: versions, entities, colours, units, layers, type, images.
use std::sync::Arc;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, CharStyle, Dash, Document, ImageBlob, ImageObject, Node, NodeKind, TextObject, Unit};
use vectorcraft_geom::{Affine, PathData, Point, Rect, shapes};
use crate::*;
type Pairs = Vec<(i32, String)>;
/// The `(code, value)` pairs of a DXF file.
fn pairs(dxf: &[u8]) -> Pairs {
let text = std::str::from_utf8(dxf).expect("UTF-8");
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len() % 2, 0, "group codes and values come in pairs");
lines.chunks(2).map(|p| (p[0].trim().parse().expect("a group code"), p[1].to_string())).collect()
}
/// The objects of section `name`: each the pairs from its `0` code on.
fn section(p: &[(i32, String)], name: &str) -> Vec<Pairs> {
let start = p.windows(2).position(|w| w[0] == (0, "SECTION".into()) && w[1] == (2, name.into()));
let Some(start) = start else { return vec![] };
let mut out: Vec<Pairs> = vec![];
for pair in &p[start + 2..] {
if *pair == (0, "ENDSEC".into()) {
break;
}
if pair.0 == 0 {
out.push(vec![]);
}
if let Some(o) = out.last_mut() {
o.push(pair.clone());
}
}
out
}
fn kind(o: &[(i32, String)]) -> &str {
o.first().map_or("", |p| p.1.as_str())
}
fn get(o: &[(i32, String)], code: i32) -> Option<&str> {
o.iter().find(|p| p.0 == code).map(|p| p.1.as_str())
}
fn all(o: &[(i32, String)], code: i32) -> Vec<f64> {
o.iter().filter(|p| p.0 == code).map(|p| p.1.trim().parse().expect("a number")).collect()
}
fn header(p: &[(i32, String)], var: &str) -> Option<String> {
let i = p.iter().position(|x| *x == (9, var.into()))?;
p.get(i + 1).map(|x| x.1.clone())
}
fn entities(out: &DxfOutput) -> Vec<Pairs> {
section(&pairs(&out.bytes), "ENTITIES")
}
fn kinds(out: &DxfOutput) -> Vec<String> {
entities(out).iter().map(|e| kind(e).to_string()).collect()
}
fn table(out: &DxfOutput, kind_: &str) -> Vec<Pairs> {
section(&pairs(&out.bytes), "TABLES").into_iter().filter(|o| kind(o) == kind_).collect()
}
/// A 200 × 100 pt document whose first layer holds what `nodes` makes.
fn doc_with(nodes: impl FnOnce(&mut Document) -> Vec<Node>) -> Document {
let mut d = Document::new(200.0, 100.0);
let nodes = nodes(&mut d);
let layer = d.layers[0].id;
for n in nodes {
d.insert(Some(layer), usize::MAX, n).unwrap();
}
d
}
fn path(d: &mut Document, p: PathData, fill: Paint, stroke: Paint, width: f64) -> Node {
Node::path(d.alloc_id(), p, Appearance::basic(fill, stroke, width))
}
/// The options with the first artboard as the region, in points.
fn opts(d: &Document) -> DxfOptions {
DxfOptions { region: d.artboards[0].rect, unit: Unit::Points, ..DxfOptions::default() }
}
/// A red-filled rectangle with a black 1 pt stroke, and an unfilled blue 2 pt ellipse.
fn shapes_doc() -> Document {
doc_with(|d| {
vec![
path(d, shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 60.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::BLACK), 1.0),
path(d, shapes::ellipse(Rect::new(120.0, 20.0, 180.0, 80.0)), Paint::None, Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 2.0),
]
})
}
#[test]
fn every_version_declares_its_acadver_and_is_well_formed() {
let d = shapes_doc();
for v in DxfVersion::ALL {
let out = export(&d, &DxfOptions { version: v, ..opts(&d) }).unwrap();
let p = pairs(&out.bytes);
assert_eq!(header(&p, "$ACADVER").as_deref(), Some(v.acadver()), "{v:?}");
assert_eq!(p.last(), Some(&(0, "EOF".to_string())), "{v:?}");
let opened = p.iter().filter(|x| **x == (0, "SECTION".into())).count();
assert_eq!(opened, p.iter().filter(|x| **x == (0, "ENDSEC".into())).count(), "{v:?}: balanced sections");
assert!(!entities(&out).is_empty(), "{v:?}");
if v >= DxfVersion::R13 {
// Every handle is unique and below $HANDSEED.
let body = &p[p.iter().position(|x| *x == (0, "ENDSEC".into())).unwrap()..];
let handles: Vec<u32> = body.iter().filter(|x| x.0 == 5 || x.0 == 105).map(|x| u32::from_str_radix(&x.1, 16).unwrap()).collect();
let unique: std::collections::HashSet<_> = handles.iter().collect();
assert_eq!(unique.len(), handles.len(), "{v:?}: unique handles");
let seed = u32::from_str_radix(&header(&p, "$HANDSEED").unwrap(), 16).unwrap();
assert!(handles.iter().all(|h| *h < seed), "{v:?}: handles below the seed");
assert!(section(&p, "OBJECTS").iter().any(|o| kind(o) == "DICTIONARY"), "{v:?}: root dictionary");
} else {
assert!(header(&p, "$HANDSEED").is_none());
}
}
assert_eq!(DxfVersion::from_id("r2013"), Some(DxfVersion::R2013));
assert_eq!(DxfVersion::from_id("AC1009"), Some(DxfVersion::R12));
assert_eq!(DxfVersion::from_id("2019"), None);
}
#[test]
fn lines_are_lwpolylines_curves_splines_and_fills_hatches() {
let d = shapes_doc();
let out = export(&d, &opts(&d)).unwrap();
assert_eq!(kinds(&out), ["HATCH", "LWPOLYLINE", "SPLINE"], "fill, then stroke, then the ellipse's stroke");
let e = entities(&out);
// The rectangle, y up from the artboard's bottom-left corner: 10..110 × 40..90.
let poly = &e[1];
assert_eq!(get(poly, 70), Some("1"), "closed");
let mut xs = all(poly, 10);
xs.sort_by(f64::total_cmp);
assert_eq!(xs, [10.0, 10.0, 110.0, 110.0]);
let mut ys = all(poly, 20);
ys.sort_by(f64::total_cmp);
assert_eq!(ys, [40.0, 40.0, 90.0, 90.0]);
let hatch = &e[0];
assert_eq!((get(hatch, 2), get(hatch, 70), get(hatch, 91), get(hatch, 93)), (Some("SOLID"), Some("1"), Some("1"), Some("4")));
// The ellipse: four cubics through clamped knots.
let spline = &e[2];
assert_eq!((get(spline, 71), get(spline, 73)), (Some("3"), Some("13")));
assert_eq!(all(spline, 40), [0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 2.0, 2.0, 2.0, 3.0, 3.0, 3.0, 4.0, 4.0, 4.0, 4.0]);
// R14 has lightweight polylines but no lineweights: the stroke is a polyline width.
let r14 = export(&d, &DxfOptions { version: DxfVersion::R14, ..opts(&d) }).unwrap();
assert_eq!(get(&entities(&r14)[1], 43), Some("1"));
assert_eq!(get(&entities(&r14)[1], 370), None);
// R13 has splines and hatches, and polylines of vertices.
let r13 = export(&d, &DxfOptions { version: DxfVersion::R13, ..opts(&d) }).unwrap();
assert_eq!(kinds(&r13), ["HATCH", "POLYLINE", "VERTEX", "VERTEX", "VERTEX", "VERTEX", "SEQEND", "SPLINE"]);
// R12: polylines only; the fill is its outline, the ellipse flattened.
let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap();
let k = kinds(&r12);
assert!(k.iter().all(|k| matches!(k.as_str(), "POLYLINE" | "VERTEX" | "SEQEND")), "{k:?}");
assert_eq!(k.iter().filter(|k| *k == "POLYLINE").count(), 3);
assert!(k.iter().filter(|k| *k == "VERTEX").count() > 20, "the ellipse is flattened");
assert!(r12.warnings.iter().any(|w| w.contains("R12 has no fills")), "{:?}", r12.warnings);
}
#[test]
fn colours_map_to_the_index_palette_or_true_colour() {
let d = shapes_doc();
let style = |o: &DxfOutput, i: usize| {
let e = &entities(o)[i];
(get(e, 62).map(str::to_string), get(e, 420).map(str::to_string))
};
let out = export(&d, &opts(&d)).unwrap();
assert_eq!(style(&out, 0), (Some("1".into()), Some("16711680".into())), "red: index 1, true colour 0xFF0000");
assert_eq!(style(&out, 1), (Some("7".into()), Some("0".into())), "black is index 7");
assert_eq!(style(&out, 2), (Some("5".into()), Some("255".into())));
for depth in [ColorDepth::Aci8, ColorDepth::Aci16, ColorDepth::Aci256] {
let o = export(&d, &DxfOptions { colors: depth, ..opts(&d) }).unwrap();
assert_eq!(style(&o, 0), (Some("1".into()), None), "{depth:?}");
}
let orange = doc_with(|d| {
vec![path(d, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Paint::solid(Color::from_hex("#ff8000").unwrap()), Paint::None, 0.0)]
});
let aci = |depth| get(&entities(&export(&orange, &DxfOptions { colors: depth, ..opts(&orange) }).unwrap())[0], 62).unwrap().to_string();
assert_eq!(
(aci(ColorDepth::Aci256), aci(ColorDepth::Aci16), aci(ColorDepth::Aci8)),
("30".into(), "30".into(), "2".into()),
"8 colours: yellow is nearest"
);
// True colour needs 2004: earlier versions fall back to indices, and say so.
let old = export(&d, &DxfOptions { version: DxfVersion::R2000, ..opts(&d) }).unwrap();
assert_eq!(style(&old, 0), (Some("1".into()), None));
assert!(old.warnings.iter().any(|w| w.contains("true colour")));
}
#[test]
fn units_scale_coordinates_and_set_insunits() {
let d = shapes_doc();
let insunits = |o: &DxfOutput| header(&pairs(&o.bytes), "$INSUNITS");
let max_x = |o: &DxfOutput| all(&entities(o)[1], 10).into_iter().fold(f64::MIN, f64::max);
let mm = export(&d, &DxfOptions { unit: Unit::Millimeters, ..opts(&d) }).unwrap();
assert_eq!(insunits(&mm).as_deref(), Some("4"));
assert!((max_x(&mm) - 110.0 * 25.4 / 72.0).abs() < 1e-5);
assert_eq!(header(&pairs(&mm.bytes), "$MEASUREMENT").as_deref(), Some("1"));
let inches = export(&d, &DxfOptions { unit: Unit::Inches, ..opts(&d) }).unwrap();
assert_eq!(insunits(&inches).as_deref(), Some("1"));
// 1 in = 10 units: unitless, coordinates ten times larger.
let scaled = export(&d, &DxfOptions { unit: Unit::Inches, scale: 10.0, ..opts(&d) }).unwrap();
assert_eq!(insunits(&scaled).as_deref(), Some("0"));
assert!((max_x(&scaled) - 110.0 / 72.0 * 10.0).abs() < 1e-5);
// Lineweights: 1 pt is 0.35 mm, scaled with the drawing only when asked.
assert_eq!(get(&entities(&scaled)[1], 370), Some("35"));
let heavy = export(&d, &DxfOptions { unit: Unit::Inches, scale: 2.0, scale_lineweights: true, ..opts(&d) }).unwrap();
assert_eq!(get(&entities(&heavy)[1], 370), Some("70"));
assert!(export(&d, &DxfOptions { scale: 0.0, ..opts(&d) }).is_err());
assert!(export(&d, &DxfOptions { scale: f64::NAN, ..opts(&d) }).is_err());
}
#[test]
fn layers_become_dxf_layers() {
let mut d = shapes_doc();
let second = d.add_layer(Some("Dims: <top>"));
let hidden = d.add_layer(Some("Notes"));
let template = d.add_layer(Some("Tracing"));
for l in [second, hidden, template] {
let n = path(&mut d, shapes::rectangle(Rect::new(0.0, 0.0, 5.0, 5.0)), Paint::solid(Color::BLACK), Paint::None, 0.0);
d.insert(Some(l), 0, n).unwrap();
}
if let Some(n) = d.node_mut(hidden) {
n.visible = false;
}
if let Some(NodeKind::Layer { template, .. }) = d.node_mut(template).map(|n| &mut n.kind) {
*template = true;
}
let out = export(&d, &opts(&d)).unwrap();
let layers: Vec<(String, String)> =
table(&out, "LAYER").iter().map(|o| (get(o, 2).unwrap().to_string(), get(o, 62).unwrap().to_string())).collect();
let names: Vec<&str> = layers.iter().map(|l| l.0.as_str()).collect();
assert_eq!(names, ["0", "Layer 1", "Dims_ _top_", "Notes"], "the template layer is left out");
assert!(layers[3].1.starts_with('-'), "a hidden layer is switched off");
let on: Vec<String> = entities(&out).iter().map(|e| get(e, 8).unwrap().to_string()).collect();
assert_eq!(on, ["Layer 1", "Layer 1", "Layer 1", "Dims_ _top_", "Notes"]);
// Before 2000: capitals, letters, digits and $-_ only.
let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap();
let names: Vec<String> = table(&r12, "LAYER").iter().map(|o| get(o, 2).unwrap().to_string()).collect();
assert_eq!(names, ["0", "LAYER_1", "DIMS___TOP_", "NOTES"]);
}
#[test]
fn dashes_become_linetypes_and_opacity_transparency() {
let d = doc_with(|d| {
let mut n = path(d, shapes::line(Point::new(0.0, 50.0), Point::new(100.0, 50.0)), Paint::None, Paint::solid(Color::BLACK), 1.0);
if let Some(st) = n.appearance.stroke_mut() {
st.dash = Some(Dash { pattern: vec![6.0, 3.0], offset: 0.0, align_corners: false });
}
n.opacity = 0.5;
vec![n]
});
let out = export(&d, &opts(&d)).unwrap();
let e = &entities(&out)[0];
let alpha = (0x0200_0000 | 128).to_string();
assert_eq!((get(e, 6), get(e, 440)), (Some("DASHED1"), Some(alpha.as_str())));
let lt = table(&out, "LTYPE").into_iter().find(|o| get(o, 2) == Some("DASHED1")).unwrap();
assert_eq!(all(&lt, 49), [6.0, -3.0]);
let old = export(&d, &DxfOptions { version: DxfVersion::R2000, ..opts(&d) }).unwrap();
assert!(get(&entities(&old)[0], 440).is_none());
assert!(old.warnings.iter().any(|w| w.contains("transparency")));
}
#[test]
fn alter_paths_writes_strokes_as_filled_outlines() {
let d = shapes_doc();
let out = export(&d, &DxfOptions { alter_paths: true, ..opts(&d) }).unwrap();
assert_eq!(kinds(&out), ["HATCH", "HATCH", "HATCH"]);
// The rectangle's stroke: an outer and an inner boundary.
assert_eq!(get(&entities(&out)[1], 91), Some("2"));
// Preserve Appearance outlines only the strokes a line can't draw.
let mut d = shapes_doc();
let rect = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap();
if let Some(st) = d.node_mut(rect).and_then(|n| n.appearance.stroke_mut()) {
st.align = vectorcraft_doc::StrokeAlign::Outside;
}
assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), ["HATCH", "HATCH", "SPLINE"]);
let editable = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap();
assert_eq!(kinds(&editable), ["HATCH", "LWPOLYLINE", "SPLINE"]);
assert!(editable.warnings.iter().any(|w| w.contains("stroke alignment")));
}
fn text_doc(text: &str, size: f64) -> Document {
doc_with(|d| {
let style = CharStyle { size, fill: Paint::solid(Color::rgb(0.0, 0.5, 0.0)), ..CharStyle::default() };
let t = TextObject::point(Point::new(10.0, 50.0), text, style);
vec![Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)))]
})
}
#[test]
fn type_is_text_or_outlines() {
let d = text_doc("Plan A", 20.0);
let editable = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap();
let e = entities(&editable);
assert_eq!(e.len(), 1);
assert_eq!((kind(&e[0]), get(&e[0], 1)), ("TEXT", Some("Plan A")));
assert!((all(&e[0], 40)[0] - 14.0).abs() < 1e-6, "the cap height of 20 pt type");
assert_eq!((all(&e[0], 10)[0], all(&e[0], 20)[0]), (10.0, 50.0), "the baseline start, y up");
let family = CharStyle::default().font_family;
let style = table(&editable, "STYLE").into_iter().find(|o| get(o, 3) == Some(&*format!("{family}.ttf"))).unwrap();
assert_eq!(get(&e[0], 7), get(&style, 2));
// Outline Text, and Preserve Appearance: the glyphs as filled outlines.
for o in [DxfOptions { preserve: Preserve::Editability, outline_text: true, ..opts(&d) }, opts(&d)] {
let k = kinds(&export(&d, &o).unwrap());
assert!(!k.is_empty() && k.iter().all(|k| k == "HATCH"), "{k:?}");
}
}
#[test]
fn text_outside_ascii_is_escaped_before_2007() {
let d = text_doc("Größe", 12.0);
let text = |v| {
let out = export(&d, &DxfOptions { version: v, preserve: Preserve::Editability, ..opts(&d) }).unwrap();
get(&entities(&out)[0], 1).unwrap().to_string()
};
assert_eq!(text(DxfVersion::R2004), "Gr\\U+00F6\\U+00DFe");
assert_eq!(text(DxfVersion::R2018), "Größe");
}
#[test]
fn images_link_png_or_jpeg_files() {
let mut png = vec![];
image::RgbaImage::from_pixel(4, 2, image::Rgba([255, 0, 0, 128])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let mut d = doc_with(|d| {
let im = ImageObject {
key: "raster-1".into(),
width: 4,
height: 2,
xf: Affine::translate((10.0, 20.0)) * Affine::scale(5.0),
link: None,
placement: Default::default(),
};
vec![Node::new(d.alloc_id(), NodeKind::Image(im))]
});
d.images.insert("raster-1".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(png), proxy: None });
let out = export(&d, &opts(&d)).unwrap();
let e = entities(&out);
assert_eq!(kind(&e[0]), "IMAGE");
// The bottom-left corner (10, 30) is (10, 70) y up; a pixel is 5 units.
assert_eq!((all(&e[0], 10)[0], all(&e[0], 20)[0]), (10.0, 70.0));
assert_eq!((all(&e[0], 11)[0], all(&e[0], 22)[0], all(&e[0], 13)[0], all(&e[0], 23)[0]), (5.0, 5.0, 4.0, 2.0));
assert_eq!(out.images.len(), 1);
assert!(out.images[0].name.ends_with(".png") && out.images[0].bytes.starts_with(b"\x89PNG"));
let p = pairs(&out.bytes);
let objects = section(&p, "OBJECTS");
let def = objects.iter().find(|o| kind(o) == "IMAGEDEF").unwrap();
assert_eq!(get(def, 1), Some(out.images[0].name.as_str()));
assert_eq!(get(&e[0], 340), get(def, 5), "the image points at its definition");
assert!(objects.iter().any(|o| kind(o) == "IMAGEDEF_REACTOR"));
assert!(section(&p, "CLASSES").iter().any(|o| get(o, 1) == Some("IMAGE")));
let jpeg = export(&d, &DxfOptions { raster: RasterFormat::Jpeg, ..opts(&d) }).unwrap();
assert!(jpeg.images[0].name.ends_with(".jpg") && jpeg.images[0].bytes.starts_with(&[0xFF, 0xD8]));
let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap();
assert!(entities(&r12).is_empty() && r12.images.is_empty());
assert!(r12.warnings.iter().any(|w| w.contains("no images")));
}
#[test]
fn crop_leaves_out_art_outside_the_region() {
let d = shapes_doc();
// Only the ellipse (120..180) reaches into 115..200.
let o = DxfOptions { region: Rect::new(115.0, 0.0, 200.0, 100.0), crop: true, ..opts(&d) };
assert_eq!(kinds(&export(&d, &o).unwrap()), ["SPLINE"]);
assert_eq!(kinds(&export(&d, &DxfOptions { crop: false, ..o }).unwrap()).len(), 3);
}
+661
View File
@@ -0,0 +1,661 @@
//! The ASCII DXF file: group codes, handles, and the sections, tables and objects around the
//! entities. DXF R13 and later give every object a handle and subclass markers; R2000 and later
//! add layouts and plot styles, which CAD apps expect to find.
use std::fmt::Write as _;
use crate::DxfVersion;
/// An object handle (written in hex).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct Handle(pub u32);
/// The handles of the fixed objects every drawing has. Entities and table entries count up from
/// [`FIRST_FREE`].
pub(crate) mod fixed {
use super::Handle;
pub const LAYER_TABLE: Handle = Handle(0x1);
pub const LTYPE_TABLE: Handle = Handle(0x2);
pub const APPID_TABLE: Handle = Handle(0x3);
pub const DIMSTYLE_TABLE: Handle = Handle(0x4);
pub const STYLE_TABLE: Handle = Handle(0x5);
pub const UCS_TABLE: Handle = Handle(0x6);
pub const VIEW_TABLE: Handle = Handle(0x7);
pub const VPORT_TABLE: Handle = Handle(0x8);
pub const BLOCK_RECORD_TABLE: Handle = Handle(0x9);
pub const ROOT: Handle = Handle(0xA);
pub const GROUPS: Handle = Handle(0xB);
pub const LAYOUTS: Handle = Handle(0xC);
pub const MODEL_RECORD: Handle = Handle(0xD);
pub const PAPER_RECORD: Handle = Handle(0xE);
pub const MODEL_BLOCK: Handle = Handle(0xF);
pub const MODEL_END: Handle = Handle(0x10);
pub const PAPER_BLOCK: Handle = Handle(0x11);
pub const PAPER_END: Handle = Handle(0x12);
pub const MODEL_LAYOUT: Handle = Handle(0x13);
pub const PAPER_LAYOUT: Handle = Handle(0x14);
pub const ACTIVE_VPORT: Handle = Handle(0x15);
pub const ACAD_APPID: Handle = Handle(0x16);
pub const STANDARD_DIMSTYLE: Handle = Handle(0x17);
pub const IMAGE_DICT: Handle = Handle(0x18);
pub const IMAGE_VARS: Handle = Handle(0x19);
pub const PLOT_STYLES: Handle = Handle(0x1A);
pub const PLOT_NORMAL: Handle = Handle(0x1B);
pub const FIRST_FREE: u32 = 0x30;
}
/// A number as DXF writes it: up to 6 decimals, no trailing zeros, 0 for anything not finite.
pub(crate) fn num(v: f64) -> String {
if !v.is_finite() {
return "0".into();
}
let s = format!("{v:.6}");
let s = s.trim_end_matches('0').trim_end_matches('.');
if s == "-0" || s.is_empty() { "0".into() } else { s.into() }
}
/// Text as a DXF string value: one line, `^` escaped, and before DXF 2007 (which reads UTF-8)
/// every character outside ASCII as `\U+XXXX`.
pub(crate) fn text(s: &str, utf8: bool) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\r' | '\n' | '\t' => out.push(' '),
c if c.is_control() => {}
'^' => out.push_str("^ "),
c if !utf8 && !c.is_ascii() => match u16::try_from(u32::from(c)) {
Ok(u) => _ = write!(out, "\\U+{u:04X}"),
Err(_) => out.push('?'),
},
c => out.push(c),
}
}
out
}
/// Group-code/value pairs in the order they are written.
pub(crate) struct Codes {
pub v: DxfVersion,
out: String,
}
impl Codes {
pub fn new(v: DxfVersion) -> Self {
Self { v, out: String::new() }
}
pub fn into_string(self) -> String {
self.out
}
pub fn append(&mut self, other: &str) {
self.out.push_str(other);
}
/// A value written as it is (names and keywords the writer itself chose).
pub fn raw(&mut self, code: u16, value: &str) {
_ = writeln!(self.out, "{code:>3}\n{value}");
}
/// Text from the document, encoded for the version (see [`text`]).
pub fn str(&mut self, code: u16, value: &str) {
let t = text(value, self.v.utf8());
self.raw(code, &t);
}
pub fn int(&mut self, code: u16, value: i64) {
_ = writeln!(self.out, "{code:>3}\n{value}");
}
pub fn num(&mut self, code: u16, value: f64) {
_ = writeln!(self.out, "{code:>3}\n{}", num(value));
}
pub fn handle(&mut self, code: u16, h: Handle) {
_ = writeln!(self.out, "{code:>3}\n{:X}", h.0);
}
/// A 2D point at `code` (x), `code + 10` (y).
pub fn xy(&mut self, code: u16, x: f64, y: f64) {
self.num(code, x);
self.num(code + 10, y);
}
/// A 3D point at `code`, `code + 10`, `code + 20` (z = 0).
pub fn xyz(&mut self, code: u16, x: f64, y: f64) {
self.xy(code, x, y);
self.num(code + 20, 0.0);
}
/// The extrusion direction of planar entities: the z axis.
pub fn normal(&mut self) {
self.num(210, 0.0);
self.num(220, 0.0);
self.num(230, 1.0);
}
/// The start of an object: its type, and from R13 its handle, owner and first subclass.
fn object(&mut self, kind: &str, h: Handle, owner: Handle, subclass: &str) {
self.raw(0, kind);
if self.v.handles() {
self.handle(if kind == "DIMSTYLE" { 105 } else { 5 }, h);
self.handle(330, owner);
self.raw(100, subclass);
}
}
/// A table entry: the shared record subclass, then the table's own (from R13).
fn record(&mut self, kind: &str, h: Handle, table: Handle, subclass: &str) {
self.object(kind, h, table, "AcDbSymbolTableRecord");
if self.v.handles() {
self.raw(100, subclass);
}
}
fn section(&mut self, name: &str) {
self.raw(0, "SECTION");
self.raw(2, name);
}
fn end_section(&mut self) {
self.raw(0, "ENDSEC");
}
fn table(&mut self, name: &str, h: Handle, count: usize) {
self.raw(0, "TABLE");
self.raw(2, name);
if self.v.handles() {
self.handle(5, h);
self.raw(330, "0");
self.raw(100, "AcDbSymbolTable");
}
self.int(70, count as i64);
if name == "DIMSTYLE" && self.v.r2000() {
self.raw(100, "AcDbDimStyleTable");
}
}
fn end_table(&mut self) {
self.raw(0, "ENDTAB");
}
}
/// How an entity is drawn: its colour index, true colour, lineweight, linetype and transparency.
#[derive(Clone, Debug, Default)]
pub(crate) struct EntityStyle {
pub aci: u8,
/// `0xRRGGBB` (DXF 2004 and later).
pub rgb: Option<u32>,
/// Hundredths of a millimetre (DXF 2000 and later).
pub lineweight: Option<i32>,
pub linetype: Option<String>,
/// Opacity 0–255 (DXF 2004 and later; opaque is left out).
pub alpha: Option<u8>,
}
impl Codes {
/// The start of an entity of `kind` owned by `owner` (model space, or a polyline for its
/// vertices) on `layer` with its style, ending with `subclass` (none when empty).
pub fn entity(&mut self, kind: &str, h: Handle, owner: Handle, layer: &str, style: &EntityStyle, subclass: &str) {
self.object(kind, h, owner, "AcDbEntity");
self.str(8, layer);
if let Some(lt) = &style.linetype {
self.str(6, lt);
}
self.int(62, i64::from(style.aci.max(1)));
if self.v.r2000()
&& let Some(lw) = style.lineweight
{
self.int(370, i64::from(lw));
}
if self.v.true_color() {
if let Some(rgb) = style.rgb {
self.int(420, i64::from(rgb));
}
if let Some(a) = style.alpha {
self.int(440, 0x0200_0000 | i64::from(a));
}
}
if self.v.handles() && !subclass.is_empty() {
self.raw(100, subclass);
}
}
}
/// A DXF layer.
#[derive(Clone, Debug)]
pub(crate) struct Layer {
pub name: String,
pub aci: u8,
/// Switched off (a hidden layer).
pub off: bool,
pub locked: bool,
/// Plotted (printable).
pub plot: bool,
}
/// A dash pattern: dash, gap, dash… lengths in drawing units.
#[derive(Clone, Debug)]
pub(crate) struct LineType {
pub name: String,
pub pattern: Vec<f64>,
}
/// A placed image file and the image entities that show it.
#[derive(Clone, Debug)]
pub(crate) struct ImageDef {
pub name: String,
pub size: (u32, u32),
pub handle: Handle,
/// `(reactor, image entity)` per image entity.
pub reactors: Vec<(Handle, Handle)>,
}
/// Everything around the entities, collected while they were written.
pub(crate) struct Drawing {
pub version: DxfVersion,
pub layers: Vec<Layer>,
pub linetypes: Vec<LineType>,
/// Text styles: (style name, font family).
pub styles: Vec<(String, String)>,
pub images: Vec<ImageDef>,
/// The entities' group codes.
pub entities: String,
/// `[x0, y0, x1, y1]` of everything written (drawing units).
pub extents: [f64; 4],
/// The export region (drawing units).
pub limits: [f64; 4],
/// `$INSUNITS`.
pub insunits: u8,
pub metric: bool,
/// The next free handle.
pub next: u32,
}
/// The text style every drawing has.
pub(crate) const STANDARD_STYLE: &str = "Standard";
impl Drawing {
/// The whole file.
pub fn write(self) -> String {
let v = self.version;
let mut c = Codes::new(v);
// Table entries get the handles after the entities'.
let mut next = self.next;
let mut take = |n: usize| {
let start = next;
next = next.saturating_add(u32::try_from(n).unwrap_or(u32::MAX));
start
};
let ltype_base = take(3 + self.linetypes.len());
let layer_base = take(1 + self.layers.len());
let style_base = take(1 + self.styles.len());
self.header(&mut c, next);
if v.handles() {
self.classes(&mut c);
}
c.section("TABLES");
self.vport(&mut c);
self.ltypes(&mut c, ltype_base);
self.layer_table(&mut c, layer_base);
self.style_table(&mut c, style_base);
if v.handles() {
for (name, h) in [("VIEW", fixed::VIEW_TABLE), ("UCS", fixed::UCS_TABLE)] {
c.table(name, h, 0);
c.end_table();
}
c.table("APPID", fixed::APPID_TABLE, 1);
c.record("APPID", fixed::ACAD_APPID, fixed::APPID_TABLE, "AcDbRegAppTableRecord");
c.raw(2, "ACAD");
c.int(70, 0);
c.end_table();
c.table("DIMSTYLE", fixed::DIMSTYLE_TABLE, 1);
c.record("DIMSTYLE", fixed::STANDARD_DIMSTYLE, fixed::DIMSTYLE_TABLE, "AcDbDimStyleTableRecord");
c.raw(2, "Standard");
c.int(70, 0);
c.end_table();
c.table("BLOCK_RECORD", fixed::BLOCK_RECORD_TABLE, 2);
for (name, h, layout) in
[("*Model_Space", fixed::MODEL_RECORD, fixed::MODEL_LAYOUT), ("*Paper_Space", fixed::PAPER_RECORD, fixed::PAPER_LAYOUT)]
{
c.record("BLOCK_RECORD", h, fixed::BLOCK_RECORD_TABLE, "AcDbBlockTableRecord");
c.raw(2, name);
if v.r2000() {
c.handle(340, layout);
c.int(70, 0);
c.int(280, 1);
c.int(281, 0);
}
}
c.end_table();
}
c.end_section();
if v.handles() {
c.section("BLOCKS");
for (name, record, begin, end) in [
("*Model_Space", fixed::MODEL_RECORD, fixed::MODEL_BLOCK, fixed::MODEL_END),
("*Paper_Space", fixed::PAPER_RECORD, fixed::PAPER_BLOCK, fixed::PAPER_END),
] {
c.object("BLOCK", begin, record, "AcDbEntity");
c.raw(8, "0");
c.raw(100, "AcDbBlockBegin");
c.raw(2, name);
c.int(70, 0);
c.xyz(10, 0.0, 0.0);
c.raw(3, name);
c.raw(1, "");
c.object("ENDBLK", end, record, "AcDbEntity");
c.raw(8, "0");
c.raw(100, "AcDbBlockEnd");
}
c.end_section();
}
c.section("ENTITIES");
c.append(&self.entities);
c.end_section();
if v.handles() {
self.objects(&mut c);
}
c.raw(0, "EOF");
c.into_string()
}
fn header(&self, c: &mut Codes, handseed: u32) {
let v = self.version;
let var = |c: &mut Codes, name: &str| c.raw(9, name);
c.section("HEADER");
var(c, "$ACADVER");
c.raw(1, v.acadver());
var(c, "$DWGCODEPAGE");
c.raw(3, "ANSI_1252");
var(c, "$INSBASE");
c.xyz(10, 0.0, 0.0);
let [x0, y0, x1, y1] = self.extents;
var(c, "$EXTMIN");
c.xyz(10, x0, y0);
var(c, "$EXTMAX");
c.xyz(10, x1, y1);
let [lx0, ly0, lx1, ly1] = self.limits;
var(c, "$LIMMIN");
c.xy(10, lx0, ly0);
var(c, "$LIMMAX");
c.xy(10, lx1, ly1);
if v.r2000() {
var(c, "$LWDISPLAY");
c.int(290, 1);
var(c, "$INSUNITS");
c.int(70, i64::from(self.insunits));
var(c, "$MEASUREMENT");
c.int(70, i64::from(self.metric));
}
if v.handles() {
var(c, "$HANDSEED");
c.handle(5, Handle(handseed));
}
c.end_section();
}
fn classes(&self, c: &mut Codes) {
let mut classes: Vec<(&str, &str, &str, i64, bool)> = vec![];
if self.version.r2000() {
classes.extend([
("ACDBDICTIONARYWDFLT", "AcDbDictionaryWithDefault", "ObjectDBX Classes", 0, false),
("ACDBPLACEHOLDER", "AcDbPlaceHolder", "ObjectDBX Classes", 0, false),
("LAYOUT", "AcDbLayout", "ObjectDBX Classes", 0, false),
]);
}
if !self.images.is_empty() {
classes.extend([
("RASTERVARIABLES", "AcDbRasterVariables", "ISM", 0, false),
("IMAGE", "AcDbRasterImage", "ISM", 2175, true),
("IMAGEDEF", "AcDbRasterImageDef", "ISM", 0, false),
("IMAGEDEF_REACTOR", "AcDbRasterImageDefReactor", "ISM", 1, false),
]);
}
c.section("CLASSES");
for (name, cpp, app, flags, entity) in classes {
c.raw(0, "CLASS");
c.raw(1, name);
c.raw(2, cpp);
c.raw(3, app);
c.int(90, flags);
if self.version.true_color() {
c.int(91, 0);
}
c.int(280, 0);
c.int(281, i64::from(entity));
}
c.end_section();
}
/// The active viewport, zoomed to the drawing's extents.
fn vport(&self, c: &mut Codes) {
let [x0, y0, x1, y1] = self.extents;
c.table("VPORT", fixed::VPORT_TABLE, 1);
c.record("VPORT", fixed::ACTIVE_VPORT, fixed::VPORT_TABLE, "AcDbViewportTableRecord");
c.raw(2, if self.version.handles() { "*Active" } else { "*ACTIVE" });
c.int(70, 0);
c.xy(10, 0.0, 0.0);
c.xy(11, 1.0, 1.0);
c.xy(12, (x0 + x1) / 2.0, (y0 + y1) / 2.0);
c.xy(13, 0.0, 0.0);
c.xy(14, 1.0, 1.0);
c.xy(15, 10.0, 10.0);
c.xyz(16, 0.0, 0.0);
c.num(36, 1.0);
c.xyz(17, 0.0, 0.0);
let (w, h) = ((x1 - x0).abs(), (y1 - y0).abs());
// The view's height, with a margin; its width follows from the aspect ratio.
c.num(40, (h.max(w / 1.5) * 1.1).max(1.0));
c.num(41, 1.5);
c.num(42, 50.0);
c.num(43, 0.0);
c.num(44, 0.0);
c.num(50, 0.0);
c.num(51, 0.0);
for (code, value) in [(71, 0), (72, 1000), (73, 1), (74, 3), (75, 0), (76, 0), (77, 0), (78, 0)] {
c.int(code, value);
}
if self.version.r2000() {
c.int(281, 0);
c.int(65, 0);
c.num(146, 0.0);
}
c.end_table();
}
fn ltypes(&self, c: &mut Codes, base: u32) {
c.table("LTYPE", fixed::LTYPE_TABLE, 3 + self.linetypes.len());
let solid = [("ByBlock", ""), ("ByLayer", ""), ("Continuous", "Solid line")];
let rows = solid.iter().map(|(n, d)| (*n, *d, &[][..])).chain(self.linetypes.iter().map(|l| (l.name.as_str(), "", l.pattern.as_slice())));
for (i, (name, desc, pattern)) in rows.enumerate() {
c.record("LTYPE", Handle(base.saturating_add(i as u32)), fixed::LTYPE_TABLE, "AcDbLinetypeTableRecord");
c.str(2, name);
c.int(70, 0);
c.str(3, if pattern.is_empty() { desc } else { "Dashed" });
c.int(72, 65);
c.int(73, pattern.len() as i64);
c.num(40, pattern.iter().map(|v| v.abs()).sum());
for v in pattern {
c.num(49, *v);
if self.version.handles() {
c.int(74, 0);
}
}
}
c.end_table();
}
fn layer_table(&self, c: &mut Codes, base: u32) {
let zero = Layer { name: "0".into(), aci: 7, off: false, locked: false, plot: true };
c.table("LAYER", fixed::LAYER_TABLE, 1 + self.layers.len());
for (i, l) in std::iter::once(&zero).chain(&self.layers).enumerate() {
c.record("LAYER", Handle(base.saturating_add(i as u32)), fixed::LAYER_TABLE, "AcDbLayerTableRecord");
c.str(2, &l.name);
c.int(70, if l.locked { 4 } else { 0 });
// A negative colour switches the layer off.
let aci = i64::from(l.aci.max(1));
c.int(62, if l.off { -aci } else { aci });
c.raw(6, "Continuous");
if self.version.r2000() {
c.int(290, i64::from(l.plot));
c.int(370, -3);
c.handle(390, fixed::PLOT_NORMAL);
}
}
c.end_table();
}
fn style_table(&self, c: &mut Codes, base: u32) {
c.table("STYLE", fixed::STYLE_TABLE, 1 + self.styles.len());
let standard = (STANDARD_STYLE.to_string(), String::new());
for (i, (name, family)) in std::iter::once(&standard).chain(&self.styles).enumerate() {
c.record("STYLE", Handle(base.saturating_add(i as u32)), fixed::STYLE_TABLE, "AcDbTextStyleTableRecord");
c.str(2, name);
c.int(70, 0);
c.num(40, 0.0);
c.num(41, 1.0);
c.num(50, 0.0);
c.int(71, 0);
c.num(42, 2.5);
// The font file; from 2000 the family name too, which is how CAD apps find a TrueType font.
if family.is_empty() {
c.raw(3, "txt");
} else {
c.str(3, &format!("{family}.ttf"));
}
c.raw(4, "");
if self.version.r2000() && !family.is_empty() {
c.raw(1001, "ACAD");
c.str(1000, family);
c.int(1071, 0);
}
}
c.end_table();
}
fn objects(&self, c: &mut Codes) {
let v = self.version;
let images = !self.images.is_empty();
c.section("OBJECTS");
let dict = |c: &mut Codes, h: Handle, owner: Handle, entries: &[(&str, Handle)]| {
c.object("DICTIONARY", h, owner, "AcDbDictionary");
c.int(281, 1);
for (name, e) in entries {
c.str(3, name);
c.handle(350, *e);
}
};
let mut root = vec![("ACAD_GROUP", fixed::GROUPS)];
if images {
root.extend([("ACAD_IMAGE_DICT", fixed::IMAGE_DICT), ("ACAD_IMAGE_VARS", fixed::IMAGE_VARS)]);
}
if v.r2000() {
root.extend([("ACAD_LAYOUT", fixed::LAYOUTS), ("ACAD_PLOTSTYLENAME", fixed::PLOT_STYLES)]);
}
dict(c, fixed::ROOT, Handle(0), &root);
dict(c, fixed::GROUPS, fixed::ROOT, &[]);
if v.r2000() {
dict(c, fixed::LAYOUTS, fixed::ROOT, &[("Layout1", fixed::PAPER_LAYOUT), ("Model", fixed::MODEL_LAYOUT)]);
c.object("ACDBDICTIONARYWDFLT", fixed::PLOT_STYLES, fixed::ROOT, "AcDbDictionary");
c.int(281, 1);
c.raw(3, "Normal");
c.handle(350, fixed::PLOT_NORMAL);
c.raw(100, "AcDbDictionaryWithDefault");
c.handle(340, fixed::PLOT_NORMAL);
c.object("ACDBPLACEHOLDER", fixed::PLOT_NORMAL, fixed::PLOT_STYLES, "AcDbPlaceHolder");
self.layout(c, fixed::MODEL_LAYOUT, "Model", 0, fixed::MODEL_RECORD);
self.layout(c, fixed::PAPER_LAYOUT, "Layout1", 1, fixed::PAPER_RECORD);
}
if images {
c.object("RASTERVARIABLES", fixed::IMAGE_VARS, fixed::ROOT, "AcDbRasterVariables");
c.int(90, 0);
c.int(70, 0);
c.int(71, 1);
c.int(72, 0);
let entries: Vec<(&str, Handle)> = self.images.iter().map(|i| (i.name.as_str(), i.handle)).collect();
dict(c, fixed::IMAGE_DICT, fixed::ROOT, &entries);
for img in &self.images {
c.raw(0, "IMAGEDEF");
c.handle(5, img.handle);
c.raw(102, "{ACAD_REACTORS");
c.handle(330, fixed::IMAGE_DICT);
for (reactor, _) in &img.reactors {
c.handle(330, *reactor);
}
c.raw(102, "}");
c.handle(330, fixed::IMAGE_DICT);
c.raw(100, "AcDbRasterImageDef");
c.int(90, 0);
c.str(1, &img.name);
c.xy(10, f64::from(img.size.0), f64::from(img.size.1));
c.xy(11, 1.0, 1.0);
c.int(280, 1);
c.int(281, 0);
for (reactor, image) in &img.reactors {
c.object("IMAGEDEF_REACTOR", *reactor, *image, "AcDbRasterImageDefReactor");
c.int(90, 2);
c.handle(330, *image);
}
}
}
c.end_section();
}
/// A layout (DXF 2000 and later): its plot settings, then the layout itself.
fn layout(&self, c: &mut Codes, h: Handle, name: &str, tab: i64, record: Handle) {
let [x0, y0, x1, y1] = self.extents;
c.object("LAYOUT", h, fixed::LAYOUTS, "AcDbPlotSettings");
c.raw(1, "");
c.raw(2, "none_device");
c.raw(4, "");
c.raw(6, "");
for code in 40..=49 {
c.num(code, 0.0);
}
c.num(140, 0.0);
c.num(141, 0.0);
c.num(142, 1.0);
c.num(143, 1.0);
c.int(70, 688);
c.int(72, 1);
c.int(73, 0);
c.int(74, 5);
c.raw(7, "");
c.int(75, 16);
c.num(147, 1.0);
c.xy(148, 0.0, 0.0);
c.raw(100, "AcDbLayout");
c.raw(1, name);
c.int(70, 1);
c.int(71, tab);
let [lx0, ly0, lx1, ly1] = self.limits;
c.xy(10, lx0, ly0);
c.xy(11, lx1, ly1);
c.xyz(12, 0.0, 0.0);
c.xyz(14, x0, y0);
c.xyz(15, x1, y1);
c.num(146, 0.0);
c.xyz(13, 0.0, 0.0);
c.xyz(16, 1.0, 0.0);
c.xyz(17, 0.0, 1.0);
c.int(76, 1);
c.handle(330, record);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn numbers_and_text_are_written_plainly() {
assert_eq!(num(1.5), "1.5");
assert_eq!(num(-0.0000001), "0");
assert_eq!(num(2.0), "2");
assert_eq!(num(f64::NAN), "0");
assert_eq!(text("a^b\nc", true), "a^ b c");
assert_eq!(text("Größe 😀", false), "Gr\\U+00F6\\U+00DFe ?");
assert_eq!(text("Größe", true), "Größe");
}
}
+1
View File
@@ -11,6 +11,7 @@ workspace = true
[dependencies]
kurbo = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
moxcms = "0.8"
+220
View File
@@ -75,6 +75,11 @@ impl BlendMode {
_ => 5,
}
}
/// Whether the mode works on each colour channel separately (all but Hue, Saturation, Color
/// and Luminosity).
pub fn is_separable(self) -> bool {
self.group() != 5
}
/// Parse a label or identifier, case/space-insensitively.
pub fn parse(s: &str) -> Option<Self> {
let norm = |x: &str| x.to_ascii_lowercase().replace([' ', '_', '-'], "");
@@ -83,10 +88,225 @@ impl BlendMode {
}
}
/// Luminance weights of the non-separable modes (Hue, Saturation, Color, Luminosity), as the PDF
/// and W3C compositing specifications define them.
pub const LUM: [f32; 3] = [0.3, 0.59, 0.11];
/// Luminance weights of opacity masks (Rec. 709, applied to the sRGB values as SVG luminance masks
/// with `color-interpolation="sRGB"` apply them).
pub const MASK_LUM: [f32; 3] = [0.2126, 0.7152, 0.0722];
/// The blend function B(backdrop, source) of `mode` on straight (not premultiplied) RGB in 0..=1:
/// the colour that replaces the backdrop where an opaque source covers an opaque backdrop. Every
/// output (screen, SVG, PDF and transparency flattening) follows these formulas.
pub fn blend_rgb(mode: BlendMode, backdrop: [f32; 3], source: [f32; 3]) -> [f32; 3] {
use BlendMode as B;
let separable = |f: fn(f32, f32) -> f32| [0, 1, 2].map(|i| f(backdrop[i].clamp(0.0, 1.0), source[i].clamp(0.0, 1.0)));
match mode {
B::Normal => separable(|_, s| s),
B::Multiply => separable(|b, s| b * s),
B::Screen => separable(screen),
B::Overlay => separable(|b, s| hard_light(s, b)),
B::Darken => separable(f32::min),
B::Lighten => separable(f32::max),
B::ColorDodge => separable(|b, s| {
if b <= 0.0 {
0.0
} else if s >= 1.0 {
1.0
} else {
(b / (1.0 - s)).min(1.0)
}
}),
B::ColorBurn => separable(|b, s| {
if b >= 1.0 {
1.0
} else if s <= 0.0 {
0.0
} else {
1.0 - ((1.0 - b) / s).min(1.0)
}
}),
B::HardLight => separable(hard_light),
B::SoftLight => separable(|b, s| {
if s <= 0.5 {
b - (1.0 - 2.0 * s) * b * (1.0 - b)
} else {
let d = if b <= 0.25 { ((16.0 * b - 12.0) * b + 4.0) * b } else { b.sqrt() };
b + (2.0 * s - 1.0) * (d - b)
}
}),
B::Difference => separable(|b, s| (b - s).abs()),
B::Exclusion => separable(|b, s| b + s - 2.0 * b * s),
B::Hue => set_lum(set_sat(source, sat(backdrop)), lum(backdrop)),
B::Saturation => set_lum(set_sat(backdrop, sat(source)), lum(backdrop)),
B::Color => set_lum(source, lum(backdrop)),
B::Luminosity => set_lum(backdrop, lum(source)),
}
}
/// Source-over compositing of `source` onto `backdrop` with `mode` (straight RGBA in 0..=1, the
/// result straight too): the source colour is mixed with B(backdrop, source) by the backdrop's
/// alpha, then laid over the backdrop by its own alpha.
pub fn composite(mode: BlendMode, backdrop: [f32; 4], source: [f32; 4]) -> [f32; 4] {
let (ab, as_) = (backdrop[3].clamp(0.0, 1.0), source[3].clamp(0.0, 1.0));
let ao = as_ + ab * (1.0 - as_);
if ao <= 0.0 {
return [0.0; 4];
}
let mixed = blend_rgb(mode, [backdrop[0], backdrop[1], backdrop[2]], [source[0], source[1], source[2]]);
let c = |i: usize| ((1.0 - ab) * source[i] + ab * mixed[i]) * as_ + (1.0 - as_) * ab * backdrop[i];
[c(0) / ao, c(1) / ao, c(2) / ao, ao]
}
/// CMYK blending (CMYK documents). The formulas above work on additive values, so inks blend as
/// their complements, as subtractive colour spaces blend in PDF: a CMYK colour splits into two
/// additive planes, each blended with the formulas above, the complemented C, M and Y as one
/// colour and the complemented K as a grey. Multiply then adds inks (cyan over magenta prints
/// both: blue), and on the grey plane Hue, Saturation and Color keep the backdrop's black while
/// Luminosity takes the source's, as PDF has it for K.
pub fn cmyk_planes([c, m, y, k]: [f32; 4]) -> [[f32; 3]; 2] {
[[1.0 - c, 1.0 - m, 1.0 - y], [1.0 - k; 3]]
}
/// The inks of blended [`cmyk_planes`]: the C, M, Y plane and the K plane's grey.
pub fn planes_cmyk(cmy: [f32; 3], k: f32) -> [f32; 4] {
[1.0 - cmy[0], 1.0 - cmy[1], 1.0 - cmy[2], 1.0 - k].map(|v| v.clamp(0.0, 1.0))
}
fn screen(b: f32, s: f32) -> f32 {
b + s - b * s
}
fn hard_light(b: f32, s: f32) -> f32 {
if s <= 0.5 { b * 2.0 * s } else { screen(b, 2.0 * s - 1.0) }
}
fn lum(c: [f32; 3]) -> f32 {
LUM[0] * c[0] + LUM[1] * c[1] + LUM[2] * c[2]
}
fn sat(c: [f32; 3]) -> f32 {
c[0].max(c[1]).max(c[2]) - c[0].min(c[1]).min(c[2])
}
/// Bring a colour moved off the gamut by [`set_lum`] back in, keeping its luminance. A grey (all
/// components at its luminance, give or take rounding) is clamped instead of scaled by 0/0.
fn clip_color(c: [f32; 3]) -> [f32; 3] {
let l = lum(c);
let (n, x) = (c[0].min(c[1]).min(c[2]), c[0].max(c[1]).max(c[2]));
let mut c = c;
if n < 0.0 {
c = if l - n > f32::EPSILON { c.map(|v| l + (v - l) * l / (l - n)) } else { c.map(|v| v.max(0.0)) };
}
if x > 1.0 {
c = if x - l > f32::EPSILON { c.map(|v| l + (v - l) * (1.0 - l) / (x - l)) } else { c.map(|v| v.min(1.0)) };
}
c
}
fn set_lum(c: [f32; 3], l: f32) -> [f32; 3] {
let d = l - lum(c);
clip_color(c.map(|v| v + d))
}
/// `c` with saturation `s`: its largest component becomes `s`, its smallest 0 and the middle one
/// keeps its proportion.
fn set_sat(c: [f32; 3], s: f32) -> [f32; 3] {
let (n, x) = (c[0].min(c[1]).min(c[2]), c[0].max(c[1]).max(c[2]));
if x <= n {
return [0.0; 3];
}
c.map(|v| (v - n) * s / (x - n))
}
#[cfg(test)]
mod tests {
use super::*;
fn near(a: [f32; 3], b: [f32; 3]) -> bool {
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-5)
}
#[test]
fn separable_modes_follow_the_reference_formulas() {
let (b, s) = ([0.2, 0.5, 0.9], [0.6, 0.25, 0.5]);
let cases: [(BlendMode, [f32; 3]); 12] = [
(BlendMode::Normal, s),
(BlendMode::Multiply, [0.12, 0.125, 0.45]),
(BlendMode::Screen, [0.68, 0.625, 0.95]),
(BlendMode::Darken, [0.2, 0.25, 0.5]),
(BlendMode::Lighten, [0.6, 0.5, 0.9]),
(BlendMode::Difference, [0.4, 0.25, 0.4]),
(BlendMode::Exclusion, [0.56, 0.5, 0.5]),
// Overlay = Hard Light with the layers swapped: b ≤ ½ multiplies, else screens.
(BlendMode::Overlay, [0.24, 0.25, 0.9]),
(BlendMode::HardLight, [0.36, 0.25, 0.9]),
(BlendMode::ColorDodge, [0.5, 0.6666667, 1.0]),
(BlendMode::ColorBurn, [0.0, 0.0, 0.8]),
// s ≤ ½ darkens by b(1−b)(1−2s); s > ½ lightens towards D(b).
(BlendMode::SoftLight, [0.2 + 0.2 * (((16.0 * 0.2 - 12.0) * 0.2 + 4.0) * 0.2 - 0.2), 0.375, 0.9]),
];
for (m, want) in cases {
assert!(near(blend_rgb(m, b, s), want), "{m:?}: {:?} ≠ {want:?}", blend_rgb(m, b, s));
}
}
#[test]
fn non_separable_modes_keep_the_reference_luminance() {
let (b, s) = ([0.8, 0.3, 0.2], [0.1, 0.4, 0.9]);
// Color and Hue take the backdrop's luminance; Luminosity the source's.
for m in [BlendMode::Hue, BlendMode::Saturation, BlendMode::Color] {
assert!((lum(blend_rgb(m, b, s)) - lum(b)).abs() < 1e-5, "{m:?}");
}
assert!((lum(blend_rgb(BlendMode::Luminosity, b, s)) - lum(s)).abs() < 1e-5);
// Greys stay finite where rounding puts their luminance just off their components.
for m in [BlendMode::Hue, BlendMode::Saturation, BlendMode::Color, BlendMode::Luminosity] {
for (b, s) in [([0.7; 3], [0.0; 3]), ([0.0; 3], [0.3; 3]), ([1.0; 3], [0.9; 3]), ([0.9; 3], [1.0; 3])] {
assert!(blend_rgb(m, b, s).iter().all(|v| v.is_finite() && (-1e-6..=1.0 + 1e-6).contains(v)), "{m:?} {b:?} {s:?}");
}
}
// Saturation of grey is 0: a grey source desaturates the backdrop to its luminance.
let l = lum(b);
assert!(near(blend_rgb(BlendMode::Saturation, b, [0.5; 3]), [l; 3]));
// Hue of the source with the backdrop's saturation: blue-ish stays blue-ish.
let h = blend_rgb(BlendMode::Hue, b, s);
assert!(h[2] > h[1] && h[1] > h[0], "{h:?}");
assert!((sat(h) - sat(b)).abs() < 1e-5);
}
#[test]
fn composite_lays_the_blend_over_the_backdrop_by_alpha() {
let (b, s) = ([0.8, 0.4, 0.2, 1.0], [0.5, 0.5, 0.5, 0.5]);
let o = composite(BlendMode::Multiply, b, s);
// Half of the multiplied colour over half of the backdrop.
assert!(near([o[0], o[1], o[2]], [0.6, 0.3, 0.15]) && o[3] == 1.0, "{o:?}");
// Over a transparent backdrop the source shows unblended.
let o = composite(BlendMode::Difference, [0.3, 0.3, 0.3, 0.0], s);
assert!(near([o[0], o[1], o[2]], [0.5; 3]) && (o[3] - 0.5).abs() < 1e-6, "{o:?}");
}
#[test]
fn cmyk_planes_blend_inks() {
let blend = |m: BlendMode, b: [f32; 4], s: [f32; 4]| {
let (pb, ps) = (cmyk_planes(b), cmyk_planes(s));
planes_cmyk(blend_rgb(m, pb[0], ps[0]), blend_rgb(m, pb[1], ps[1])[0])
};
let (cyan, magenta) = ([1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0]);
// Multiply adds the inks: cyan over magenta prints both.
assert_eq!(blend(BlendMode::Multiply, magenta, cyan), [1.0, 1.0, 0.0, 0.0]);
// Screen keeps the lighter (less ink) per plate.
assert_eq!(blend(BlendMode::Screen, magenta, cyan), [0.0; 4]);
let (b, s) = ([0.2, 0.6, 0.1, 0.3], [0.7, 0.1, 0.5, 0.8]);
// K: backdrop's for Hue, Saturation and Color, the source's for Luminosity.
for m in [BlendMode::Hue, BlendMode::Saturation, BlendMode::Color] {
assert!((blend(m, b, s)[3] - b[3]).abs() < 1e-6, "{m:?}");
}
assert!((blend(BlendMode::Luminosity, b, s)[3] - s[3]).abs() < 1e-6);
let back = planes_cmyk(cmyk_planes(b)[0], cmyk_planes(b)[1][0]);
assert!(back.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6), "{back:?}");
}
#[test]
fn parse_labels() {
assert_eq!(BlendMode::parse("color burn"), Some(BlendMode::ColorBurn));
+119 -10
View File
@@ -1,10 +1,14 @@
//! ICC profiles through `moxcms` (pure Rust): built-in RGB spaces and user-supplied `.icc` files.
//! ICC profiles through `moxcms` (pure Rust): built-in RGB spaces and user-supplied `.icc` files,
//! and the profiles exports embed ([`encode_builtin`]), written in code from the CMS.
use std::sync::{Arc, OnceLock};
use moxcms::{ColorProfile, DataColorSpace, Layout, ProfileText, RenderingIntent, TransformF32Executor, TransformOptions};
use moxcms::{
ColorProfile, DataColorSpace, Layout, LocalizableString, LutMultidimensionalType, LutStore, LutWarehouse, Matrix3d, ProfileClass, ProfileText,
RenderingIntent, ToneReprCurve, TransformF32Executor, TransformOptions, Vector3d,
};
use super::{CmsError, Intent, ProfileKind};
use super::{CmsError, Intent, ProfileKind, lab};
fn moxcms_intent(i: Intent) -> RenderingIntent {
match i {
@@ -27,6 +31,8 @@ type Xf = Option<Arc<TransformF32Executor>>;
pub struct IccProfile {
pub name: String,
pub kind: ProfileKind,
/// The file it was loaded from (empty for profiles made in code).
source: Vec<u8>,
profile: ColorProfile,
srgb: ColorProfile,
to_srgb: [OnceLock<Xf>; 4],
@@ -58,12 +64,25 @@ impl IccProfile {
other => return Err(CmsError::Unsupported(format!("profile colour space {other:?} is not RGB, CMYK or Gray"))),
};
let name = name.or_else(|| text(&profile.description)).unwrap_or_else(|| format!("Untitled {kind:?} profile"));
Ok(Self { name, kind, profile, srgb: ColorProfile::new_srgb(), to_srgb: Default::default(), from_srgb: Default::default() })
Ok(Self {
name,
kind,
source: Vec::new(),
profile,
srgb: ColorProfile::new_srgb(),
to_srgb: Default::default(),
from_srgb: Default::default(),
})
}
pub fn from_bytes(name: Option<String>, bytes: &[u8]) -> Result<Self, CmsError> {
let p = ColorProfile::new_from_slice(bytes).map_err(|e| CmsError::BadProfile(format!("{e:?}")))?;
Self::from_profile(name, p)
Ok(Self { source: bytes.to_vec(), ..Self::from_profile(name, p)? })
}
/// The profile as an ICC file: the bytes it was loaded from, else encoded.
pub fn bytes(&self) -> Result<Vec<u8>, CmsError> {
if self.source.is_empty() { encode(&self.profile) } else { Ok(self.source.clone()) }
}
fn layout(&self) -> Layout {
@@ -122,14 +141,104 @@ impl IccProfile {
}
}
/// Built-in RGB working spaces from `moxcms`'s standard primaries.
pub fn builtin_rgb(name: &str) -> Option<IccProfile> {
let p = match name {
super::ADOBE_RGB => ColorProfile::new_adobe_rgb(),
/// The primaries and tone curves of built-in RGB space `name`.
fn rgb_space(name: &str) -> Option<ColorProfile> {
Some(match name {
super::WIDE_GAMUT_RGB => ColorProfile::new_adobe_rgb(),
super::DISPLAY_P3 => ColorProfile::new_display_p3(),
super::PROPHOTO_RGB => ColorProfile::new_pro_photo_rgb(),
super::SRGB => ColorProfile::new_srgb(),
_ => return None,
})
}
/// Built-in RGB working spaces from `moxcms`'s standard primaries.
pub fn builtin_rgb(name: &str) -> Option<IccProfile> {
IccProfile::from_profile(Some(name.to_string()), rgb_space(name)?).ok()
}
/// Built-in RGB space `name` as an ICC file, under its name here.
pub(super) fn builtin_rgb_bytes(name: &str) -> Result<Vec<u8>, CmsError> {
encode(&labelled(rgb_space(name).ok_or_else(|| CmsError::UnknownProfile(name.into()))?, name))
}
/// Encode `p` as an ICC file. The creation date is fixed so exports are reproducible.
pub(super) fn encode(p: &ColorProfile) -> Result<Vec<u8>, CmsError> {
let mut bytes = p.encode().map_err(|e| CmsError::BadProfile(format!("{e:?}")))?;
// Header bytes 24..36: year, month, day, hours, minutes, seconds (u16 each).
if let Some(date) = bytes.get_mut(24..36) {
date.copy_from_slice(&[2026u16, 1, 1, 0, 0, 0].map(u16::to_be_bytes).concat());
}
Ok(bytes)
}
fn text_tag(s: &str) -> Option<ProfileText> {
Some(ProfileText::Localizable(vec![LocalizableString::new("en".into(), "US".into(), s.into())]))
}
/// Profiles made here carry their name and no copyright claim.
fn labelled(mut p: ColorProfile, name: &str) -> ColorProfile {
p.description = text_tag(name);
p.copyright = text_tag("No copyright, use freely");
p
}
/// Grid points per input channel of the CMYK → Lab table and of the Lab → CMYK table.
const A2B_GRID: usize = 9;
const B2A_GRID: usize = 17;
/// A CMYK output profile sampled from a conversion: `to_lab` (CMYK → media-relative Lab) in a
/// 9⁴ table, `from_lab` (Lab → CMYK, gamut-mapped) in a 17³ table, Lab PCS (ICC v4 encoding).
pub(super) fn cmyk_profile(name: &str, to_lab: impl Fn([f32; 4]) -> lab::Lab, from_lab: impl Fn(lab::Lab) -> [f32; 4]) -> ColorProfile {
let unit = |i: usize, n: usize| i as f32 / (n - 1) as f32;
let q = |v: f32| (v.clamp(0.0, 1.0) * 65535.0).round() as u16;
let mut a2b = Vec::with_capacity(A2B_GRID.pow(4) * 3);
for i in 0..A2B_GRID.pow(4) {
let cmyk = [3, 2, 1, 0].map(|d| unit(i / A2B_GRID.pow(d) % A2B_GRID, A2B_GRID));
let l = to_lab(cmyk);
a2b.extend([l.l / 100.0, (l.a + 128.0) / 255.0, (l.b + 128.0) / 255.0].map(q));
}
let mut b2a = Vec::with_capacity(B2A_GRID.pow(3) * 4);
for i in 0..B2A_GRID.pow(3) {
let [l, a, b] = [2, 1, 0].map(|d| unit(i / B2A_GRID.pow(d) % B2A_GRID, B2A_GRID));
b2a.extend(from_lab(lab::Lab::new(l * 100.0, a * 255.0 - 128.0, b * 255.0 - 128.0)).map(q));
}
let identity = |n: usize| vec![ToneReprCurve::Parametric(vec![1.0]); n];
let table = |inputs: usize, outputs: usize, grid: usize, clut: Vec<u16>| {
let mut grid_points = [0u8; 16];
grid_points[..inputs].fill(grid as u8);
LutWarehouse::Multidimensional(LutMultidimensionalType {
num_input_channels: inputs as u8,
num_output_channels: outputs as u8,
grid_points,
clut: Some(LutStore::Store16(clut)),
// A curves sit on the device side, B curves on the PCS side.
a_curves: identity(4),
b_curves: identity(3),
m_curves: vec![],
matrix: Matrix3d::IDENTITY,
bias: Vector3d::default(),
})
};
IccProfile::from_profile(Some(name.to_string()), p).ok()
let a2b = table(4, 3, A2B_GRID, a2b);
let b2a = table(3, 4, B2A_GRID, b2a);
let mut p = ColorProfile::default();
p.profile_class = ProfileClass::OutputDevice;
p.color_space = DataColorSpace::Cmyk;
p.pcs = DataColorSpace::Lab;
p.rendering_intent = RenderingIntent::RelativeColorimetric;
p.white_point = moxcms::WHITE_POINT_D50.to_xyzd();
p.media_white_point = Some(p.white_point);
p.lut_a_to_b_perceptual = Some(a2b.clone());
p.lut_a_to_b_colorimetric = Some(a2b);
p.lut_b_to_a_perceptual = Some(b2a.clone());
p.lut_b_to_a_colorimetric = Some(b2a);
labelled(p, name)
}
/// The grey space of greyscale exports: sRGB's tone curve, D50 white.
pub(super) fn gray_profile(name: &str) -> ColorProfile {
let mut p = ColorProfile::new_gray_with_gamma(2.2);
p.gray_trc = ColorProfile::new_srgb().red_trc;
labelled(p, name)
}
+81 -15
View File
@@ -1,8 +1,8 @@
//! Colour management: working spaces, RGB ↔ CMYK ↔ Lab conversions with rendering intents,
//! gamut checks and soft-proof transforms.
//!
//! * **RGB working spaces** are ICC matrix/shaper profiles via [`moxcms`] (sRGB, Adobe RGB (1998)
//! compatible, Display P3, ProPhoto RGB) or user `.icc` files. Display output is sRGB.
//! * **RGB working spaces** are ICC matrix/shaper profiles via [`moxcms`] (sRGB, Wide Gamut RGB
//! (1998 primaries), Display P3, ProPhoto RGB) or user `.icc` files. Display output is sRGB.
//! * **CMYK spaces:** [`GENERIC_CMYK`] is our own documented parametric press model
//! ([`generic`]); [`DEVICE_CMYK`] is the old profile-free formula (kept for exact legacy numbers);
//! any CMYK `.icc` the user loads goes through `moxcms` (LUT-based profiles).
@@ -25,13 +25,24 @@ pub use lab::{Lab, delta_e76, delta_e2000};
use crate::Color;
pub const SRGB: &str = "sRGB IEC61966-2.1";
pub const ADOBE_RGB: &str = "Adobe RGB (1998) compatible";
/// The wide-gamut RGB space with the 1998 primaries (γ 2.2, D65).
pub const WIDE_GAMUT_RGB: &str = "Wide Gamut RGB (1998 primaries)";
pub const DISPLAY_P3: &str = "Display P3";
pub const PROPHOTO_RGB: &str = "ProPhoto RGB";
/// Our parametric press model (see [`generic`]). Not an Adobe/ECI profile.
pub const GENERIC_CMYK: &str = "VectorCraft Generic CMYK (SWOP-like)";
/// Profile-free CMYK (`rgb = (1−c)(1−k)` …): uncalibrated, kept for legacy numbers.
pub const DEVICE_CMYK: &str = "Device CMYK (uncalibrated)";
/// The grey space greyscale exports are written in: sRGB's tone curve (a grey shows as on screen).
pub const GRAY: &str = "Gray (sRGB tone curve)";
/// Built-in RGB and CMYK working spaces, in menu order.
const BUILTIN_RGB: [&str; 4] = [SRGB, WIDE_GAMUT_RGB, DISPLAY_P3, PROPHOTO_RGB];
const BUILTIN_CMYK: [&str; 2] = [GENERIC_CMYK, DEVICE_CMYK];
/// Names earlier versions gave built-in profiles: (old name, current name). Settings, documents
/// and commands that still use an old name get the current profile.
pub const LEGACY_NAMES: &[(&str, &str)] = &[("Adobe RGB (1998) compatible", WIDE_GAMUT_RGB)]; // brand-ok: legacy alias
/// ΔE2000 above which a colour counts as out of the CMYK gamut (the gamut warning).
pub const GAMUT_THRESHOLD: f32 = 2.0;
@@ -131,6 +142,8 @@ pub enum Model {
Rgb,
Cmyk,
Gray,
/// CIE L*a*b* (D50).
Lab,
}
#[derive(Clone)]
@@ -169,7 +182,7 @@ fn user_profile(name: &str) -> Option<Arc<IccProfile>> {
}
fn cmyk_space(name: &str) -> Result<CmykSpace, CmsError> {
match name {
match canonical_name(name) {
GENERIC_CMYK => Ok(CmykSpace::Generic(generic())),
DEVICE_CMYK => Ok(CmykSpace::Device),
_ => match user_profile(name) {
@@ -194,12 +207,23 @@ fn rgb_space(name: &str) -> Result<RgbSpace, CmsError> {
}
}
/// The current name of a profile: names earlier versions used resolve to today's name.
pub fn canonical_name(name: &str) -> &str {
LEGACY_NAMES.iter().find(|(old, _)| *old == name).map_or(name, |(_, new)| new)
}
/// An available profile by its current or legacy name.
pub fn profile(name: &str) -> Option<ProfileInfo> {
let name = canonical_name(name);
profiles().into_iter().find(|p| p.name == name)
}
/// Every profile available (built-in first, then user-loaded).
pub fn profiles() -> Vec<ProfileInfo> {
let mut v: Vec<ProfileInfo> = [SRGB, ADOBE_RGB, DISPLAY_P3, PROPHOTO_RGB]
let mut v: Vec<ProfileInfo> = BUILTIN_RGB
.iter()
.map(|n| ProfileInfo { name: (*n).into(), kind: ProfileKind::Rgb, builtin: true })
.chain([GENERIC_CMYK, DEVICE_CMYK].iter().map(|n| ProfileInfo { name: (*n).into(), kind: ProfileKind::Cmyk, builtin: true }))
.chain(BUILTIN_CMYK.iter().map(|n| ProfileInfo { name: (*n).into(), kind: ProfileKind::Cmyk, builtin: true }))
.collect();
for p in USER.read().unwrap_or_else(|e| e.into_inner()).iter() {
v.push(ProfileInfo { name: p.name.clone(), kind: p.kind, builtin: false });
@@ -214,7 +238,8 @@ pub fn register_icc(bytes: &[u8], name: Option<String>) -> Result<ProfileInfo, C
if p.kind == ProfileKind::Gray {
return Err(CmsError::Unsupported("grayscale profiles can't be working spaces yet".into()));
}
if [SRGB, ADOBE_RGB, DISPLAY_P3, PROPHOTO_RGB, GENERIC_CMYK, DEVICE_CMYK].contains(&p.name.as_str()) {
let builtin = canonical_name(&p.name);
if BUILTIN_RGB.contains(&builtin) || BUILTIN_CMYK.contains(&builtin) {
return Err(CmsError::Unsupported(format!("{} clashes with a built-in profile name", p.name)));
}
if !p.usable() {
@@ -229,6 +254,33 @@ pub fn register_icc(bytes: &[u8], name: Option<String>) -> Result<ProfileInfo, C
Ok(info)
}
/// Profile `name` as an ICC file to embed in an export: any RGB or CMYK profile of [`profiles`]
/// (by its current or legacy name), or [`GRAY`]. Built-in profiles are written in code from the
/// CMS: the RGB spaces from their primaries, the CMYK spaces as lookup tables sampled from their
/// model (both directions, perceptual and relative colorimetric, Lab PCS); a profile the user
/// loaded is the file it came from. Cached.
pub fn icc_bytes(name: &str) -> Result<Arc<[u8]>, CmsError> {
static CACHE: Mutex<Vec<(String, Arc<[u8]>)>> = Mutex::new(Vec::new());
let name = canonical_name(name);
if let Some(p) = user_profile(name) {
return p.bytes().map(Arc::from);
}
if let Some((_, b)) = CACHE.lock().unwrap_or_else(|e| e.into_inner()).iter().find(|(n, _)| n == name) {
return Ok(b.clone());
}
let bytes: Arc<[u8]> = if name == GRAY {
icc::encode(&icc::gray_profile(name))?
} else if BUILTIN_CMYK.contains(&name) {
let cms = Cms::new(&ColorSettings { cmyk: name.into(), ..ColorSettings::default() })?;
icc::encode(&icc::cmyk_profile(name, |c| cms.cmyk_to_lab(c), |l| cms.lab_to_cmyk(l, Intent::RelativeColorimetric)))?
} else {
icc::builtin_rgb_bytes(name)?
}
.into();
CACHE.lock().unwrap_or_else(|e| e.into_inner()).push((name.to_string(), bytes.clone()));
Ok(bytes)
}
/// Load a `.icc`/`.icm` file from disk (native only).
#[cfg(not(target_arch = "wasm32"))]
pub fn load_icc_file(path: &std::path::Path) -> Result<ProfileInfo, CmsError> {
@@ -257,8 +309,10 @@ impl Default for Cms {
}
impl Cms {
/// Profiles named by a legacy name are stored under their current name.
pub fn new(settings: &ColorSettings) -> Result<Self, CmsError> {
Ok(Self { settings: settings.clone(), rgb: rgb_space(&settings.rgb)?, cmyk: cmyk_space(&settings.cmyk)? })
let settings = ColorSettings { rgb: canonical_name(&settings.rgb).into(), cmyk: canonical_name(&settings.cmyk).into(), ..settings.clone() };
Ok(Self { rgb: rgb_space(&settings.rgb)?, cmyk: cmyk_space(&settings.cmyk)?, settings })
}
pub fn settings(&self) -> &ColorSettings {
&self.settings
@@ -331,6 +385,7 @@ impl Cms {
Color::Rgb { r, g, b } => self.rgb_to_srgb([r, g, b]),
Color::Cmyk { c, m, y, k } => self.cmyk_to_srgb([c, m, y, k], false),
Color::Gray { k } => [1.0 - k.clamp(0.0, 1.0); 3],
Color::Lab { l, a, b } => lab::lab_to_srgb(Lab::new(l, a, b)),
}
}
@@ -338,23 +393,33 @@ impl Cms {
pub fn lab(&self, c: &Color) -> Lab {
match *c {
Color::Cmyk { c, m, y, k } => self.cmyk_to_lab([c, m, y, k]),
Color::Lab { l, a, b } => Lab::new(l, a, b),
_ => lab::srgb_to_lab(self.display_rgb(c)),
}
}
/// Ink values of any colour in the working CMYK space (CMYK passes through; grey is K only).
/// Ink values of any colour in the working CMYK space (CMYK passes through; grey is K only;
/// Lab separates from its own values).
pub fn to_cmyk(&self, c: &Color, intent: Intent) -> [f32; 4] {
match *c {
Color::Cmyk { c, m, y, k } => [c, m, y, k],
Color::Gray { k } => [0.0, 0.0, 0.0, k],
Color::Rgb { .. } => self.srgb_to_cmyk(self.display_rgb(c), intent),
Color::Lab { l, a, b } => self.lab_to_cmyk(Lab::new(l, a, b), intent),
}
}
/// Convert `c` into `model` (a colour already in `model` is returned unchanged).
pub fn convert(&self, c: &Color, model: Model, intent: Intent) -> Color {
match (c, model) {
(Color::Rgb { .. }, Model::Rgb) | (Color::Cmyk { .. }, Model::Cmyk) | (Color::Gray { .. }, Model::Gray) => *c,
(Color::Rgb { .. }, Model::Rgb)
| (Color::Cmyk { .. }, Model::Cmyk)
| (Color::Gray { .. }, Model::Gray)
| (Color::Lab { .. }, Model::Lab) => *c,
(_, Model::Lab) => {
let Lab { l, a, b } = self.lab(c);
Color::Lab { l, a, b }
}
(_, Model::Cmyk) => {
let [c, m, y, k] = self.to_cmyk(c, intent);
Color::Cmyk { c, m, y, k }
@@ -381,19 +446,18 @@ impl Cms {
/// How far (ΔE2000) the best CMYK reproduction of `c` is from `c` (0 for CMYK/grey colours).
pub fn gamut_error(&self, c: &Color) -> f32 {
match c {
Color::Rgb { .. } => {
let srgb = self.display_rgb(c);
let src = lab::srgb_to_lab(srgb);
Color::Rgb { .. } | Color::Lab { .. } => {
let src = self.lab(c);
match &self.cmyk {
CmykSpace::Generic(g) => g.gamut_error(src),
CmykSpace::Device => 0.0,
CmykSpace::Icc(_) => {
let back = self.cmyk_to_srgb(self.srgb_to_cmyk(srgb, Intent::RelativeColorimetric), false);
let back = self.cmyk_to_srgb(self.to_cmyk(c, Intent::RelativeColorimetric), false);
delta_e2000(src, lab::srgb_to_lab(back))
}
}
}
_ => 0.0,
Color::Cmyk { .. } | Color::Gray { .. } => 0.0,
}
}
@@ -636,3 +700,5 @@ pub(crate) fn cmyk_is_device() -> bool {
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_icc;
+43 -2
View File
@@ -138,11 +138,11 @@ fn convert_between_models() {
#[test]
fn wide_gamut_working_space() {
let c = Cms::new(&ColorSettings { rgb: ADOBE_RGB.into(), ..Default::default() }).unwrap();
let c = Cms::new(&ColorSettings { rgb: WIDE_GAMUT_RGB.into(), ..Default::default() }).unwrap();
assert!(!c.rgb_is_srgb());
let grey = c.rgb_to_srgb([0.5, 0.5, 0.5]);
assert!(de(grey, [0.5, 0.5, 0.5]) < 1.5, "{grey:?}");
// Adobe RGB green is outside sRGB: it clips to (nearly) full sRGB green.
// Wide-gamut green is outside sRGB: it clips to (nearly) full sRGB green.
let g = c.rgb_to_srgb([0.0, 1.0, 0.0]);
assert!(g[1] > 0.95 && g[0] < 0.1, "{g:?}");
let back = c.srgb_to_rgb(c.rgb_to_srgb([0.4, 0.5, 0.3]));
@@ -150,6 +150,21 @@ fn wide_gamut_working_space() {
assert!(matches!(Cms::new(&ColorSettings { rgb: "Nope".into(), ..Default::default() }), Err(CmsError::UnknownProfile(_))));
}
#[test]
fn legacy_profile_name_resolves() {
let (old, _) = LEGACY_NAMES[0];
assert_eq!(canonical_name(old), WIDE_GAMUT_RGB);
assert_eq!(canonical_name(SRGB), SRGB);
assert_eq!(profile(old).map(|p| (p.name, p.kind, p.builtin)), Some((WIDE_GAMUT_RGB.to_string(), ProfileKind::Rgb, true)));
assert!(profile("Nope").is_none());
assert!(profiles().iter().all(|p| p.name != old), "only the current name is listed");
// Settings saved with the old name load as the current profile, under its current name.
let c = Cms::new(&ColorSettings { rgb: old.into(), ..Default::default() }).unwrap();
assert_eq!(c.settings().rgb, WIDE_GAMUT_RGB);
let same = Cms::new(&ColorSettings { rgb: WIDE_GAMUT_RGB.into(), ..Default::default() }).unwrap();
assert_eq!(c.rgb_to_srgb([0.0, 1.0, 0.0]), same.rgb_to_srgb([0.0, 1.0, 0.0]));
}
#[test]
fn proof_lut_matches_direct_proof() {
let c = cms();
@@ -200,3 +215,29 @@ fn loads_system_cmyk_icc() {
assert!(de(rgb, back) < 5.0, "{back:?}");
assert_eq!(c.cmyk_to_srgb([0.0; 4], false).map(|v| (v * 255.0).round() as u8), [255, 255, 255]);
}
#[test]
fn lab_colours_round_trip_through_the_cms() {
let c = cms_no_bpc();
let ri = Intent::RelativeColorimetric;
// In-gamut Lab colours separate into working CMYK and come back within a small ΔE.
for (l, a, b) in [(60.0, 20.0, 30.0), (50.0, -20.0, -10.0), (75.0, 5.0, 40.0), (40.0, 30.0, -25.0)] {
let lab = Color::lab(l, a, b);
assert_eq!(c.lab(&lab), Lab::new(l, a, b), "Lab passes through");
assert_eq!(c.convert(&lab, Model::Lab, ri), lab, "already Lab: unchanged");
let ink = c.convert(&lab, Model::Cmyk, ri);
let Color::Cmyk { c: cc, m, y, k } = ink else { panic!("CMYK") };
assert_eq!([cc, m, y, k], c.to_cmyk(&lab, ri), "separation = conversion");
let back = c.lab(&ink);
assert!(delta_e2000(back, Lab::new(l, a, b)) < 1.5, "{l} {a} {b} → {ink:?} → {back:?}");
assert!(!c.out_of_gamut(&lab), "{l} {a} {b} is printable");
// Through RGB and back to Lab.
let rgb = c.convert(&lab, Model::Rgb, ri);
let Color::Lab { l: l2, a: a2, b: b2 } = c.convert(&rgb, Model::Lab, ri) else { panic!("Lab") };
assert!(delta_e76(Lab::new(l2, a2, b2), Lab::new(l, a, b)) < 0.05, "{l} {a} {b} → {rgb:?} → {l2} {a2} {b2}");
}
// A saturated Lab blue no press reaches is out of gamut; Lab grey converts to a neutral.
assert!(c.out_of_gamut(&Color::lab(30.0, 60.0, -100.0)));
let Color::Gray { k } = c.convert(&Color::lab(50.0, 0.0, 0.0), Model::Gray, ri) else { panic!("Gray") };
assert!((k - 0.53).abs() < 0.01, "{k}");
}
+62
View File
@@ -0,0 +1,62 @@
use super::*;
fn parse(bytes: &[u8]) -> moxcms::ColorProfile {
moxcms::ColorProfile::new_from_slice(bytes).unwrap()
}
#[test]
fn builtin_rgb_spaces_encode_under_their_names() {
for name in BUILTIN_RGB {
let p = IccProfile::from_bytes(None, &icc_bytes(name).unwrap()).unwrap();
assert_eq!((p.name.as_str(), p.kind), (name, ProfileKind::Rgb));
}
// Legacy names find today's profile.
assert_eq!(icc_bytes(LEGACY_NAMES[0].0).unwrap(), icc_bytes(WIDE_GAMUT_RGB).unwrap());
assert!(matches!(icc_bytes("No Such Profile"), Err(CmsError::UnknownProfile(_))));
}
#[test]
fn cmyk_profiles_convert_like_the_cms() {
for name in BUILTIN_CMYK {
let bytes = icc_bytes(name).unwrap();
assert_eq!(parse(&bytes).color_space, moxcms::DataColorSpace::Cmyk);
let p = IccProfile::from_bytes(None, &bytes).unwrap();
assert_eq!((p.name.as_str(), p.kind), (name, ProfileKind::Cmyk));
let cms = Cms::new(&ColorSettings { cmyk: name.into(), ..Default::default() }).unwrap();
for cmyk in [[0.0; 4], [1.0, 0.0, 0.0, 0.0], [0.2, 0.7, 0.1, 0.3], [0.0, 0.0, 0.0, 1.0], [0.5, 0.5, 0.5, 0.0]] {
let want = cms.cmyk_to_srgb(cmyk, false);
let got = p.to_srgb(&cmyk, Intent::RelativeColorimetric).unwrap();
let de = delta_e2000(lab::srgb_to_lab(want), lab::srgb_to_lab(got));
assert!(de < 2.5, "{name} {cmyk:?}: {want:?} vs {got:?} (ΔE {de})");
}
// And back: an in-gamut colour separates to about the same inks.
let srgb = cms.cmyk_to_srgb([0.1, 0.6, 0.8, 0.0], false);
let back = p.from_srgb(srgb, Intent::RelativeColorimetric).unwrap();
let again = cms.cmyk_to_srgb([back[0], back[1], back[2], back[3]], false);
assert!(delta_e2000(lab::srgb_to_lab(srgb), lab::srgb_to_lab(again)) < 3.0, "{back:?}");
}
}
#[test]
fn gray_is_srgb_toned() {
let p = IccProfile::from_bytes(None, &icc_bytes(GRAY).unwrap()).unwrap();
assert_eq!((p.name.as_str(), p.kind), (GRAY, ProfileKind::Gray));
for v in [0.0f32, 0.25, 0.5, 1.0] {
let got = p.to_srgb(&[v], Intent::RelativeColorimetric).unwrap();
assert!(got.iter().all(|c| (c - v).abs() < 0.01), "{v} → {got:?}");
}
}
#[test]
fn encoded_profiles_are_reproducible() {
assert_eq!(icc::builtin_rgb_bytes(DISPLAY_P3).unwrap(), icc::builtin_rgb_bytes(DISPLAY_P3).unwrap());
}
#[test]
fn a_loaded_profile_is_embedded_as_loaded() {
let mut bytes = icc::builtin_rgb_bytes(PROPHOTO_RGB).unwrap();
// A byte the parser ignores (in the reserved header area) marks the file as this one.
bytes[100] = 7;
register_icc(&bytes, Some("Embedded As Loaded RGB".into())).unwrap();
assert_eq!(&*icc_bytes("Embedded As Loaded RGB").unwrap(), &bytes[..]);
}
+575
View File
@@ -0,0 +1,575 @@
//! Freeform gradients: colour points placed anywhere on an object (and smooth lines through them),
//! blended across it.
//!
//! Each point paints a disc of pure colour (its spread, a fraction of half the larger side of the
//! painted box) and blends into its neighbours beyond it by inverse-distance weighting on the
//! distance from the disc's edge. A line is a Catmull-Rom curve through its points: every spot
//! on it carries the colour, opacity and spread interpolated between the two points it lies
//! between, and acts as a source along its length (each short piece of it weighted by its share
//! of the line). Where discs overlap the deeper one wins, so the field stays continuous.
use kurbo::{Affine, BezPath, CubicBez, ParamCurve, ParamCurveNearest, Point, Rect};
use serde::{Deserialize, Serialize};
use crate::{Color, Gradient, GradientStop};
/// The spread a new point gets.
pub const DEFAULT_SPREAD: f32 = 0.0;
/// Line segments are tessellated into this many pieces for sampling.
const LINE_STEPS: usize = 16;
fn one() -> f32 {
1.0
}
fn default_spread() -> f32 {
DEFAULT_SPREAD
}
/// One colour point.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct FreeformPoint {
/// Position in the paint's space (document coordinates; text space for type runs).
pub at: Point,
pub color: Color,
#[serde(default = "one")]
pub opacity: f32,
/// Radius (0..=1) of the disc of pure colour around the point, as a fraction of
/// [`spread_scale`].
#[serde(default = "default_spread")]
pub spread: f32,
}
impl FreeformPoint {
pub fn new(at: Point, color: Color) -> Self {
Self { at, color, opacity: 1.0, spread: DEFAULT_SPREAD }
}
}
/// How the Gradient tool adds points: free points, or points joined into lines.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FreeformMode {
#[default]
Points,
Lines,
}
impl FreeformMode {
/// Parse a mode name (`points`, `lines`; any case).
pub fn parse(s: &str) -> Option<Self> {
[FreeformMode::Points, FreeformMode::Lines].into_iter().find(|m| m.label().eq_ignore_ascii_case(s))
}
pub fn label(self) -> &'static str {
match self {
FreeformMode::Points => "Points",
FreeformMode::Lines => "Lines",
}
}
fn is_points(&self) -> bool {
*self == FreeformMode::Points
}
}
/// A freeform gradient: its colour points and the lines through them.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Freeform {
pub points: Vec<FreeformPoint>,
/// Each line is a smooth curve through `points` at these indices, in order.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub lines: Vec<Vec<usize>>,
/// Draw: how the Gradient tool adds points.
#[serde(default, skip_serializing_if = "FreeformMode::is_points")]
pub mode: FreeformMode,
}
/// The length spreads are fractions of: half the larger side of the painted box.
pub fn spread_scale(b: Rect) -> f64 {
b.width().abs().max(b.height().abs()) / 2.0
}
/// The box a freeform gradient painted over `bounds` is placed on: `bounds` with positive sides, a
/// side without length getting a sliver (so a grid over it maps back invertibly). `None` for an
/// empty or non-finite box.
pub fn painted_box(bounds: Rect) -> Option<Rect> {
let b = bounds.abs();
let side = b.width().max(b.height());
(side > 1e-9 && side.is_finite()).then(|| Rect::from_center_size(b.center(), (b.width().max(side * 1e-3), b.height().max(side * 1e-3))))
}
/// Device pixels per cell of the grid a freeform gradient is sampled on.
const CELL_PX: f64 = 4.0;
/// Fewest and most cells along the painted box's longer side.
const MIN_CELLS: f64 = 8.0;
const MAX_CELLS: f64 = 256.0;
/// The grid (columns, rows) a freeform gradient on box `b` (see [`painted_box`]) is sampled on
/// when the box's longer side spans `device` pixels: a cell per few pixels, in power-of-two steps
/// (so zooming reuses grids), 8 to 256 cells along the longer side.
pub fn grid_size(b: Rect, device: f64) -> (u16, u16) {
let side = b.width().max(b.height());
let cells = 2f64.powf((device / CELL_PX).max(1.0).log2().ceil()).clamp(MIN_CELLS, MAX_CELLS);
let along = |len: f64| (cells * len / side).ceil().clamp(2.0, MAX_CELLS) as u16;
(along(b.width()), along(b.height()))
}
/// Where a point lies nearest on a line (see [`Freeform::nearest_on_lines`]).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct LineHit {
pub line: usize,
/// The segment from the line's point `segment` to point `segment + 1`.
pub segment: usize,
/// Curve parameter along the segment, 0..=1.
pub t: f64,
pub point: Point,
pub distance: f64,
}
impl Freeform {
/// The points the first application places on an object with box `b`: a 2 × n grid (n = 2–4
/// along the longer side), each moved to the nearest spot `inside` the shape when its grid
/// position isn't. They take colours along `g`, in a loop around the grid.
pub fn auto(b: Rect, g: &Gradient, inside: &dyn Fn(Point) -> bool) -> Self {
let (w, h) = (b.width().max(1e-9), b.height().max(1e-9));
let long = ((2.0 * w.max(h) / w.min(h)).round() as usize).clamp(2, 4);
let (cols, rows) = if w >= h { (long, 2) } else { (2, long) };
let cell = |i: usize, n: usize| (i as f64 + 0.5) / n as f64;
// Row by row, every other row backwards: neighbours in the list are neighbours on the art.
let targets: Vec<Point> = (0..rows)
.flat_map(|j| (0..cols).map(move |k| if j % 2 == 0 { (k, j) } else { (cols - 1 - k, j) }))
.map(|(i, j)| Point::new(b.x0 + cell(i, cols) * w, b.y0 + cell(j, rows) * h))
.collect();
const GRID: usize = 24;
let candidates: Vec<Point> =
(0..GRID * GRID).map(|k| Point::new(b.x0 + cell(k % GRID, GRID) * w, b.y0 + cell(k / GRID, GRID) * h)).filter(|p| inside(*p)).collect();
let apart = w.min(h) * 0.15;
let mut placed: Vec<Point> = Vec::with_capacity(targets.len());
for t in &targets {
let free = |c: &&Point| placed.iter().all(|q| q.distance(**c) >= apart);
let at = if inside(*t) {
*t
} else {
candidates.iter().filter(free).min_by(|a, b| a.distance_squared(*t).total_cmp(&b.distance_squared(*t))).copied().unwrap_or(*t)
};
placed.push(at);
}
let mut f = Self { points: placed.into_iter().map(|at| FreeformPoint::new(at, Color::BLACK)).collect(), ..Default::default() };
f.recolor(g);
f
}
/// Colour the points along `g`, first to last.
pub fn recolor(&mut self, g: &Gradient) {
let n = self.points.len();
for (k, p) in self.points.iter_mut().enumerate() {
(p.color, p.opacity) = g.sample(k as f32 / (n - 1).max(1) as f32);
}
}
/// Gradient stops standing for the points (their colours evenly spaced, at least two): what
/// swatch chips, exports without freeform shading and re-seeding on other art use.
pub fn stops(&self) -> Vec<GradientStop> {
let n = self.points.len();
let stop = |offset: f32, p: &FreeformPoint| GradientStop { opacity: p.opacity, ..GradientStop::new(offset, p.color) };
match n {
0 => Gradient::default().stops,
1 => vec![stop(0.0, &self.points[0]), stop(1.0, &self.points[0])],
_ => self.points.iter().enumerate().map(|(i, p)| stop(i as f32 / (n - 1) as f32, p)).collect(),
}
}
/// Map every point through `f`.
pub fn map_points(&mut self, f: impl Fn(Point) -> Point) {
for p in &mut self.points {
p.at = f(p.at);
}
}
/// Map every point through `a`.
pub fn transform(&mut self, a: Affine) {
self.map_points(|p| a * p);
}
/// The cubic segments of line `line` (a Catmull-Rom curve through its points).
pub fn segments(&self, line: usize) -> Vec<CubicBez> {
let Some(ix) = self.lines.get(line) else { return vec![] };
// A line naming a missing point (a damaged file) draws nothing.
let Some(pts) = ix.iter().map(|i| self.points.get(*i).map(|p| p.at)).collect::<Option<Vec<Point>>>() else { return vec![] };
let n = pts.len();
(0..n.saturating_sub(1))
.map(|i| {
let (p0, p1, p2, p3) = (pts[i.saturating_sub(1)], pts[i], pts[i + 1], pts[(i + 2).min(n - 1)]);
CubicBez::new(p1, p1 + (p2 - p0) / 6.0, p2 - (p3 - p1) / 6.0, p2)
})
.collect()
}
/// Line `line` as a path (for drawing it).
pub fn line_path(&self, line: usize) -> BezPath {
let mut bp = BezPath::new();
for (i, c) in self.segments(line).into_iter().enumerate() {
if i == 0 {
bp.move_to(c.p0);
}
bp.curve_to(c.p1, c.p2, c.p3);
}
bp
}
/// The spot on a line nearest `p`.
pub fn nearest_on_lines(&self, p: Point) -> Option<LineHit> {
let mut best: Option<LineHit> = None;
for line in 0..self.lines.len() {
for (segment, c) in self.segments(line).into_iter().enumerate() {
let n = c.nearest(p, 1e-6);
let distance = n.distance_sq.sqrt();
if best.is_none_or(|b| distance < b.distance) {
best = Some(LineHit { line, segment, t: n.t, point: c.eval(n.t), distance });
}
}
}
best
}
/// The colour and opacity at `p` for a painted box whose [`spread_scale`] is `scale`.
pub fn sample(&self, p: Point, scale: f64) -> (Color, f32) {
let ([r, g, b], a) = self.field(scale).sample(p);
(Color::rgb(r, g, b), a)
}
/// The colour field, with colours converted and lines tessellated once (for sampling many
/// spots).
pub fn field(&self, scale: f64) -> Field {
self.field_with(scale, &Color::to_rgb)
}
/// [`Self::field`] with colours turned into the three blended values by `rgb` (CMYK
/// documents blend ink planes, see [`crate::blend::cmyk_planes`]).
pub fn field_with(&self, scale: f64, rgb: &dyn Fn(&Color) -> [f32; 3]) -> Field {
let src = |p: &FreeformPoint| Source { at: p.at, rgb: rgb(&p.color), opacity: p.opacity, radius: p.spread.clamp(0.0, 1.0) as f64 * scale };
let points: Vec<Source> = self.points.iter().map(src).collect();
let mut pieces = vec![];
for (l, ix) in self.lines.iter().enumerate() {
// The line tessellated into a polyline of sources.
let mut v: Vec<Source> = vec![];
for (s, c) in self.segments(l).into_iter().enumerate() {
let (Some(a), Some(b)) = (ix.get(s).and_then(|i| points.get(*i)), ix.get(s + 1).and_then(|i| points.get(*i))) else { continue };
for k in (if s == 0 { 0 } else { 1 })..=LINE_STEPS {
let t = k as f64 / LINE_STEPS as f64;
v.push(Source { at: c.eval(t), ..a.lerp(b, t as f32) });
}
}
let len: f64 = v.windows(2).map(|w| w[0].at.distance(w[1].at)).sum();
let n = v.len().saturating_sub(1).max(1) as f64;
pieces.extend(v.windows(2).map(|w| Piece { a: w[0], b: w[1], share: if len > 0.0 { w[0].at.distance(w[1].at) / len } else { 1.0 / n } }));
}
Field { points, pieces, eps: 1e-9 * (scale.abs() + 1.0) }
}
/// Add a point; returns its index.
pub fn add_point(&mut self, p: FreeformPoint) -> usize {
self.points.push(p);
self.points.len() - 1
}
/// Join point `to` to point `from`: extend the line that ends (or starts) at `from`, else start
/// a line from it. Returns the line's index (None when an index is out of range or equal).
pub fn connect(&mut self, from: usize, to: usize) -> Option<usize> {
if from == to || from >= self.points.len() || to >= self.points.len() {
return None;
}
if let Some(l) = self.lines.iter().position(|l| l.last() == Some(&from)) {
self.lines[l].push(to);
return Some(l);
}
if let Some(l) = self.lines.iter().position(|l| l.first() == Some(&from)) {
self.lines[l].insert(0, to);
return Some(l);
}
self.lines.push(vec![from, to]);
Some(self.lines.len() - 1)
}
/// Add a line through `points` (at least two, each a valid index, none twice in a row).
pub fn add_line(&mut self, points: Vec<usize>) -> Result<usize, String> {
if points.len() < 2 {
return Err("a line needs at least two points".into());
}
if let Some(i) = points.iter().find(|i| **i >= self.points.len()) {
return Err(format!("no point {i} (the gradient has {})", self.points.len()));
}
if points.windows(2).any(|w| w[0] == w[1]) {
return Err("a line can't run from a point to itself".into());
}
self.lines.push(points);
Ok(self.lines.len() - 1)
}
/// Remove point `i` (never the last one). Lines through it close up around it; lines left
/// with fewer than two points go.
pub fn remove_point(&mut self, i: usize) -> bool {
if i >= self.points.len() || self.points.len() <= 1 {
return false;
}
self.points.remove(i);
for l in &mut self.lines {
l.retain(|j| *j != i);
for j in l.iter_mut() {
if *j > i {
*j -= 1;
}
}
l.dedup();
}
self.lines.retain(|l| l.len() >= 2);
true
}
/// Insert a point on segment `segment` of line `line` at curve parameter `t`, with the colour,
/// opacity and spread between the segment's ends. Returns the new point's index.
pub fn split_line(&mut self, line: usize, segment: usize, t: f64) -> Option<usize> {
let c = *self.segments(line).get(segment)?;
let l = &self.lines[line];
let (a, b) = (self.points[l[segment]], self.points[l[segment + 1]]);
let t = t.clamp(0.0, 1.0);
let u = t as f32;
let p = FreeformPoint {
at: c.eval(t),
color: a.color.lerp(&b.color, u),
opacity: a.opacity + (b.opacity - a.opacity) * u,
spread: a.spread + (b.spread - a.spread) * u,
};
let i = self.add_point(p);
self.lines[line].insert(segment + 1, i);
Some(i)
}
}
/// One colour source of a [`Field`].
#[derive(Clone, Copy, Debug)]
struct Source {
at: Point,
rgb: [f32; 3],
opacity: f32,
radius: f64,
}
impl Source {
fn lerp(&self, o: &Source, t: f32) -> Source {
let l = |a: f32, b: f32| a + (b - a) * t;
Source {
at: self.at.lerp(o.at, t as f64),
rgb: [l(self.rgb[0], o.rgb[0]), l(self.rgb[1], o.rgb[1]), l(self.rgb[2], o.rgb[2])],
opacity: l(self.opacity, o.opacity),
radius: self.radius + (o.radius - self.radius) * t as f64,
}
}
}
/// A freeform gradient ready to sample ([`Freeform::field`]).
#[derive(Clone, Debug)]
pub struct Field {
points: Vec<Source>,
/// The lines tessellated into short pieces.
pieces: Vec<Piece>,
eps: f64,
}
/// A piece of a tessellated line: a source at its spot nearest the sample, weighted by `share`
/// (its fraction of the line's length), so a whole line weighs as much as a point from afar and
/// the field stays continuous where the nearest spot on the line jumps.
#[derive(Clone, Copy, Debug)]
struct Piece {
a: Source,
b: Source,
share: f64,
}
impl Piece {
/// The distance from `p` and the source at the nearest spot.
fn nearest(&self, p: Point) -> (f64, Source) {
let v = self.b.at - self.a.at;
let l2 = v.hypot2();
let t = if l2 > 0.0 { ((p - self.a.at).dot(v) / l2).clamp(0.0, 1.0) } else { 0.0 };
let s = self.a.lerp(&self.b, t as f32);
(s.at.distance(p), s)
}
}
impl Field {
/// Samples at the centres of a `cols` × `rows` grid of cells over `b`, row by row: RGB and
/// opacity (as [`Self::sample`]).
pub fn grid(&self, b: Rect, cols: u16, rows: u16) -> impl Iterator<Item = ([f32; 3], f32)> + '_ {
let (cw, ch) = (b.width() / cols as f64, b.height() / rows as f64);
(0..rows as usize * cols as usize).map(move |k| {
let (i, j) = (k % cols as usize, k / cols as usize);
self.sample(Point::new(b.x0 + (i as f64 + 0.5) * cw, b.y0 + (j as f64 + 0.5) * ch))
})
}
/// Display RGB and opacity at `p`.
pub fn sample(&self, p: Point) -> ([f32; 3], f32) {
// (weight sum, weighted rgba) inside discs, and blending by distance outside them.
let mut disc = (0.0f64, [0.0f64; 4]);
let mut far = (0.0f64, [0.0f64; 4]);
let mut add = |d: f64, s: &Source, share: f64| {
let (acc, w) = if d < s.radius || d <= self.eps {
// The deeper inside, the stronger: weights fade to nothing at the disc's edge.
(&mut disc, (s.radius - d + self.eps) * share)
} else {
let e = d - s.radius;
(&mut far, share / (e * e))
};
acc.0 += w;
for (k, v) in s.rgb.iter().chain(std::iter::once(&s.opacity)).enumerate() {
acc.1[k] += w * *v as f64;
}
};
for s in &self.points {
add(s.at.distance(p), s, 1.0);
}
for piece in &self.pieces {
let (d, s) = piece.nearest(p);
add(d, &s, piece.share);
}
let (w, acc) = if disc.0 > 0.0 { disc } else { far };
if !(w > 0.0 && w.is_finite()) {
return ([0.0; 3], 0.0);
}
let c = |k: usize| (acc[k] / w) as f32;
([c(0), c(1), c(2)], c(3))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn red() -> Color {
Color::rgb(1.0, 0.0, 0.0)
}
fn blue() -> Color {
Color::rgb(0.0, 0.0, 1.0)
}
fn two(spread: f32) -> Freeform {
Freeform {
points: vec![
FreeformPoint { spread, ..FreeformPoint::new(Point::new(0.0, 0.0), red()) },
FreeformPoint { spread, opacity: 0.5, ..FreeformPoint::new(Point::new(100.0, 0.0), blue()) },
],
..Default::default()
}
}
fn close(a: [f32; 3], b: [f32; 3]) -> bool {
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-4)
}
#[test]
fn the_sample_at_a_point_is_its_colour() {
let f = two(0.0);
let (c, o) = f.sample(Point::new(0.0, 0.0), 50.0);
assert!(close(c.to_rgb(), [1.0, 0.0, 0.0]) && (o - 1.0).abs() < 1e-6, "{c:?} {o}");
let (c, o) = f.sample(Point::new(100.0, 0.0), 50.0);
assert!(close(c.to_rgb(), [0.0, 0.0, 1.0]) && (o - 0.5).abs() < 1e-6);
// Half way between equal points: an even mix.
let (c, o) = f.sample(Point::new(50.0, 0.0), 50.0);
assert!(close(c.to_rgb(), [0.5, 0.0, 0.5]) && (o - 0.75).abs() < 1e-6, "{c:?}");
}
#[test]
fn spread_keeps_the_colour_pure_inside_and_falls_off_outside() {
// Scale 100: a 0.2 spread is a 20 pt disc.
let mut f = two(0.0);
f.points[0].spread = 0.2;
let at = |x: f64| f.sample(Point::new(x, 0.0), 100.0).0.to_rgb()[0];
assert!((at(15.0) - 1.0).abs() < 1e-6, "inside the disc: pure red");
assert!(at(30.0) < 1.0 && at(30.0) > at(50.0) && at(50.0) > at(70.0), "falls off: {} {} {}", at(30.0), at(50.0), at(70.0));
// Without the spread the same spot is bluer.
let plain = two(0.0).sample(Point::new(30.0, 0.0), 100.0).0.to_rgb()[0];
assert!(at(30.0) > plain);
// Overlapping discs blend continuously (no jump at the edge of the smaller one).
f.points[1].spread = 0.9;
let a = f.sample(Point::new(10.01, 0.0), 100.0).0.to_rgb()[0];
let b = f.sample(Point::new(9.99, 0.0), 100.0).0.to_rgb()[0];
assert!((a - b).abs() < 0.01, "{a} vs {b}");
}
#[test]
fn lines_carry_colour_between_their_points() {
let mut f = two(0.0);
f.points.push(FreeformPoint::new(Point::new(50.0, 100.0), Color::rgb(0.0, 1.0, 0.0)));
let off_line = f.sample(Point::new(50.0, 5.0), 100.0).0.to_rgb();
f.add_line(vec![0, 1]).unwrap();
let on_line = f.sample(Point::new(50.0, 5.0), 100.0).0.to_rgb();
// Near the line's middle the red-blue mix dominates the green point.
assert!(on_line[1] < off_line[1], "{on_line:?} vs {off_line:?}");
let hit = f.nearest_on_lines(Point::new(50.0, 7.0)).unwrap();
assert_eq!((hit.line, hit.segment), (0, 0));
assert!((hit.t - 0.5).abs() < 1e-3 && (hit.distance - 7.0).abs() < 1e-3, "{hit:?}");
}
#[test]
fn the_field_is_continuous_around_lines() {
// A bent line: where the nearest spot on it jumps the colour must not.
let mut f = two(0.0);
f.points.push(FreeformPoint::new(Point::new(50.0, 100.0), Color::rgb(0.0, 1.0, 0.0)));
f.points.push(FreeformPoint { spread: 0.1, ..FreeformPoint::new(Point::new(-50.0, 80.0), Color::WHITE) });
f.add_line(vec![0, 1, 2]).unwrap();
let field = f.field(100.0);
let at = |x: f64, y: f64| field.sample(Point::new(x, y)).0;
for i in 0..80 {
for j in 0..80 {
let (x, y) = (-60.0 + i as f64 * 2.5, -60.0 + j as f64 * 2.5);
let (a, b, c) = (at(x, y), at(x + 0.5, y), at(x, y + 0.5));
let step = (0..3).map(|k| (a[k] - b[k]).abs().max((a[k] - c[k]).abs())).fold(0.0, f32::max);
assert!(step < 0.06, "jump of {step} at ({x}, {y})");
}
}
}
#[test]
fn auto_places_four_or_more_points_inside_the_shape() {
let g = Gradient::default();
let b = Rect::new(0.0, 0.0, 200.0, 100.0);
// A triangle with its apex at the top middle.
let inside = |p: Point| p.y >= 0.0 && p.y <= 100.0 && (p.x - 100.0).abs() <= p.y;
let f = Freeform::auto(b, &g, &inside);
assert!(f.points.len() >= 4, "{}", f.points.len());
assert!(f.points.iter().all(|p| inside(p.at)), "{:?}", f.points);
// Colours run from the gradient's start to its end.
assert_eq!((f.points[0].color.to_hex(), f.points.last().unwrap().color.to_hex()), ("#ffffff".into(), "#000000".into()));
// A long box gets more points along its length.
assert!(Freeform::auto(Rect::new(0.0, 0.0, 400.0, 100.0), &g, &|_| true).points.len() > 4);
}
#[test]
fn editing_keeps_lines_consistent() {
let mut f = two(0.0);
let c = f.add_point(FreeformPoint::new(Point::new(200.0, 0.0), red()));
assert_eq!(f.connect(0, 1), Some(0));
assert_eq!(f.connect(1, c), Some(0), "extends the line ending at 1");
assert_eq!(f.lines, vec![vec![0, 1, 2]]);
let mid = f.split_line(0, 0, 0.5).unwrap();
assert_eq!((mid, &f.lines[0]), (3, &vec![0, 3, 1, 2]));
let p = f.points[3];
assert!(p.at.y.abs() < 1e-9 && p.at.x > 0.0 && p.at.x < 100.0 && (p.opacity - 0.75).abs() < 1e-6, "on the curve, half way: {p:?}");
assert!(f.remove_point(1));
assert_eq!(f.lines, vec![vec![0, 2, 1]]);
assert!(f.add_line(vec![0, 9]).is_err() && f.add_line(vec![0]).is_err());
f.remove_point(0);
f.remove_point(0);
assert!(f.lines.is_empty() && f.points.len() == 1);
assert!(!f.remove_point(0), "the last point stays");
assert_eq!(f.stops().len(), 2);
}
#[test]
fn serde_defaults_and_mode() {
let f: Freeform = serde_json::from_str(r#"{"points":[{"at":{"x":1.0,"y":2.0},"color":{"model":"rgb","r":1.0,"g":0.0,"b":0.0}}]}"#).unwrap();
assert_eq!((f.points[0].opacity, f.points[0].spread, f.mode), (1.0, DEFAULT_SPREAD, FreeformMode::Points));
let s = serde_json::to_string(&f).unwrap();
assert!(!s.contains("\"lines\"") && !s.contains("\"mode\""), "{s}");
assert_eq!(FreeformMode::parse("LINES"), Some(FreeformMode::Lines));
}
}
+605 -35
View File
@@ -1,20 +1,27 @@
//! Gradients: definitions (swatch-able) and their placement on an object.
use std::borrow::Cow;
use kurbo::{Affine, Point, Rect, Vec2};
use serde::{Deserialize, Serialize};
use crate::Color;
use crate::freeform::Freeform;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum GradientKind {
#[default]
Linear,
Radial,
/// Freeform gradients (points/lines) — rendered via a mesh approximation.
/// Freeform gradients: colour points and lines (see [`crate::freeform`]).
Freeform,
}
impl GradientKind {
/// Parse a kind name (`linear`, `radial`, `freeform`; any case).
pub fn parse(s: &str) -> Option<Self> {
[GradientKind::Linear, GradientKind::Radial, GradientKind::Freeform].into_iter().find(|k| k.label().eq_ignore_ascii_case(s))
}
pub fn label(self) -> &'static str {
match self {
GradientKind::Linear => "Linear",
@@ -24,7 +31,7 @@ impl GradientKind {
}
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct GradientStop {
/// 0..=1 along the gradient.
pub offset: f32,
@@ -34,6 +41,27 @@ pub struct GradientStop {
/// Midpoint to the next stop, 0.13..=0.87 (Illustrator's diamond), default 0.5.
#[serde(default = "half")]
pub midpoint: f32,
/// The global or spot swatch this stop's colour is linked to (edits to the swatch recolour it).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub swatch: Option<String>,
/// Tint of the linked swatch (0..1; 1 without a link), as on a solid paint.
#[serde(default = "crate::full_tint", skip_serializing_if = "crate::is_full_tint")]
pub tint: f32,
}
impl GradientStop {
/// An opaque, unlinked stop of `color` at `offset` with a centred midpoint.
pub fn new(offset: f32, color: Color) -> Self {
Self { offset, color, opacity: 1.0, midpoint: 0.5, swatch: None, tint: 1.0 }
}
/// Give the stop `color`, linked to `link` (a global swatch and tint) or unlinked.
pub fn set_color(&mut self, color: Color, link: Option<(String, f32)>) {
self.color = color;
(self.swatch, self.tint) = match link {
Some((n, t)) => (Some(n), t),
None => (None, 1.0),
};
}
}
fn one() -> f32 {
@@ -52,19 +80,18 @@ pub struct Gradient {
impl Default for Gradient {
/// Illustrator's default "White, Black" gradient.
fn default() -> Self {
Self {
kind: GradientKind::Linear,
stops: vec![
GradientStop { offset: 0.0, color: Color::WHITE, opacity: 1.0, midpoint: 0.5 },
GradientStop { offset: 1.0, color: Color::BLACK, opacity: 1.0, midpoint: 0.5 },
],
}
Self { kind: GradientKind::Linear, stops: vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)] }
}
}
impl Gradient {
/// Colour and opacity at `t` (honours midpoints).
pub fn sample(&self, t: f32) -> (Color, f32) {
self.sample_with(t, Color::lerp)
}
/// [`Gradient::sample`] with neighbouring stop colours mixed by `mix` (e.g. in their own colour
/// model rather than display RGB).
pub fn sample_with(&self, t: f32, mix: impl Fn(&Color, &Color, f32) -> Color) -> (Color, f32) {
let stops = &self.stops;
if stops.is_empty() {
return (Color::BLACK, 1.0);
@@ -80,26 +107,29 @@ impl Gradient {
// Map through the midpoint: u=mid → 0.5.
let m = a.midpoint.clamp(0.01, 0.99);
let v = if u < m { 0.5 * u / m } else { 0.5 + 0.5 * (u - m) / (1.0 - m) };
return (a.color.lerp(&b.color, v), a.opacity + (b.opacity - a.opacity) * v);
return (mix(&a.color, &b.color, v), a.opacity + (b.opacity - a.opacity) * v);
}
}
let l = stops.last().unwrap();
(l.color, l.opacity)
match stops.last() {
Some(l) => (l.color, l.opacity),
None => (Color::BLACK, 1.0),
}
}
/// Stops expanded so that midpoints are represented as explicit stops (for renderers without midpoints).
pub fn expanded_stops(&self) -> Vec<(f32, Color, f32)> {
let mut out = Vec::new();
for (i, s) in self.stops.iter().enumerate() {
out.push((s.offset, s.color, s.opacity));
if let Some(n) = self.stops.get(i + 1)
&& (s.midpoint - 0.5).abs() > 1e-3
{
self.expanded().map(|(t, c, o, _)| (t, c, o)).collect()
}
/// [`Self::expanded_stops`], each stop that stands for a midpoint with that midpoint (`None`
/// on the gradient's own stops).
pub fn expanded(&self) -> impl Iterator<Item = (f32, Color, f32, Option<f32>)> + '_ {
self.stops.iter().enumerate().flat_map(move |(i, s)| {
let mid = self.stops.get(i + 1).filter(|_| (s.midpoint - 0.5).abs() > 1e-3).map(|n| {
let t = s.offset + (n.offset - s.offset) * s.midpoint;
let (c, o) = self.sample(t);
out.push((t, c, o));
}
}
out
(t, c, o, Some(s.midpoint))
});
std::iter::once((s.offset, s.color, s.opacity, None)).chain(mid)
})
}
pub fn reverse(&mut self) {
self.stops.reverse();
@@ -110,6 +140,121 @@ impl Gradient {
pub fn sort(&mut self) {
self.stops.sort_by(|a, b| a.offset.total_cmp(&b.offset));
}
/// The part of the gradient from `from` to `to` (0..1, either way round) as a linear gradient
/// of its own running 0 → 1: the same colours (midpoints become stops, as in
/// [`Self::expanded_stops`]). One colour throughout when `from == to`.
pub fn span(&self, from: f32, to: f32) -> Gradient {
if to < from {
let mut g = self.span(to, from);
g.reverse();
return g;
}
let stop = |offset: f32, t: f32| {
let (color, opacity) = self.sample(t);
GradientStop { opacity, ..GradientStop::new(offset, color) }
};
let d = to - from;
let mut stops = vec![stop(0.0, from)];
if d > 1e-6 {
stops.extend(
self.expanded_stops()
.into_iter()
.filter(|(t, ..)| *t > from && *t < to)
.map(|(t, color, opacity)| GradientStop { opacity, ..GradientStop::new((t - from) / d, color) }),
);
}
stops.push(stop(1.0, to));
Gradient { kind: GradientKind::Linear, stops }
}
}
// ---------- stop editing (the Gradient panel, the annotator and agents share these) ----------
/// Fewest stops a gradient keeps: deleting a stop never goes below this.
pub const MIN_STOPS: usize = 2;
/// Put `s` among `stops` (sorted by offset, after stops at the same offset). Returns its index.
fn place_stop(stops: &mut Vec<GradientStop>, s: GradientStop) -> usize {
let i = stops.iter().position(|o| o.offset > s.offset).unwrap_or(stops.len());
stops.insert(i, s);
i
}
/// Insert a stop at `offset`, coloured by sampling the gradient there. Returns the new stops and
/// the new stop's index.
pub fn insert_stop(g: &Gradient, offset: f32) -> (Vec<GradientStop>, usize) {
let offset = offset.clamp(0.0, 1.0);
let (color, opacity) = g.sample(offset);
let mut stops = g.stops.clone();
let i = place_stop(&mut stops, GradientStop { opacity, ..GradientStop::new(offset, color) });
(stops, i)
}
/// Remove stop `i`; `None` when that would leave fewer than [`MIN_STOPS`].
pub fn remove_stop(stops: &[GradientStop], i: usize) -> Option<Vec<GradientStop>> {
if stops.len() <= MIN_STOPS || i >= stops.len() {
return None;
}
let mut v = stops.to_vec();
v.remove(i);
Some(v)
}
/// Move stop `i` to `offset`, keeping the list sorted. Returns the stops and the stop's new index.
pub fn move_stop(stops: &[GradientStop], i: usize, offset: f32) -> (Vec<GradientStop>, usize) {
let mut v = stops.to_vec();
if i >= v.len() {
return (v, i);
}
let mut s = v.remove(i);
s.offset = offset.clamp(0.0, 1.0);
let ni = place_stop(&mut v, s);
(v, ni)
}
/// Add a copy of stop `i` at `offset` (Alt-drag). Returns the stops and the copy's index.
pub fn duplicate_stop(stops: &[GradientStop], i: usize, offset: f32) -> (Vec<GradientStop>, usize) {
let mut v = stops.to_vec();
let Some(s) = v.get(i).cloned() else { return (v, i) };
let ni = place_stop(&mut v, GradientStop { offset: offset.clamp(0.0, 1.0), ..s });
(v, ni)
}
/// Swap the colours of stops `a` and `b` (Alt-dropping one stop on another), with their swatch
/// links and tints; offsets, opacities and midpoints stay. Out-of-range indices leave the stops
/// unchanged.
pub fn swap_stop_colors(stops: &[GradientStop], a: usize, b: usize) -> Vec<GradientStop> {
let mut v = stops.to_vec();
if a < v.len() && b < v.len() && a != b {
let (lo, hi) = v.split_at_mut(a.max(b));
let (x, y) = (&mut lo[a.min(b)], &mut hi[0]);
std::mem::swap(&mut x.color, &mut y.color);
std::mem::swap(&mut x.swatch, &mut y.swatch);
std::mem::swap(&mut x.tint, &mut y.tint);
}
v
}
/// Set the midpoint between stop `i` and `i + 1` (clamped to the diamond's 13–87 %).
pub fn set_midpoint(stops: &[GradientStop], i: usize, m: f32) -> Vec<GradientStop> {
let mut v = stops.to_vec();
if let Some(s) = v.get_mut(i) {
s.midpoint = m.clamp(0.13, 0.87);
}
v
}
/// Absolute position (0..1) of the midpoint diamond after stop `i`.
pub fn midpoint_pos(stops: &[GradientStop], i: usize) -> Option<f32> {
let (a, b) = (stops.get(i)?, stops.get(i + 1)?);
Some(a.offset + (b.offset - a.offset) * a.midpoint)
}
/// Inverse of [`midpoint_pos`]: the relative midpoint for an absolute position.
pub fn midpoint_from_pos(stops: &[GradientStop], i: usize, pos: f32) -> Option<f32> {
let (a, b) = (stops.get(i)?, stops.get(i + 1)?);
let span = (b.offset - a.offset).max(1e-6);
Some(((pos - a.offset) / span).clamp(0.13, 0.87))
}
/// Where the gradient sits on an object, in document coordinates.
@@ -120,34 +265,162 @@ pub struct GradientGeom {
/// Radial aspect ratio (height / width), 1 = circle.
#[serde(default = "one64")]
pub aspect: f64,
/// A radial gradient's focal point, where its first stop sits (an off-centre radial), in the
/// same space; inside the extent ellipse. None: the centre (`start`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub focal: Option<Point>,
}
/// How far out a focal point may sit, as a fraction of the extent ellipse.
const FOCAL_MAX: f64 = 0.99;
fn one64() -> f64 {
1.0
}
impl GradientGeom {
/// Default placement for a bounding box: linear spans left→right through the centre at `angle_deg`,
/// radial is centred with radius = half the larger dimension.
/// Default placement for a bounding box: linear spans the box through the centre at `angle_deg`,
/// radial is centred with radius = half the larger dimension and its vector at `angle_deg`.
pub fn fit(kind: GradientKind, b: Rect, angle_deg: f64) -> Self {
let c = b.center();
let a = angle_deg.to_radians();
let d = Vec2::new(a.cos(), -a.sin());
match kind {
GradientKind::Radial => {
let r = b.width().max(b.height()) / 2.0;
Self { start: c, end: c + Vec2::new(r, 0.0), aspect: 1.0 }
}
GradientKind::Radial => Self { start: c, end: c + d * (b.width().max(b.height()) / 2.0), aspect: 1.0, focal: None },
_ => {
let a = angle_deg.to_radians();
let d = Vec2::new(a.cos(), -a.sin());
// Project the box corners onto the direction to cover the whole box.
let half = (b.width() * d.x.abs() + b.height() * d.y.abs()) / 2.0;
Self { start: c - d * half, end: c + d * half, aspect: 1.0 }
Self { start: c - d * half, end: c + d * half, aspect: 1.0, focal: None }
}
}
}
pub fn transform(&mut self, a: Affine) {
self.start = a * self.start;
self.end = a * self.end;
/// Map the placement through `a` so the gradient follows its object exactly.
///
/// Linear (and freeform) gradients keep their isolines: the new vector is the normal to the
/// mapped isolines, reaching the line the old end maps onto. Radial gradients map their ellipse
/// (the radius along the vector, `aspect` × the radius across it); its principal axes give the
/// new end (on the axis nearest the mapped vector) and aspect.
pub fn transform(&mut self, a: Affine, kind: GradientKind) {
// The focal point is a point like any other.
self.focal = self.focal.map(|f| a * f);
let [m0, m1, m2, m3, _, _] = a.as_coeffs();
let lin = |v: Vec2| Vec2::new(m0 * v.x + m2 * v.y, m1 * v.x + m3 * v.y);
let perp = |v: Vec2| Vec2::new(-v.y, v.x);
let start = a * self.start;
let u = self.end - self.start;
if u.hypot2() < 1e-18 {
self.start = start;
self.end = start;
return;
}
if kind != GradientKind::Radial {
let far = a * self.end;
let nrm = perp(lin(perp(u)));
self.start = start;
self.end = if nrm.hypot2() > 1e-18 { start + nrm * ((far - start).dot(nrm) / nrm.hypot2()) } else { far };
return;
}
let (lu, lw) = (lin(u), lin(perp(u) * self.aspect));
// Eigen-decomposition of M·Mᵀ with M = [lu lw]: the mapped ellipse's axes and squared radii.
let p = lu.x * lu.x + lw.x * lw.x;
let q = lu.y * lu.y + lw.y * lw.y;
let r = lu.x * lu.y + lw.x * lw.y;
let disc = ((p - q).powi(2) + 4.0 * r * r).sqrt();
let s1 = ((p + q + disc) / 2.0).max(0.0).sqrt();
let s2 = ((p + q - disc) / 2.0).max(0.0).sqrt();
let (axis, len, other) = if disc <= 1e-12 * (p + q) {
// A circle: every direction is an axis, so keep the mapped vector's.
(lu / lu.hypot().max(1e-300), s1, s2)
} else {
let th = 0.5 * (2.0 * r).atan2(p - q);
let e1 = Vec2::new(th.cos(), th.sin());
let e2 = perp(e1);
if e1.dot(lu).abs() >= e2.dot(lu).abs() { (e1, s1, s2) } else { (e2, s2, s1) }
};
let axis = if axis.dot(lu) < 0.0 { -axis } else { axis };
self.start = start;
self.end = start + axis * len;
self.aspect = if len > 1e-12 { other / len } else { 1.0 };
}
/// Move the placement from box `from` to box `to`: the start and end keep their position
/// relative to the box (along a side `from` has no extent on, their offset from its centre).
/// The aspect ratio is kept.
pub fn rebase(&mut self, from: Rect, to: Rect) {
self.start = rebase_point(self.start, from, to);
self.end = rebase_point(self.end, from, to);
self.focal = self.focal.map(|f| rebase_point(f, from, to));
}
/// The gradient parameter (0 at the start, 1 at the end) at document point `p`: the projection
/// onto the vector for linear gradients, the elliptical radius (honouring `aspect`) for radial,
/// measured from the focal point (each level is the ellipse scaled about the line from the
/// focal point to the centre).
pub fn param_at(&self, kind: GradientKind, p: Point) -> f64 {
let u = self.end - self.start;
if u.hypot2() < 1e-18 {
return 0.0;
}
if kind != GradientKind::Radial {
return (p - self.start).dot(u) / u.hypot2();
}
let Some(to_unit) = self.unit_frame().map(|m| m.inverse()) else { return 0.0 };
let q = (to_unit * p).to_vec2();
let Some(f) = self.focal.map(|f| (to_unit * f).to_vec2()) else { return q.hypot() };
// The level t through q: |q - f(1 - t)| = t, the root at t >= 0 (|f| < 1).
let d = q - f;
let a = f.hypot2() - 1.0;
if a.abs() < 1e-12 {
return q.hypot();
}
let b = d.dot(f);
(-b - (b * b - a * d.hypot2()).max(0.0).sqrt()) / a
}
/// The map from the unit circle onto a radial's extent ellipse: (1, 0) to the end, (0, 1) to
/// `aspect` × the radius across the vector. None when the ellipse is flat (no inverse).
pub fn unit_frame(&self) -> Option<Affine> {
let u = self.end - self.start;
(u.hypot2() * self.aspect.abs() > 1e-18).then(|| Affine::new([u.x, u.y, -u.y * self.aspect, u.x * self.aspect, self.start.x, self.start.y]))
}
/// The map that squashes a radial gradient's circle (centre `start`, radius the vector's
/// length) into its extent ellipse: by `aspect` across the vector, about the centre.
pub fn radial_squash(&self) -> Affine {
let angle = (self.end - self.start).atan2();
let c = self.start.to_vec2();
Affine::translate(c)
* Affine::rotate(angle)
* Affine::scale_non_uniform(1.0, self.aspect.max(1e-3))
* Affine::rotate(-angle)
* Affine::translate(-c)
}
/// Where a radial gradient's first stop sits: its focal point, else its centre.
pub fn focal_point(&self) -> Point {
self.focal.unwrap_or(self.start)
}
/// Set the focal point, pulled inside the extent ellipse; one on the centre (or a gradient
/// with no extent) has none.
pub fn set_focal(&mut self, f: Option<Point>) {
self.focal = f.and_then(|f| {
let m = self.unit_frame()?;
let q = (m.inverse() * f).to_vec2();
let r = q.hypot();
(r > 1e-6).then(|| if r > FOCAL_MAX { m * (q * (FOCAL_MAX / r)).to_point() } else { f })
});
}
/// After a change of the vector or aspect from `before`, put the focal point back where it
/// was relative to the extent ellipse.
pub fn keep_focal_from(&mut self, before: &GradientGeom) {
let rel = before.focal.zip(before.unit_frame()).map(|(f, m)| m.inverse() * f);
self.focal = None;
if let (Some(q), Some(m)) = (rel, self.unit_frame()) {
self.set_focal(Some(m * q));
}
}
pub fn angle_deg(&self) -> f64 {
let v = self.end - self.start;
@@ -158,6 +431,15 @@ impl GradientGeom {
}
}
/// `p` moved from box `from` to box `to`, keeping its position relative to the box (along a side
/// `from` has no extent on, its offset from the centre).
fn rebase_point(p: Point, from: Rect, to: Rect) -> Point {
let axis = |v: f64, a0: f64, a1: f64, b0: f64, b1: f64| {
if (a1 - a0).abs() > 1e-12 { b0 + (v - a0) / (a1 - a0) * (b1 - b0) } else { v - (a0 + a1) / 2.0 + (b0 + b1) / 2.0 }
};
Point::new(axis(p.x, from.x0, from.x1, to.x0, to.x1), axis(p.y, from.y0, from.y1, to.y0, to.y1))
}
/// A gradient applied to a fill or stroke.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct GradientPaint {
@@ -170,15 +452,80 @@ pub struct GradientPaint {
/// Name of the gradient swatch, if linked.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub swatch: Option<String>,
/// A freeform gradient's points, in the same space as `geom` (None: placed automatically on
/// the object's box each render, coloured along the stops).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub freeform: Option<Freeform>,
}
impl GradientPaint {
pub fn new(gradient: Gradient) -> Self {
Self { gradient, geom: None, angle: 0.0, swatch: None }
Self { gradient, geom: None, angle: 0.0, swatch: None, freeform: None }
}
/// The freeform gradient drawn on box `bounds`: its own points, else the automatic ones
/// (fitted to the box: [`Freeform::auto`] without a shape to keep them inside).
pub fn freeform_on(&self, bounds: Rect) -> Cow<'_, Freeform> {
match &self.freeform {
Some(f) if !f.points.is_empty() => Cow::Borrowed(f),
f => Cow::Owned(Freeform {
mode: f.as_ref().map_or_else(Default::default, |f| f.mode),
..Freeform::auto(bounds, &self.gradient, &|_| true)
}),
}
}
/// Does it carry its own freeform points (rather than placing them on each render)?
pub fn has_freeform_points(&self) -> bool {
self.freeform.as_ref().is_some_and(|f| !f.points.is_empty())
}
/// Give a freeform gradient without points its automatic ones on `bounds`, kept `inside` the
/// shape (the Draw mode stays).
pub fn seed_freeform(&mut self, bounds: Rect, inside: &dyn Fn(Point) -> bool) {
if self.gradient.kind == GradientKind::Freeform && !self.has_freeform_points() {
let mode = self.freeform.as_ref().map_or_else(Default::default, |f| f.mode);
self.set_freeform(Freeform { mode, ..Freeform::auto(bounds, &self.gradient, inside) });
}
}
/// Set the freeform points, with the stops following their colours (what swatch chips,
/// exports without freeform shading and re-seeding on other art show).
pub fn set_freeform(&mut self, f: Freeform) {
self.gradient.stops = f.stops();
self.freeform = Some(f);
}
pub fn resolve(&self, bounds: Rect) -> GradientGeom {
self.geom.unwrap_or_else(|| GradientGeom::fit(self.gradient.kind, bounds, self.angle))
}
/// Is the gradient placed in its paint's space (its vector, and a freeform gradient's points),
/// rather than fitted to the object's box on each render?
pub fn is_placed(&self) -> bool {
self.geom.is_some() && (self.gradient.kind != GradientKind::Freeform || self.has_freeform_points())
}
/// Fix an unplaced gradient (geom None) to its fit on `bounds`, so it can follow transforms
/// that refitting wouldn't reproduce (rotation, shear, non-uniform scale, warps).
/// Freeform gradients get their automatic points.
pub fn pin(&mut self, bounds: Rect) {
if self.geom.is_none() {
self.geom = Some(self.resolve(bounds));
}
self.seed_freeform(bounds, &|_| true);
}
/// Move a placed gradient from box `from` to box `to` (see [`GradientGeom::rebase`]).
pub fn rebase(&mut self, from: Rect, to: Rect) {
if let Some(g) = &mut self.geom {
g.rebase(from, to);
}
if let Some(f) = &mut self.freeform {
f.map_points(|p| rebase_point(p, from, to));
}
}
/// Map a placed gradient (and freeform points) through `a` (see [`GradientGeom::transform`]).
pub fn transform(&mut self, a: Affine) {
if let Some(g) = &mut self.geom {
g.transform(a, self.gradient.kind);
}
if let Some(f) = &mut self.freeform {
f.transform(a);
}
}
}
#[cfg(test)]
@@ -221,4 +568,227 @@ mod tests {
assert_eq!(g.stops[0].color.to_hex(), "#000000");
assert_eq!(g.stops[0].offset, 0.0);
}
fn close(a: Point, b: Point) -> bool {
a.distance(b) < 1e-9
}
fn linear(start: (f64, f64), end: (f64, f64)) -> GradientGeom {
GradientGeom { start: start.into(), end: end.into(), aspect: 1.0, focal: None }
}
#[test]
fn rotate_90_gives_vertical_vector() {
let mut g = linear((0.0, 0.0), (100.0, 0.0));
g.transform(Affine::rotate(std::f64::consts::FRAC_PI_2), GradientKind::Linear);
assert!(close(g.start, Point::ZERO) && close(g.end, Point::new(0.0, 100.0)), "{g:?}");
assert!((g.angle_deg() + 90.0).abs() < 1e-9);
}
#[test]
fn reflect_swaps_start_and_end() {
let mut g = linear((0.0, 25.0), (100.0, 25.0));
// Reflect across the vertical axis through x = 50.
let flip = Affine::translate((50.0, 0.0)) * Affine::scale_non_uniform(-1.0, 1.0) * Affine::translate((-50.0, 0.0));
g.transform(flip, GradientKind::Linear);
assert!(close(g.start, Point::new(100.0, 25.0)) && close(g.end, Point::new(0.0, 25.0)), "{g:?}");
}
#[test]
fn non_uniform_scale_turns_a_circle_into_an_ellipse() {
let mut g = GradientGeom { start: Point::new(10.0, 10.0), end: Point::new(20.0, 10.0), aspect: 1.0, focal: None };
g.transform(Affine::scale_non_uniform(2.0, 1.0), GradientKind::Radial);
assert!(close(g.start, Point::new(20.0, 10.0)) && close(g.end, Point::new(40.0, 10.0)), "{g:?}");
assert!((g.aspect - 0.5).abs() < 1e-9);
// A vertical vector keeps its axis: the ellipse is twice as wide as the vector is long.
let mut v = GradientGeom { start: Point::ZERO, end: Point::new(0.0, -10.0), aspect: 1.0, focal: None };
v.transform(Affine::scale_non_uniform(2.0, 1.0), GradientKind::Radial);
assert!(close(v.end, Point::new(0.0, -10.0)) && (v.aspect - 2.0).abs() < 1e-9, "{v:?}");
}
#[test]
fn samples_follow_shear_and_rotation() {
let shear = Affine::new([1.0, 0.3, 0.7, 1.2, 15.0, -4.0]) * Affine::rotate(0.4);
for kind in [GradientKind::Linear, GradientKind::Radial] {
let g = GradientGeom { start: Point::new(30.0, 40.0), end: Point::new(80.0, 55.0), aspect: 0.6, focal: None };
let mut m = g;
m.transform(shear, kind);
for p in [Point::new(30.0, 40.0), Point::new(55.0, 70.0), Point::new(90.0, 10.0), Point::new(-20.0, 48.0)] {
let (a, b) = (g.param_at(kind, p), m.param_at(kind, shear * p));
assert!((a - b).abs() < 1e-9, "{kind:?} at {p:?}: {a} vs {b}");
}
}
}
#[test]
fn radial_fit_uses_the_angle_and_param_honours_aspect() {
let b = Rect::new(0.0, 0.0, 100.0, 60.0);
let g = GradientGeom::fit(GradientKind::Radial, b, 90.0);
assert!(close(g.start, Point::new(50.0, 30.0)) && close(g.end, Point::new(50.0, -20.0)), "end above the centre: {g:?}");
let e = GradientGeom { start: Point::ZERO, end: Point::new(10.0, 0.0), aspect: 0.5, focal: None };
assert!((e.param_at(GradientKind::Radial, Point::new(0.0, 5.0)) - 1.0).abs() < 1e-12);
assert!((e.param_at(GradientKind::Radial, Point::new(5.0, 0.0)) - 0.5).abs() < 1e-12);
assert!((e.param_at(GradientKind::Linear, Point::new(5.0, 7.0)) - 0.5).abs() < 1e-12);
}
fn g3() -> Gradient {
let mut g = Gradient::default();
g.stops.insert(1, GradientStop::new(0.5, Color::rgb(1.0, 0.0, 0.0)));
g
}
#[test]
fn insert_samples_color_and_sorts() {
let g = Gradient::default();
let (stops, i) = insert_stop(&g, 0.25);
assert_eq!(stops.len(), 3);
assert_eq!(i, 1);
let r = stops[1].color.to_rgb()[0];
assert!((r - 0.75).abs() < 1e-4, "sampled {r}");
let (stops, i) = insert_stop(&g, 2.0);
assert_eq!(i, 2);
assert_eq!(stops[2].offset, 1.0);
}
#[test]
fn remove_keeps_two() {
let g = g3();
let v = remove_stop(&g.stops, 1).unwrap();
assert_eq!(v.len(), 2);
assert!(remove_stop(&v, 0).is_none());
assert!(remove_stop(&g.stops, 9).is_none());
}
#[test]
fn move_reorders_and_tracks_index() {
let g = g3();
let (v, i) = move_stop(&g.stops, 0, 0.8);
assert_eq!(i, 1);
assert_eq!(v[1].color.to_hex(), "#ffffff");
assert!(v.windows(2).all(|w| w[0].offset <= w[1].offset));
let (v, i) = move_stop(&g.stops, 1, -3.0);
// Clamped to 0 and placed after the existing stop at 0.
assert_eq!((i, v[i].offset, v[i].color.to_hex()), (1, 0.0, "#ff0000".to_string()));
}
#[test]
fn duplicate_copies_the_stop_to_a_new_offset() {
let g = g3();
let (v, i) = duplicate_stop(&g.stops, 1, 0.9);
assert_eq!((v.len(), i, v[i].offset, v[i].color.to_hex()), (4, 2, 0.9, "#ff0000".to_string()));
assert_eq!(v[1], g.stops[1], "the original stays");
assert_eq!(duplicate_stop(&g.stops, 7, 0.5).0, g.stops);
}
#[test]
fn swap_exchanges_only_the_colours() {
let mut g = g3();
g.stops[0].opacity = 0.25;
let v = swap_stop_colors(&g.stops, 0, 1);
assert_eq!((v[0].color.to_hex(), v[1].color.to_hex()), ("#ff0000".to_string(), "#ffffff".to_string()));
assert_eq!((v[0].offset, v[0].opacity, v[1].offset, v[1].opacity), (0.0, 0.25, 0.5, 1.0));
assert_eq!(swap_stop_colors(&g.stops, 0, 9), g.stops);
}
#[test]
fn midpoint_math() {
let g = g3();
assert_eq!(midpoint_pos(&g.stops, 0), Some(0.25));
assert_eq!(midpoint_pos(&g.stops, 2), None);
let m = midpoint_from_pos(&g.stops, 1, 0.6).unwrap();
assert!((m - 0.2).abs() < 1e-5);
assert_eq!(midpoint_from_pos(&g.stops, 1, 0.51).unwrap(), 0.13);
let v = set_midpoint(&g.stops, 0, 0.99);
assert_eq!(v[0].midpoint, 0.87);
}
#[test]
fn rebase_keeps_relative_positions() {
let mut g = GradientGeom { start: Point::new(110.0, 150.0), end: Point::new(190.0, 120.0), aspect: 0.5, focal: None };
g.rebase(Rect::new(100.0, 100.0, 200.0, 200.0), Rect::new(500.0, 0.0, 700.0, 50.0));
assert!(close(g.start, Point::new(520.0, 25.0)) && close(g.end, Point::new(680.0, 10.0)) && g.aspect == 0.5, "{g:?}");
// A box without height (a horizontal line) keeps the offset from its centre line.
let mut h = GradientGeom { start: Point::new(0.0, 12.0), end: Point::new(10.0, 10.0), aspect: 1.0, focal: None };
h.rebase(Rect::new(0.0, 10.0, 10.0, 10.0), Rect::new(0.0, 0.0, 20.0, 40.0));
assert!(close(h.start, Point::new(0.0, 22.0)) && close(h.end, Point::new(20.0, 20.0)), "{h:?}");
// Unplaced paints have nothing to move.
let mut p = GradientPaint::new(Gradient::default());
p.rebase(Rect::new(0.0, 0.0, 1.0, 1.0), Rect::new(5.0, 5.0, 9.0, 9.0));
assert_eq!(p.geom, None);
}
#[test]
fn pin_fixes_the_fit_once() {
let mut p = GradientPaint::new(Gradient::default());
p.angle = 90.0;
let b = Rect::new(0.0, 0.0, 100.0, 50.0);
p.pin(b);
assert_eq!(p.geom, Some(GradientGeom::fit(GradientKind::Linear, b, 90.0)));
p.pin(Rect::new(0.0, 0.0, 1.0, 1.0));
assert_eq!(p.geom, Some(GradientGeom::fit(GradientKind::Linear, b, 90.0)));
}
/// A 100 pt circle at (200, 200) with its focal point 40 pt left of the centre.
fn off_centre() -> GradientGeom {
let mut g = GradientGeom { start: Point::new(200.0, 200.0), end: Point::new(300.0, 200.0), aspect: 1.0, focal: None };
g.set_focal(Some(Point::new(160.0, 200.0)));
g
}
#[test]
fn focal_round_trips_through_serde_and_defaults_to_none() {
let g = off_centre();
let s = serde_json::to_string(&g).unwrap();
assert!(s.contains("\"focal\""), "{s}");
assert_eq!(serde_json::from_str::<GradientGeom>(&s).unwrap(), g);
// Placements saved before focal points existed load centred, and centred ones write none.
let old: GradientGeom = serde_json::from_str(r#"{"start":{"x":0.0,"y":0.0},"end":{"x":1.0,"y":0.0}}"#).unwrap();
assert_eq!((old.focal, old.aspect), (None, 1.0));
assert!(!serde_json::to_string(&old).unwrap().contains("focal"));
}
#[test]
fn focal_follows_transforms_and_rebases() {
let mut g = off_centre();
let a = Affine::translate((10.0, -5.0)) * Affine::rotate(0.7) * Affine::scale_non_uniform(2.0, 0.5);
let f = a * g.focal.unwrap();
g.transform(a, GradientKind::Radial);
assert!(close(g.focal.unwrap(), f), "{g:?}");
// Still at the same parameter: the first stop.
assert!(g.param_at(GradientKind::Radial, f).abs() < 1e-9);
let mut h = off_centre();
h.rebase(Rect::new(100.0, 100.0, 300.0, 300.0), Rect::new(0.0, 0.0, 100.0, 100.0));
assert!(close(h.focal.unwrap(), Point::new(30.0, 50.0)) && close(h.start, Point::new(50.0, 50.0)), "{h:?}");
}
#[test]
fn param_runs_from_the_focal_point_to_the_ellipse() {
let g = off_centre();
let t = |x: f64, y: f64| g.param_at(GradientKind::Radial, Point::new(x, y));
assert!(t(160.0, 200.0).abs() < 1e-9);
// The extent circle is the last stop all round.
for a in [0.0f64, 1.0, 2.5, 4.0] {
let p = Point::new(200.0 + 100.0 * a.cos(), 200.0 + 100.0 * a.sin());
assert!((g.param_at(GradientKind::Radial, p) - 1.0).abs() < 1e-9, "{a}");
}
// The centre lies on the level-2/7 circle: centred 2/7 of the way back to the focal point
// (0.4 of the radius off), with a radius of 2/7.
assert!((t(200.0, 200.0) - 2.0 / 7.0).abs() < 1e-9, "{}", t(200.0, 200.0));
assert!(t(180.0, 200.0) < t(220.0, 200.0), "nearer the focal side is earlier");
assert_eq!(GradientGeom { focal: None, ..g }.param_at(GradientKind::Radial, Point::new(250.0, 200.0)), 0.5);
}
#[test]
fn focal_is_kept_inside_the_ellipse_and_follows_its_reshaping() {
let mut g = off_centre();
g.set_focal(Some(Point::new(500.0, 200.0)));
assert!(close(g.focal.unwrap(), Point::new(299.0, 200.0)), "pulled inside: {g:?}");
g.set_focal(Some(Point::new(200.0, 200.0)));
assert_eq!(g.focal, None, "on the centre: centred");
// A new vector and aspect keep it in its place in the ellipse.
let before = off_centre();
let mut h = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(0.0, 50.0), aspect: 0.5, focal: None };
h.keep_focal_from(&before);
assert!(close(h.focal.unwrap(), Point::new(0.0, -20.0)), "{h:?}");
}
}
+457 -33
View File
@@ -1,72 +1,358 @@
//! Colour harmony rules (the Color Guide panel).
//! Colour harmony rules, the variation grid of the Color Guide panel and the five-colour themes of
//! the Color Themes panel (our own colour math).
use crate::Color;
use serde::{Deserialize, Serialize};
use crate::recolor::Palette;
use crate::{Color, keep_model};
/// A harmony rule: a group of colours built from a base colour, base first.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Harmony {
Complementary,
Complementary2,
SplitComplementary,
LeftComplement,
RightComplement,
Analogous,
Triad,
Tetrad,
Square,
Analogous2,
Monochromatic,
Shades,
Triad,
Triad2,
Triad3,
Tetrad,
Tetrad2,
Tetrad3,
Compound,
Compound2,
HighContrast,
HighContrast2,
HighContrast3,
Pentagram,
}
/// One colour of a rule: the base turned by `turn` degrees of hue, its saturation scaled by `sat`
/// and its brightness by `bright` (or flipped to the contrasting brightness).
#[derive(Clone, Copy)]
struct Tone {
turn: f32,
sat: f32,
bright: Bright,
}
#[derive(Clone, Copy)]
enum Bright {
Times(f32),
/// Dark for a light base, light for a dark one ([`contrast`]).
Contrast,
}
const fn sv(turn: f32, sat: f32, bright: f32) -> Tone {
Tone { turn, sat, bright: Bright::Times(bright) }
}
/// The base's hue turned by `turn`.
const fn t(turn: f32) -> Tone {
sv(turn, 1.0, 1.0)
}
/// A softer (half as saturated) partner.
const fn soft(turn: f32) -> Tone {
sv(turn, 0.5, 1.0)
}
/// A deeper (darker) partner.
const fn deep(turn: f32) -> Tone {
sv(turn, 1.0, 0.6)
}
/// A partner of contrasting brightness.
const fn hc(turn: f32) -> Tone {
Tone { turn, sat: 1.0, bright: Bright::Contrast }
}
/// The brightness that contrasts with `v`.
fn contrast(v: f32) -> f32 {
if v >= 0.5 { v * 0.3 } else { 0.7 + 0.3 * v }
}
impl Harmony {
pub const ALL: [Harmony; 9] = [
pub const ALL: [Harmony; 21] = [
Harmony::Complementary,
Harmony::Complementary2,
Harmony::SplitComplementary,
Harmony::LeftComplement,
Harmony::RightComplement,
Harmony::Analogous,
Harmony::Triad,
Harmony::Tetrad,
Harmony::Square,
Harmony::Analogous2,
Harmony::Monochromatic,
Harmony::Shades,
Harmony::Triad,
Harmony::Triad2,
Harmony::Triad3,
Harmony::Tetrad,
Harmony::Tetrad2,
Harmony::Tetrad3,
Harmony::Compound,
Harmony::Compound2,
Harmony::HighContrast,
Harmony::HighContrast2,
Harmony::HighContrast3,
Harmony::Pentagram,
];
pub fn label(self) -> &'static str {
match self {
Harmony::Complementary => "Complementary",
Harmony::Complementary2 => "Complementary 2",
Harmony::SplitComplementary => "Split Complementary",
Harmony::LeftComplement => "Left Complement",
Harmony::RightComplement => "Right Complement",
Harmony::Analogous => "Analogous",
Harmony::Triad => "Triad",
Harmony::Tetrad => "Tetrad",
Harmony::Square => "Square",
Harmony::Analogous2 => "Analogous 2",
Harmony::Monochromatic => "Monochromatic",
Harmony::Shades => "Shades",
Harmony::Triad => "Triad",
Harmony::Triad2 => "Triad 2",
Harmony::Triad3 => "Triad 3",
Harmony::Tetrad => "Tetrad",
Harmony::Tetrad2 => "Tetrad 2",
Harmony::Tetrad3 => "Tetrad 3",
Harmony::Compound => "Compound",
Harmony::Compound2 => "Compound 2",
Harmony::HighContrast => "High Contrast",
Harmony::HighContrast2 => "High Contrast 2",
Harmony::HighContrast3 => "High Contrast 3",
Harmony::Pentagram => "Pentagram",
}
}
/// Colours of the harmony, base colour first.
/// The rule's id in commands: its label in camelCase ("splitComplementary", "triad2").
pub fn id(self) -> String {
let mut out = String::new();
for (i, w) in self.label().split(' ').enumerate() {
let mut c = w.chars();
if let Some(f) = c.next() {
if i == 0 {
out.extend(f.to_lowercase());
} else {
out.extend(f.to_uppercase());
}
out.push_str(c.as_str());
}
}
out
}
/// A rule from its id or label (case and spaces ignored).
pub fn parse(s: &str) -> Option<Self> {
let key = |x: &str| x.chars().filter(|c| !c.is_whitespace()).collect::<String>().to_ascii_lowercase();
let want = key(s);
Self::ALL.into_iter().find(|h| key(h.label()) == want)
}
fn tones(self) -> &'static [Tone] {
match self {
Harmony::Complementary => const { &[t(0.0), t(180.0)] },
Harmony::Complementary2 => const { &[t(0.0), soft(0.0), t(180.0), soft(180.0)] },
Harmony::SplitComplementary => const { &[t(0.0), t(150.0), t(210.0)] },
Harmony::LeftComplement => const { &[t(0.0), t(-30.0), t(150.0)] },
Harmony::RightComplement => const { &[t(0.0), t(30.0), t(210.0)] },
Harmony::Analogous => const { &[t(0.0), t(30.0), t(-30.0)] },
Harmony::Analogous2 => const { &[t(0.0), t(30.0), t(60.0), t(-30.0), t(-60.0)] },
Harmony::Monochromatic => const { &[t(0.0), sv(0.0, 0.5, 1.0), sv(0.0, 1.0, 0.6), sv(0.0, 0.25, 1.0), sv(0.0, 1.0, 0.35)] },
Harmony::Shades => const { &[t(0.0), sv(0.0, 1.0, 0.8), sv(0.0, 1.0, 0.6), sv(0.0, 1.0, 0.4), sv(0.0, 1.0, 0.2)] },
Harmony::Triad => const { &[t(0.0), t(120.0), t(240.0)] },
Harmony::Triad2 => const { &[t(0.0), t(120.0), soft(120.0), t(240.0), soft(240.0)] },
Harmony::Triad3 => const { &[t(0.0), t(120.0), deep(120.0), t(240.0), deep(240.0)] },
Harmony::Tetrad => const { &[t(0.0), t(60.0), t(180.0), t(240.0)] },
Harmony::Tetrad2 => const { &[t(0.0), t(90.0), t(180.0), t(270.0)] },
Harmony::Tetrad3 => const { &[t(0.0), t(30.0), t(180.0), t(210.0)] },
Harmony::Compound => const { &[t(0.0), t(40.0), t(160.0), t(200.0)] },
Harmony::Compound2 => const { &[t(0.0), t(-40.0), t(160.0), t(200.0)] },
Harmony::HighContrast => const { &[t(0.0), hc(180.0)] },
Harmony::HighContrast2 => const { &[t(0.0), hc(0.0), t(180.0), hc(180.0)] },
Harmony::HighContrast3 => const { &[t(0.0), hc(120.0), hc(240.0)] },
Harmony::Pentagram => const { &[t(0.0), t(72.0), t(144.0), t(216.0), t(288.0)] },
}
}
/// The rule's colours in the base colour's model, the base colour itself first.
pub fn apply(self, base: Color) -> Vec<Color> {
let [h, s, v] = base.to_hsb();
let hue = |d: f32| Color::from_hsb(h + d, s, v);
std::iter::once(base)
.chain(self.tones()[1..].iter().map(|tone| {
let b = match tone.bright {
Bright::Times(f) => v * f,
Bright::Contrast => contrast(v),
};
keep_model(base, Color::from_hsb(h + tone.turn, (s * tone.sat).clamp(0.0, 1.0), b.clamp(0.0, 1.0)))
}))
.collect()
}
/// A [`THEME_SIZE`]-colour theme from `base`: the rule's colours, each followed by the lighter or
/// darker variations of it that fill the remaining places (the rule's first colours get them
/// first). The base colour comes first, every colour in its model.
pub fn theme(self, base: Color) -> Vec<Color> {
let colors = self.apply(base);
let n = colors.len();
let mut rows: Vec<Vec<Color>> = colors.iter().map(|c| vec![*c]).collect();
let opts = GuideOptions::default();
for k in 0..THEME_SIZE.saturating_sub(n) {
let (i, pass) = (k % n, k / n);
// Alternate lighter and darker along the rule and from one pass to the next.
let step = if (i + pass) % 2 == 0 { 3 } else { -3 };
rows[i].push(variation(colors[i], &opts, step));
}
rows.into_iter().flatten().take(THEME_SIZE).collect()
}
}
/// The number of colours in a colour theme.
pub const THEME_SIZE: usize = 5;
/// Move colour `i` of a harmony on the hue and saturation wheel to `hsb` (hue in degrees,
/// saturation and brightness 0..1), keeping its model. With `linked` the other colours keep their
/// relation to it: they turn by the same hue, scale their saturation alike and shift their
/// brightness by as much.
pub fn move_on_wheel(colors: &mut [Color], i: usize, hsb: [f32; 3], linked: bool) {
let Some(&old) = colors.get(i) else { return };
let [h0, s0, v0] = old.to_hsb();
let (dh, ratio, dv) = (hsb[0] - h0, if s0 > 0.01 { hsb[1] / s0 } else { 1.0 }, hsb[2] - v0);
for (j, c) in colors.iter_mut().enumerate() {
let new = if j == i {
hsb
} else if linked {
let [h, s, v] = c.to_hsb();
[h + dh, (s * ratio).clamp(0.0, 1.0), (v + dv).clamp(0.0, 1.0)]
} else {
continue;
};
*c = keep_model(*c, Color::from_hsb(new[0], new[1], new[2]));
}
}
/// What the variation grid varies: towards black and white, towards cool and warm, or towards
/// grey and full saturation.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Variation {
#[default]
TintsShades,
WarmCool,
VividMuted,
}
impl Variation {
pub const ALL: [Variation; 3] = [Variation::TintsShades, Variation::WarmCool, Variation::VividMuted];
pub fn id(self) -> &'static str {
match self {
Harmony::Complementary => vec![base, hue(180.0)],
Harmony::SplitComplementary => vec![base, hue(150.0), hue(210.0)],
Harmony::LeftComplement => vec![base, hue(150.0)],
Harmony::RightComplement => vec![base, hue(210.0)],
Harmony::Analogous => vec![hue(-60.0), hue(-30.0), base, hue(30.0), hue(60.0)],
Harmony::Triad => vec![base, hue(120.0), hue(240.0)],
Harmony::Tetrad => vec![base, hue(60.0), hue(180.0), hue(240.0)],
Harmony::Square => vec![base, hue(90.0), hue(180.0), hue(270.0)],
Harmony::Shades => (0..5).map(|i| Color::from_hsb(h, s, (v * (1.0 - i as f32 * 0.18)).max(0.0))).collect(),
Variation::TintsShades => "tintsShades",
Variation::WarmCool => "warmCool",
Variation::VividMuted => "vividMuted",
}
}
pub fn parse(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|v| v.id().eq_ignore_ascii_case(s))
}
/// The ends of the grid: (left, right).
pub fn sides(self) -> (&'static str, &'static str) {
match self {
Variation::TintsShades => ("Shades", "Tints"),
Variation::WarmCool => ("Cool", "Warm"),
Variation::VividMuted => ("Muted", "Vivid"),
}
}
}
/// Tints and shades row for the Color Guide variation grid.
pub fn variations(base: Color, steps: usize) -> Vec<Color> {
let n = steps.max(1) as f32;
(0..steps)
.map(|i| {
let t = (i as f32 + 1.0) / (n + 1.0);
if i < steps / 2 { base.lerp(&Color::BLACK, 1.0 - t * 2.0) } else { base.lerp(&Color::WHITE, (t - 0.5) * 2.0) }
})
.collect()
/// The Color Guide's options: variation kind, steps on each side and how far they reach.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct GuideOptions {
pub variation: Variation,
/// Variations on each side of the harmony colour (1..=20).
pub steps: u32,
/// How far the outermost variations go, 0..=100 (Less … More).
pub amount: f32,
}
impl Default for GuideOptions {
fn default() -> Self {
Self { variation: Variation::TintsShades, steps: 4, amount: 50.0 }
}
}
impl GuideOptions {
pub const MAX_STEPS: u32 = 20;
/// The options with `steps` and `amount` clamped to their ranges.
pub fn clamped(self) -> Self {
Self { steps: self.steps.clamp(1, Self::MAX_STEPS), amount: self.amount.clamp(0.0, 100.0), ..self }
}
/// The share of the way to the target that the outermost steps reach (10% … 90%).
fn reach(&self) -> f32 {
0.1 + 0.8 * self.amount.clamp(0.0, 100.0) / 100.0
}
}
/// Variation of `c` at `step` (−steps..=steps: negative towards shades/cool/muted, positive towards
/// tints/warm/vivid; 0 is `c` itself), in `c`'s model.
pub fn variation(c: Color, opts: &GuideOptions, step: i32) -> Color {
if step == 0 {
return c;
}
let f = (step.unsigned_abs() as f32 / opts.steps.max(1) as f32).min(1.0) * opts.reach();
let v = match opts.variation {
Variation::TintsShades => c.lerp(if step < 0 { &Color::BLACK } else { &Color::WHITE }, f),
Variation::WarmCool => {
let target = if step < 0 { Color::rgb(0.2, 0.45, 1.0) } else { Color::rgb(1.0, 0.55, 0.1) };
c.lerp(&target, f * 0.6)
}
Variation::VividMuted => {
let [h, s, b] = c.to_hsb();
let s2 = if step < 0 { s * (1.0 - f) } else { s + (1.0 - s) * f };
Color::from_hsb(h, s2.clamp(0.0, 1.0), b)
}
};
keep_model(c, v)
}
/// The Color Guide for a base colour: the harmony colours and, per colour, its row of variations
/// from `-steps` to `+steps` (the colour itself in the centre column).
#[derive(Clone, Debug, PartialEq)]
pub struct Guide {
pub colors: Vec<Color>,
pub grid: Vec<Vec<Color>>,
}
impl Guide {
pub fn new(base: Color, rule: Harmony, opts: &GuideOptions) -> Self {
let opts = opts.clamped();
let n = opts.steps as i32;
let colors = rule.apply(base);
let grid = colors.iter().map(|c| (-n..=n).map(|step| variation(*c, &opts, step)).collect()).collect();
Self { colors, grid }
}
/// Index of the centre column (the harmony colours) in each grid row.
pub fn centre(&self) -> usize {
self.grid.first().map_or(0, |r| r.len() / 2)
}
/// The guide limited to `palette` (Limit to Library): every harmony colour and variation snaps
/// to the palette's nearest colour (CIEDE2000), in that colour's own model.
pub fn limited(mut self, palette: &Palette) -> Self {
if palette.is_empty() {
return self;
}
let centre = self.centre();
for (row, c) in self.grid.iter_mut().zip(&mut self.colors) {
for v in row.iter_mut() {
*v = palette.nearest(*v);
}
// The centre column is the harmony colour itself.
*c = row[centre];
}
self
}
}
#[cfg(test)]
@@ -78,6 +364,144 @@ mod tests {
let c = Harmony::Complementary.apply(Color::rgb(1.0, 0.0, 0.0));
assert_eq!(c[1].to_hex(), "#00ffff");
assert_eq!(Harmony::Triad.apply(Color::rgb(1.0, 0.0, 0.0))[1].to_hex(), "#00ff00");
assert_eq!(variations(Color::rgb(1.0, 0.0, 0.0), 6).len(), 6);
assert_eq!(Harmony::Pentagram.apply(Color::rgb(1.0, 0.0, 0.0))[1].to_hsb()[0].round(), 72.0);
}
#[test]
fn every_rule_puts_the_base_first() {
let counts = [2, 4, 3, 3, 3, 3, 5, 5, 5, 3, 5, 5, 4, 4, 4, 4, 4, 2, 4, 3, 5];
assert_eq!(Harmony::ALL.len(), counts.len());
for base in [Color::rgb8(230, 120, 40), Color::cmyk(0.1, 0.8, 0.3, 0.05), Color::gray(0.4)] {
for (h, n) in Harmony::ALL.into_iter().zip(counts) {
let c = h.apply(base);
assert_eq!(c.len(), n, "{}", h.label());
assert_eq!(c[0], base, "{}: the base comes first, in its own model", h.label());
}
}
}
#[test]
fn harmonies_keep_the_base_model() {
let base = Color::cmyk(0.0, 1.0, 1.0, 0.0);
for h in Harmony::ALL {
assert!(h.apply(base).iter().all(|c| matches!(c, Color::Cmyk { .. })), "{}", h.label());
}
let Color::Cmyk { c, m, y, .. } = Harmony::Complementary.apply(base)[1] else { panic!("a CMYK complement") };
assert!(c > m && c > y, "the complement of red is a cyan: {c} {m} {y}");
let g = Guide::new(Color::gray(0.4), Harmony::Triad, &GuideOptions::default());
assert!(g.grid.iter().flatten().all(|c| matches!(c, Color::Gray { .. })));
}
#[test]
fn rules_parse_by_id_and_label() {
for h in Harmony::ALL {
assert_eq!(Harmony::parse(&h.id()), Some(h));
assert_eq!(Harmony::parse(h.label()), Some(h));
}
assert_eq!(Harmony::SplitComplementary.id(), "splitComplementary");
assert_eq!(Harmony::HighContrast3.id(), "highContrast3");
assert_eq!(Harmony::parse("square"), None);
}
#[test]
fn high_contrast_flips_brightness() {
let light = Harmony::HighContrast.apply(Color::rgb(0.9, 0.8, 0.2));
assert!(light[1].to_hsb()[2] < 0.5);
let dark = Harmony::HighContrast.apply(Color::rgb(0.2, 0.1, 0.05));
assert!(dark[1].to_hsb()[2] > 0.5);
}
#[test]
fn variations_step_out_from_the_colour() {
let c = Color::rgb(0.5, 0.5, 0.5);
let o = GuideOptions::default();
assert_eq!(variation(c, &o, 0), c, "step 0 is the colour");
assert!(variation(c, &o, 4).to_rgb()[0] > variation(c, &o, 2).to_rgb()[0]);
assert!(variation(c, &o, -4).to_rgb()[0] < 0.5);
let red = Color::rgb(1.0, 0.2, 0.2);
let vm = GuideOptions { variation: Variation::VividMuted, ..o };
assert!(variation(red, &vm, -2).to_hsb()[1] < red.to_hsb()[1]);
let warm = variation(c, &GuideOptions { variation: Variation::WarmCool, ..o }, 2).to_rgb();
assert!(warm[0] > warm[2]);
// More variation reaches further.
let more = GuideOptions { amount: 100.0, ..o };
assert!(variation(c, &more, -4).to_rgb()[0] < variation(c, &o, -4).to_rgb()[0]);
}
#[test]
fn guide_rows_hold_the_colour_in_the_centre() {
let base = Color::rgb8(30, 120, 200);
let g = Guide::new(base, Harmony::Triad, &GuideOptions { steps: 3, ..Default::default() });
assert_eq!(g.colors.len(), 3);
assert!(g.grid.iter().all(|r| r.len() == 7));
assert_eq!(g.centre(), 3);
for (row, c) in g.grid.iter().zip(&g.colors) {
assert_eq!(row[3], *c);
}
// Steps are clamped to 1..=20.
let g = Guide::new(base, Harmony::Triad, &GuideOptions { steps: 99, ..Default::default() });
assert_eq!(g.grid[0].len(), 41);
}
#[test]
fn a_limited_guide_only_holds_palette_colours() {
let lib =
[Color::rgb(1.0, 0.0, 0.0), Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::gray(0.5), Color::rgb8(250, 240, 200), Color::lab(30.0, 10.0, -40.0)];
let palette = Palette::new(lib);
for rule in Harmony::ALL {
let g = Guide::new(Color::rgb8(200, 60, 40), rule, &GuideOptions::default()).limited(&palette);
assert!(g.grid.iter().flatten().chain(&g.colors).all(|c| lib.contains(c)), "{}", rule.label());
for (row, c) in g.grid.iter().zip(&g.colors) {
assert_eq!(row[g.centre()], *c, "{}: the harmony colour stays in the centre", rule.label());
}
}
// The nearest colour wins: a pale yellow snaps to the cream and its darkest shade to the grey.
let g = Guide::new(Color::rgb(0.95, 0.9, 0.75), Harmony::Complementary, &GuideOptions::default()).limited(&palette);
assert_eq!(g.colors[0], lib[3]);
assert_eq!(g.grid[0][0], lib[2]);
// An empty palette limits nothing.
let free = Guide::new(Color::rgb(0.8, 0.1, 0.1), Harmony::Triad, &GuideOptions::default());
assert_eq!(free.clone().limited(&Palette::default()), free);
}
#[test]
fn themes_have_five_colours_base_first() {
for base in [Color::rgb8(230, 120, 40), Color::cmyk(0.1, 0.8, 0.3, 0.05)] {
for rule in Harmony::ALL {
let t = rule.theme(base);
assert_eq!(t.len(), THEME_SIZE, "{}", rule.label());
assert_eq!(t[0], base, "{}", rule.label());
assert!(t.iter().all(|c| c.model() == base.model()), "{}: colours keep the base model", rule.label());
}
}
// A two-colour rule fills in variations next to the colour they vary.
let red = Color::rgb(1.0, 0.0, 0.0);
let t = Harmony::Complementary.theme(red);
assert_eq!(t[3].to_hex(), "#00ffff", "the complement follows the base's variations");
let l = |c: &Color| c.to_lab().l;
assert!(l(&t[1]) > l(&t[0]) && l(&t[2]) < l(&t[0]), "the base, a lighter and a darker red");
assert!(l(&t[4]) < l(&t[3]));
for (i, a) in t.iter().enumerate() {
assert!(t[i + 1..].iter().all(|b| b.to_hex() != a.to_hex()), "{} repeats", a.to_hex());
}
// Five-colour rules are themes as they are.
assert_eq!(Harmony::Pentagram.theme(red), Harmony::Pentagram.apply(red));
}
#[test]
fn moving_a_linked_colour_moves_the_others_alike() {
let mut c = [Color::rgb(1.0, 0.0, 0.0), Color::rgb(1.0, 1.0, 0.0), Color::cmyk(0.0, 1.0, 0.0, 0.0)];
move_on_wheel(&mut c, 0, [120.0, 1.0, 1.0], false);
assert_eq!(c[0].to_hex(), "#00ff00");
assert_eq!(c[1].to_hex(), "#ffff00", "unlinked: only that colour moves");
move_on_wheel(&mut c, 0, [240.0, 0.5, 1.0], true);
assert_eq!(c[0].to_hex(), "#8080ff");
let [h, s, v] = c[1].to_hsb();
assert!((h - 180.0).abs() < 0.5 && (s - 0.5).abs() < 0.01 && v > 0.99, "it turned and paled as much: {h} {s} {v}");
assert!(matches!(c[2], Color::Cmyk { .. }), "colours keep their model");
// An index past the end moves nothing.
let before = c;
move_on_wheel(&mut c, 5, [0.0, 0.0, 0.0], true);
assert_eq!(c, before);
}
}
+270 -9
View File
@@ -1,28 +1,52 @@
//! Colour, paint and blend-mode types for VectorCraft.
//!
//! Colours keep the model the user picked them in (RGB, CMYK, Gray, HSB is a UI view of RGB), so
//! documents don't drift when converting back and forth. Rendering asks for [`Color::to_rgba`].
//! Colours keep the model the user picked them in (RGB, CMYK, Gray, Lab; HSB is a UI view of RGB),
//! so documents don't drift when converting back and forth. Rendering asks for [`Color::to_rgba`].
#![forbid(unsafe_code)]
pub mod blend;
pub mod cms;
pub mod freeform;
pub mod gradient;
pub mod harmony;
pub mod libraries;
pub mod palette_io;
pub mod recolor;
pub mod swatch;
pub use blend::BlendMode;
pub use freeform::{Freeform, FreeformMode, FreeformPoint};
pub use gradient::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
pub use libraries::SwatchLibrary;
pub use swatch::{Swatch, SwatchGroup, default_swatches};
use serde::{Deserialize, Serialize};
/// A colour in its authoring model. Components are 0..=1.
/// A colour in its authoring model. Components are 0..=1, except Lab's.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "model", rename_all = "lowercase")]
pub enum Color {
Rgb { r: f32, g: f32, b: f32 },
Cmyk { c: f32, m: f32, y: f32, k: f32 },
Gray { k: f32 },
Rgb {
r: f32,
g: f32,
b: f32,
},
Cmyk {
c: f32,
m: f32,
y: f32,
k: f32,
},
Gray {
k: f32,
},
/// CIE L*a*b* (D50): L 0..100, a and b about −128..127. Device independent, the way spot
/// colours are defined; it shows and separates through the active colour settings.
Lab {
l: f32,
a: f32,
b: f32,
},
}
impl Default for Color {
@@ -47,6 +71,9 @@ impl Color {
pub fn gray(k: f32) -> Self {
Color::Gray { k }
}
pub fn lab(l: f32, a: f32, b: f32) -> Self {
Color::Lab { l, a, b }
}
/// Display (sRGB) colour through the active colour settings ([`cms::active`]): RGB is
/// converted from the working RGB space, CMYK through the working CMYK profile (relative
@@ -57,15 +84,18 @@ impl Color {
Color::Cmyk { c, m, y, k } if cms::cmyk_is_device() => cms::naive_cmyk_to_rgb([c, m, y, k]),
// Illustrator's Gray is ink percentage: 0 = white, 1 = black.
Color::Gray { k } => [1.0 - k; 3],
Color::Lab { l, a, b } => cms::lab::lab_to_srgb(cms::Lab::new(l, a, b)),
_ => cms::active().display_rgb(self),
}
}
/// Profile-free display RGB (`(1−c)(1−k)` …), the pre-colour-management formula.
/// Profile-free display RGB (`(1−c)(1−k)` …), the pre-colour-management formula (Lab, being
/// device independent, is its sRGB colour).
pub fn to_rgb_uncalibrated(&self) -> [f32; 3] {
match *self {
Color::Rgb { r, g, b } => [r, g, b],
Color::Cmyk { c, m, y, k } => cms::naive_cmyk_to_rgb([c, m, y, k]),
Color::Gray { k } => [1.0 - k; 3],
Color::Lab { l, a, b } => cms::lab::lab_to_srgb(cms::Lab::new(l, a, b)),
}
}
/// CIE Lab (D50) through the active colour settings.
@@ -167,21 +197,115 @@ impl Color {
let [r, g, b] = self.to_rgb();
Color::rgb(1.0 - r, 1.0 - g, 1.0 - b)
}
/// The RGB inverse expressed in the colour's own model (CMYK through the profile-free
/// [`Color::to_cmyk`]; a grey's ink is inverted).
pub fn invert_keep_model(&self) -> Self {
match *self {
Color::Rgb { .. } => self.invert(),
Color::Cmyk { .. } => {
let [c, m, y, k] = self.invert().to_cmyk();
Color::Cmyk { c, m, y, k }
}
Color::Gray { k } => Color::Gray { k: 1.0 - k },
Color::Lab { .. } => self.invert().in_model(cms::Model::Lab),
}
}
/// Complement in the colour's own model: each component becomes (highest + lowest) − itself,
/// over R, G, B or over C, M, Y (K kept). For RGB this is the hue turned by 180°; a grey is its
/// own complement; Lab turns its hue by 180° (a and b negated).
pub fn complement_keep_model(&self) -> Self {
let flip = |v: [f32; 3]| {
let s = v[0].max(v[1]).max(v[2]) + v[0].min(v[1]).min(v[2]);
v.map(|x| (s - x).clamp(0.0, 1.0))
};
match *self {
Color::Rgb { r, g, b } => {
let [r, g, b] = flip([r, g, b]);
Color::Rgb { r, g, b }
}
Color::Cmyk { c, m, y, k } => {
let [c, m, y] = flip([c, m, y]);
Color::Cmyk { c, m, y, k }
}
Color::Gray { .. } => *self,
Color::Lab { l, a, b } => Color::Lab { l, a: -a, b: -b },
}
}
/// Linear interpolation in display RGB.
pub fn lerp(&self, other: &Color, t: f32) -> Color {
let a = self.to_rgb();
let b = other.to_rgb();
Color::rgb(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t)
}
/// The colour model this colour is authored in.
pub fn model(&self) -> cms::Model {
match self {
Color::Rgb { .. } => cms::Model::Rgb,
Color::Cmyk { .. } => cms::Model::Cmyk,
Color::Gray { .. } => cms::Model::Gray,
Color::Lab { .. } => cms::Model::Lab,
}
}
/// This colour expressed in `model` with the profile-free formulas Edit Colors uses (RGB from
/// the display colour, CMYK by [`Color::to_cmyk`], Gray as ink from luminance, Lab by
/// [`Color::to_lab`]); unchanged when it is in `model` already.
pub fn in_model(self, model: cms::Model) -> Color {
if self.model() == model {
return self;
}
match model {
cms::Model::Rgb => {
let [r, g, b] = self.to_rgb();
Color::rgb(r, g, b)
}
cms::Model::Cmyk => {
let [c, m, y, k] = self.to_cmyk();
Color::cmyk(c, m, y, k)
}
cms::Model::Gray => {
let [r, g, b] = self.to_rgb();
Color::gray((1.0 - (0.299 * r + 0.587 * g + 0.114 * b)).clamp(0.0, 1.0))
}
cms::Model::Lab => {
let cms::Lab { l, a, b } = self.to_lab();
Color::Lab { l, a, b }
}
}
}
/// Tint `t` (0..1) of this colour, the colour a tint of a global or spot swatch shows: CMYK and
/// Gray inks scale by `t`, RGB and Lab mix toward white (`t` = 0 is paper white, 1 the colour
/// itself; Lab toward L 100, a = b = 0, as a PDF Lab alternate interpolates).
pub fn tinted(self, t: f32) -> Color {
let t = t.clamp(0.0, 1.0);
if t == 1.0 {
return self;
}
match self {
Color::Cmyk { c, m, y, k } => Color::Cmyk { c: c * t, m: m * t, y: y * t, k: k * t },
Color::Gray { k } => Color::Gray { k: k * t },
Color::Rgb { r, g, b } => {
let w = |v: f32| v + (1.0 - v) * (1.0 - t);
Color::Rgb { r: w(r), g: w(g), b: w(b) }
}
Color::Lab { l, a, b } => Color::Lab { l: 100.0 - (100.0 - l) * t, a: a * t, b: b * t },
}
}
pub fn model_name(&self) -> &'static str {
match self {
Color::Rgb { .. } => "RGB",
Color::Cmyk { .. } => "CMYK",
Color::Gray { .. } => "Grayscale",
Color::Lab { .. } => "Lab",
}
}
}
/// `new` expressed in the colour model of `orig` ([`Color::in_model`]): results of colour operations
/// (harmonies, blends, inversions, recolouring) keep the model of the colour they came from.
pub fn keep_model(orig: Color, new: Color) -> Color {
new.in_model(orig.model())
}
/// What fills or strokes an object.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
@@ -189,10 +313,15 @@ pub enum Paint {
#[default]
None,
Solid {
/// The colour shown: with a link, the swatch's colour at `tint` ([`Color::tinted`]).
color: Color,
/// Name of the global swatch this colour is linked to (edits to the swatch update it).
#[serde(default, skip_serializing_if = "Option::is_none")]
swatch: Option<String>,
/// Tint of the linked swatch (0..1; 1 without a link): a spot tint prints this much of its
/// plate, and swatch edits keep it.
#[serde(default = "full_tint", skip_serializing_if = "is_full_tint")]
tint: f32,
},
Gradient(Box<GradientPaint>),
Pattern {
@@ -208,9 +337,22 @@ fn is_identity(a: &kurbo::Affine) -> bool {
*a == kurbo::Affine::IDENTITY
}
pub(crate) fn full_tint() -> f32 {
1.0
}
pub(crate) fn is_full_tint(t: &f32) -> bool {
*t == 1.0
}
/// A tint as a whole percentage ("40" for 0.4).
pub fn tint_percent(t: f32) -> f32 {
(t.clamp(0.0, 1.0) * 100.0).round()
}
impl Paint {
pub fn solid(c: Color) -> Self {
Paint::Solid { color: c, swatch: None }
Paint::Solid { color: c, swatch: None, tint: 1.0 }
}
pub fn is_none(&self) -> bool {
matches!(self, Paint::None)
@@ -221,15 +363,41 @@ impl Paint {
_ => None,
}
}
/// The Registration colour: 100% of every ink, linked to [`swatch::REGISTRATION`].
pub fn registration() -> Self {
Paint::Solid { color: Color::cmyk(1.0, 1.0, 1.0, 1.0), swatch: Some(swatch::REGISTRATION.into()), tint: 1.0 }
}
/// Is this a colour linked to the Registration swatch (it prints on every plate)?
pub fn is_registration(&self) -> bool {
matches!(self, Paint::Solid { swatch: Some(s), .. } if s == swatch::REGISTRATION)
}
pub fn label(&self) -> String {
match self {
Paint::None => "None".into(),
Paint::Solid { color, swatch: Some(n) } => format!("{n} ({})", color.to_hex()),
Paint::Solid { color, swatch: Some(n), tint } if *tint < 1.0 => format!("{n} {}% ({})", tint_percent(*tint), color.to_hex()),
Paint::Solid { color, swatch: Some(n), .. } => format!("{n} ({})", color.to_hex()),
Paint::Solid { color, .. } => color.to_hex(),
Paint::Gradient(g) => format!("{} gradient", g.gradient.kind.label()),
Paint::Pattern { pattern, .. } => format!("pattern {pattern}"),
}
}
/// Apply `f` to the colours of this paint that can link to a swatch (a solid colour, each
/// gradient stop) with their link and tint; `f` returns true when it changed them. A colour
/// left without a link gets tint 1. Returns whether anything changed.
pub fn map_links(&mut self, f: &mut dyn FnMut(&mut Color, &mut Option<String>, &mut f32) -> bool) -> bool {
let mut one = |c: &mut Color, link: &mut Option<String>, tint: &mut f32| {
let changed = f(c, link, tint);
if link.is_none() {
*tint = 1.0;
}
changed
};
match self {
Paint::Solid { color, swatch, tint } => one(color, swatch, tint),
Paint::Gradient(g) => g.gradient.stops.iter_mut().fold(false, |ch, s| one(&mut s.color, &mut s.swatch, &mut s.tint) | ch),
_ => false,
}
}
}
#[cfg(test)]
@@ -277,6 +445,99 @@ mod tests {
assert_eq!(Color::rgb(1.0, 0.0, 0.0).invert().to_hex(), "#00ffff");
}
#[test]
fn complement_keeps_the_model() {
assert_eq!(Color::rgb(1.0, 0.0, 0.0).complement_keep_model(), Color::rgb(0.0, 1.0, 1.0));
// Same as turning the hue by 180° for RGB.
let c = Color::rgb8(204, 102, 51);
assert_eq!(c.complement_keep_model().to_hex(), c.complement().to_hex());
assert_eq!(Color::cmyk(0.0, 1.0, 1.0, 0.2).complement_keep_model(), Color::cmyk(1.0, 0.0, 0.0, 0.2));
assert_eq!(Color::gray(0.3).complement_keep_model(), Color::gray(0.3));
assert_eq!(Color::gray(0.25).invert_keep_model(), Color::gray(0.75));
assert!(matches!(Color::cmyk(0.0, 1.0, 1.0, 0.0).invert_keep_model(), Color::Cmyk { .. }));
assert_eq!(Color::rgb(1.0, 1.0, 0.0).invert_keep_model(), Color::rgb(0.0, 0.0, 1.0));
}
#[test]
fn colours_convert_between_models_and_keep_them() {
use cms::Model;
let red = Color::rgb(1.0, 0.0, 0.0);
assert_eq!(red.in_model(Model::Cmyk), Color::cmyk(0.0, 1.0, 1.0, 0.0));
assert_eq!(red.in_model(Model::Rgb), red, "already RGB: unchanged");
assert_eq!(Color::gray(0.25).in_model(Model::Gray), Color::gray(0.25));
assert_eq!(Color::WHITE.in_model(Model::Gray), Color::gray(0.0));
assert_eq!(keep_model(Color::cmyk(0.1, 0.2, 0.3, 0.0), red).model(), Model::Cmyk);
assert_eq!(keep_model(Color::gray(0.5), red).model(), Model::Gray);
assert_eq!(keep_model(red, Color::rgb(0.0, 0.5, 1.0)), Color::rgb(0.0, 0.5, 1.0));
}
#[test]
fn tints_scale_inks_and_mix_rgb_toward_white() {
let close = |a: Color, b: Color| {
let v = |c: Color| match c {
Color::Cmyk { c, m, y, k } => vec![c, m, y, k],
Color::Rgb { r, g, b } => vec![r, g, b],
Color::Gray { k } => vec![k],
Color::Lab { l, a, b } => vec![l, a, b],
};
a.model() == b.model() && v(a).iter().zip(v(b)).all(|(x, y)| (x - y).abs() < 1e-6)
};
assert!(close(Color::cmyk(0.5, 1.0, 0.0, 0.2).tinted(0.4), Color::cmyk(0.2, 0.4, 0.0, 0.08)));
assert!(close(Color::gray(0.5).tinted(0.5), Color::gray(0.25)));
assert!(close(Color::rgb(1.0, 0.0, 0.5).tinted(0.4), Color::rgb(1.0, 0.6, 0.8)), "RGB mixes toward white");
assert!(close(Color::lab(40.0, 60.0, -20.0).tinted(0.25), Color::lab(85.0, 15.0, -5.0)), "Lab mixes toward paper white");
assert_eq!(Color::rgb(0.2, 0.4, 0.6).tinted(1.0), Color::rgb(0.2, 0.4, 0.6));
assert_eq!(Color::cmyk(0.3, 0.3, 0.3, 0.3).tinted(0.0), Color::cmyk(0.0, 0.0, 0.0, 0.0));
assert_eq!(Color::rgb(0.0, 0.0, 0.0).tinted(-1.0).to_hex(), "#ffffff", "clamped");
assert_eq!(tint_percent(0.404), 40.0);
}
#[test]
fn tints_serialize_only_below_full_and_read_old_files() {
let full = serde_json::to_string(&Paint::Solid { color: Color::gray(1.0), swatch: Some("Ink".into()), tint: 1.0 }).unwrap();
assert!(!full.contains("tint"), "{full}");
let old: Paint = serde_json::from_str(r#"{"type":"solid","color":{"model":"gray","k":1.0},"swatch":"Ink"}"#).unwrap();
assert_eq!(old, Paint::Solid { color: Color::gray(1.0), swatch: Some("Ink".into()), tint: 1.0 });
let t = Paint::Solid { color: Color::gray(0.4), swatch: Some("Ink".into()), tint: 0.4 };
let s = serde_json::to_string(&t).unwrap();
assert!(s.contains("\"tint\":0.4"), "{s}");
assert_eq!(serde_json::from_str::<Paint>(&s).unwrap(), t);
assert_eq!(t.label(), "Ink 40% (#999999)");
}
#[test]
fn mapping_links_resets_the_tint_of_unlinked_colours() {
let mut p = Paint::Solid { color: Color::gray(0.4), swatch: Some("Ink".into()), tint: 0.4 };
assert!(p.map_links(&mut |_, l, _| l.take().is_some()));
assert_eq!(p, Paint::solid(Color::gray(0.4)));
let mut g = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
g.map_links(&mut |_, l, t| {
*l = Some("Ink".into());
*t = 0.5;
true
});
let Paint::Gradient(g) = g else { panic!("a gradient") };
assert!(g.gradient.stops.iter().all(|s| s.swatch.as_deref() == Some("Ink") && s.tint == 0.5));
}
#[test]
fn lab_colours_serialize_with_their_tag_and_keep_their_model() {
let c = Color::lab(52.5, 70.0, -30.0);
let s = serde_json::to_string(&c).unwrap();
assert_eq!(s, r#"{"model":"lab","l":52.5,"a":70.0,"b":-30.0}"#);
assert_eq!(serde_json::from_str::<Color>(&s).unwrap(), c);
assert_eq!((c.model(), c.model_name()), (cms::Model::Lab, "Lab"));
assert_eq!(c.complement_keep_model(), Color::lab(52.5, -70.0, 30.0));
assert_eq!(c.invert_keep_model().model(), cms::Model::Lab);
// Results of colour operations on a Lab colour stay Lab, and the conversion is exact.
let red = Color::rgb(1.0, 0.0, 0.0);
let Color::Lab { l, a, b } = keep_model(c, red) else { panic!("Lab") };
assert!((l - 54.29).abs() < 0.1 && (a - 80.8).abs() < 0.3 && (b - 69.9).abs() < 0.3, "{l} {a} {b}");
assert_eq!(keep_model(c, red).to_hex(), "#ff0000");
assert_eq!(Color::lab(100.0, 0.0, 0.0).to_hex(), "#ffffff");
assert_eq!(Color::lab(0.0, 0.0, 0.0).to_rgb_uncalibrated(), [0.0; 3]);
}
#[test]
fn paint_serde() {
let p = Paint::solid(Color::rgb8(10, 20, 30));
+534
View File
@@ -0,0 +1,534 @@
//! Swatch libraries: read-only sets of swatches the library panel opens and adds from. Every
//! built-in library is computed here (names and palettes are our own; nothing is copied from any
//! other application or colour system), so the colours are exact and the same on every run.
//!
//! Colours are designed in OKLCH (perceptual lightness, chroma and hue), mapped into sRGB by
//! lowering chroma until they fit, and rounded to 8-bit RGB.
use std::sync::{Arc, OnceLock};
use serde::{Deserialize, Serialize};
use crate::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint, Swatch, SwatchGroup};
/// A library: its name, its ungrouped swatches and its colour groups.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct SwatchLibrary {
pub name: String,
#[serde(default)]
pub swatches: Vec<Swatch>,
#[serde(default)]
pub groups: Vec<SwatchGroup>,
}
impl SwatchLibrary {
/// Every swatch: the ungrouped ones first, then each group's in order.
pub fn iter(&self) -> impl Iterator<Item = &Swatch> {
self.swatches.iter().chain(self.groups.iter().flat_map(|g| g.swatches.iter()))
}
/// The number of swatches.
pub fn len(&self) -> usize {
self.swatches.len() + self.groups.iter().map(|g| g.swatches.len()).sum::<usize>()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn swatch(&self, name: &str) -> Option<&Swatch> {
self.iter().find(|s| s.name == name)
}
pub fn group(&self, name: &str) -> Option<&SwatchGroup> {
self.groups.iter().find(|g| g.name == name)
}
}
/// A built-in library: a stable id, its menu name and the function that computes it.
pub struct BuiltinLibrary {
pub id: &'static str,
pub name: &'static str,
make: fn() -> (Vec<Swatch>, Vec<SwatchGroup>),
}
macro_rules! lib {
($id:literal, $name:literal, $make:expr) => {
BuiltinLibrary { id: $id, name: $name, make: $make }
};
}
/// The built-in swatch libraries, in menu order.
pub const SWATCH_LIBRARIES: &[BuiltinLibrary] = &[
lib!("web-safe-216", "Web Safe 216", web_safe),
lib!("grays-neutrals", "Grays and Neutrals", grays_neutrals),
lib!("earth-tones", "Earth Tones", earth_tones),
lib!("skin-tone-ramps", "Skin Tone Ramps", skin_tones),
lib!("pastels", "Pastels", pastels),
lib!("brights", "Brights", brights),
lib!("metallic-gradients", "Metallic Gradients", metallic),
lib!("perceptual-scales", "Perceptual Scales", perceptual),
lib!("harmony-sets", "Harmony Sets", harmony_sets),
];
/// The built-in gradient libraries (Window → Swatch Libraries → Gradients), in menu order.
pub const GRADIENT_LIBRARIES: &[BuiltinLibrary] = &[
lib!("spectrum-gradients", "Spectrum Gradients", spectrum_gradients),
lib!("sky-gradients", "Sky Gradients", sky_gradients),
lib!("neutral-gradients", "Neutral Gradients", neutral_gradients),
lib!("duotone-gradients", "Duotone Gradients", duotone_gradients),
lib!("radial-glows", "Radial Glows", radial_glows),
];
/// Every built-in library: the swatch libraries, then the gradient libraries.
fn builtins() -> impl Iterator<Item = &'static BuiltinLibrary> {
SWATCH_LIBRARIES.iter().chain(GRADIENT_LIBRARIES)
}
/// Built-in library `id` (swatch or gradient library), computed once.
pub fn builtin_library(id: &str) -> Option<Arc<SwatchLibrary>> {
static LIBS: OnceLock<Vec<Arc<SwatchLibrary>>> = OnceLock::new();
let libs = LIBS.get_or_init(|| {
builtins()
.map(|b| {
let (swatches, groups) = (b.make)();
Arc::new(SwatchLibrary { name: b.name.into(), swatches, groups })
})
.collect()
});
builtins().position(|b| b.id == id).map(|i| libs[i].clone())
}
// ---------- colour construction ----------
/// sRGB (0..=1, gamma encoded) of OKLab `(l, a, b)`, unclamped.
fn oklab_to_srgb(l: f64, a: f64, b: f64) -> [f64; 3] {
let l_ = l + 0.396_337_777_4 * a + 0.215_803_757_3 * b;
let m_ = l - 0.105_561_345_8 * a - 0.063_854_172_8 * b;
let s_ = l - 0.089_484_177_5 * a - 1.291_485_548_0 * b;
let (l3, m3, s3) = (l_ * l_ * l_, m_ * m_ * m_, s_ * s_ * s_);
let lin = [
4.076_741_662_1 * l3 - 3.307_711_591_3 * m3 + 0.230_969_929_2 * s3,
-1.268_438_004_6 * l3 + 2.609_757_401_1 * m3 - 0.341_319_396_5 * s3,
-0.004_196_086_3 * l3 - 0.703_418_614_7 * m3 + 1.707_614_701_0 * s3,
];
lin.map(|v| if v <= 0.003_130_8 { 12.92 * v } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 })
}
/// OKLab `[l, a, b]` of a colour's display RGB.
pub fn to_oklab(c: &Color) -> [f64; 3] {
let [r, g, b] = c.to_rgb_uncalibrated().map(|v| {
let v = f64::from(v);
if v <= 0.040_45 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
});
let l = (0.412_221_470_8 * r + 0.536_332_536_3 * g + 0.051_445_992_9 * b).cbrt();
let m = (0.211_903_498_2 * r + 0.680_699_545_1 * g + 0.107_396_956_6 * b).cbrt();
let s = (0.088_302_461_9 * r + 0.281_718_837_6 * g + 0.629_978_700_5 * b).cbrt();
[
0.210_454_255_3 * l + 0.793_617_785_0 * m - 0.004_072_046_8 * s,
1.977_998_495_1 * l - 2.428_592_205_0 * m + 0.450_593_709_9 * s,
0.025_904_037_1 * l + 0.782_771_766_2 * m - 0.808_675_766_0 * s,
]
}
/// The 8-bit sRGB colour of OKLCH `(l, c, h°)`, with the chroma lowered until it fits in sRGB.
fn oklch(l: f64, c: f64, h: f64) -> Color {
let (sin, cos) = h.to_radians().sin_cos();
let at = |c: f64| oklab_to_srgb(l, c * cos, c * sin);
let fits = |rgb: [f64; 3]| rgb.iter().all(|v| (-1e-4..=1.0001).contains(v));
let rgb = if fits(at(c)) {
at(c)
} else {
// Bisect the largest chroma in gamut.
let (mut lo, mut hi) = (0.0, c);
for _ in 0..24 {
let mid = (lo + hi) / 2.0;
if fits(at(mid)) { lo = mid } else { hi = mid }
}
at(lo)
};
let q = |v: f64| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
Color::rgb8(q(rgb[0]), q(rgb[1]), q(rgb[2]))
}
/// OKLab interpolation of `a` → `b` at `t`, as an 8-bit sRGB colour.
fn mix(a: [f64; 3], b: [f64; 3], t: f64) -> Color {
let p = [0, 1, 2].map(|i| a[i] + (b[i] - a[i]) * t);
let (c, h) = (p[1].hypot(p[2]), p[2].atan2(p[1]).to_degrees());
oklch(p[0], c, h)
}
/// OKLab `[l, a, b]` of OKLCH `(l, c, h°)`.
fn lab(l: f64, c: f64, h: f64) -> [f64; 3] {
let (sin, cos) = h.to_radians().sin_cos();
[l, c * cos, c * sin]
}
fn solid(name: impl Into<String>, color: Color) -> Swatch {
Swatch { name: name.into(), paint: Paint::solid(color), global: false, spot: false }
}
fn group(name: &str, swatches: Vec<Swatch>) -> SwatchGroup {
SwatchGroup { name: name.into(), swatches }
}
/// A gradient swatch through `stops` of (offset, colour, opacity).
pub(crate) fn gradient(name: &str, kind: GradientKind, stops: &[(f32, Color, f32)]) -> Swatch {
let stops = stops.iter().map(|&(offset, color, opacity)| GradientStop { opacity, ..GradientStop::new(offset, color) }).collect();
Swatch { name: name.into(), paint: Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind, stops }))), global: false, spot: false }
}
/// `n` steps of OKLCH lightness from `l0` to `l1`, chroma `c(t)` and hue `h(t)` (`t` in 0..=1),
/// named "`name` 1"…
fn ramp(name: &str, n: usize, (l0, l1): (f64, f64), c: impl Fn(f64) -> f64, h: impl Fn(f64) -> f64) -> SwatchGroup {
let swatches = (0..n)
.map(|i| {
let t = i as f64 / (n - 1).max(1) as f64;
solid(format!("{name} {}", i + 1), oklch(l0 + (l1 - l0) * t, c(t), h(t)))
})
.collect();
group(name, swatches)
}
/// Twelve named OKLCH hues around the wheel.
const HUES: [(&str, f64); 12] = [
("Red", 25.0),
("Orange", 55.0),
("Amber", 75.0),
("Yellow", 100.0),
("Lime", 125.0),
("Green", 145.0),
("Teal", 180.0),
("Cyan", 205.0),
("Azure", 235.0),
("Blue", 265.0),
("Violet", 295.0),
("Magenta", 330.0),
];
/// One group of the twelve hues at OKLCH lightness `l` and chroma `c`, named "`prefix` Red"…
fn hue_row(prefix: &str, l: f64, c: f64) -> SwatchGroup {
group(prefix, HUES.iter().map(|(n, h)| solid(format!("{prefix} {n}"), oklch(l, c, *h))).collect())
}
// ---------- the libraries ----------
/// The 216 colours whose channels are multiples of 0x33, named by their hex value.
fn web_safe() -> (Vec<Swatch>, Vec<SwatchGroup>) {
let mut s = Vec::with_capacity(216);
for r in 0..6u8 {
for g in 0..6u8 {
for b in 0..6u8 {
let c = Color::rgb8(r * 0x33, g * 0x33, b * 0x33);
s.push(solid(c.to_hex().to_uppercase(), c));
}
}
}
(s, vec![])
}
fn grays_neutrals() -> (Vec<Swatch>, Vec<SwatchGroup>) {
let grays = (0..=20).map(|i| solid(format!("K={}", i * 5), Color::gray(i as f32 / 20.0))).collect();
let tinted = |name: &str, c: f64, h: f64| ramp(name, 9, (0.95, 0.25), |_| c, |_| h);
let neutrals = [
("Ivory", 0.96, 0.02, 95.0),
("Linen", 0.92, 0.02, 70.0),
("Oat", 0.86, 0.035, 80.0),
("Greige", 0.72, 0.02, 75.0),
("Stone", 0.66, 0.015, 95.0),
("Taupe", 0.56, 0.03, 50.0),
("Slate", 0.48, 0.025, 250.0),
("Charcoal", 0.30, 0.01, 260.0),
];
let neutrals = neutrals.iter().map(|&(n, l, c, h)| solid(n, oklch(l, c, h))).collect();
let groups = vec![group("Grays", grays), tinted("Warm Gray", 0.012, 70.0), tinted("Cool Gray", 0.015, 250.0), group("Neutrals", neutrals)];
(vec![], groups)
}
fn earth_tones() -> (Vec<Swatch>, Vec<SwatchGroup>) {
let families = [
("Clay", 0.10, 40.0),
("Terracotta", 0.13, 35.0),
("Ochre", 0.11, 80.0),
("Umber", 0.06, 60.0),
("Sand", 0.05, 85.0),
("Olive", 0.08, 115.0),
("Moss", 0.07, 130.0),
];
// Chroma peaks mid-ramp, as earth pigments do.
let groups = families.iter().map(|&(n, c, h)| ramp(n, 6, (0.86, 0.34), |t| c * (0.6 + 0.4 * (1.0 - (2.0 * t - 1.0).powi(2))), |_| h)).collect();
(vec![], groups)
}
fn skin_tones() -> (Vec<Swatch>, Vec<SwatchGroup>) {
let ramps = [("Rosy", 0.075, 35.0), ("Neutral", 0.07, 55.0), ("Golden", 0.08, 70.0), ("Olive", 0.06, 85.0)];
// Light tones are paler; deep tones keep warmth but lose a little chroma.
let groups = ramps.iter().map(|&(n, c, h)| ramp(n, 8, (0.93, 0.32), |t| c * (0.45 + 0.75 * t - 0.35 * t * t), |t| h - 8.0 * t)).collect();
(vec![], groups)
}
fn pastels() -> (Vec<Swatch>, Vec<SwatchGroup>) {
(vec![], vec![hue_row("Pastel", 0.91, 0.055), hue_row("Soft", 0.84, 0.085)])
}
fn brights() -> (Vec<Swatch>, Vec<SwatchGroup>) {
// Chroma beyond sRGB: each hue lands on the gamut edge at that lightness.
(vec![], vec![hue_row("Vivid", 0.72, 0.4), hue_row("Deep", 0.52, 0.4)])
}
fn metallic() -> (Vec<Swatch>, Vec<SwatchGroup>) {
let metals: [(&str, f64, f64, f64); 10] = [
("Gold", 0.78, 0.12, 85.0),
("Silver", 0.80, 0.005, 250.0),
("Bronze", 0.62, 0.09, 65.0),
("Copper", 0.64, 0.12, 45.0),
("Brass", 0.76, 0.10, 95.0),
("Chrome", 0.72, 0.012, 230.0),
("Rose Gold", 0.76, 0.07, 30.0),
("Gunmetal", 0.45, 0.015, 250.0),
("Platinum", 0.86, 0.008, 90.0),
("Pewter", 0.60, 0.01, 200.0),
];
// Light, base, shadow, highlight, base: the banding of polished metal.
let bands: [(f32, f64); 5] = [(0.0, 0.16), (0.3, 0.0), (0.5, -0.2), (0.72, 0.12), (1.0, -0.06)];
let s = metals
.iter()
.map(|&(n, l, c, h)| {
let stops: Vec<(f32, Color, f32)> = bands.iter().map(|&(at, dl)| (at, oklch((l + dl).clamp(0.05, 0.98), c, h), 1.0)).collect();
gradient(n, GradientKind::Linear, &stops)
})
.collect();
(s, vec![])
}
/// OKLCH `(l, c, h°)` anchors a scale passes through.
type Anchors = &'static [(f64, f64, f64)];
/// A linear gradient through evenly spaced, opaque `colors`.
fn even(name: &str, kind: GradientKind, colors: &[Color]) -> Swatch {
let last = (colors.len() - 1).max(1) as f32;
let stops: Vec<(f32, Color, f32)> = colors.iter().enumerate().map(|(i, c)| (i as f32 / last, *c, 1.0)).collect();
gradient(name, kind, &stops)
}
/// OKLCH `(l, c, h°)` colours.
fn lch(points: &[(f64, f64, f64)]) -> Vec<Color> {
points.iter().map(|&(l, c, h)| oklch(l, c, h)).collect()
}
/// Full-hue sweeps and hue-neighbour blends.
fn spectrum_gradients() -> (Vec<Swatch>, Vec<SwatchGroup>) {
let wheel: Vec<Color> = (0..=6).map(|i| oklch(0.72, 0.4, 25.0 + i as f64 * 60.0)).collect();
let mut s = vec![
even("Spectrum", GradientKind::Linear, &wheel),
even("Soft Spectrum", GradientKind::Linear, &lch(&[(0.88, 0.08, 25.0), (0.9, 0.08, 145.0), (0.86, 0.08, 265.0), (0.88, 0.08, 25.0)])),
];
// Each hue to the one two steps round the wheel.
s.extend((0..HUES.len()).map(|i| {
let ((a, ha), (b, hb)) = (HUES[i], HUES[(i + 2) % HUES.len()]);
even(&format!("{a} to {b}"), GradientKind::Linear, &[oklch(0.7, 0.4, ha), oklch(0.7, 0.4, hb)])
}));
(s, vec![])
}
fn sky_gradients() -> (Vec<Swatch>, Vec<SwatchGroup>) {
let skies: [(&str, Anchors); 6] = [
("Dawn", &[(0.45, 0.08, 280.0), (0.72, 0.12, 350.0), (0.9, 0.09, 75.0)]),
("Noon", &[(0.55, 0.13, 250.0), (0.78, 0.09, 235.0), (0.95, 0.03, 220.0)]),
("Sunset", &[(0.35, 0.09, 300.0), (0.62, 0.18, 20.0), (0.85, 0.15, 70.0)]),
("Dusk", &[(0.25, 0.07, 275.0), (0.48, 0.1, 310.0), (0.7, 0.1, 30.0)]),
("Night", &[(0.15, 0.04, 270.0), (0.3, 0.07, 265.0)]),
("Overcast", &[(0.62, 0.015, 240.0), (0.86, 0.01, 230.0)]),
];
(skies.iter().map(|(n, p)| even(n, GradientKind::Linear, &lch(p))).collect(), vec![])
}
fn neutral_gradients() -> (Vec<Swatch>, Vec<SwatchGroup>) {
let (white, black) = (Color::gray(0.0), Color::gray(1.0));
let s = vec![
even("White to Black", GradientKind::Linear, &[white, black]),
even("Light Grays", GradientKind::Linear, &[white, Color::gray(0.4)]),
even("Dark Grays", GradientKind::Linear, &[Color::gray(0.6), black]),
even("Warm Grays", GradientKind::Linear, &lch(&[(0.95, 0.012, 70.0), (0.3, 0.012, 70.0)])),
even("Cool Grays", GradientKind::Linear, &lch(&[(0.95, 0.015, 250.0), (0.3, 0.015, 250.0)])),
gradient("Fade to Transparent", GradientKind::Linear, &[(0.0, black, 1.0), (1.0, black, 0.0)]),
gradient("White Fade", GradientKind::Linear, &[(0.0, white, 1.0), (1.0, white, 0.0)]),
gradient("Vignette", GradientKind::Radial, &[(0.0, black, 0.0), (0.6, black, 0.0), (1.0, black, 0.7)]),
];
(s, vec![])
}
/// Pairs of hues from opposite sides of the wheel, at different lightness.
fn duotone_gradients() -> (Vec<Swatch>, Vec<SwatchGroup>) {
let pairs = [
("Coral", 0.7, 30.0, "Navy", 0.32, 265.0),
("Plum", 0.38, 330.0, "Lime", 0.88, 125.0),
("Teal", 0.5, 190.0, "Gold", 0.85, 90.0),
("Indigo", 0.3, 280.0, "Peach", 0.86, 55.0),
("Forest", 0.35, 150.0, "Rose", 0.8, 0.0),
];
let s = pairs
.iter()
.map(|&(a, la, ha, b, lb, hb)| even(&format!("{a} and {b}"), GradientKind::Linear, &[oklch(la, 0.15, ha), oklch(lb, 0.15, hb)]))
.collect();
(s, vec![])
}
/// A bright centre fading to a transparent edge, per hue.
fn radial_glows() -> (Vec<Swatch>, Vec<SwatchGroup>) {
let s = HUES
.iter()
.map(|(n, h)| {
let c = oklch(0.78, 0.2, *h);
gradient(&format!("{n} Glow"), GradientKind::Radial, &[(0.0, Color::WHITE, 1.0), (0.25, c, 1.0), (1.0, c, 0.0)])
})
.collect();
(s, vec![])
}
/// The sequential scales of [`perceptual`]: name and anchors of rising lightness.
const SEQUENTIAL: [(&str, Anchors); 5] = [
("Ember", &[(0.22, 0.10, 300.0), (0.55, 0.18, 30.0), (0.93, 0.15, 100.0)]),
("Lagoon", &[(0.25, 0.07, 265.0), (0.60, 0.12, 200.0), (0.93, 0.12, 130.0)]),
("Dusk", &[(0.20, 0.08, 280.0), (0.60, 0.15, 330.0), (0.92, 0.07, 60.0)]),
("Moss", &[(0.25, 0.05, 150.0), (0.92, 0.07, 110.0)]),
("Ink", &[(0.20, 0.02, 260.0), (0.96, 0.01, 260.0)]),
];
/// `n` colours along OKLab segments between `anchors`.
fn scale(name: &str, n: usize, anchors: &[(f64, f64, f64)]) -> SwatchGroup {
let pts: Vec<[f64; 3]> = anchors.iter().map(|&(l, c, h)| lab(l, c, h)).collect();
let segs = (pts.len() - 1) as f64;
let swatches = (0..n)
.map(|i| {
let t = i as f64 / (n - 1) as f64 * segs;
let k = (t.floor() as usize).min(pts.len() - 2);
solid(format!("{name} {}", i + 1), mix(pts[k], pts[k + 1], t - k as f64))
})
.collect();
group(name, swatches)
}
/// Scales whose lightness rises evenly (sequential) plus two diverging ones.
fn perceptual() -> (Vec<Swatch>, Vec<SwatchGroup>) {
let mut groups: Vec<SwatchGroup> = SEQUENTIAL.iter().map(|(n, a)| scale(n, 9, a)).collect();
groups.push(scale("Clay to Teal", 9, &[(0.45, 0.12, 40.0), (0.96, 0.01, 80.0), (0.45, 0.09, 200.0)]));
groups.push(scale("Plum to Olive", 9, &[(0.40, 0.11, 330.0), (0.96, 0.01, 90.0), (0.45, 0.09, 120.0)]));
(vec![], groups)
}
/// Colour-wheel harmonies of a few base colours (the Color Guide's rules).
fn harmony_sets() -> (Vec<Swatch>, Vec<SwatchGroup>) {
use crate::harmony::Harmony;
let bases = [("Coral", Color::from_hsb(8.0, 0.68, 0.94)), ("Teal", Color::from_hsb(176.0, 0.72, 0.62))];
let rules = [Harmony::Complementary, Harmony::SplitComplementary, Harmony::Analogous, Harmony::Triad, Harmony::Tetrad, Harmony::Pentagram];
let mut groups = vec![];
for (bn, base) in bases {
for rule in rules {
let name = format!("{} {bn}", rule.label());
// Rounded to 8 bits like the other libraries.
let swatches = rule
.apply(base)
.into_iter()
.enumerate()
.map(|(i, c)| {
let [r, g, b, _] = c.to_rgba8(1.0);
solid(format!("{name} {}", i + 1), Color::rgb8(r, g, b))
})
.collect();
groups.push(SwatchGroup { name, swatches });
}
}
(vec![], groups)
}
#[cfg(test)]
mod tests {
use super::*;
fn all() -> Vec<Arc<SwatchLibrary>> {
builtins().map(|b| builtin_library(b.id).unwrap()).collect()
}
/// Every component of every colour (solid or gradient stop).
fn components(lib: &SwatchLibrary) -> Vec<f32> {
let comps = |c: &Color| match *c {
Color::Rgb { r, g, b } => vec![r, g, b],
Color::Cmyk { c, m, y, k } => vec![c, m, y, k],
Color::Gray { k } => vec![k],
// Normalized to 0..1 like the others.
Color::Lab { l, a, b } => vec![l / 100.0, (a + 128.0) / 255.0, (b + 128.0) / 255.0],
};
lib.iter()
.flat_map(|s| match &s.paint {
Paint::Solid { color, .. } => comps(color),
Paint::Gradient(g) => g.gradient.stops.iter().flat_map(|st| comps(&st.color)).collect(),
_ => vec![],
})
.collect()
}
#[test]
fn libraries_are_deterministic_unique_and_in_range() {
let mut ids = std::collections::HashSet::new();
for (b, lib) in builtins().zip(all()) {
assert!(ids.insert(b.id), "duplicate id {}", b.id);
assert_eq!(lib.name, b.name);
assert!(lib.len() >= 5, "{} has {}", b.id, lib.len());
// Recomputing gives the same library.
let (s, g) = (b.make)();
assert_eq!((s, g), (lib.swatches.clone(), lib.groups.clone()), "{} is deterministic", b.id);
// Swatch and group names share one namespace, as in a document.
let mut names = std::collections::HashSet::new();
for n in lib.iter().map(|s| &s.name).chain(lib.groups.iter().map(|g| &g.name)) {
assert!(names.insert(n.clone()), "{}: duplicate name {n}", b.id);
}
assert!(components(&lib).iter().all(|v| (0.0..=1.0).contains(v)), "{} out of range", b.id);
assert!(lib.iter().all(|s| !s.paint.is_none() && !s.global && !s.spot));
}
}
#[test]
fn web_safe_has_the_216_web_colours() {
let lib = builtin_library("web-safe-216").unwrap();
assert_eq!(lib.len(), 216);
let mut hex: Vec<String> = lib.iter().map(|s| s.paint.color().unwrap().to_hex()).collect();
hex.dedup();
assert_eq!(hex.len(), 216);
assert!(lib.iter().all(|s| s.paint.color().unwrap().to_rgba8(1.0)[..3].iter().all(|v| v % 0x33 == 0)));
assert_eq!(lib.swatches[0].name, "#000000");
assert_eq!(lib.swatches[215].name, "#FFFFFF");
}
#[test]
fn sequential_scales_rise_in_lightness() {
let lib = builtin_library("perceptual-scales").unwrap();
for (name, _) in SEQUENTIAL {
let l: Vec<f64> = lib.group(name).unwrap().swatches.iter().map(|s| to_oklab(&s.paint.color().unwrap())[0]).collect();
assert!(l.windows(2).all(|w| w[1] > w[0] + 0.03), "{name}: {l:?}");
}
}
#[test]
fn oklch_round_trips_in_gamut_colours() {
let c = oklch(0.7, 0.1, 150.0);
let [l, a, b] = to_oklab(&c);
assert!((l - 0.7).abs() < 0.01 && (a.hypot(b) - 0.1).abs() < 0.01, "{l} {a} {b}");
// Out-of-gamut chroma is clipped to the gamut edge, keeping lightness.
let v = oklch(0.72, 0.4, 25.0);
assert!((to_oklab(&v)[0] - 0.72).abs() < 0.02);
}
#[test]
fn gradient_libraries_have_sorted_stops() {
for b in GRADIENT_LIBRARIES {
let lib = builtin_library(b.id).unwrap();
assert!(lib.iter().all(|s| matches!(s.paint, Paint::Gradient(_))), "{} holds gradients only", b.id);
}
for lib in all() {
for s in lib.iter() {
if let Paint::Gradient(g) = &s.paint {
let st = &g.gradient.stops;
assert!(st.len() >= 2 && st.windows(2).all(|w| w[0].offset <= w[1].offset), "{}", s.name);
assert!(st.iter().all(|s| (0.0..=1.0).contains(&s.offset) && (0.0..=1.0).contains(&s.opacity)));
}
}
}
}
}
+324
View File
@@ -0,0 +1,324 @@
//! Swatch library files: the native `.vcswatches` JSON (colour models, global, spot, gradients
//! and colour groups exactly), `.gpl` palettes (8-bit RGB; colour groups as `# Group:` comment
//! headers) and CSS custom properties (written only).
use serde::{Deserialize, Serialize};
use crate::{Color, GradientKind, Paint, Swatch, SwatchGroup, SwatchLibrary};
/// A swatch library file format.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PaletteFormat {
/// `.vcswatches`: JSON, lossless.
Native,
/// `.gpl`: a plain-text RGB palette many paint and design tools read.
Gpl,
/// `.css`: custom properties on `:root` (colours and gradients; written only).
Css,
}
impl PaletteFormat {
pub const ALL: [PaletteFormat; 3] = [PaletteFormat::Native, PaletteFormat::Gpl, PaletteFormat::Css];
/// The format's id, which is also its extension.
pub fn id(self) -> &'static str {
match self {
PaletteFormat::Native => "vcswatches",
PaletteFormat::Gpl => "gpl",
PaletteFormat::Css => "css",
}
}
pub fn label(self) -> &'static str {
match self {
PaletteFormat::Native => "VectorCraft Swatches (.vcswatches)",
PaletteFormat::Gpl => "GPL Palette (.gpl)",
PaletteFormat::Css => "CSS Custom Properties (.css)",
}
}
/// The format with id or extension `s` (any case, leading dot allowed).
pub fn parse(s: &str) -> Option<Self> {
let s = s.trim_start_matches('.').to_ascii_lowercase();
Self::ALL.into_iter().find(|f| f.id() == s)
}
/// Can libraries be read back from this format?
pub fn readable(self) -> bool {
self != PaletteFormat::Css
}
}
/// The native file: a header around the library.
#[derive(Serialize, Deserialize)]
struct NativeFile {
format: String,
version: u32,
#[serde(flatten)]
library: SwatchLibrary,
}
const NATIVE_FORMAT: &str = "vcswatches";
const GPL_HEADER: &str = "GIMP Palette";
const GPL_GROUP: &str = "# Group:";
/// Write `lib` in `format`. Pattern swatches (their tiles live in a document) and None are left
/// out; `.gpl` keeps solid colours only, as 8-bit RGB.
pub fn write(lib: &SwatchLibrary, format: PaletteFormat) -> String {
let keep = |w: &&Swatch| matches!(w.paint, Paint::Solid { .. } | Paint::Gradient(_));
match format {
PaletteFormat::Native => {
let strip = |list: &[Swatch]| list.iter().filter(keep).cloned().collect::<Vec<_>>();
let library = SwatchLibrary {
name: lib.name.clone(),
swatches: strip(&lib.swatches),
groups: lib.groups.iter().map(|g| SwatchGroup { name: g.name.clone(), swatches: strip(&g.swatches) }).collect(),
};
let file = NativeFile { format: NATIVE_FORMAT.into(), version: 1, library };
serde_json::to_string_pretty(&file).unwrap_or_default()
}
PaletteFormat::Gpl => write_gpl(lib),
PaletteFormat::Css => write_css(lib, keep),
}
}
/// One line of text: no line breaks or tabs.
fn one_line(s: &str) -> String {
s.split(['\n', '\r', '\t']).collect::<Vec<_>>().join(" ").trim().to_string()
}
fn write_gpl(lib: &SwatchLibrary) -> String {
let mut out = format!("{GPL_HEADER}\nName: {}\nColumns: 0\n#\n", one_line(&lib.name));
let colors = |out: &mut String, list: &[Swatch]| {
for w in list {
if let Some(c) = w.paint.color() {
let [r, g, b, _] = c.to_rgba8(1.0);
out.push_str(&format!("{r:3} {g:3} {b:3}\t{}\n", one_line(&w.name)));
}
}
};
colors(&mut out, &lib.swatches);
for g in &lib.groups {
out.push_str(&format!("{GPL_GROUP} {}\n", one_line(&g.name)));
colors(&mut out, &g.swatches);
}
out
}
/// `name` as a CSS custom property (`--` and an escaped identifier): lower case, spaces as
/// hyphens, other characters an identifier can't hold escaped with a backslash.
pub fn css_property(name: &str) -> String {
let mut out = String::from("--");
for word in name.split_whitespace() {
if out.len() > 2 {
out.push('-');
}
for ch in word.chars().flat_map(char::to_lowercase) {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || !ch.is_ascii() {
out.push(ch);
} else if ch.is_ascii_graphic() {
out.push('\\');
out.push(ch);
} else {
out.push_str(&format!("\\{:x} ", ch as u32));
}
}
}
if out.len() == 2 {
out.push_str("swatch");
}
out
}
/// A colour as CSS (`#rrggbb`, or `#rrggbbaa` below full opacity).
fn css_color(c: &Color, opacity: f32) -> String {
let [r, g, b, a] = c.to_rgba8(opacity);
if a == 255 { format!("#{r:02x}{g:02x}{b:02x}") } else { format!("#{r:02x}{g:02x}{b:02x}{a:02x}") }
}
/// A comment's text: `*/` can't end it early.
fn css_comment(s: &str) -> String {
one_line(s).replace("*/", "* /")
}
fn write_css(lib: &SwatchLibrary, keep: impl Fn(&&Swatch) -> bool) -> String {
let mut out = format!("/* {} */\n:root {{\n", css_comment(&lib.name));
let mut taken: Vec<String> = vec![];
let mut props = |out: &mut String, list: &[Swatch]| {
for w in list.iter().filter(&keep) {
let value = match &w.paint {
Paint::Solid { color, .. } => css_color(color, 1.0),
Paint::Gradient(g) if g.gradient.kind != GradientKind::Freeform => {
let stops: Vec<String> =
g.gradient.stops.iter().map(|s| format!("{} {}%", css_color(&s.color, s.opacity), (s.offset * 100.0).round())).collect();
match g.gradient.kind {
GradientKind::Radial => format!("radial-gradient(circle, {})", stops.join(", ")),
_ => format!("linear-gradient({}deg, {})", (90.0 - g.angle).rem_euclid(360.0).round(), stops.join(", ")),
}
}
_ => continue,
};
// Names differing only in case or punctuation map to one property: number the others.
let base = css_property(&w.name);
let name = (1..).map(|i| if i == 1 { base.clone() } else { format!("{base}-{i}") }).find(|n| !taken.contains(n)).unwrap_or(base);
taken.push(name.clone());
let note = if w.spot { " /* spot */" } else { "" };
out.push_str(&format!(" {name}: {value};{note}\n"));
}
};
props(&mut out, &lib.swatches);
for g in &lib.groups {
out.push_str(&format!(" /* {} */\n", css_comment(&g.name)));
props(&mut out, &g.swatches);
}
out.push_str("}\n");
out
}
/// Read a `.vcswatches` or `.gpl` library (detected from its content). `name` names it when the
/// file doesn't.
pub fn read(text: &str, name: &str) -> Result<SwatchLibrary, String> {
let text = text.trim_start_matches('\u{feff}').trim_start();
if text.starts_with(GPL_HEADER) {
return Ok(read_gpl(text, name));
}
if text.starts_with('{') {
let f: NativeFile = serde_json::from_str(text).map_err(|e| format!("not a swatch library: {e}"))?;
if f.format != NATIVE_FORMAT {
return Err(format!("not a swatch library (format `{}`)", f.format));
}
let mut lib = f.library;
if lib.name.trim().is_empty() {
lib.name = name.into();
}
return Ok(lib);
}
Err("not a swatch library (.vcswatches or .gpl)".into())
}
/// Is `text` a library [`read`] understands?
pub fn sniff(text: &str) -> bool {
let t = text.trim_start_matches('\u{feff}').trim_start();
t.starts_with(GPL_HEADER) || (t.starts_with('{') && t.contains(NATIVE_FORMAT))
}
/// `.gpl`: `Name:` names the library, `# Group: name` starts a colour group, other `#` lines and
/// headers (`Columns:`) are skipped, as are lines that aren't `r g b [name]`. Unnamed colours are
/// named by their values; repeated names get a number.
fn read_gpl(text: &str, fallback: &str) -> SwatchLibrary {
let mut lib = SwatchLibrary { name: fallback.into(), ..Default::default() };
let mut names: Vec<String> = vec![];
for line in text.lines().skip(1) {
let line = line.trim();
if let Some(g) = line.strip_prefix(GPL_GROUP) {
lib.groups.push(SwatchGroup { name: g.trim().to_string(), swatches: vec![] });
continue;
}
if let Some(n) = line.strip_prefix("Name:") {
lib.name = n.trim().to_string();
continue;
}
// Comments, headers (`Columns: 4`) and anything else that isn't a colour are skipped.
let words: Vec<&str> = line.split_whitespace().collect();
let rgb: Vec<u8> = words.iter().take(3).map_while(|v| v.parse().ok()).collect();
let [r, g, b] = rgb[..] else { continue };
let rest = words[3..].join(" ");
let base = if rest.is_empty() { format!("R={r} G={g} B={b}") } else { rest };
let name = (1..).map(|i| if i == 1 { base.clone() } else { format!("{base} {i}") }).find(|n| !names.contains(n)).unwrap_or(base);
names.push(name.clone());
let w = Swatch { name, paint: Paint::solid(Color::rgb8(r, g, b)), global: false, spot: false };
match lib.groups.last_mut() {
Some(grp) => grp.swatches.push(w),
None => lib.swatches.push(w),
}
}
lib
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Gradient, GradientPaint, GradientStop};
fn sample() -> SwatchLibrary {
let grad = Paint::Gradient(Box::new(GradientPaint::new(Gradient {
kind: GradientKind::Linear,
stops: vec![
GradientStop { midpoint: 0.4, ..GradientStop::new(0.0, Color::cmyk(0.1, 0.2, 0.3, 0.0)) },
GradientStop { opacity: 0.5, ..GradientStop::new(1.0, Color::rgb8(255, 0, 0)) },
],
})));
SwatchLibrary {
name: "Brand */ Colours".into(),
swatches: vec![
Swatch { name: "Ink".into(), paint: Paint::solid(Color::cmyk(1.0, 0.5, 0.0, 0.2)), global: true, spot: true },
Swatch { name: "Fade".into(), paint: grad, global: false, spot: false },
Swatch { name: "Tiles".into(), paint: Paint::Pattern { pattern: "Dots".into(), xf: Default::default() }, global: false, spot: false },
],
groups: vec![SwatchGroup {
name: "Neutrals".into(),
swatches: vec![
Swatch { name: "R=255 G=0 B=0".into(), paint: Paint::solid(Color::rgb8(255, 0, 0)), global: false, spot: false },
Swatch { name: "Mist".into(), paint: Paint::solid(Color::gray(0.25)), global: true, spot: false },
],
}],
}
}
#[test]
fn native_round_trip_keeps_cmyk_spot_and_groups() {
let lib = sample();
let text = write(&lib, PaletteFormat::Native);
assert!(sniff(&text));
let back = read(&text, "fallback").unwrap();
let mut expected = lib.clone();
expected.swatches.retain(|w| !matches!(w.paint, Paint::Pattern { .. }));
assert_eq!(back, expected, "everything but the pattern comes back exactly");
assert!(back.swatches[0].spot && back.swatches[0].global);
assert_eq!(back.swatches[0].paint.color(), Some(Color::cmyk(1.0, 0.5, 0.0, 0.2)));
assert!(read("{\"format\": \"other\", \"version\": 1, \"name\": \"x\"}", "x").is_err());
}
#[test]
fn gpl_writes_rgb_with_group_headers_and_reads_comments_and_headers() {
let text = write(&sample(), PaletteFormat::Gpl);
assert!(text.starts_with("GIMP Palette\nName: Brand */ Colours\n"));
assert!(text.contains("# Group: Neutrals\n255 0 0\tR=255 G=0 B=0\n"), "{text}");
assert!(!text.contains("Fade") && !text.contains("Tiles"), "only solid colours");
let back = read(&text, "x").unwrap();
assert_eq!(back.name, "Brand */ Colours");
assert_eq!(back.swatches.len(), 1);
assert_eq!(back.groups[0].swatches[1].paint.color(), Some(Color::rgb8(191, 191, 191)), "grey as 8-bit RGB");
// Another tool's palette: comments, a Columns header, blank lines, unnamed and repeated names.
let other = "GIMP Palette\r\nName: Sunset\r\nColumns: 4\r\n# a comment\r\n\r\n 0 128 255\tSky Blue\r\n10 20 30\r\n0 0 0 Black\r\n1 1 1 Black\r\nbad line\r\n";
let lib = read(other, "file").unwrap();
assert_eq!(lib.name, "Sunset");
let names: Vec<&str> = lib.swatches.iter().map(|w| w.name.as_str()).collect();
assert_eq!(names, ["Sky Blue", "R=10 G=20 B=30", "Black", "Black 2"]);
assert_eq!(lib.swatches[0].paint.color(), Some(Color::rgb8(0, 128, 255)));
assert_eq!(read("GIMP Palette\n1 2 3 x\n", "Fallback").unwrap().name, "Fallback");
}
#[test]
fn css_escapes_names_and_writes_gradients() {
assert_eq!(css_property("Sky Blue"), "--sky-blue");
assert_eq!(css_property("R=255 G=0 B=0"), "--r\\=255-g\\=0-b\\=0");
assert_eq!(css_property("#FF00CC"), "--\\#ff00cc");
assert_eq!(css_property("Café 50%"), "--café-50\\%");
assert_eq!(css_property(" "), "--swatch");
let text = write(&sample(), PaletteFormat::Css);
assert!(text.starts_with("/* Brand * / Colours */\n:root {\n"), "{text}");
assert!(text.contains(" --ink: #"), "{text}");
assert!(text.contains("; /* spot */"));
assert!(text.contains(" --fade: linear-gradient(90deg, #"), "{text}");
assert!(text.contains("#ff000080 100%)"), "half-transparent stop: {text}");
assert!(text.contains(" /* Neutrals */\n --r\\=255-g\\=0-b\\=0: #ff0000;\n"));
assert!(!text.contains("tiles"));
// Names that collide once escaped are numbered.
let lib = SwatchLibrary {
name: "x".into(),
swatches: ["Red", "red"].map(|n| Swatch { name: n.into(), paint: Paint::solid(Color::BLACK), global: false, spot: false }).to_vec(),
groups: vec![],
};
let text = write(&lib, PaletteFormat::Css);
assert!(text.contains("--red: #000000;") && text.contains("--red-2: #000000;"));
assert!(read(&text, "x").is_err(), "CSS is written only");
}
}
+586
View File
@@ -0,0 +1,586 @@
//! Recolor Artwork's colour math (our own): colour identity keys, the White/Black/Grays preserve
//! rules, the methods that map a row of current colours to its new colour, colour reduction by
//! clustering in Lab, the nearest colour of a library and the randomizers.
use std::fmt;
use crate::cms::{Lab, delta_e2000};
use crate::{Color, keep_model};
/// A colour's identity: its model and exact components (to a hundredth of an RGB level, a percent
/// or a Lab unit), so colours that only look alike (a CMYK red and an RGB red) stay apart and keep
/// their model. Written as `rgb 255 128 0` (0–255), `cmyk 0 100 100 0`, `gray 40` (percent) or
/// `lab 55 60 -40` (L 0–100, a and b).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct ColorKey {
model: u8,
v: [i32; 4],
}
/// Key resolution: RGB components are kept to 1/RGB_Q, CMYK and Gray ones to 1/INK_Q and Lab ones
/// to 1/LAB_Q (hundredths of the written units).
const RGB_Q: f32 = 25_500.0;
const INK_Q: f32 = 10_000.0;
const LAB_Q: f32 = 100.0;
impl ColorKey {
pub fn of(c: &Color) -> Self {
let rgb = |x: f32| (x.clamp(0.0, 1.0) * RGB_Q).round() as i32;
let ink = |x: f32| (x.clamp(0.0, 1.0) * INK_Q).round() as i32;
match *c {
Color::Rgb { r, g, b } => Self { model: 0, v: [rgb(r), rgb(g), rgb(b), 0] },
Color::Cmyk { c, m, y, k } => Self { model: 1, v: [ink(c), ink(m), ink(y), ink(k)] },
Color::Gray { k } => Self { model: 2, v: [ink(k), 0, 0, 0] },
Color::Lab { l, a, b } => {
let lab = |x: f32| (x * LAB_Q).round() as i32;
Self { model: 3, v: [lab(l.clamp(0.0, 100.0)), lab(a), lab(b), 0] }
}
}
}
/// The colour this key names.
pub fn color(&self) -> Color {
let f = |i: usize| self.v[i] as f32 / INK_Q;
match self.model {
0 => Color::rgb(self.v[0] as f32 / RGB_Q, self.v[1] as f32 / RGB_Q, self.v[2] as f32 / RGB_Q),
1 => Color::cmyk(f(0), f(1), f(2), f(3)),
2 => Color::gray(f(0)),
_ => Color::lab(self.v[0] as f32 / LAB_Q, self.v[1] as f32 / LAB_Q, self.v[2] as f32 / LAB_Q),
}
}
/// Parse the written form (see [`ColorKey`]); extra spaces and case are ignored.
pub fn parse(s: &str) -> Option<Self> {
let mut it = s.split_whitespace();
let model = it.next()?.to_ascii_lowercase();
let nums: Vec<f32> = it.map(|t| t.parse::<f32>().ok().filter(|v| v.is_finite())).collect::<Option<_>>()?;
let c = match (model.as_str(), nums.as_slice()) {
("rgb", [r, g, b]) => Color::rgb(r / 255.0, g / 255.0, b / 255.0),
("cmyk", [c, m, y, k]) => Color::cmyk(c / 100.0, m / 100.0, y / 100.0, k / 100.0),
("gray", [k]) => Color::gray(k / 100.0),
("lab", [l, a, b]) => Color::lab(*l, *a, *b),
_ => return None,
};
Some(Self::of(&c))
}
}
impl fmt::Display for ColorKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// Components are hundredths of the written units: exact with two decimals.
let num = |q: i32| {
let s = format!("{}{}.{:02}", if q < 0 { "-" } else { "" }, q.abs() / 100, q.abs() % 100);
s.trim_end_matches('0').trim_end_matches('.').to_string()
};
let [a, b, c, k] = self.v.map(num);
match self.model {
0 => write!(f, "rgb {a} {b} {c}"),
1 => write!(f, "cmyk {a} {b} {c} {k}"),
2 => write!(f, "gray {a}"),
_ => write!(f, "lab {a} {b} {c}"),
}
}
}
/// The neutral a colour is, if any: paper white, black (rich blacks included) or a grey.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Neutral {
White,
Black,
Gray,
}
impl Neutral {
pub fn of(c: &Color) -> Option<Self> {
const E: f32 = 0.005;
match *c {
Color::Gray { k } if k <= E => Some(Neutral::White),
Color::Gray { k } if k >= 1.0 - E => Some(Neutral::Black),
Color::Gray { .. } => Some(Neutral::Gray),
Color::Cmyk { c, m, y, k } if c.max(m).max(y).max(k) <= E => Some(Neutral::White),
Color::Cmyk { k, .. } if k >= 1.0 - E => Some(Neutral::Black),
Color::Cmyk { c, m, y, .. } => (c.max(m).max(y) <= E).then_some(Neutral::Gray),
// Lab: lightness at the ends, no chroma between them (to half a unit).
Color::Lab { l, a, b } if l >= 99.5 && a.abs().max(b.abs()) <= 0.5 => Some(Neutral::White),
Color::Lab { l, .. } if l <= 0.5 => Some(Neutral::Black),
Color::Lab { a, b, .. } => (a.abs().max(b.abs()) <= 0.5).then_some(Neutral::Gray),
Color::Rgb { r, g, b } => {
let (lo, hi) = (r.min(g).min(b), r.max(g).max(b));
if lo >= 1.0 - E {
Some(Neutral::White)
} else if hi <= E {
Some(Neutral::Black)
} else {
(hi - lo <= E).then_some(Neutral::Gray)
}
}
}
}
}
/// Which neutrals Recolor Artwork leaves alone (White and Black by default).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Preserve {
pub white: bool,
pub black: bool,
pub grays: bool,
}
impl Default for Preserve {
fn default() -> Self {
Self { white: true, black: true, grays: false }
}
}
impl Preserve {
pub const NONE: Self = Self { white: false, black: false, grays: false };
/// Whether `c` stays as it is.
pub fn keeps(&self, c: &Color) -> bool {
match Neutral::of(c) {
Some(Neutral::White) => self.white,
Some(Neutral::Black) => self.black,
Some(Neutral::Gray) => self.grays,
None => false,
}
}
}
/// How the colours of a row take its new colour.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Method {
/// Every colour becomes the new colour.
#[default]
Exact,
/// Tints of the row's darkest colour become the same tints of the new colour; other colours
/// become the new colour.
PreserveTints,
/// Every colour becomes a tint of the new colour as light, relative to the row's darkest colour,
/// as it is.
ScaleTints,
/// The colour of average lightness becomes the new colour; lighter and darker ones become tints
/// and shades of it.
TintsShades,
/// The row's most saturated colour becomes the new colour; the others turn by the same hue and
/// keep their saturation and brightness.
HueShift,
}
impl Method {
pub const ALL: [Method; 5] = [Method::Exact, Method::PreserveTints, Method::ScaleTints, Method::TintsShades, Method::HueShift];
pub fn id(self) -> &'static str {
match self {
Method::Exact => "exact",
Method::PreserveTints => "preserveTints",
Method::ScaleTints => "scaleTints",
Method::TintsShades => "tintsShades",
Method::HueShift => "hueShift",
}
}
pub fn label(self) -> &'static str {
match self {
Method::Exact => "Exact",
Method::PreserveTints => "Preserve Tints",
Method::ScaleTints => "Scale Tints",
Method::TintsShades => "Tints and Shades",
Method::HueShift => "Hue Shift",
}
}
/// A method from its id or label (case and spaces ignored).
pub fn parse(s: &str) -> Option<Self> {
let key = |x: &str| x.chars().filter(|c| c.is_alphanumeric()).collect::<String>().to_ascii_lowercase();
let want = key(s);
Self::ALL.into_iter().find(|m| key(m.id()) == want || key(m.label()) == want)
}
/// The colour of `row` the new colour stands for: the most saturated for Hue Shift, the one
/// of average lightness for Tints and Shades, else the darkest. `None` for an empty row.
pub fn key_color(self, row: &[Color]) -> Option<Color> {
let labs: Vec<(Color, Lab)> = row.iter().map(|c| (*c, c.to_lab())).collect();
let pick = |score: &dyn Fn(&Lab) -> f32| labs.iter().min_by(|a, b| score(&a.1).total_cmp(&score(&b.1))).map(|x| x.0);
match self {
Method::HueShift => pick(&|l| -l.chroma() * 1000.0 + l.l),
Method::TintsShades => {
let mean = mean_l(&labs);
pick(&|l| (l.l - mean).abs())
}
_ => pick(&|l| l.l * 1000.0 - l.chroma()),
}
}
}
fn mean_l(labs: &[(Color, Lab)]) -> f32 {
labs.iter().map(|x| x.1.l).sum::<f32>() / labs.len().max(1) as f32
}
/// Ink of `c` in `model`'s terms: CMYK and Gray components, 1 − RGB, or Lab's distance from paper
/// white (as [`Color::tinted`] scales it).
fn ink(c: Color, model: crate::cms::Model) -> [f32; 4] {
match c.in_model(model) {
Color::Cmyk { c, m, y, k } => [c, m, y, k],
Color::Gray { k } => [k, 0.0, 0.0, 0.0],
Color::Rgb { r, g, b } => [1.0 - r, 1.0 - g, 1.0 - b, 0.0],
Color::Lab { l, a, b } => [(100.0 - l) / 100.0, a / 100.0, b / 100.0, 0.0],
}
}
fn dot(a: [f32; 4], b: [f32; 4]) -> f32 {
a.iter().zip(b).map(|(x, y)| x * y).sum()
}
/// How much of `base`'s ink `c` carries (0 = paper, 1 = `base` or darker), measured in `c`'s model:
/// the tint of `base` closest to `c`.
pub fn tint_of(c: Color, base: Color) -> f32 {
let (a, b) = (ink(c, c.model()), ink(base, c.model()));
let bb = dot(b, b);
if bb <= 1e-9 { 1.0 } else { (dot(a, b) / bb).clamp(0.0, 1.0) }
}
/// Whether `c` is a tint of `base` (its ink a fraction of `base`'s).
pub fn is_tint(c: Color, base: Color) -> bool {
let t = tint_of(c, base);
let (a, b) = (ink(c, c.model()), ink(base, c.model()));
a.iter().zip(b).all(|(x, y)| (x - y * t).abs() <= 0.01)
}
/// `c` darkened by `f` (0..1) toward black: more K for CMYK and Gray, scaled RGB and Lab.
pub fn shaded(c: Color, f: f32) -> Color {
let f = f.clamp(0.0, 1.0);
match c {
Color::Cmyk { c, m, y, k } => Color::cmyk(c, m, y, k + (1.0 - k) * f),
Color::Gray { k } => Color::gray(k + (1.0 - k) * f),
Color::Lab { l, a, b } => Color::lab(l * (1.0 - f), a * (1.0 - f), b * (1.0 - f)),
Color::Rgb { r, g, b } => Color::rgb(r * (1.0 - f), g * (1.0 - f), b * (1.0 - f)),
}
}
/// One row of Recolor Artwork prepared for mapping: its current colours take `to` by `method`.
#[derive(Clone, Copy, Debug)]
pub struct RowMap {
method: Method,
to: Color,
key: Color,
mean_l: f32,
/// Hue turn from the key colour to the new colour (Hue Shift).
turn: f32,
}
impl RowMap {
/// The map of a row of current colours `row` (non-empty) to the new colour `to`.
pub fn new(method: Method, row: &[Color], to: Color) -> Self {
let key = method.key_color(row).unwrap_or(to);
let mean_l = match method {
Method::TintsShades => mean_l(&row.iter().map(|c| (*c, c.to_lab())).collect::<Vec<_>>()),
_ => 0.0,
};
Self { method, to, key, mean_l, turn: to.to_hsb()[0] - key.to_hsb()[0] }
}
/// The colour of the row the new colour stands for.
pub fn key(&self) -> Color {
self.key
}
/// The new colour of current colour `c`, in `c`'s model.
pub fn map(&self, c: Color) -> Color {
let to = keep_model(c, self.to);
match self.method {
Method::Exact => to,
Method::PreserveTints if is_tint(c, self.key) => to.tinted(tint_of(c, self.key)),
Method::PreserveTints => to,
Method::ScaleTints => to.tinted(tint_of(c, self.key)),
Method::TintsShades => {
let d = c.to_lab().l - self.mean_l;
if d >= 0.0 { to.tinted(1.0 - d / (100.0 - self.mean_l).max(1e-3)) } else { shaded(to, -d / self.mean_l.max(1e-3)) }
}
Method::HueShift if c == self.key => to,
Method::HueShift => {
let [h, s, v] = c.to_hsb();
if s <= 0.0 { c } else { keep_model(c, Color::from_hsb(h + self.turn, s, v)) }
}
}
}
}
/// Group `colors` into at most `n` rows of similar colours, for colour reduction: k-means in Lab
/// (lightness counting half, so tints and shades of a hue stay together) weighted by `weights`,
/// seeded with the heaviest colour and then farthest-first, so the result is the same on every
/// run. Returns the rows as indices into `colors`, ordered by their first index (each in input
/// order).
pub fn cluster(colors: &[Color], weights: &[f32], n: usize) -> Vec<Vec<usize>> {
let pts: Vec<[f32; 3]> = colors
.iter()
.map(|c| {
let l = c.to_lab();
[l.l * 0.5, l.a, l.b]
})
.collect();
let w = |i: usize| weights.get(i).copied().unwrap_or(1.0).max(1e-6);
let d2 = |a: &[f32; 3], b: &[f32; 3]| (a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2);
let n = n.max(1);
if pts.is_empty() {
return vec![];
}
// Seeds: the heaviest colour, then each time the one farthest from every seed so far.
let first = (0..pts.len()).fold(0, |best, i| if w(i) > w(best) { i } else { best });
let mut centres = vec![pts[first]];
let mut near: Vec<f32> = pts.iter().map(|p| d2(p, &pts[first])).collect();
while centres.len() < n {
let (i, far) = near.iter().enumerate().fold((0, 0.0f32), |b, (i, d)| if *d > b.1 { (i, *d) } else { b });
if far <= 1e-6 {
break;
}
centres.push(pts[i]);
for (j, p) in pts.iter().enumerate() {
near[j] = near[j].min(d2(p, &pts[i]));
}
}
let nearest = |p: &[f32; 3], cs: &[[f32; 3]]| (0..cs.len()).min_by(|a, b| d2(p, &cs[*a]).total_cmp(&d2(p, &cs[*b]))).unwrap_or(0);
let mut of: Vec<usize> = pts.iter().map(|p| nearest(p, &centres)).collect();
for _ in 0..50 {
let mut sum = vec![([0.0f32; 3], 0.0f32); centres.len()];
for (i, p) in pts.iter().enumerate() {
let s = &mut sum[of[i]];
for (acc, v) in s.0.iter_mut().zip(p) {
*acc += v * w(i);
}
s.1 += w(i);
}
for (c, (s, wt)) in centres.iter_mut().zip(&sum) {
if *wt > 0.0 {
*c = s.map(|v| v / wt);
}
}
let next: Vec<usize> = pts.iter().map(|p| nearest(p, &centres)).collect();
if next == of {
break;
}
of = next;
}
let mut rows: Vec<Vec<usize>> = vec![vec![]; centres.len()];
for (i, c) in of.into_iter().enumerate() {
rows[c].push(i);
}
rows.retain(|r| !r.is_empty());
rows.sort_by_key(|r| r[0]);
rows
}
/// A set of colours to snap to (Limit to Library), with their Lab values cached.
#[derive(Clone, Debug, Default)]
pub struct Palette(Vec<(Color, Lab)>);
impl Palette {
pub fn new(colors: impl IntoIterator<Item = Color>) -> Self {
Self(colors.into_iter().map(|c| (c, c.to_lab())).collect())
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
/// The palette colour closest to `c` (CIEDE2000), in its own model; `c` for an empty palette.
pub fn nearest(&self, c: Color) -> Color {
let l = c.to_lab();
self.0.iter().min_by(|a, b| delta_e2000(a.1, l).total_cmp(&delta_e2000(b.1, l))).map_or(c, |x| x.0)
}
}
/// A small deterministic generator (xorshift64*) for the randomizers.
pub struct Rng(u64);
impl Rng {
pub fn new(seed: u64) -> Self {
Self(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1)
}
pub fn next_u64(&mut self) -> u64 {
let mut x = self.0;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.0 = x;
x.wrapping_mul(0x2545_F491_4F6C_DD1D)
}
/// Uniform in `lo..hi`.
pub fn range(&mut self, lo: f32, hi: f32) -> f32 {
lo + (hi - lo) * ((self.next_u64() >> 40) as f32 / (1u64 << 24) as f32)
}
/// Shuffle `v` in place (Fisher–Yates).
pub fn shuffle<T>(&mut self, v: &mut [T]) {
for i in (1..v.len()).rev() {
let j = (self.next_u64() % (i as u64 + 1)) as usize;
v.swap(i, j);
}
}
/// `c` with a random saturation and brightness, its hue kept (greys stay grey), in its model.
pub fn saturation_brightness(&mut self, c: Color) -> Color {
let [h, s, _] = c.to_hsb();
let (s2, v2) = (self.range(0.25, 1.0), self.range(0.3, 1.0));
keep_model(c, Color::from_hsb(h, if s <= 0.0 { 0.0 } else { s2 }, v2))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn close(a: Color, b: Color) -> bool {
let v = |c: Color| match c {
Color::Cmyk { c, m, y, k } => vec![c, m, y, k],
Color::Rgb { r, g, b } => vec![r, g, b],
Color::Gray { k } => vec![k],
Color::Lab { l, a, b } => vec![l / 100.0, a / 100.0, b / 100.0],
};
a.model() == b.model() && v(a).iter().zip(v(b)).all(|(x, y)| (x - y).abs() < 1e-3)
}
#[test]
fn keys_keep_the_model_and_round_trip() {
let colors = [
Color::rgb(1.0, 0.5, 0.0),
Color::rgb(1.0 / 3.0, 0.2, 0.9),
Color::cmyk(0.0, 1.0, 1.0, 0.0),
Color::cmyk(0.1234, 0.5, 0.0, 0.07),
Color::gray(0.4),
];
for c in colors {
let k = ColorKey::of(&c);
assert_eq!(ColorKey::parse(&k.to_string()), Some(k), "{k}");
assert!(close(k.color(), c));
}
assert_eq!(ColorKey::of(&Color::rgb(1.0, 0.5, 0.0)).to_string(), "rgb 255 127.5 0");
assert_eq!(ColorKey::of(&Color::cmyk(0.0, 1.0, 1.0, 0.0)).to_string(), "cmyk 0 100 100 0");
assert_eq!(ColorKey::of(&Color::gray(0.4)).to_string(), "gray 40");
assert_eq!(ColorKey::of(&Color::rgb8(18, 52, 86)).to_string(), "rgb 18 52 86", "8-bit colours read as whole levels");
// A CMYK red and an RGB red are different colours.
assert_ne!(ColorKey::of(&Color::cmyk(0.0, 1.0, 1.0, 0.0)), ColorKey::of(&Color::rgb(1.0, 0.0, 0.0)));
assert_eq!(ColorKey::parse(" CMYK 0 100 100 0 "), ColorKey::parse("cmyk 0 100 100 0"));
let lab = Color::lab(55.0, 60.5, -40.25);
assert_eq!(ColorKey::of(&lab).to_string(), "lab 55 60.5 -40.25");
assert_eq!(ColorKey::parse("lab 55 60.5 -40.25").map(|k| k.color()), Some(lab));
assert_ne!(ColorKey::of(&lab), ColorKey::of(&keep_model(Color::WHITE, lab)), "a Lab colour isn't its RGB lookalike");
assert_eq!(ColorKey::parse("#ff0000"), None);
assert_eq!(ColorKey::parse("rgb 1 2"), None);
}
#[test]
fn neutrals_and_preserve() {
assert_eq!(Neutral::of(&Color::WHITE), Some(Neutral::White));
assert_eq!(Neutral::of(&Color::cmyk(0.0, 0.0, 0.0, 0.0)), Some(Neutral::White));
assert_eq!(Neutral::of(&Color::cmyk(0.6, 0.4, 0.4, 1.0)), Some(Neutral::Black), "rich black");
assert_eq!(Neutral::of(&Color::gray(0.5)), Some(Neutral::Gray));
assert_eq!(Neutral::of(&Color::rgb(0.5, 0.5, 0.5)), Some(Neutral::Gray));
assert_eq!(Neutral::of(&Color::cmyk(0.0, 0.0, 0.0, 0.3)), Some(Neutral::Gray));
assert_eq!(Neutral::of(&Color::rgb(0.5, 0.4, 0.5)), None);
assert_eq!(Neutral::of(&Color::lab(100.0, 0.0, 0.0)), Some(Neutral::White));
assert_eq!(Neutral::of(&Color::lab(0.0, 0.0, 0.0)), Some(Neutral::Black));
assert_eq!(Neutral::of(&Color::lab(50.0, 0.2, -0.3)), Some(Neutral::Gray));
assert_eq!(Neutral::of(&Color::lab(50.0, 20.0, 0.0)), None);
let p = Preserve::default();
assert!(p.keeps(&Color::BLACK) && p.keeps(&Color::WHITE) && !p.keeps(&Color::gray(0.5)));
assert!(!Preserve::NONE.keeps(&Color::BLACK));
}
#[test]
fn methods_parse_by_id_and_label() {
for m in Method::ALL {
assert_eq!(Method::parse(m.id()), Some(m));
assert_eq!(Method::parse(m.label()), Some(m));
}
assert_eq!(Method::parse("tints & shades"), Some(Method::TintsShades));
assert_eq!(Method::parse("nope"), None);
}
#[test]
fn scale_tints_keep_tint_ratios() {
let red = Color::cmyk(0.0, 1.0, 1.0, 0.0);
let pink = red.tinted(0.4);
let row = [pink, red];
let m = RowMap::new(Method::ScaleTints, &row, Color::cmyk(1.0, 0.5, 0.0, 0.0));
assert!(close(m.key(), red), "the darkest colour is the key");
assert!(close(m.map(red), Color::cmyk(1.0, 0.5, 0.0, 0.0)));
assert!(close(m.map(pink), Color::cmyk(0.4, 0.2, 0.0, 0.0)), "{:?}", m.map(pink));
// RGB tints mix toward white.
let blue = Color::rgb(0.0, 0.0, 1.0);
let m = RowMap::new(Method::ScaleTints, &[blue, blue.tinted(0.5)], Color::rgb(1.0, 0.0, 0.0));
assert!(close(m.map(blue.tinted(0.5)), Color::rgb(1.0, 0.5, 0.5)));
// Lab tints too (toward paper white).
let base = Color::lab(40.0, 60.0, -20.0);
let to = Color::lab(50.0, -40.0, 30.0);
let m = RowMap::new(Method::ScaleTints, &[base, base.tinted(0.25)], to);
assert!(close(m.map(base.tinted(0.25)), to.tinted(0.25)), "{:?}", m.map(base.tinted(0.25)));
}
#[test]
fn preserve_tints_only_scales_real_tints() {
let red = Color::cmyk(0.0, 1.0, 1.0, 0.0);
let other = Color::cmyk(0.0, 0.3, 0.8, 0.0);
let to = Color::cmyk(1.0, 0.0, 0.0, 0.0);
let m = RowMap::new(Method::PreserveTints, &[red, red.tinted(0.5), other], to);
assert!(close(m.map(red.tinted(0.5)), Color::cmyk(0.5, 0.0, 0.0, 0.0)));
assert!(close(m.map(other), to), "not a tint: exact");
let s = RowMap::new(Method::ScaleTints, &[red, other], to);
assert!(!close(s.map(other), to), "Scale Tints scales every colour");
}
#[test]
fn exact_tints_shades_and_hue_shift() {
let to = Color::rgb(0.0, 0.4, 1.0);
let row = [Color::rgb(0.9, 0.2, 0.2), Color::rgb(0.5, 0.1, 0.1), Color::rgb(1.0, 0.7, 0.7)];
let e = RowMap::new(Method::Exact, &row, to);
assert!(row.iter().all(|c| e.map(*c) == to));
let ts = RowMap::new(Method::TintsShades, &row, to);
let l = |c: Color| c.to_lab().l;
assert!(l(ts.map(row[2])) > l(ts.map(row[0])) && l(ts.map(row[0])) > l(ts.map(row[1])), "lighter stays lighter");
let hs = RowMap::new(Method::HueShift, &row, to);
assert_eq!(hs.map(hs.key()), to);
let [h, s, v] = hs.map(row[1]).to_hsb();
let [_, s0, v0] = row[1].to_hsb();
assert!((h - to.to_hsb()[0]).abs() < 1.0 && (s - s0).abs() < 1e-3 && (v - v0).abs() < 1e-3, "{h} {s} {v}");
// Results keep the model of the colour they replace.
let m = RowMap::new(Method::Exact, &[Color::gray(0.3)], to);
assert!(matches!(m.map(Color::gray(0.3)), Color::Gray { .. }));
let lab = [Color::lab(40.0, 50.0, 30.0), Color::lab(70.0, 20.0, 10.0)];
for method in Method::ALL {
let m = RowMap::new(method, &lab, to);
assert!(lab.iter().all(|c| matches!(m.map(*c), Color::Lab { .. })), "{}", method.label());
}
assert!(matches!(shaded(lab[0], 0.5), Color::Lab { .. }));
}
#[test]
fn clustering_reduces_to_n_rows_of_similar_colours() {
let colors =
[Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0), Color::rgb(0.9, 0.1, 0.05), Color::rgb(0.1, 0.1, 0.9), Color::rgb(1.0, 0.2, 0.2)];
let rows = cluster(&colors, &[5.0, 4.0, 1.0, 1.0, 1.0], 2);
assert_eq!(rows, vec![vec![0, 2, 4], vec![1, 3]]);
assert_eq!(cluster(&colors, &[1.0; 5], 9).len(), 5, "never more rows than colours");
assert_eq!(cluster(&colors, &[1.0; 5], 1), vec![vec![0, 1, 2, 3, 4]]);
assert_eq!(cluster(&[Color::WHITE, Color::WHITE], &[1.0, 1.0], 2).len(), 1, "identical colours share a row");
assert!(cluster(&[], &[], 3).is_empty());
}
#[test]
fn palette_snaps_to_the_nearest_colour() {
let p = Palette::new([Color::rgb(1.0, 0.0, 0.0), Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::gray(0.5)]);
assert_eq!(p.nearest(Color::rgb(0.9, 0.1, 0.1)), Color::rgb(1.0, 0.0, 0.0));
assert_eq!(p.nearest(Color::rgb(0.45, 0.45, 0.45)), Color::gray(0.5));
let lab = Palette::new([Color::lab(50.0, 70.0, 50.0), Color::lab(50.0, -60.0, 50.0)]);
assert_eq!(lab.nearest(Color::rgb(0.9, 0.1, 0.1)), Color::lab(50.0, 70.0, 50.0), "library colours keep their model");
assert_eq!(Palette::default().nearest(Color::WHITE), Color::WHITE);
}
#[test]
fn randomizers_are_deterministic_and_keep_hue() {
let mut a: Vec<u32> = (0..10).collect();
let mut b = a.clone();
Rng::new(7).shuffle(&mut a);
Rng::new(7).shuffle(&mut b);
assert_eq!(a, b);
assert_ne!(a, (0..10).collect::<Vec<_>>());
let c = Color::cmyk(0.0, 0.8, 0.9, 0.1);
let r = Rng::new(3).saturation_brightness(c);
assert!(matches!(r, Color::Cmyk { .. }));
assert!(matches!(Rng::new(3).saturation_brightness(Color::lab(50.0, 40.0, 20.0)), Color::Lab { .. }));
assert_eq!(Rng::new(3).saturation_brightness(Color::gray(0.4)).to_hsb()[1], 0.0, "greys stay grey");
}
}
+85 -6
View File
@@ -2,6 +2,7 @@
use serde::{Deserialize, Serialize};
use crate::cms::Model;
use crate::{Color, Gradient, GradientKind, GradientStop, Paint};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -16,6 +17,30 @@ pub struct Swatch {
pub spot: bool,
}
/// The name of the built-in Registration swatch ([`registration`]).
pub const REGISTRATION: &str = "[Registration]";
/// The built-in Registration swatch: a global colour of 100% of every ink that prints on every
/// plate, process and spot (printer's marks). Every document has it; it can't be edited or deleted.
pub fn registration() -> &'static Swatch {
static S: std::sync::OnceLock<Swatch> = std::sync::OnceLock::new();
S.get_or_init(|| Swatch { name: REGISTRATION.into(), paint: Paint::solid(Color::cmyk(1.0, 1.0, 1.0, 1.0)), global: true, spot: false })
}
impl Swatch {
/// A tint swatch's base swatch and tint: its colour links to another (global) swatch.
pub fn tint_of(&self) -> Option<(&str, f32)> {
match &self.paint {
Paint::Solid { swatch: Some(base), tint, .. } => Some((base, *tint)),
_ => None,
}
}
/// None and Registration: built in, they can't be edited, moved or deleted.
pub fn is_reserved(&self) -> bool {
self.paint.is_none() || self.name == REGISTRATION
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SwatchGroup {
pub name: String,
@@ -23,12 +48,36 @@ pub struct SwatchGroup {
}
fn solid(name: &str, hex: &str) -> Swatch {
Swatch { name: name.into(), paint: Paint::solid(Color::from_hex(hex).unwrap()), global: false, spot: false }
Swatch { name: name.into(), paint: Paint::solid(Color::from_hex(hex).unwrap_or(Color::BLACK)), global: false, spot: false }
}
/// The default swatches of a document in colour `model`: [`base_swatches`] with their RGB colours
/// (gradient stops too) as whole-percent CMYK in a CMYK document. Greys stay Gray.
pub fn default_swatches(model: Model) -> (Vec<Swatch>, Vec<SwatchGroup>) {
let (mut s, mut g) = base_swatches();
if model == Model::Cmyk {
let cmyk = |c: &mut Color| {
if let Color::Rgb { .. } = c {
let pct = |v: f32| (v.clamp(0.0, 1.0) * 100.0).round() / 100.0;
let [cc, m, y, k] = c.to_cmyk();
*c = Color::cmyk(pct(cc), pct(m), pct(y), pct(k));
}
};
for w in s.iter_mut().chain(g.iter_mut().flat_map(|g| g.swatches.iter_mut())) {
match &mut w.paint {
Paint::Solid { color, .. } => cmyk(color),
Paint::Gradient(gp) => gp.gradient.stops.iter_mut().for_each(|st| cmyk(&mut st.color)),
_ => {}
}
}
}
(s, g)
}
/// The default document swatches: None, Registration-like black, white, black, a spectrum, greys,
/// gradients. (Composition is ours; Illustrator's layout grammar — None first, then specials — is kept.)
pub fn default_swatches() -> (Vec<Swatch>, Vec<SwatchGroup>) {
/// gradients. (The composition is ours; the reference app's layout grammar — None first, then
/// specials — is kept.)
fn base_swatches() -> (Vec<Swatch>, Vec<SwatchGroup>) {
let mut s =
vec![Swatch { name: "[None]".into(), paint: Paint::None, global: false, spot: false }, solid("White", "#ffffff"), solid("Black", "#000000")];
let spectrum = [
@@ -77,8 +126,8 @@ pub fn default_swatches() -> (Vec<Swatch>, Vec<SwatchGroup>) {
paint: Paint::Gradient(Box::new(crate::GradientPaint::new(Gradient {
kind,
stops: vec![
GradientStop { offset: 0.0, color: Color::from_hex(a).unwrap(), opacity: 1.0, midpoint: 0.5 },
GradientStop { offset: 1.0, color: Color::from_hex(b).unwrap(), opacity: 1.0, midpoint: 0.5 },
GradientStop::new(0.0, Color::from_hex(a).unwrap_or(Color::WHITE)),
GradientStop::new(1.0, Color::from_hex(b).unwrap_or(Color::BLACK)),
],
}))),
global: false,
@@ -110,10 +159,40 @@ mod tests {
#[test]
fn defaults_start_with_none() {
let (s, g) = default_swatches();
let (s, g) = default_swatches(Model::Rgb);
assert!(s[0].paint.is_none());
assert!(s.len() > 30);
assert_eq!(g.len(), 2);
assert_eq!(g[0].swatches.len(), 9);
}
#[test]
fn cmyk_defaults_are_cmyk_with_the_same_names() {
let (rgb, _) = default_swatches(Model::Rgb);
let (s, g) = default_swatches(Model::Cmyk);
assert_eq!(s.iter().map(|w| &w.name).collect::<Vec<_>>(), rgb.iter().map(|w| &w.name).collect::<Vec<_>>());
let colors = s.iter().chain(g.iter().flat_map(|g| &g.swatches)).flat_map(|w| match &w.paint {
Paint::Solid { color, .. } => vec![*color],
Paint::Gradient(gp) => gp.gradient.stops.iter().map(|st| st.color).collect(),
_ => vec![],
});
assert!(colors.clone().all(|c| !matches!(c, Color::Rgb { .. })));
assert!(colors.clone().any(|c| matches!(c, Color::Gray { .. })), "greys stay Gray");
let white = s.iter().find(|w| w.name == "White").and_then(|w| w.paint.color());
assert_eq!(white, Some(Color::cmyk(0.0, 0.0, 0.0, 0.0)));
let black = s.iter().find(|w| w.name == "Black").and_then(|w| w.paint.color());
assert_eq!(black, Some(Color::cmyk(0.0, 0.0, 0.0, 1.0)));
}
#[test]
fn default_swatches_parse() {
// `solid`/`grad` fall back to black or white on a bad literal instead of panicking; make sure
// none falls back.
let src = include_str!("swatch.rs");
let hexes: Vec<&str> = src.split('"').filter(|t| t.starts_with('#') && t.len() == 7).collect();
assert!(hexes.len() > 30);
for h in hexes {
assert!(Color::from_hex(h).is_some(), "{h}");
}
}
}
+1
View File
@@ -12,6 +12,7 @@ workspace = true
vectorcraft-geom = { workspace = true }
vectorcraft-color = { workspace = true }
kurbo = { workspace = true }
image = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
File diff suppressed because it is too large. Load diff
+30 -17
View File
@@ -76,13 +76,22 @@ pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit> {
None
}
/// Is `p` inside the region `clip` clips to ([`Node::clip_shapes`])? Text counts by its frame:
/// glyph outlines need the font engine, above this crate.
fn clip_contains(clip: &Node, p: Point) -> bool {
let frame = |n: &Node| {
let b = n.geometric_bounds()?;
Some(Node::path(n.id, vectorcraft_geom::shapes::rectangle(b), Default::default()))
};
clip.clip_shapes(Some(&frame)).iter().any(|(bp, rule)| fill_contains(bp, *rule, p))
}
fn hit_children(parent: &Node, p: Point, opt: HitOptions, chain: &mut Vec<NodeId>) -> Option<Hit> {
let children = parent.children()?;
// A clip group only hits inside its clipping path.
if let NodeKind::Group { clip: true, .. } = parent.kind
// A clip group (or a layer with a clipping mask) only hits inside its clipping path.
if parent.clips()
&& let Some(clip) = children.first()
&& let Some(path) = clip.path_data()
&& !fill_contains(&path.to_bezpath(), vectorcraft_geom::FillRule::NonZero, p)
&& !clip_contains(clip, p)
{
return None;
}
@@ -90,7 +99,7 @@ fn hit_children(parent: &Node, p: Point, opt: HitOptions, chain: &mut Vec<NodeId
if !c.visible || c.locked {
continue;
}
if let Some(b) = c.visual_bounds()
if let Some(b) = c.reach_bounds()
&& !b.inflate(opt.tol, opt.tol).contains(p)
{
continue;
@@ -108,13 +117,22 @@ fn hit_children(parent: &Node, p: Point, opt: HitOptions, chain: &mut Vec<NodeId
None
}
/// A click on the strokes of a path or compound path `n` along `bp`: what any visible stroke
/// paints there (arrowheads, alignment, width profile, projecting caps, miter spikes), else its
/// outline within the tolerance. Outline mode hits the outline only.
fn hit_stroke(n: &Node, bp: &vectorcraft_geom::BezPath, rule: vectorcraft_geom::FillRule, p: Point, opt: HitOptions) -> Option<HitKind> {
if !opt.outline && n.appearance.stroke_hit(bp, rule, p, opt.tol) {
return Some(HitKind::Stroke);
}
stroke_contains(bp, 0.0, opt.tol, p).then_some(HitKind::Outline)
}
fn hit_leaf(n: &Node, p: Point, opt: HitOptions) -> Option<HitKind> {
match &n.kind {
NodeKind::Path { path, rule, .. } => {
let bp = path.to_bezpath();
let sw = n.appearance.stroke_width();
if stroke_contains(&bp, if opt.outline { 0.0 } else { sw }, opt.tol, p) {
return Some(if opt.outline || sw == 0.0 { HitKind::Outline } else { HitKind::Stroke });
if let Some(k) = hit_stroke(n, &bp, *rule, p, opt) {
return Some(k);
}
let filled = !n.appearance.fill_paint().is_none();
if !opt.outline && !opt.path_only && filled && fill_contains(&bp, *rule, p) {
@@ -122,15 +140,10 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions) -> Option<HitKind> {
}
None
}
NodeKind::Compound { children, rule } => {
let mut bp = vectorcraft_geom::BezPath::new();
for c in children {
if let Some(pd) = c.path_data() {
bp.extend(pd.to_bezpath());
}
}
if stroke_contains(&bp, n.appearance.stroke_width(), opt.tol, p) {
return Some(HitKind::Stroke);
NodeKind::Compound { rule, .. } => {
let bp = n.stroke_path()?;
if let Some(k) = hit_stroke(n, &bp, *rule, p, opt) {
return Some(k);
}
(!opt.outline && fill_contains(&bp, *rule, p)).then_some(HitKind::Fill)
}
+347 -31
View File
@@ -8,10 +8,22 @@
pub mod appearance;
pub mod graph;
pub mod hit;
pub mod links;
pub mod live;
pub mod marks;
pub mod metadata;
pub mod node;
pub mod overprint;
pub mod pattern;
mod pixels;
pub mod profiles;
pub mod rastersettings;
mod reach;
pub mod selection;
pub mod setup;
pub mod slices;
pub mod style_libs;
pub mod swatches;
pub mod text;
use std::collections::BTreeMap;
@@ -30,16 +42,30 @@ pub(crate) mod skip {
}
}
pub use appearance::{Appearance, AppearanceItem, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, StrokeAlign, StrokeLayer, WidthProfile};
pub use appearance::StrokeGradientMode;
pub use appearance::{
Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, ProfilePreset, SavedProfile, StrokeAlign,
StrokeLayer, WidthProfile,
};
pub use graph::{GraphKind, GraphSpec};
pub use hit::{Hit, HitKind};
pub use links::{LinkInfo, PlacementOptions};
pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint};
pub use metadata::{CopyrightStatus, DocMetadata};
pub use node::Knockout;
pub use node::Scaling;
pub use node::{ImageMap, ObjectAttributes};
pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, NodeKind, OpacityMask};
pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType};
pub use profiles::ColorProfiles;
pub use rastersettings::{RasterColorModel, RasterEffectsSettings};
pub use selection::{AnchorRef, Selection};
pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes};
pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource};
pub use style_libs::StyleLibrary;
pub use text::{
AreaOptions, CharStyle, FirstBaseline, Justify, ParaStyle, PathEffect, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap,
WrapShape,
AreaOptions, CharPosition, CharStyle, FirstBaseline, Justify, ParaStyle, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject,
TextRun, TextStyleDef, TextWrap, WrapShape,
};
pub use vectorcraft_color as color;
pub use vectorcraft_geom as geom;
@@ -135,23 +161,55 @@ impl Unit {
}
/// Format a point value in this unit the way fields show it (e.g. `12.5 pt`, `3 in`).
pub fn format(self, pt: f64) -> String {
let v = self.from_pt(pt);
let s = format!("{:.3}", v);
format!("{} {}", self.number(pt), self.suffix())
}
/// [`Unit::format`] without the suffix (`12.5`), for narrow fields.
pub fn number(self, pt: f64) -> String {
let s = format!("{:.3}", self.from_pt(pt));
let s = s.trim_end_matches('0').trim_end_matches('.');
let s = if s == "-0" { "0" } else { s };
format!("{s} {}", self.suffix())
if s == "-0" { "0".into() } else { s.into() }
}
/// Parse `12`, `12pt`, `1in`, `3 mm`, `2p6` (picas+points), simple `+ - * /` arithmetic.
pub fn parse(self, s: &str) -> Option<f64> {
parse_measure(s, self)
}
/// The value naming this unit in the Units preferences (`points`, `millimeters`,
/// `feetInches`).
pub fn key(self) -> &'static str {
match self {
Unit::Points => "points",
Unit::Picas => "picas",
Unit::Inches => "inches",
Unit::Millimeters => "millimeters",
Unit::Centimeters => "centimeters",
Unit::Pixels => "pixels",
Unit::FeetInches => "feetInches",
Unit::Meters => "meters",
Unit::Yards => "yards",
Unit::Feet => "feet",
}
}
/// The unit `name` names: its label, suffix or [`Unit::key`] in any case, ignoring spaces and
/// punctuation (`Millimeters`, `mm`, `Feet & Inches`, `feetInches`).
pub fn named(name: &str) -> Option<Unit> {
let letters = |s: &'static str| s.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase());
let given = || name.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase());
Unit::ALL.into_iter().find(|u| letters(u.label()).eq(given()) || u.suffix().chars().eq(given()))
}
/// A point value as the canvas measurement labels show it: two decimals and the suffix
/// (`12.50 mm`).
pub fn readout(self, pt: f64) -> String {
format!("{:.2} {}", self.from_pt(pt), self.suffix())
}
}
fn parse_measure(s: &str, default: Unit) -> Option<f64> {
let s = s.trim();
// Simple arithmetic on the right (Illustrator fields accept "10+5", "100/2", "3in*2").
for op in ['+', '-', '*', '/'] {
if let Some(i) = s[1.min(s.len())..].rfind(op).map(|i| i + 1.min(s.len())) {
// Past the first character, so a leading sign isn't an operator.
let skip = s.chars().next().map_or(0, char::len_utf8);
if let Some(i) = s[skip..].rfind(op).map(|i| i + skip) {
let (l, r) = (&s[..i], &s[i + 1..]);
if l.trim().is_empty() {
continue;
@@ -202,6 +260,16 @@ pub enum ColorMode {
Cmyk,
}
impl ColorMode {
/// The colour model new colours take in a document of this mode.
pub fn model(self) -> vectorcraft_color::cms::Model {
match self {
ColorMode::Rgb => vectorcraft_color::cms::Model::Rgb,
ColorMode::Cmyk => vectorcraft_color::cms::Model::Cmyk,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Artboard {
pub id: u32,
@@ -250,11 +318,80 @@ impl Default for GridPrefs {
}
}
/// A named graphic style (Graphic Styles panel).
/// A named graphic style (Graphic Styles panel): an appearance plus the object's transparency.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct GraphicStyle {
pub name: String,
pub appearance: Appearance,
/// Stable id linked objects refer to ([`Node::graphic_style`]); 0 until first needed (styles
/// from older files get one when an object is first linked to them).
#[serde(default, skip_serializing_if = "skip::is_default")]
pub id: u32,
#[serde(default = "one", skip_serializing_if = "skip::is_one")]
pub opacity: f32,
#[serde(default, skip_serializing_if = "skip::is_default")]
pub blend: vectorcraft_color::BlendMode,
#[serde(default, skip_serializing_if = "skip::is_default")]
pub isolate: bool,
#[serde(default, skip_serializing_if = "skip::is_default")]
pub knockout: Knockout,
/// Placed gradients are stored relative to the unit box (0, 0)–(1, 1), so applying the style
/// places them on each object's own bounds. Styles saved before this kept document
/// coordinates (false).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub unit_box: bool,
}
fn one() -> f32 {
1.0
}
/// The name of the style new documents list first (Sort by Name keeps it first).
pub const DEFAULT_GRAPHIC_STYLE: &str = "Default Graphic Style";
impl GraphicStyle {
/// The box a style's placed gradients are stored relative to.
pub const UNIT_BOX: Rect = Rect::new(0.0, 0.0, 1.0, 1.0);
/// A style with `appearance` (its placed gradients, if any, in unit-box space) and default
/// transparency (no id yet).
pub fn new(name: impl Into<String>, appearance: Appearance) -> Self {
Self {
name: name.into(),
appearance,
id: 0,
opacity: 1.0,
blend: Default::default(),
isolate: false,
knockout: Knockout::Neutral,
unit_box: true,
}
}
/// A style capturing `n`'s transparency and `appearance` (in unit-box space).
pub fn of(name: impl Into<String>, appearance: Appearance, n: &Node) -> Self {
Self { opacity: n.opacity, blend: n.blend, isolate: n.isolate, knockout: n.knockout, ..Self::new(name, appearance) }
}
/// The style's appearance as object `n` takes it: placed gradients stored in unit-box space
/// land at the same place relative to `n`'s geometric bounds.
pub fn appearance_on(&self, n: &Node) -> std::borrow::Cow<'_, Appearance> {
let placed = self.unit_box && self.appearance.has_placed_gradient();
match n.geometric_bounds().filter(|_| placed) {
Some(b) => {
let mut ap = self.appearance.clone();
ap.rebase_gradients(Self::UNIT_BOX, b);
std::borrow::Cow::Owned(ap)
}
None => std::borrow::Cow::Borrowed(&self.appearance),
}
}
/// Give `n` this style's transparency.
pub fn apply_transparency(&self, n: &mut Node) {
(n.opacity, n.blend, n.isolate, n.knockout) = (self.opacity, self.blend, self.isolate, self.knockout);
}
/// Does `n` have this style's transparency?
pub fn transparency_matches(&self, n: &Node) -> bool {
(n.opacity, n.blend, n.isolate, n.knockout) == (self.opacity, self.blend, self.isolate, self.knockout)
}
}
/// A symbol definition.
@@ -270,6 +407,10 @@ pub struct ImageBlob {
pub mime: String,
#[serde(skip)]
pub bytes: Arc<Vec<u8>>,
/// A linked image's low-resolution preview (PNG, see [`links`]): what a save writes when only
/// linked images show this blob, and what `bytes` hold while the linked file can't be read.
#[serde(skip)]
pub proxy: Option<Arc<Vec<u8>>>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -321,31 +462,91 @@ pub struct Document {
/// Pattern editing mode, while active.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pattern_edit: Option<PatternEdit>,
/// Opacity-mask editing mode, while active.
#[serde(default, skip_serializing_if = "Option::is_none")]
/// Opacity-mask editing mode, while active. Never saved (see [`Document::without_edit_modes`]);
/// still read so files saved mid-edit by older versions load without the editing layer.
#[serde(default, skip_serializing)]
pub mask_edit: Option<MaskEdit>,
next_id: u64,
/// Foreign data preserved on round-trip.
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
pub unknown: serde_json::Map<String, serde_json::Value>,
/// Transparency panel → Page Isolated Blending: the page is an isolated transparency group,
/// so top-level blend modes don't blend with what lies under the page.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub page_isolate: bool,
/// Transparency panel → Page Knockout Group: the page's elements (its layers; neutral layers
/// pass their contents through) knock each other out.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub page_knockout: bool,
/// Swatches panel → Spot Colors: spot colours defined in Lab show and separate from their Lab
/// values (on, the default) or from their working-CMYK equivalents (off)
/// ([`Document::linked_color`]).
#[serde(default = "yes", skip_serializing_if = "skip::is_true")]
pub spot_use_lab: bool,
/// File → Document Setup (bleed, transparency grid, paper, type options).
#[serde(default, skip_serializing_if = "skip::is_default")]
pub setup: DocSetup,
/// Layers panel → Paste Remembers Layers: pasted objects go back into the layers (by name)
/// they were copied from instead of the current layer.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub paste_remembers_layers: bool,
/// File → File Info (the title is [`Document::title`]).
#[serde(default, skip_serializing_if = "skip::is_default")]
pub metadata: DocMetadata,
/// Effect → Document Raster Effects Settings besides the resolution
/// ([`Document::raster_effects_ppi`]).
#[serde(default, skip_serializing_if = "skip::is_default")]
pub raster_effects: RasterEffectsSettings,
/// The view (zoom, centre, rotation) the document was saved with; it reopens there. Written at
/// save time only, so changing the view never marks the document modified.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_view: Option<SavedView>,
/// Edit → Assign Profile: the profiles the document is tagged with (files before format v3
/// kept them in `unknown`, see [`Document::migrate_color_profiles`]).
#[serde(default, skip_serializing_if = "ColorProfiles::is_empty")]
pub color_profiles: ColorProfiles,
/// File → Export for Screens: the settings it last exported with (`document.exportForScreens`
/// params), so the dialog reopens on them.
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
pub export_settings: serde_json::Map<String, serde_json::Value>,
/// Top-level keys this version doesn't know (written by a newer one), kept so saving doesn't
/// lose them. Separate from [`Document::unknown`], which holds foreign data by design.
#[serde(flatten)]
pub extra: serde_json::Map<String, serde_json::Value>,
/// User slices (Slice tool, Object → Slice); object slices are [`Node::slice`].
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub slices: Vec<Slice>,
/// Object → Slice → Clip to Artboard: slices are clipped to the artboards and auto slices fill
/// them (on, the default); off, auto slices cover the art and the slices.
#[serde(default = "yes", skip_serializing_if = "skip::is_true")]
pub slices_clip_to_artboard: bool,
}
fn ppi72() -> f64 {
72.0
}
fn yes() -> bool {
true
}
pub const FORMAT_VERSION: u32 = 1;
impl Document {
/// A new document with one artboard of `size` and one layer ("Layer 1").
/// A new RGB document with one artboard of `size` and one layer ("Layer 1").
pub fn new(width: f64, height: f64) -> Self {
let (swatches, swatch_groups) = vectorcraft_color::default_swatches();
Self::new_with_mode(width, height, ColorMode::Rgb)
}
/// [`Document::new`] in colour `mode`, with that mode's default swatches.
pub fn new_with_mode(width: f64, height: f64, mode: ColorMode) -> Self {
let (swatches, swatch_groups) = vectorcraft_color::default_swatches(mode.model());
let mut d = Self {
version: FORMAT_VERSION,
title: "Untitled-1".into(),
template: false,
units: Unit::Points,
color_mode: ColorMode::Rgb,
color_mode: mode,
artboards: vec![Artboard {
id: 1,
name: "Artboard 1".into(),
@@ -371,6 +572,19 @@ impl Document {
mask_edit: None,
next_id: 1,
unknown: Default::default(),
page_isolate: false,
page_knockout: false,
spot_use_lab: true,
setup: DocSetup::default(),
paste_remembers_layers: false,
metadata: DocMetadata::default(),
raster_effects: RasterEffectsSettings::default(),
last_view: None,
color_profiles: ColorProfiles::default(),
export_settings: Default::default(),
extra: Default::default(),
slices: vec![],
slices_clip_to_artboard: true,
};
let id = d.alloc_id();
d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0))));
@@ -392,6 +606,7 @@ impl Document {
for l in &self.layers {
l.walk(&mut |n| max = max.max(n.id.0));
}
max = self.slices.iter().fold(max, |m, s| m.max(s.id.0));
self.next_id = self.next_id.max(max + 1);
}
@@ -496,8 +711,11 @@ impl Document {
pub fn remove(&mut self, id: NodeId) -> Result<Arc<Node>, DocError> {
let parent = self.parent_of(id);
let path = self.index_path(id).ok_or(DocError::NoNode(id))?;
let idx = *path.last().unwrap();
let idx = *path.last().ok_or(DocError::NoNode(id))?;
let ch = self.children_mut(parent)?;
if idx >= ch.len() {
return Err(DocError::NoNode(id));
}
Ok(ch.remove(idx))
}
@@ -594,36 +812,92 @@ impl Document {
pub fn pattern_mut(&mut self, name: &str) -> Option<&mut PatternDef> {
self.patterns.iter_mut().find(|p| p.name == name)
}
pub fn swatch(&self, name: &str) -> Option<&Swatch> {
self.swatches.iter().chain(self.swatch_groups.iter().flat_map(|g| g.swatches.iter())).find(|s| s.name == name)
}
/// Deep-clone `node` with fresh ids for it and all descendants.
pub fn reid(&mut self, node: &Node) -> Node {
let mut n = node.clone();
n.id = self.alloc_id();
if let Some(ch) = n.children_mut() {
let old: Vec<Arc<Node>> = std::mem::take(ch);
let fresh: Vec<Arc<Node>> = old.iter().map(|c| Arc::new(self.reid(c))).collect();
*n.children_mut().unwrap() = fresh;
*ch = old.iter().map(|c| Arc::new(self.reid(c))).collect();
}
n
}
}
impl Document {
/// Leave opacity-mask editing: drop the temporary editing layer, a working copy of art the
/// mask already holds (the engine syncs it after every edit).
pub fn drop_edit_modes(&mut self) {
if let Some(me) = self.mask_edit.take() {
self.layers.retain(|l| l.id != me.layer);
}
}
/// The document as saved and exported: without the opacity-mask editing layer. Borrowed when
/// no mask is being edited. (Pattern editing is saved: its tile layer holds unapplied edits.)
pub fn without_edit_modes(&self) -> std::borrow::Cow<'_, Document> {
if self.mask_edit.is_none() {
return std::borrow::Cow::Borrowed(self);
}
let mut d = self.clone();
d.drop_edit_modes();
std::borrow::Cow::Owned(d)
}
}
/// Graphic styles.
impl Document {
/// Index of the graphic style named `name`.
pub fn graphic_style_index(&self, name: &str) -> Option<usize> {
self.graphic_styles.iter().position(|g| g.name == name)
}
/// The graphic style named `name`.
pub fn graphic_style(&self, name: &str) -> Option<&GraphicStyle> {
self.graphic_styles.iter().find(|g| g.name == name)
}
/// The graphic style with id `id` (0 never matches).
pub fn graphic_style_by_id(&self, id: u32) -> Option<&GraphicStyle> {
self.graphic_styles.iter().find(|g| g.id != 0 && g.id == id)
}
/// An id no graphic style has.
pub fn next_graphic_style_id(&self) -> u32 {
self.graphic_styles.iter().map(|g| g.id).max().unwrap_or(0) + 1
}
/// The id of graphic style `index`, assigning one if it has none yet.
pub fn graphic_style_id(&mut self, index: usize) -> u32 {
if self.graphic_styles[index].id == 0 {
self.graphic_styles[index].id = self.next_graphic_style_id();
}
self.graphic_styles[index].id
}
/// The default name of a new graphic style: the first free "Graphic Style N", N counting on
/// from the number of styles.
pub fn new_graphic_style_name(&self) -> String {
(self.graphic_styles.len() + 1..).map(|i| format!("Graphic Style {i}")).find(|n| self.graphic_style(n).is_none()).unwrap_or_default()
}
}
fn default_graphic_styles() -> Vec<GraphicStyle> {
use vectorcraft_color::{Color, Paint};
vec![
GraphicStyle { name: "Default Graphic Style".into(), appearance: Appearance::default_art() },
GraphicStyle { name: "Black Outline".into(), appearance: Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0) },
GraphicStyle {
name: "Heavy Ink".into(),
appearance: Appearance::basic(Paint::solid(Color::from_hex("#1b1464").unwrap()), Paint::solid(Color::BLACK), 4.0),
},
GraphicStyle {
name: "Sunshine".into(),
appearance: Appearance::basic(Paint::solid(Color::from_hex("#fbb03b").unwrap()), Paint::solid(Color::from_hex("#f15a24").unwrap()), 2.0),
},
let solid = |hex| Paint::solid(Color::from_hex(hex).unwrap_or(Color::BLACK));
[
GraphicStyle::new(DEFAULT_GRAPHIC_STYLE, Appearance::default_art()),
GraphicStyle::new("Black Outline", Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0)),
GraphicStyle::new("Heavy Ink", Appearance::basic(solid("#1b1464"), Paint::solid(Color::BLACK), 4.0)),
GraphicStyle::new("Sunshine", Appearance::basic(solid("#fbb03b"), solid("#f15a24"), 2.0)),
]
.into_iter()
.zip(1..)
.map(|(g, id)| GraphicStyle { id, ..g })
.collect()
}
impl Document {
/// Make the next id allocated at least `next`: ids handed out outside the layer tree (such
/// as an importer's opacity mask and pattern art), which [`Document::fix_next_id`] doesn't see.
pub fn reserve_ids(&mut self, next: u64) {
self.next_id = self.next_id.max(next);
}
}
#[cfg(test)]
@@ -717,6 +991,43 @@ mod tests {
assert!(!ids.contains(&a) && !ids.contains(&b));
}
#[test]
fn setup_round_trips_and_old_files_load() {
let (mut d, _, _) = doc_with_rects();
// A document without a setup writes no `setup` key (old readers and files stay unchanged).
assert!(!serde_json::to_string(&d).unwrap().contains("\"setup\""));
d.setup.bleed = [9.0, 9.0, 0.0, 18.0];
d.setup.grid_size = GridSize::Large;
d.setup.typographers_quotes = false;
d.setup.quotes = setup::language_quotes("German").unwrap();
d.setup.flattener_preset = Some("High Resolution".into());
d.setup.background = Background::White;
d.setup.export_text = ExportText::Appearance;
let back: Document = serde_json::from_str(&serde_json::to_string(&d).unwrap()).unwrap();
assert_eq!(back.setup, d.setup);
// Files written before Document Setup existed load with the defaults.
let mut v = serde_json::to_value(&d).unwrap();
v.as_object_mut().unwrap().remove("setup");
let old: Document = serde_json::from_value(v).unwrap();
assert_eq!(old.setup, DocSetup::default());
}
#[test]
fn char_position_round_trips() {
let st = CharStyle {
position: CharPosition::Subscript(ScriptMetrics { size: 50.0, position: 20.0 }),
small_caps: Some(75.0),
..CharStyle::default()
};
let v = serde_json::to_value(&st).unwrap();
assert_eq!(v["position"], serde_json::json!({"kind": "subscript", "size": 50.0, "position": 20.0}));
assert_eq!(serde_json::from_value::<CharStyle>(v).unwrap(), st);
let plain = serde_json::to_value(CharStyle::default()).unwrap();
assert!(plain.get("position").is_none() && plain.get("small_caps").is_none());
let (scale, shift) = CharPosition::Subscript(ScriptMetrics::DEFAULT).scale_shift(10.0);
assert!((scale - 0.583).abs() < 1e-9 && (shift + 3.33).abs() < 1e-9);
}
#[test]
fn units() {
assert_eq!(Unit::Inches.parse("1"), Some(72.0));
@@ -731,3 +1042,8 @@ mod tests {
assert_eq!(Unit::Inches.format(144.0), "2 in");
}
}
#[cfg(test)]
mod tests_slices;
#[cfg(test)]
mod tests_units;
+352
View File
@@ -0,0 +1,352 @@
//! Linked files: where a placed file lives and what it was when last read ([`LinkInfo`]), the
//! low-resolution preview a linked image keeps for when its file can't be read
//! ([`ImageBlob::proxy`]), and every image object of a document ([`Document::visit_images`]).
use std::collections::{BTreeMap, BTreeSet};
use std::io::Cursor;
use std::sync::Arc;
use serde::{Deserialize, Deserializer, Serialize};
use crate::{Document, ImageBlob, ImageObject, Node, NodeKind};
/// The longest side of a linked image's preview (pixels). Smaller images keep no preview: their
/// full pixels are saved instead.
pub const PROXY_SIZE: u32 = 256;
/// The MIME type of previews.
pub const PROXY_MIME: &str = "image/png";
/// A linked file: an image object shows the file at `path` (File → Place with Link) and the
/// document saves only a preview of it.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct LinkInfo {
/// The file's absolute path.
pub path: String,
/// The path relative to the document's folder (`/` separators), written on save: how the file
/// is found after the document and its links move together.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub relative: Option<String>,
/// The file's modification time when last read (milliseconds since the Unix epoch).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub modified: Option<u64>,
/// The file's size when last read (bytes).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size: Option<u64>,
/// The hash of the file's bytes when last read ([`hash_bytes`]).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hash: Option<String>,
/// The page shown, for a linked document page (1-based).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub page: Option<u32>,
}
impl LinkInfo {
/// A link to `path` with nothing known about the file.
pub fn new(path: impl Into<String>) -> Self {
Self { path: path.into(), ..Self::default() }
}
/// The file name: the path's last component, whichever system's separators it uses.
pub fn name(&self) -> &str {
self.path.rsplit(['/', '\\']).next().unwrap_or(&self.path)
}
}
impl From<&str> for LinkInfo {
fn from(path: &str) -> Self {
Self::new(path)
}
}
/// [`ImageObject::link`] from a link object or, as files saved before links had details, a path.
pub(crate) fn de_link<'de, D: Deserializer<'de>>(d: D) -> Result<Option<LinkInfo>, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum Repr {
Path(String),
Info(LinkInfo),
}
Ok(Option::<Repr>::deserialize(d)?.map(|r| match r {
Repr::Path(path) => LinkInfo::new(path),
Repr::Info(info) => info,
}))
}
/// A hash of `bytes` (FNV-1a), the same for identical files: `img` and 16 hex digits.
pub fn hash_bytes(bytes: &[u8]) -> String {
let h = bytes.iter().fold(0xcbf29ce484222325u64, |h, x| (h ^ *x as u64).wrapping_mul(0x100000001b3));
format!("img{h:016x}")
}
impl ImageBlob {
/// A blob of encoded image `bytes` of type `mime`, without a preview.
pub fn new(mime: impl Into<String>, bytes: Vec<u8>) -> Self {
Self { mime: mime.into(), bytes: Arc::new(bytes), proxy: None }
}
/// This blob with a preview of at most [`PROXY_SIZE`] pixels a side (PNG), when the image is
/// larger than that and decodes; otherwise unchanged.
pub fn with_proxy(mut self) -> Self {
if self.proxy.is_none() {
self.proxy = proxy_png(&self.bytes).map(Arc::new);
}
self
}
/// Are the bytes the preview (the linked file hasn't been read)?
pub fn is_proxy(&self) -> bool {
self.proxy.as_ref().is_some_and(|p| Arc::ptr_eq(p, &self.bytes))
}
}
/// `bytes` decoded and shrunk to fit [`PROXY_SIZE`] as a PNG; `None` when small enough already or
/// undecodable.
fn proxy_png(bytes: &[u8]) -> Option<Vec<u8>> {
let (w, h) = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_dimensions().ok()?;
if w.max(h) <= PROXY_SIZE {
return None;
}
let small = image::load_from_memory(bytes).ok()?.thumbnail(PROXY_SIZE, PROXY_SIZE);
let mut png = Vec::new();
small.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?;
Some(png)
}
/// `n` and every descendant, opacity-mask art included.
fn walk_all<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) {
f(n);
if let Some(m) = &n.mask {
walk_all(&m.art, f);
}
for c in n.children().into_iter().flatten() {
walk_all(c, f);
}
}
impl Document {
/// Visit every image object: in the layers, then in symbol definitions and pattern swatches,
/// opacity-mask art included.
pub fn visit_images<'a>(&'a self, mut f: impl FnMut(&'a Node, &'a ImageObject)) {
let roots = self.layers.iter().chain(self.symbols.iter().map(|s| &s.art)).chain(self.patterns.iter().flat_map(|p| &p.art));
for root in roots {
walk_all(root, &mut |n| {
if let NodeKind::Image(im) = &n.kind {
f(n, im);
}
});
}
}
/// The keys of image blobs only linked images show: what a save writes as their preview.
pub fn linked_only_images(&self) -> BTreeSet<String> {
if self.images.values().all(|b| b.proxy.is_none()) {
return BTreeSet::new();
}
// Per key: (shown linked, shown embedded).
let mut uses: BTreeMap<&str, (bool, bool)> = BTreeMap::new();
self.visit_images(|_, im| {
let u = uses.entry(im.key.as_str()).or_default();
if im.link.is_some() {
u.0 = true;
} else {
u.1 = true;
}
});
uses.into_iter().filter(|(_, (linked, embedded))| *linked && !embedded).map(|(k, _)| k.to_string()).collect()
}
}
/// Links panel → Placement Options: how a file read again (Relink, Update Link) takes the place of
/// the art it replaces.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlacementOptions {
#[serde(default)]
pub preserve: Preserve,
/// Where the new art sits in the old bounds (all but [`Preserve::Bounds`]).
#[serde(default)]
pub align: Align,
/// Clip the new art to the old bounds where it is larger.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub clip: bool,
}
/// What a relinked image keeps of the art it replaces.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Preserve {
/// Its scale (relative to each file's 100% size), rotation and position.
Transforms,
/// Its bounds: the new art is stretched into them.
#[default]
Bounds,
/// Nothing but its position: the new art at 100%, unrotated.
FileDimensions,
/// Its bounds: the new art scaled proportionally to fit inside them.
Fit,
/// Its bounds: the new art scaled proportionally to fill them.
Fill,
}
impl Preserve {
pub const ALL: [Preserve; 5] = [Preserve::Transforms, Preserve::Bounds, Preserve::FileDimensions, Preserve::Fit, Preserve::Fill];
/// The id commands use (`transforms`, `bounds`, `fileDimensions`, `fit`, `fill`).
pub fn id(self) -> &'static str {
match self {
Preserve::Transforms => "transforms",
Preserve::Bounds => "bounds",
Preserve::FileDimensions => "fileDimensions",
Preserve::Fit => "fit",
Preserve::Fill => "fill",
}
}
pub fn from_id(id: &str) -> Option<Self> {
Self::ALL.into_iter().find(|p| p.id() == id)
}
}
/// A point of the 3×3 alignment grid, row by row from the top left.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Align {
TopLeft,
Top,
TopRight,
Left,
#[default]
Center,
Right,
BottomLeft,
Bottom,
BottomRight,
}
impl Align {
pub const ALL: [Align; 9] =
[Align::TopLeft, Align::Top, Align::TopRight, Align::Left, Align::Center, Align::Right, Align::BottomLeft, Align::Bottom, Align::BottomRight];
/// The id commands use (`topLeft` … `bottomRight`).
pub fn id(self) -> &'static str {
match self {
Align::TopLeft => "topLeft",
Align::Top => "top",
Align::TopRight => "topRight",
Align::Left => "left",
Align::Center => "center",
Align::Right => "right",
Align::BottomLeft => "bottomLeft",
Align::Bottom => "bottom",
Align::BottomRight => "bottomRight",
}
}
pub fn from_id(id: &str) -> Option<Self> {
Self::ALL.into_iter().find(|a| a.id() == id)
}
/// The fractions (0, ½ or 1) of the free room left of and above the new art.
pub fn fractions(self) -> (f64, f64) {
let i = Self::ALL.iter().position(|a| *a == self).unwrap_or(4);
((i % 3) as f64 / 2.0, (i / 3) as f64 / 2.0)
}
}
impl Document {
/// Change every image object (in the layers, symbol definitions and pattern swatches, opacity-mask
/// art included) for which `pick` holds; only the subtrees holding one are copied.
pub fn update_images(&mut self, pick: impl Fn(&ImageObject) -> bool, mut f: impl FnMut(&mut ImageObject)) {
fn holds(n: &Node, pick: &dyn Fn(&ImageObject) -> bool) -> bool {
let mut hit = false;
walk_all(n, &mut |c| hit |= matches!(&c.kind, NodeKind::Image(im) if pick(im)));
hit
}
fn update(n: &mut Arc<Node>, pick: &dyn Fn(&ImageObject) -> bool, f: &mut dyn FnMut(&mut ImageObject)) {
if !holds(n, pick) {
return;
}
let n = Arc::make_mut(n);
if let NodeKind::Image(im) = &mut n.kind
&& pick(im)
{
f(im);
}
if let Some(m) = &mut n.mask {
update(&mut m.art, pick, f);
}
for c in n.children_mut().into_iter().flatten() {
update(c, pick, f);
}
}
let roots =
self.layers.iter_mut().chain(self.symbols.iter_mut().map(|s| &mut s.art)).chain(self.patterns.iter_mut().flat_map(|p| &mut p.art));
for root in roots {
update(root, &pick, &mut f);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::NodeId;
use vectorcraft_geom::Affine;
fn png(w: u32, h: u32) -> Vec<u8> {
let mut out = vec![];
image::RgbaImage::from_pixel(w, h, image::Rgba([10, 200, 30, 255])).write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
out
}
fn image(d: &mut Document, key: &str, link: Option<LinkInfo>) -> NodeId {
let id = d.alloc_id();
let layer = d.layers[0].id;
d.insert(
Some(layer),
0,
Node::new(
id,
NodeKind::Image(ImageObject { key: key.into(), width: 4, height: 4, xf: Affine::IDENTITY, link, placement: Default::default() }),
),
)
.unwrap()
}
#[test]
fn a_path_string_from_older_files_reads_as_a_link() {
let old: ImageObject = serde_json::from_str(r#"{"key":"k","width":2,"height":1,"xf":[1,0,0,1,0,0],"link":"C:\\art\\photo.png"}"#).unwrap();
let link = old.link.unwrap();
assert_eq!((link.path.as_str(), link.name(), link.hash.as_deref()), ("C:\\art\\photo.png", "photo.png", None));
let new: ImageObject =
serde_json::from_str(r#"{"key":"k","width":2,"height":1,"xf":[1,0,0,1,0,0],"link":{"path":"/a/b.png","size":9,"page":2}}"#).unwrap();
assert_eq!(new.link, Some(LinkInfo { size: Some(9), page: Some(2), ..LinkInfo::new("/a/b.png") }));
let none: ImageObject = serde_json::from_str(r#"{"key":"k","width":2,"height":1,"xf":[1,0,0,1,0,0]}"#).unwrap();
assert_eq!(none.link, None);
// Written as an object, with only what is known.
let v = serde_json::to_value(&new).unwrap();
assert_eq!(v["link"], serde_json::json!({"path": "/a/b.png", "size": 9, "page": 2}));
}
#[test]
fn large_images_get_a_small_png_preview() {
let big = ImageBlob::new("image/png", png(600, 300)).with_proxy();
let p = image::load_from_memory(big.proxy.as_ref().unwrap()).unwrap();
assert_eq!((p.width(), p.height()), (256, 128));
assert!(!big.is_proxy(), "the bytes are the full image");
assert!(ImageBlob::new("image/png", png(40, 20)).with_proxy().proxy.is_none(), "small images keep their pixels");
assert!(ImageBlob::new("image/png", vec![1, 2, 3]).with_proxy().proxy.is_none(), "undecodable");
}
#[test]
fn only_blobs_no_embedded_image_shows_are_linked_only() {
let mut d = Document::new(100.0, 100.0);
for k in ["a", "b", "c"] {
d.images.insert(k.into(), ImageBlob::new("image/png", png(300, 10)).with_proxy());
}
image(&mut d, "a", Some(LinkInfo::new("/a.png")));
image(&mut d, "b", Some(LinkInfo::new("/b.png")));
image(&mut d, "b", None);
image(&mut d, "c", None);
assert_eq!(d.linked_only_images().into_iter().collect::<Vec<_>>(), ["a"]);
}
}
+88 -38
View File
@@ -162,6 +162,7 @@ pub fn lerp_color(a: &Color, b: &Color, t: f32) -> Color {
match (*a, *b) {
(Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => Color::cmyk(l(c, c2), l(m, m2), l(y, y2), l(k, k2)),
(Color::Gray { k }, Color::Gray { k: k2 }) => Color::gray(l(k, k2)),
(Color::Lab { l: l1, a, b }, Color::Lab { l: l2, a: a2, b: b2 }) => Color::lab(l(l1, l2), l(a, a2), l(b, b2)),
_ => a.lerp(b, t),
}
}
@@ -195,6 +196,7 @@ fn lerp_gradient(a: &GradientPaint, b: &GradientPaint, t: f32) -> Option<Gradien
g.start = ga.start.lerp(gb.start, t as f64);
g.end = ga.end.lerp(gb.end, t as f64);
g.aspect = ga.aspect + (gb.aspect - ga.aspect) * t as f64;
g.focal = (ga.focal.is_some() || gb.focal.is_some()).then(|| ga.focal_point().lerp(gb.focal_point(), t as f64));
out.geom = Some(g);
} else {
out.geom = None;
@@ -464,11 +466,7 @@ impl Spine {
if pts.len() < 2 {
return None;
}
let mut cum = vec![0.0];
for w in pts.windows(2) {
cum.push(cum.last().unwrap() + w[0].distance(w[1]));
}
Some(Self { pts, cum })
Some(Self { cum: cumulative_lengths(&pts), pts })
}
pub fn length(&self) -> f64 {
*self.cum.last().unwrap_or(&0.0)
@@ -637,6 +635,18 @@ fn poly_len(c: &CubicBez) -> f64 {
c.p0.distance(c.p1) + c.p1.distance(c.p2) + c.p2.distance(c.p3)
}
/// Running length along a polyline: `0, |p0p1|, |p0p1| + |p1p2|, …` (one entry per point).
fn cumulative_lengths(pts: &[Point]) -> Vec<f64> {
let mut total = 0.0;
let mut cum = Vec::with_capacity(pts.len());
cum.push(0.0);
for w in pts.windows(2) {
total += w[0].distance(w[1]);
cum.push(total);
}
cum
}
/// Map every point of `path` through the non-linear function `f`. Segments are split into pieces
/// no longer than about `max_piece` (up to 64 per segment) and their control points mapped, which
/// approximates the image curve to O(h²). Closedness and subpath structure are preserved.
@@ -660,7 +670,9 @@ pub fn map_nonlinear(path: &PathData, max_piece: f64, f: impl Fn(Point) -> Point
let k = ((poly_len(&c) / max_piece).ceil() as usize).clamp(1, 64);
for j in 0..k {
let sub = c.subsegment((j as f64 / k as f64)..((j + 1) as f64 / k as f64));
res.last_mut().unwrap().h_out = f(sub.p1);
if let Some(last) = res.last_mut() {
last.h_out = f(sub.p1);
}
if j + 1 < k {
res.push(Anchor { p: f(sub.p3), h_in: f(sub.p2), h_out: f(sub.p3), kind: Default::default() });
} else {
@@ -671,8 +683,9 @@ pub fn map_nonlinear(path: &PathData, max_piece: f64, f: impl Fn(Point) -> Point
}
if let Some(h) = pending_in
&& sp.closed
&& let Some(first) = res.first_mut()
{
res[0].h_in = h;
first.h_in = h;
}
let anchors = res.into_iter().map(|a| Anchor::with_handles(a.p, a.h_in, a.h_out)).collect();
subs.push(SubPath::new(anchors, sp.closed));
@@ -702,11 +715,8 @@ fn resample(poly: &[Point], m: usize) -> Vec<Point> {
if poly.len() == 1 {
return vec![poly[0]; m];
}
let mut cum = vec![0.0];
for w in poly.windows(2) {
cum.push(cum.last().unwrap() + w[0].distance(w[1]));
}
let total = *cum.last().unwrap();
let cum = cumulative_lengths(poly);
let total = cum.last().copied().unwrap_or(0.0);
let mut out = Vec::with_capacity(m);
let mut i = 0;
for k in 0..m {
@@ -747,7 +757,10 @@ impl Coons {
let r = path.bounds()?;
let tol = (r.width() + r.height()).max(1e-6) / 2000.0;
let mut poly = flatten_subpath(sp, tol);
if poly.len() > 1 && poly[0].distance(*poly.last().unwrap()) < 1e-9 {
if poly.len() > 1
&& let (Some(a), Some(b)) = (poly.first(), poly.last())
&& a.distance(*b) < 1e-9
{
poly.pop();
}
if poly.len() < 3 {
@@ -812,6 +825,12 @@ impl Coons {
// Gradient meshes
// =====================================================================================
/// `0, 1/n, …, 1` for `n` (clamped to 1..=200) even steps.
fn even_steps(n: u32) -> Vec<f64> {
let n = n.clamp(1, 200);
(0..=n).map(|i| i as f64 / n as f64).collect()
}
impl GradientMesh {
pub fn idx(&self, r: usize, c: usize) -> usize {
r * (self.cols as usize + 1) + c
@@ -822,43 +841,73 @@ impl GradientMesh {
/// A mesh over surface `s` (u, v ∈ [0,1]) with handles from its partial derivatives.
pub fn from_surface(rows: u32, cols: u32, s: &dyn Fn(f64, f64) -> Point, color: &dyn Fn(f64, f64) -> Color) -> Self {
let (rows, cols) = (rows.clamp(1, 200), cols.clamp(1, 200));
let (du, dv) = (1.0 / cols as f64, 1.0 / rows as f64);
let (us, vs) = (even_steps(cols), even_steps(rows));
Self::from_grid(&us, &vs, s, &|r, c, _| (color(us[c], vs[r]), 1.0))
}
/// A mesh over surface `s` (u, v ∈ [0,1]) whose column lines sit at `us` and row lines at `vs`
/// (ascending, at least two each; equal neighbours make a patch of no width, a sharp colour
/// change), with handles from its partial derivatives. `color(row, col, point)` gives each
/// point its colour and opacity.
pub fn from_grid(us: &[f64], vs: &[f64], s: &dyn Fn(f64, f64) -> Point, color: &dyn Fn(usize, usize, Point) -> (Color, f32)) -> Self {
let (rows, cols) = (vs.len().saturating_sub(1), us.len().saturating_sub(1));
let eps = 1e-3;
let mut points = Vec::with_capacity(((rows + 1) * (cols + 1)) as usize);
for r in 0..=rows {
for c in 0..=cols {
let (u, v) = (c as f64 * du, r as f64 * dv);
let mut points = Vec::with_capacity(us.len() * vs.len());
for (r, &v) in vs.iter().enumerate() {
for (c, &u) in us.iter().enumerate() {
let p = s(u, v);
let d_u = (s((u + eps).min(1.0), v) - s((u - eps).max(0.0), v)) / ((u + eps).min(1.0) - (u - eps).max(0.0));
let d_v = (s(u, (v + eps).min(1.0)) - s(u, (v - eps).max(0.0))) / ((v + eps).min(1.0) - (v - eps).max(0.0));
let hu = d_u * (du / 3.0);
let hv = d_v * (dv / 3.0);
// Outward handles on the border are unused: keep them at the point.
let z = Vec2::ZERO;
let handles =
[if c < cols { hu } else { z }, if c > 0 { -hu } else { z }, if r < rows { hv } else { z }, if r > 0 { -hv } else { z }];
points.push(MeshPoint { p, color: color(u, v), opacity: 1.0, handles });
// A third of the way to the neighbouring line along the tangent; outward handles on
// the border are unused, so they stay at the point.
let h = |d: Vec2, from: f64, to: Option<&f64>| to.map_or(Vec2::ZERO, |to| d * ((to - from) / 3.0));
let handles = [
h(d_u, u, us.get(c + 1)),
h(d_u, u, c.checked_sub(1).map(|i| &us[i])),
h(d_v, v, vs.get(r + 1)),
h(d_v, v, r.checked_sub(1).map(|i| &vs[i])),
];
let (color, opacity) = color(r, c, p);
points.push(MeshPoint { p, color, opacity, handles });
}
}
Self { rows, cols, points }
Self { rows: rows as u32, cols: cols as u32, points }
}
/// Create Gradient Mesh for a path: the outline split at its corners, coloured per `appearance`.
pub fn for_path(path: &PathData, rows: u32, cols: u32, base: Color, appearance: MeshAppearance, highlight: f64) -> Option<Self> {
let mut m = Self::for_path_with(path, rows, cols, &|_| (base, 1.0))?;
m.highlight(appearance, highlight);
Some(m)
}
/// Create Gradient Mesh for a path: the outline split at its corners, each point taking the
/// colour and opacity `color_at` gives at its position (a gradient fill's, say).
pub fn for_path_with(path: &PathData, rows: u32, cols: u32, color_at: &dyn Fn(Point) -> (Color, f32)) -> Option<Self> {
let coons = Coons::from_path(path)?;
Some(Self::from_grid(&even_steps(cols), &even_steps(rows), &|u, v| coons.eval(u, v), &|_, _, p| color_at(p)))
}
/// Create Gradient Mesh → Appearance: lighten the points towards white, to the centre or to
/// the edges, by at most `highlight` %.
pub fn highlight(&mut self, appearance: MeshAppearance, highlight: f64) {
let h = (highlight / 100.0).clamp(0.0, 1.0) as f32;
let color = |u: f64, v: f64| -> Color {
let d = ((u - 0.5).abs().max((v - 0.5).abs()) * 2.0) as f32; // 0 centre … 1 edge
let w = match appearance {
MeshAppearance::Flat => 0.0,
MeshAppearance::ToCenter => (1.0 - d) * h,
MeshAppearance::ToEdge => d * h,
};
if w > 0.0 { lerp_color(&base, &Color::WHITE, w) } else { base }
};
let s = |u: f64, v: f64| coons.eval(u, v);
Some(Self::from_surface(rows, cols, &s, &color))
let (rows, cols) = (self.rows as usize, self.cols as usize);
for r in 0..=rows {
for c in 0..=cols {
let (u, v) = (c as f64 / cols as f64, r as f64 / rows as f64);
let d = ((u - 0.5).abs().max((v - 0.5).abs()) * 2.0) as f32; // 0 centre … 1 edge
let w = match appearance {
MeshAppearance::Flat => 0.0,
MeshAppearance::ToCenter => (1.0 - d) * h,
MeshAppearance::ToEdge => d * h,
};
if w > 0.0 {
let i = self.idx(r, c);
self.points[i].color = lerp_color(&self.points[i].color, &Color::WHITE, w);
}
}
}
}
/// Horizontal edge from (r, c) to (r, c+1).
@@ -1417,6 +1466,7 @@ pub fn expanded_group(n: &Node, outline: Outliner) -> Node {
g.blend = n.blend;
g.isolate = n.isolate;
g.knockout = n.knockout;
g.knockout_shape = n.knockout_shape;
g
}
+367
View File
@@ -0,0 +1,367 @@
//! Printer's marks drawn as art: the trim marks of Object → Create Trim Marks and Effect → Crop
//! Marks, stroked in the Registration colour so that they print on every plate, and the printer's
//! marks around a printed page ([`PrinterMarks`]: PDF export's Marks and Bleeds, and print).
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use vectorcraft_color::{Color, Paint};
use vectorcraft_geom::{Point, Rect, shapes};
use crate::{Appearance, CharStyle, Node, NodeId, NodeKind, TextObject};
/// Trim mark style (the preference `japaneseCropMarks` picks Japanese).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MarkStyle {
/// One line per edge at each corner, set off from the trim box.
#[default]
Roman,
/// Double lines at each corner (the trim and the bleed edges) and centre marks on each side.
Japanese,
}
impl MarkStyle {
pub fn id(self) -> &'static str {
match self {
MarkStyle::Roman => "roman",
MarkStyle::Japanese => "japanese",
}
}
/// Parse an id, ignoring case.
pub fn parse(s: &str) -> Option<Self> {
[MarkStyle::Roman, MarkStyle::Japanese].into_iter().find(|m| m.id().eq_ignore_ascii_case(s))
}
}
/// Trim marks around a rectangle (the trim box).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TrimMarks {
pub style: MarkStyle,
/// Roman: the gap between the trim box and the marks.
pub offset: f64,
/// How long each mark is.
pub length: f64,
/// Japanese: the bleed, between the trim lines and the outer lines (3 mm).
pub bleed: f64,
/// Stroke weight.
pub weight: f64,
}
impl Default for TrimMarks {
fn default() -> Self {
Self { style: MarkStyle::Roman, offset: 9.0, length: 18.0, bleed: 3.0 * 72.0 / 25.4, weight: 0.3 }
}
}
impl TrimMarks {
/// The default marks of `style`.
pub fn of(style: MarkStyle) -> Self {
Self { style, ..Default::default() }
}
/// How far the marks reach outside the trim box.
pub fn reach(&self) -> f64 {
let gap = match self.style {
MarkStyle::Roman => self.offset,
MarkStyle::Japanese => self.bleed,
};
gap + self.length + self.weight
}
/// The marks' lines around `r`.
pub fn lines(&self, r: Rect) -> Vec<(Point, Point)> {
let (l, mut out) = (self.length, vec![]);
// Each corner with its outward directions.
let corners = [(r.x0, r.y0, -1.0, -1.0), (r.x1, r.y0, 1.0, -1.0), (r.x1, r.y1, 1.0, 1.0), (r.x0, r.y1, -1.0, 1.0)];
match self.style {
MarkStyle::Roman => {
let o = self.offset;
for (x, y, sx, sy) in corners {
out.push((Point::new(x + sx * o, y), Point::new(x + sx * (o + l), y)));
out.push((Point::new(x, y + sy * o), Point::new(x, y + sy * (o + l))));
}
}
MarkStyle::Japanese => {
let b = self.bleed;
for (x, y, sx, sy) in corners {
// The trim lines, outside the bleed, and the bleed lines meeting at its corner.
out.push((Point::new(x + sx * b, y), Point::new(x + sx * (b + l), y)));
out.push((Point::new(x, y + sy * b), Point::new(x, y + sy * (b + l))));
out.push((Point::new(x, y + sy * b), Point::new(x + sx * (b + l), y + sy * b)));
out.push((Point::new(x + sx * b, y), Point::new(x + sx * b, y + sy * (b + l))));
}
// A cross outside the bleed at the middle of each side.
let c = r.center();
for (p, dx, dy) in [
(Point::new(c.x, r.y0), 0.0, -1.0),
(Point::new(r.x1, c.y), 1.0, 0.0),
(Point::new(c.x, r.y1), 0.0, 1.0),
(Point::new(r.x0, c.y), -1.0, 0.0),
] {
let at = |d: f64| Point::new(p.x + dx * d, p.y + dy * d);
out.push((at(b), at(b + l)));
let m = at(b + l / 2.0);
out.push((Point::new(m.x - dy * l / 2.0, m.y - dx * l / 2.0), Point::new(m.x + dy * l / 2.0, m.y + dx * l / 2.0)));
}
}
}
out
}
/// The marks around `r` as a group named "Trim Marks" of lines stroked in Registration, with
/// ids from `alloc` (the group's last).
pub fn group(&self, r: Rect, alloc: &mut dyn FnMut() -> NodeId) -> Node {
let ap = Appearance::basic(Paint::None, Paint::registration(), self.weight);
let children = self.lines(r).into_iter().map(|(a, b)| Arc::new(Node::path(alloc(), shapes::line(a, b), ap.clone()))).collect();
let mut g = Node::group(alloc(), children);
g.name = Some("Trim Marks".into());
g
}
}
/// `r` grown by `[top, bottom, left, right]` (a bleed, or how far marks reach).
pub fn outset(r: Rect, [top, bottom, left, right]: [f64; 4]) -> Rect {
Rect::new(r.x0 - left, r.y0 - top, r.x1 + right, r.y1 + bottom)
}
/// The printer's marks around a printed page's trim box (its artboard): trim marks, a
/// registration target at the middle of each side, colour bars along the top and the page
/// information line below the bottom marks. They sit in a band [`PrinterMarks::LENGTH`] deep that
/// starts at the offset from the trim box (outside the bleed), and are drawn in Registration, the
/// colour bars aside.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PrinterMarks {
pub trim: bool,
pub registration: bool,
pub color_bars: bool,
pub page_info: bool,
pub style: MarkStyle,
/// Stroke weight of the trim marks and registration targets.
pub weight: f64,
/// Roman: the gap between the trim box and the marks (at least the bleed).
pub offset: f64,
}
/// Where the printer's marks of one page go ([`PrinterMarks::layout`]).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct MarksLayout {
/// Lines stroked in Registration: the trim marks and the targets' cross hairs.
pub lines: Vec<(Point, Point)>,
/// Registration target rings (centre, radius), stroked in Registration.
pub rings: Vec<(Point, f64)>,
/// The colour bars: each patch and its paint.
pub patches: Vec<(Rect, Paint)>,
/// The page information line's baseline start.
pub info: Option<Point>,
}
impl PrinterMarks {
/// How deep the band of marks is (the trim marks' length).
pub const LENGTH: f64 = 18.0;
/// The side of a colour bar patch.
pub const PATCH: f64 = 10.0;
/// The page information's type size.
pub const INFO_SIZE: f64 = 7.0;
/// Any mark is on.
pub fn any(&self) -> bool {
self.trim || self.registration || self.color_bars || self.page_info
}
/// The trim marks for a page with `bleed` (`[top, bottom, left, right]`): Roman ones outside
/// the bleed, Japanese ones with their outer lines on the bleed (3 mm without one).
pub fn trim_marks(&self, bleed: [f64; 4]) -> TrimMarks {
let most = bleed.iter().copied().fold(0.0, f64::max);
let d = TrimMarks::of(self.style);
TrimMarks { offset: self.offset.max(most), length: Self::LENGTH, bleed: if most > 0.0 { most } else { d.bleed }, weight: self.weight, ..d }
}
/// The distance from the trim box to the band of marks.
fn gap(&self, bleed: [f64; 4]) -> f64 {
let t = self.trim_marks(bleed);
match self.style {
MarkStyle::Roman => t.offset,
MarkStyle::Japanese => t.bleed,
}
}
/// How far the marks reach outside the trim box, `[top, bottom, left, right]` (0 without
/// marks): the band on every side, and the page information below it.
pub fn reach(&self, bleed: [f64; 4]) -> [f64; 4] {
if !self.any() {
return [0.0; 4];
}
let band = self.gap(bleed) + Self::LENGTH + self.weight;
let bottom = if self.page_info { band + Self::INFO_SIZE * 1.6 } else { band };
[band, bottom, band, band]
}
/// Where the marks around trim box `trim` go, for a page with `bleed` and the spot inks
/// `spots` (their colour bar patches follow the process ones).
pub fn layout(&self, trim: Rect, bleed: [f64; 4], spots: &[Paint]) -> MarksLayout {
let mut out = MarksLayout::default();
if !self.any() {
return out;
}
let (gap, l) = (self.gap(bleed), Self::LENGTH);
if self.trim {
out.lines = self.trim_marks(bleed).lines(trim);
}
let c = trim.center();
// The middle of the band.
let mid = gap + l / 2.0;
if self.registration {
let sides = [
(Point::new(c.x, trim.y0), 0.0, -1.0),
(Point::new(trim.x1, c.y), 1.0, 0.0),
(Point::new(c.x, trim.y1), 0.0, 1.0),
(Point::new(trim.x0, c.y), -1.0, 0.0),
];
for (p, dx, dy) in sides {
let at = Point::new(p.x + dx * mid, p.y + dy * mid);
out.rings.push((at, l / 3.0));
out.rings.push((at, l / 6.0));
// Japanese trim marks draw this cross already.
if !(self.trim && self.style == MarkStyle::Japanese) {
out.lines.push((Point::new(at.x - l / 2.0, at.y), Point::new(at.x + l / 2.0, at.y)));
out.lines.push((Point::new(at.x, at.y - l / 2.0), Point::new(at.x, at.y + l / 2.0)));
}
}
}
if self.color_bars {
let s = Self::PATCH;
let y = trim.y0 - mid - s / 2.0;
let cmyk = |c, m, y, k| Paint::solid(Color::cmyk(c, m, y, k));
// Solid inks, their overprints and the spot inks left of the top target; black tints
// right of it. Patches that don't fit are left out.
let solids = [
cmyk(1.0, 0.0, 0.0, 0.0),
cmyk(0.0, 1.0, 0.0, 0.0),
cmyk(0.0, 0.0, 1.0, 0.0),
cmyk(0.0, 0.0, 0.0, 1.0),
cmyk(1.0, 1.0, 0.0, 0.0),
cmyk(1.0, 0.0, 1.0, 0.0),
cmyk(0.0, 1.0, 1.0, 0.0),
cmyk(1.0, 1.0, 1.0, 0.0),
];
let tints: Vec<Paint> = (1..=10).map(|i| cmyk(0.0, 0.0, 0.0, i as f32 / 10.0)).collect();
let clear = l / 2.0 + 2.0;
let rows = [(solids.iter().chain(spots).cloned().collect(), trim.x0 + s / 2.0, c.x - clear), (tints, c.x + clear, trim.x1 - s / 2.0)];
for (paints, from, to) in rows {
let fits = ((to - from) / s).floor().max(0.0) as usize;
for (i, paint) in paints.into_iter().take(fits).enumerate() {
let x = from + i as f64 * s;
out.patches.push((Rect::new(x, y, x + s, y + s), paint));
}
}
}
if self.page_info {
out.info = Some(Point::new(trim.x0, trim.y1 + gap + l + self.weight + Self::INFO_SIZE * 1.1));
}
out
}
/// The marks around `trim` as a group named "Printer's Marks", with ids from `alloc` (the
/// group's last): the colour bar patches, then Registration lines and rings, then `info` as
/// point type in Registration.
pub fn art(&self, trim: Rect, bleed: [f64; 4], spots: &[Paint], info: &str, alloc: &mut dyn FnMut() -> NodeId) -> Node {
let m = self.layout(trim, bleed, spots);
let reg = Appearance::basic(Paint::None, Paint::registration(), self.weight);
let shape = |id, path, ap: &Appearance| Arc::new(Node::path(id, path, ap.clone()));
let mut children: Vec<Arc<Node>> =
m.patches.into_iter().map(|(r, p)| shape(alloc(), shapes::rectangle(r), &Appearance::basic(p, Paint::None, 0.0))).collect();
children.extend(m.lines.into_iter().map(|(a, b)| shape(alloc(), shapes::line(a, b), &reg)));
children.extend(m.rings.into_iter().map(|(c, r)| shape(alloc(), shapes::ellipse(Rect::from_center_size(c, (2.0 * r, 2.0 * r))), &reg)));
if let Some(at) = m.info.filter(|_| !info.is_empty()) {
let style = CharStyle { size: Self::INFO_SIZE, fill: Paint::registration(), ..Default::default() };
children.push(Arc::new(Node::new(alloc(), NodeKind::Text(Box::new(TextObject::point(at, info, style))))));
}
let mut g = Node::group(alloc(), children);
g.name = Some("Printer's Marks".into());
g
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roman_marks_sit_off_the_corners_and_japanese_ones_are_doubled() {
let r = Rect::new(0.0, 0.0, 100.0, 50.0);
let roman = TrimMarks::default().lines(r);
assert_eq!(roman.len(), 8);
assert!(roman.iter().all(|(a, b)| !r.contains(*a) && !r.contains(*b)));
assert_eq!(roman[0], (Point::new(-9.0, 0.0), Point::new(-27.0, 0.0)));
let jp = TrimMarks::of(MarkStyle::Japanese);
let lines = jp.lines(r);
assert_eq!(lines.len(), 24, "four lines per corner and a cross per side");
// Two horizontal lines at the top-left corner: the trim line and the bleed line.
let tl: Vec<f64> = lines[..4].iter().filter(|(a, b)| a.y == b.y).map(|(a, _)| a.y).collect();
assert_eq!(tl.len(), 2);
assert!((tl[0] - 0.0).abs() < 1e-9 && (tl[1] + jp.bleed).abs() < 1e-9, "{tl:?}");
let mut n = 0;
let g = jp.group(r, &mut || {
n += 1;
NodeId(n)
});
assert_eq!((g.children().unwrap().len(), g.id), (24, NodeId(25)));
assert!(g.children().unwrap()[0].appearance.stroke().unwrap().paint.is_registration());
assert_eq!(MarkStyle::parse("Japanese"), Some(MarkStyle::Japanese));
}
fn all(style: MarkStyle) -> PrinterMarks {
PrinterMarks { trim: true, registration: true, color_bars: true, page_info: true, style, weight: 0.25, offset: 6.0 }
}
#[test]
fn printer_marks_sit_in_a_band_outside_the_trim_box() {
let trim = Rect::new(0.0, 0.0, 200.0, 100.0);
let m = all(MarkStyle::Roman);
let lay = m.layout(trim, [0.0; 4], &[]);
// Golden positions: the band starts at the 6 pt offset and is 18 pt deep.
assert_eq!(lay.lines[0], (Point::new(-6.0, 0.0), Point::new(-24.0, 0.0)), "top-left trim mark");
assert_eq!(lay.rings[..2], [(Point::new(100.0, -15.0), 6.0), (Point::new(100.0, -15.0), 3.0)], "top target");
assert_eq!(lay.lines[8..10], [(Point::new(91.0, -15.0), Point::new(109.0, -15.0)), (Point::new(100.0, -24.0), Point::new(100.0, -6.0))]);
assert_eq!(lay.patches[0], (Rect::new(5.0, -20.0, 15.0, -10.0), Paint::solid(Color::cmyk(1.0, 0.0, 0.0, 0.0))), "cyan first");
assert_eq!(lay.patches[8].0, Rect::new(111.0, -20.0, 121.0, -10.0), "black tints right of the target");
assert_eq!(lay.patches.len(), 8 + 8, "as many patches as fit each half");
assert_eq!(lay.info, Some(Point::new(0.0, 100.0 + 24.25 + 7.7)));
let reach = m.reach([0.0; 4]);
assert_eq!(reach, [24.25, 24.25 + 11.2, 24.25, 24.25]);
// Everything lies outside the trim box and inside the sheet the reach makes.
let sheet = outset(trim, reach);
let points = lay.lines.iter().flat_map(|(a, b)| [*a, *b]).chain(lay.rings.iter().map(|(c, _)| *c));
for p in points.chain(lay.patches.iter().flat_map(|(r, _)| [r.origin(), Point::new(r.x1, r.y1)])) {
assert!(sheet.contains(p) && !trim.inflate(-1e-9, -1e-9).contains(p), "{p:?}");
}
// A bleed pushes the marks out; no marks reach nothing.
assert_eq!(m.layout(trim, [9.0, 0.0, 0.0, 0.0], &[]).lines[0], (Point::new(-9.0, 0.0), Point::new(-27.0, 0.0)));
assert_eq!(PrinterMarks { trim: false, registration: false, color_bars: false, page_info: false, ..m }.reach([9.0; 4]), [0.0; 4]);
}
#[test]
fn japanese_printer_marks_share_the_centre_crosses_and_draw_as_art() {
let trim = Rect::new(0.0, 0.0, 200.0, 100.0);
let m = all(MarkStyle::Japanese);
let lay = m.layout(trim, [0.0; 4], &[]);
assert_eq!(lay.lines.len(), 24, "the targets add rings to the trim marks' centre crosses");
let b = TrimMarks::default().bleed;
assert_eq!(lay.rings[0].0, Point::new(100.0, -(b + 9.0)));
let spot = Paint::Solid { color: Color::cmyk(0.0, 0.5, 1.0, 0.0), swatch: Some("Ink".into()), tint: 1.0 };
let wide = Rect::new(0.0, 0.0, 400.0, 100.0);
assert_eq!(m.layout(wide, [0.0; 4], std::slice::from_ref(&spot)).patches[8].1, spot, "spot inks follow the process patches");
let mut n = 0;
let g = m.art(trim, [0.0; 4], &[], "Page 1", &mut || {
n += 1;
NodeId(n)
});
let kids = g.children().unwrap();
let NodeKind::Text(t) = &kids.last().unwrap().kind else { panic!("the page information is type") };
assert_eq!((t.plain_text().as_str(), t.first_style().fill.is_registration()), ("Page 1", true));
let strokes: Vec<_> = kids.iter().filter_map(|k| k.appearance.stroke()).filter(|s| !s.paint.is_none()).collect();
assert!(strokes.len() == 24 + 8 && strokes.iter().all(|s| s.paint.is_registration()), "lines and rings in Registration");
}
}
+151
View File
@@ -0,0 +1,151 @@
//! File → File Info: the document's descriptive metadata (author, description, keywords, rating,
//! copyright) and its created and modified dates, which the SVG, PDF and PNG writers carry. The
//! document title is [`crate::Document::title`]. Every field is defaulted, so documents saved
//! before File Info existed load unchanged.
use serde::{Deserialize, Serialize};
/// File Info → Copyright Status.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum CopyrightStatus {
#[default]
Unknown,
Copyrighted,
PublicDomain,
}
impl CopyrightStatus {
pub const ALL: [CopyrightStatus; 3] = [CopyrightStatus::Unknown, CopyrightStatus::Copyrighted, CopyrightStatus::PublicDomain];
pub fn id(self) -> &'static str {
match self {
CopyrightStatus::Unknown => "unknown",
CopyrightStatus::Copyrighted => "copyrighted",
CopyrightStatus::PublicDomain => "publicDomain",
}
}
pub fn label(self) -> &'static str {
match self {
CopyrightStatus::Unknown => "Unknown",
CopyrightStatus::Copyrighted => "Copyrighted",
CopyrightStatus::PublicDomain => "Public Domain",
}
}
pub fn parse(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|c| c.id().eq_ignore_ascii_case(s) || c.label().eq_ignore_ascii_case(s))
}
}
/// Highest File Info rating (stars).
pub const MAX_RATING: u8 = 5;
/// File → File Info.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct DocMetadata {
pub author: String,
/// The author's position or job title.
pub author_title: String,
pub description: String,
/// Keywords, each once, in the order they were added.
pub keywords: Vec<String>,
/// 0 (unrated) to [`MAX_RATING`] stars.
pub rating: u8,
pub copyright_status: CopyrightStatus,
pub copyright_notice: String,
/// Where to read more about the copyright.
pub copyright_url: String,
/// When the document was created, as Unix seconds (UTC).
pub created: Option<i64>,
/// When the document was last saved, as Unix seconds (UTC).
pub modified: Option<i64>,
}
impl DocMetadata {
/// The PNG text entries (registered keywords where one fits) for a document titled `title`:
/// only the non-empty ones.
pub fn png_text<'a>(&'a self, title: &'a str) -> Vec<(&'static str, std::borrow::Cow<'a, str>)> {
let mut out: Vec<(&'static str, std::borrow::Cow<'a, str>)> = vec![];
let mut push = |k: &'static str, v: std::borrow::Cow<'a, str>| {
if !v.trim().is_empty() {
out.push((k, v));
}
};
push("Title", title.into());
push("Author", self.author.as_str().into());
push("Description", self.description.as_str().into());
push("Keywords", self.keywords.join(", ").into());
push("Copyright", self.copyright_notice.as_str().into());
push("Copyright URL", self.copyright_url.as_str().into());
push("Creation Time", self.created.map(iso8601).unwrap_or_default().into());
out
}
}
/// The current time as Unix seconds (UTC); `None` where the platform has no clock (the web build).
#[cfg(not(target_arch = "wasm32"))]
pub fn now_unix() -> Option<i64> {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now().duration_since(UNIX_EPOCH).ok().and_then(|d| i64::try_from(d.as_secs()).ok())
}
#[cfg(target_arch = "wasm32")]
pub fn now_unix() -> Option<i64> {
None
}
/// Unix seconds (UTC) → `[year, month, day, hour, minute, second]` (civil-from-days, proleptic
/// Gregorian; years clamped to 0–9999).
pub fn civil(t: i64) -> [i64; 6] {
// Clamp first so every step stays in range for any input.
let t = t.clamp(-62_167_219_200, 253_402_300_799);
let days = t.div_euclid(86_400);
let secs = t.rem_euclid(86_400);
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = yoe + era * 400 + i64::from(month <= 2);
[year.clamp(0, 9999), month, day, secs / 3600, secs / 60 % 60, secs % 60]
}
/// Unix seconds → `2026-10-05T14:03:00Z`.
pub fn iso8601(t: i64) -> String {
let [y, mo, d, h, mi, s] = civil(t);
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dates_and_statuses() {
assert_eq!(iso8601(0), "1970-01-01T00:00:00Z");
assert_eq!(iso8601(1_791_208_980), "2026-10-05T14:03:00Z");
assert_eq!(civil(i64::MIN)[0], 0);
assert_eq!(civil(i64::MAX)[0], 9999);
assert_eq!(CopyrightStatus::parse("Public Domain"), Some(CopyrightStatus::PublicDomain));
assert_eq!(CopyrightStatus::parse("publicdomain"), Some(CopyrightStatus::PublicDomain));
assert_eq!(CopyrightStatus::parse("maybe"), None);
}
#[test]
fn png_text_skips_empty_entries() {
let m = DocMetadata { author: "Ada".into(), keywords: vec!["a".into(), "b".into()], created: Some(0), ..Default::default() };
let t: Vec<(&str, String)> = m.png_text("Poster").into_iter().map(|(k, v)| (k, v.into_owned())).collect();
assert_eq!(
t,
[
("Title", "Poster".to_string()),
("Author", "Ada".into()),
("Keywords", "a, b".into()),
("Creation Time", "1970-01-01T00:00:00Z".into())
]
);
assert!(DocMetadata::default().png_text("").is_empty());
}
}
+640 -37
View File
@@ -4,10 +4,10 @@ use std::sync::Arc;
use serde::{Deserialize, Serialize};
use vectorcraft_color::BlendMode;
use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect, shapes};
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, shapes};
use crate::appearance::Appearance;
use crate::live::{BlendSpec, EnvelopeKind, GradientMesh};
use crate::live::{BlendSpec, EnvelopeKind, GradientMesh, Outliner};
use crate::pattern::RepeatSpec;
use crate::text::TextObject;
@@ -138,6 +138,17 @@ impl LiveShape {
}
}
}
/// Divide live corner radii by `k`, the mean scale of a transform just applied, so the corners
/// keep their size (Scale Corners off). False when nothing changed.
pub fn keep_corners(&mut self, k: f64) -> bool {
match self {
LiveShape::Rectangle { radii, .. } if radii.iter().any(|r| *r > 0.0) && k > 1e-12 => {
radii.iter_mut().for_each(|r| *r /= k);
true
}
_ => false,
}
}
/// Rotation angle of the live shape in degrees (shown in the Properties panel).
pub fn angle_deg(&self) -> f64 {
match self {
@@ -150,6 +161,36 @@ impl LiveShape {
}
}
/// What a transform scales besides geometry, by its mean scale (the square root of its
/// determinant). `Scaling::default()` (and `false`) scales nothing else; `true` scales stroke
/// weights and dashes only. Transforms the user asks for take theirs from Scale Strokes & Effects
/// and Scale Corners.
#[derive(Clone, Copy, Debug, Default)]
pub struct Scaling {
/// Stroke weights, dash lengths and dash offsets scale.
pub strokes: bool,
/// With `strokes`: scales an effect's distance parameters (the effects catalogue's).
pub effects: Option<fn(&mut crate::Effect, f64)>,
/// Type keeps its character strokes' weight (they otherwise scale with the type).
pub keep_type_strokes: bool,
/// Live corner radii keep their size.
pub keep_corners: bool,
}
impl Scaling {
/// The mean scale of `a` when it scales (isn't 1): what strokes, effects and corners scale by.
pub fn factor(a: Affine) -> Option<f64> {
let k = a.determinant().abs().sqrt();
((k - 1.0).abs() > 1e-9).then_some(k)
}
}
impl From<bool> for Scaling {
fn from(strokes: bool) -> Self {
Self { strokes, ..Self::default() }
}
}
/// Embedded or linked raster image.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ImageObject {
@@ -159,8 +200,13 @@ pub struct ImageObject {
pub height: u32,
/// Maps pixel space (0..w, 0..h) into the document.
pub xf: Affine,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub link: Option<String>,
/// The file a linked image shows (File → Place with Link); `None`: embedded. Files saved
/// before links had details hold just the path.
#[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "crate::links::de_link")]
pub link: Option<crate::LinkInfo>,
/// Links panel → Placement Options: how a file read again takes this image's place.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub placement: crate::links::PlacementOptions,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -174,6 +220,9 @@ pub enum NodeKind {
#[serde(default = "yes")]
printable: bool,
children: Vec<Arc<Node>>,
/// Layer clipping mask: the first (bottom-most) child clips the others, as in a clip group.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
clip: bool,
},
/// A group. With `clip`, the first (bottom-most) child is the clipping path.
Group {
@@ -253,8 +302,13 @@ pub struct Node {
pub blend: BlendMode,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub isolate: bool,
/// Knockout Group: whether a container's children knock each other out (see [`Knockout`]).
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub knockout: bool,
pub knockout: Knockout,
/// Opacity & Mask Define Knockout Shape: inside a knockout group, this object's opacity and
/// opacity mask scale how much of the objects below it in the group it knocks out.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub knockout_shape: bool,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub appearance: Appearance,
/// Opacity mask (Transparency panel). Its art lives here, outside the layer tree.
@@ -270,6 +324,17 @@ pub struct Node {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub graph: Option<Box<crate::graph::GraphSpec>>,
pub kind: NodeKind,
/// The [`crate::GraphicStyle::id`] last applied to this object. It stays linked while it keeps
/// that style's look: editing its appearance or transparency breaks the link.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub graphic_style: Option<u32>,
/// Attributes panel: centre point display, image map, URL and note (`None`: all defaults).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attrs: Option<Box<ObjectAttributes>>,
/// Object → Slice → Make: the object is an object slice (its slice follows its bounds) with
/// these options.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slice: Option<Box<crate::SliceOptions>>,
}
/// Opacity mask: the luminance of the mask art sets the object's opacity (white = opaque).
@@ -307,13 +372,17 @@ impl Node {
opacity: 1.0,
blend: BlendMode::Normal,
isolate: false,
knockout: false,
knockout: Knockout::Neutral,
knockout_shape: false,
appearance: Appearance::default(),
mask: None,
trace: None,
wrap: None,
graph: None,
kind,
graphic_style: None,
attrs: None,
slice: None,
}
}
pub fn path(id: NodeId, path: PathData, appearance: Appearance) -> Self {
@@ -325,7 +394,7 @@ impl Node {
Self::new(id, NodeKind::Group { children, clip: false })
}
pub fn layer(id: NodeId, name: &str, color: LayerColor) -> Self {
let mut n = Self::new(id, NodeKind::Layer { color, template: false, printable: true, children: vec![] });
let mut n = Self::new(id, NodeKind::Layer { color, template: false, printable: true, children: vec![], clip: false });
n.name = Some(name.to_string());
n
}
@@ -357,6 +426,16 @@ impl Node {
pub fn is_container(&self) -> bool {
self.children().is_some()
}
/// Full opacity, Normal blending, no isolation, knockout or opacity mask (the Layers panel
/// fills an object's target circle otherwise).
pub fn has_default_transparency(&self) -> bool {
self.opacity >= 1.0
&& self.blend == BlendMode::Normal
&& !self.isolate
&& self.knockout == Knockout::Neutral
&& !self.knockout_shape
&& self.mask.is_none()
}
/// Kind name as the Layers panel / Properties panel shows it.
pub fn kind_label(&self) -> &'static str {
match &self.kind {
@@ -367,7 +446,7 @@ impl Node {
NodeKind::Path { guide: true, .. } => "Guide",
NodeKind::Path { .. } => "Path",
NodeKind::Compound { .. } => "Compound Path",
NodeKind::Text(_) => "Text",
NodeKind::Text(_) => "Type",
NodeKind::Image(_) => "Image",
NodeKind::SymbolInstance { .. } => "Symbol",
NodeKind::Blend { .. } => "Blend",
@@ -402,11 +481,37 @@ impl Node {
_ => None,
}
}
/// The path a path's or compound path's own strokes follow (a compound's members' together).
pub fn stroke_path(&self) -> Option<vectorcraft_geom::BezPath> {
match &self.kind {
NodeKind::Path { path, .. } => Some(path.to_bezpath()),
NodeKind::Compound { children, .. } => {
let mut bp = vectorcraft_geom::BezPath::new();
children.iter().filter_map(|c| c.path_data()).for_each(|pd| bp.extend(pd.to_bezpath()));
Some(bp)
}
_ => None,
}
}
/// A quick box round everything the object can paint, for culling clicks: paths and compound
/// paths grow their geometric bounds by the farthest their strokes can reach from any path
/// ([`Appearance::outset`]) instead of measuring along their shape.
pub fn reach_bounds(&self) -> Option<Rect> {
match &self.kind {
NodeKind::Path { .. } | NodeKind::Compound { .. } => {
let o = self.appearance.outset();
self.geometric_bounds().map(|b| b.inflate(o, o))
}
_ => self.visual_bounds(),
}
}
/// Geometric bounds (no stroke), recursively. Clip groups are bounded by their clip path.
pub fn geometric_bounds(&self) -> Option<Rect> {
match &self.kind {
NodeKind::Path { path, .. } => path.bounds(),
NodeKind::Group { children, clip: true } => children.first().and_then(|c| c.geometric_bounds()),
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => {
children.first().and_then(|c| c.geometric_bounds())
}
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => children
.iter()
.filter(|c| c.visible || !matches!(self.kind, NodeKind::Layer { .. }))
@@ -423,12 +528,23 @@ impl Node {
NodeKind::Repeat(r) => r.bounds(),
}
}
/// Visual bounds (includes stroke outset).
/// Visual bounds: what the object paints, its strokes included (paths and compound paths
/// measure theirs along their own shape, see [`Appearance::stroked_bounds`]).
pub fn visual_bounds(&self) -> Option<Rect> {
match &self.kind {
NodeKind::Group { children, clip: true } => children.first().and_then(|c| c.geometric_bounds()),
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => {
// A clipping path that paints (see `clip_paint`) strokes over the clip edge.
let clip = children.first()?;
if matches!(clip.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_)) {
clip.visual_bounds()
} else {
clip.geometric_bounds()
}
}
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual_bounds()))
// The container's own strokes paint around its members.
let o = self.appearance.outset();
children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual_bounds())).map(|b| b.inflate(o, o))
}
NodeKind::Blend { children, spec } => {
let b = children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual_bounds()));
@@ -439,6 +555,10 @@ impl Node {
let o = crate::live::max_outset(content);
self.geometric_bounds().map(|b| b.inflate(o, o))
}
NodeKind::Path { .. } | NodeKind::Compound { .. } => {
let b = self.geometric_bounds()?;
Some(self.appearance.stroked_bounds(b, || self.stroke_path().unwrap_or_default()))
}
_ => {
let o = self.appearance.outset();
self.geometric_bounds().map(|b| b.inflate(o, o))
@@ -446,33 +566,58 @@ impl Node {
}
}
/// Apply an affine transform to the geometry (and gradients) of this node and its descendants.
/// `scale_strokes` also scales stroke weights by the transform's mean scale.
pub fn transform(&mut self, a: Affine, scale_strokes: bool) {
if scale_strokes {
let det = a.determinant().abs().sqrt();
if (det - 1.0).abs() > 1e-9 {
self.appearance.scale_strokes(det);
/// `scaling` says what else scales by the transform's mean scale (a bool: stroke weights
/// only, see [`Scaling`]).
pub fn transform(&mut self, a: Affine, scaling: impl Into<Scaling>) {
self.transform_scaled(a, &scaling.into());
}
fn transform_scaled(&mut self, a: Affine, sc: &Scaling) {
// Refitting an unplaced gradient only reproduces moves and uniform scales.
if !keeps_gradient_fit(a) {
self.pin_gradients();
}
let k = Scaling::factor(a);
if let Some(k) = k
&& sc.strokes
{
self.appearance.scale_strokes(k);
if let Some(f) = sc.effects {
self.appearance.scale_effects(k, f);
}
}
transform_paints(&mut self.appearance, a);
self.appearance.transform_gradients(a);
match &mut self.kind {
NodeKind::Path { path, live, .. } => {
path.transform(a);
if let Some(l) = live {
l.transform(a);
if sc.keep_corners
&& let Some(k) = k
&& l.keep_corners(k)
{
*path = l.to_path();
}
}
}
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => {
for c in children.iter_mut() {
Arc::make_mut(c).transform(a, scale_strokes);
Arc::make_mut(c).transform_scaled(a, sc);
}
}
NodeKind::Text(t) => {
t.transform(a);
// Character strokes scale with the type: undo that when strokes keep their weight.
if sc.keep_type_strokes
&& let Some(k) = k.filter(|k| *k > 1e-12)
{
t.scale_char_strokes(1.0 / k);
}
}
NodeKind::Text(t) => t.transform(a),
NodeKind::Image(im) => im.xf = a * im.xf,
NodeKind::SymbolInstance { xf, .. } => *xf = a * *xf,
NodeKind::Blend { children, spec } => {
for c in children.iter_mut() {
Arc::make_mut(c).transform(a, scale_strokes);
Arc::make_mut(c).transform_scaled(a, sc);
}
if let Some(s) = &mut spec.spine {
s.transform(a);
@@ -480,7 +625,7 @@ impl Node {
}
NodeKind::Envelope { content, kind, .. } => {
for c in content.iter_mut() {
Arc::make_mut(c).transform(a, scale_strokes);
Arc::make_mut(c).transform_scaled(a, sc);
}
match kind {
EnvelopeKind::Mesh { points, .. } => {
@@ -493,12 +638,12 @@ impl Node {
}
}
NodeKind::Mesh(m) => m.transform(a),
NodeKind::Repeat(r) => r.transform(a, scale_strokes),
NodeKind::Repeat(r) => r.transform(a, *sc),
}
if let Some(m) = &mut self.mask
&& m.linked
{
Arc::make_mut(&mut m.art).transform(a, scale_strokes);
Arc::make_mut(&mut m.art).transform_scaled(a, sc);
}
}
/// Visit this node and all descendants depth first (paint order).
@@ -516,22 +661,437 @@ impl Node {
self.walk(&mut |_| n += 1);
n
}
/// Fix this node's unplaced gradients to their fit on its current bounds (before a transform
/// or warp that refitting wouldn't reproduce). Descendants pin their own when transformed.
pub fn pin_gradients(&mut self) {
if self.appearance.has_unplaced_gradient()
&& let Some(b) = self.geometric_bounds()
{
self.appearance.pin_gradients(b);
}
}
/// The paint behind the Fill (or Stroke) proxy, the map from its space to the document and
/// the box (in that space) an unplaced gradient fits. `item` (the Appearance panel's active
/// item) stands in for the topmost fill or stroke when it is of the proxy's kind. Otherwise
/// type objects paint their runs (the first run speaks for all) in text space, fitted to the
/// layout bounds; everything else paints its fill or stroke in the document, fitted to the
/// geometric bounds. Strokes fit the box grown by half their weight.
pub fn proxy_paint(&self, stroke: bool, item: Option<usize>) -> Option<(&vectorcraft_color::Paint, Affine, Rect)> {
let item = self.appearance.item_of_kind(item, !stroke);
if item.is_none()
&& let NodeKind::Text(t) = &self.kind
{
let st = &t.runs.first()?.style;
let b = t.local_bounds();
return Some(if stroke { (&st.stroke, t.xf, crate::appearance::stroke_paint_bounds(b, st.stroke_width)) } else { (&st.fill, t.xf, b) });
}
// An object without bounds (an empty path) still shows its paints, fitted to an empty box.
let b = self.geometric_bounds().unwrap_or(Rect::ZERO);
if stroke {
self.appearance.stroke_at(item).map(|s| (&s.paint, Affine::IDENTITY, s.paint_bounds(b)))
} else {
self.appearance.fill_at(item).map(|f| (&f.paint, Affine::IDENTITY, b))
}
}
/// The gradient behind the Fill (or Stroke) proxy and its placement in document coordinates
/// (see [`Node::proxy_paint`]): what the gradient annotator shows and edits.
pub fn proxy_gradient(&self, stroke: bool, item: Option<usize>) -> Option<(&vectorcraft_color::GradientPaint, vectorcraft_color::GradientGeom)> {
let (paint, to_doc, b) = self.proxy_paint(stroke, item)?;
let vectorcraft_color::Paint::Gradient(g) = paint else { return None };
let mut geom = g.resolve(b);
if to_doc != Affine::IDENTITY {
geom.transform(to_doc, g.gradient.kind);
}
Some((g, geom))
}
/// The freeform gradient behind the Fill (or Stroke) proxy, its points in document coordinates
/// (the automatic ones while unplaced), and the length its spreads are fractions of there
/// (see [`Node::proxy_paint`]): what the Gradient tool's freeform annotator shows and edits.
pub fn proxy_freeform(&self, stroke: bool, item: Option<usize>) -> Option<(vectorcraft_color::Freeform, f64)> {
let (paint, to_doc, b) = self.proxy_paint(stroke, item)?;
let vectorcraft_color::Paint::Gradient(g) = paint else { return None };
if g.gradient.kind != vectorcraft_color::GradientKind::Freeform {
return None;
}
let mut f = g.freeform_on(b).into_owned();
let mut scale = vectorcraft_color::freeform::spread_scale(b);
if to_doc != Affine::IDENTITY {
f.transform(to_doc);
scale *= to_doc.determinant().abs().sqrt();
}
Some((f, scale))
}
}
fn transform_paints(ap: &mut Appearance, a: Affine) {
use crate::appearance::AppearanceItem;
use vectorcraft_color::Paint;
for it in &mut ap.items {
let p = match it {
AppearanceItem::Fill(f) => &mut f.paint,
AppearanceItem::Stroke(s) => &mut s.paint,
};
if let Paint::Gradient(g) = p
&& let Some(geom) = &mut g.geom
{
geom.transform(a);
/// Unites filled regions (each under its own fill rule) into one path filled non-zero. Booleans
/// live above this crate (`vectorcraft-pathops`), so callers supply it.
pub type Uniter<'a> = &'a dyn Fn(&[(BezPath, FillRule)]) -> BezPath;
impl Node {
/// The filled regions this object clips to as the clipping path of a clip group, in document
/// space, each with its fill rule: a path, a compound path (with its holes), an image's frame,
/// text outlined by `text` (glyph outlines need the font engine, above this crate), the visible
/// members of a group (their union) and the clipping path of a nested clip group. Live objects
/// are evaluated first. Guides and symbol instances add nothing.
pub fn clip_shapes(&self, text: Outliner) -> Vec<(BezPath, FillRule)> {
let mut out = vec![];
self.push_clip_shapes(text, &mut out);
out
}
fn push_clip_shapes(&self, text: Outliner, out: &mut Vec<(BezPath, FillRule)>) {
match &self.kind {
NodeKind::Path { guide: true, .. } | NodeKind::SymbolInstance { .. } => {}
NodeKind::Path { path, rule, .. } => out.push((path.to_bezpath(), *rule)),
NodeKind::Compound { children, rule } => {
let mut bp = BezPath::new();
for p in children.iter().filter_map(|c| c.path_data()) {
bp.extend(p.to_bezpath());
}
out.push((bp, *rule));
}
NodeKind::Group { children, clip: true } => {
if let Some(c) = children.first() {
c.push_clip_shapes(text, out);
}
}
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => {
for c in children.iter().filter(|c| c.visible) {
c.push_clip_shapes(text, out);
}
}
NodeKind::Text(_) => {
if let Some(o) = text.and_then(|f| f(self)) {
o.push_clip_shapes(None, out);
}
}
NodeKind::Image(im) => {
let frame = shapes::rectangle(Rect::new(0.0, 0.0, im.width as f64, im.height as f64)).transformed(im.xf);
out.push((frame.to_bezpath(), FillRule::NonZero));
}
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => {
crate::live::expand_deep(self, text).push_clip_shapes(text, out);
}
}
}
/// The region this object clips to as one path and fill rule ([`Self::clip_shapes`]), shared
/// by the renderer and the SVG and PDF writers so every output clips alike. One shape keeps its
/// own rule; several are united by `unite` (filled non-zero). `None` when there is nothing to
/// clip by: the clipped art is then hidden.
pub fn clip_outline(&self, text: Outliner, unite: Uniter) -> Option<(BezPath, FillRule)> {
let mut shapes = self.clip_shapes(text);
match shapes.len() {
0 => None,
1 => shapes.pop(),
_ => Some((unite(&shapes), FillRule::NonZero)),
}
}
/// What this object paints as the clipping path of a clip group, shared by the renderer and
/// the SVG and PDF writers: its fills, painted behind the clipped art, and its strokes, painted
/// over it (unclipped). Each part is this object keeping only those appearance items (type:
/// also only its characters' fills or strokes); `None` where it paints nothing, as right after
/// Make Clipping Mask. Only paths, compound paths and type paint as clipping paths.
pub fn clip_paint(&self) -> ClipPaint {
use crate::appearance::AppearanceItem;
let runs = match &self.kind {
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => None,
NodeKind::Text(t) => Some(&t.runs),
_ => return ClipPaint::default(),
};
let paints = |fill: bool| {
let item = |i: &AppearanceItem| match i {
AppearanceItem::Fill(f) => fill && f.visible && !f.paint.is_none(),
AppearanceItem::Stroke(s) => !fill && s.visible && !s.paint.is_none() && s.width > 0.0,
};
let run = |r: &crate::text::TextRun| if fill { !r.style.fill.is_none() } else { !r.style.stroke.is_none() && r.style.stroke_width > 0.0 };
self.appearance.items.iter().any(item) || runs.is_some_and(|rs| rs.iter().any(run))
};
let part = |fill: bool| {
paints(fill).then(|| {
let mut n = self.clone();
for i in (0..n.appearance.items.len()).rev() {
if matches!(n.appearance.items[i], AppearanceItem::Fill(_)) != fill {
n.appearance.remove_item(i);
}
}
if let NodeKind::Text(t) = &mut n.kind {
for r in &mut t.runs {
*(if fill { &mut r.style.stroke } else { &mut r.style.fill }) = vectorcraft_color::Paint::None;
}
}
n
})
};
ClipPaint { fill: part(true), stroke: part(false) }
}
}
/// What a clipping path paints ([`Node::clip_paint`]).
#[derive(Clone, Debug, Default)]
pub struct ClipPaint {
/// The clipping path with only its fills: painted behind the clipped art (inside the clip).
pub fill: Option<Node>,
/// The clipping path with only its strokes: painted over the clipped art, not clipped.
pub stroke: Option<Node>,
}
/// Knockout Group state of a container (the Transparency panel's three-state checkbox). In a
/// knockout group each child composites against the group's backdrop, so it hides the children
/// below it instead of showing them through its transparency. Neutral passes the enclosing
/// group's setting through to the children. Files from before the three states stored a bool:
/// `true` loads as On, `false` as Neutral.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum Knockout {
#[default]
Neutral,
Off,
On,
}
impl Knockout {
pub const ALL: [Knockout; 3] = [Knockout::Neutral, Knockout::Off, Knockout::On];
/// Name in files and command parameters.
pub fn label(self) -> &'static str {
match self {
Knockout::Neutral => "neutral",
Knockout::Off => "off",
Knockout::On => "on",
}
}
/// A name ([`Self::label`], any case) or a bool as older files and commands wrote it.
pub fn from_value(v: &serde_json::Value) -> Option<Self> {
match v {
serde_json::Value::Bool(b) => Some(Self::from(*b)),
serde_json::Value::String(s) => Self::ALL.into_iter().find(|k| k.label().eq_ignore_ascii_case(s.trim())),
_ => None,
}
}
/// The state a click on the checkbox moves to: on → neutral → off → on.
pub fn cycle(self) -> Self {
match self {
Knockout::On => Knockout::Neutral,
Knockout::Neutral => Knockout::Off,
Knockout::Off => Knockout::On,
}
}
/// Whether children of a group with this state knock each other out, inside a group (or page)
/// where that is `enclosing`.
pub fn resolve(self, enclosing: bool) -> bool {
match self {
Knockout::Neutral => enclosing,
Knockout::Off => false,
Knockout::On => true,
}
}
}
impl From<bool> for Knockout {
fn from(b: bool) -> Self {
if b { Knockout::On } else { Knockout::Neutral }
}
}
impl Serialize for Knockout {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(self.label())
}
}
impl<'de> Deserialize<'de> for Knockout {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = serde_json::Value::deserialize(d)?;
Self::from_value(&v).ok_or_else(|| serde::de::Error::custom(format!("invalid knockout {v}")))
}
}
impl Node {
/// Whether the children of this group or layer knock each other out, inside a group (or page)
/// where that is `enclosing`.
pub fn knocks_out(&self, enclosing: bool) -> bool {
matches!(self.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) && self.knockout.resolve(enclosing)
}
/// A plain group or layer that is neutral to knockout and has no transparency or appearance
/// of its own: inside a knockout group its children composite as the group's own children.
pub fn passes_knockout_through(&self) -> bool {
matches!(self.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { template: false, clip: false, .. })
&& self.has_default_transparency()
&& self.appearance.items.is_empty()
&& self.appearance.effects.is_empty()
}
/// The visible elements of a knockout group made of `children` (bottom first): neutral plain
/// groups ([`Self::passes_knockout_through`]) contribute their children instead of themselves.
pub fn knockout_elements(children: &[Arc<Node>]) -> Vec<&Arc<Node>> {
fn push<'a>(children: &'a [Arc<Node>], out: &mut Vec<&'a Arc<Node>>) {
for c in children.iter().filter(|c| c.visible) {
match c.children() {
Some(ch) if c.passes_knockout_through() => push(ch, out),
_ => out.push(c),
}
}
}
let mut out = Vec::with_capacity(children.len());
push(children, &mut out);
out
}
/// Whether this is a clip group or a layer with a clipping mask: its first child clips the rest.
pub fn clips(&self) -> bool {
matches!(self.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. })
}
/// Make this group or layer clip its children by its first child, or stop it.
pub fn set_clips(&mut self, on: bool) {
if let NodeKind::Group { clip, .. } | NodeKind::Layer { clip, .. } = &mut self.kind {
*clip = on;
}
}
/// Whether blending inside this object reaches the art below it unless the object isolates
/// it: a blend mode other than Normal on a child (or, in a leaf, on a fill or stroke),
/// directly or through children that don't isolate their own blending.
pub fn blends_through(&self) -> bool {
self.blends_through_with(&mut |c| c.blends_through())
}
/// [`Self::blends_through`], with `inner` answering it for the children (e.g. from a cache).
pub fn blends_through_with(&self, inner: &mut dyn FnMut(&Arc<Node>) -> bool) -> bool {
match self.children() {
Some(ch) if !matches!(self.kind, NodeKind::Compound { .. }) => Self::children_blend(ch, inner),
_ => self.appearance.items.iter().any(|i| i.visible() && i.blend() != BlendMode::Normal),
}
}
/// [`Self::blends_through`] of a group made of `children` (`inner` answers it for each child).
pub fn children_blend(children: &[Arc<Node>], inner: &mut dyn FnMut(&Arc<Node>) -> bool) -> bool {
children.iter().any(|c| c.visible && (c.blend != BlendMode::Normal || (!c.isolate && inner(c))))
}
}
impl Node {
/// The Appearance panel's row for what this object's own fills and strokes paint around:
/// "Contents" for a group or layer (its members), "Characters" for type; `None` for objects
/// whose fills and strokes paint their own geometry.
pub fn contents_label(&self) -> Option<&'static str> {
match self.kind {
NodeKind::Group { .. } | NodeKind::Layer { .. } => Some("Contents"),
NodeKind::Text(_) => Some("Characters"),
_ => None,
}
}
}
/// Window → Attributes: an object's settings that don't change how it prints.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ObjectAttributes {
/// Show Center / Don't Show Center (`None`: the object kind's default, [`Node::shows_center`]).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub show_center: Option<bool>,
/// Image Map: the clickable area the URL covers in web output.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub image_map: ImageMap,
/// URL the object links to (SVG `<a href>`; empty: none).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub url: String,
/// The note shown in the Attributes panel.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub note: String,
}
/// The Attributes panel's Image Map shapes.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ImageMap {
#[default]
None,
Rectangle,
Polygon,
}
impl ImageMap {
pub const ALL: [ImageMap; 3] = [ImageMap::None, ImageMap::Rectangle, ImageMap::Polygon];
pub fn label(self) -> &'static str {
match self {
ImageMap::None => "None",
ImageMap::Rectangle => "Rectangle",
ImageMap::Polygon => "Polygon",
}
}
/// Parse a label or serialized name, ignoring case.
pub fn parse(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|m| m.label().eq_ignore_ascii_case(s))
}
}
impl Node {
/// The URL this object links to, if any.
pub fn url(&self) -> Option<&str> {
self.attrs.as_deref().map(|a| a.url.as_str()).filter(|u| !u.is_empty())
}
/// Whether the canvas shows this object's centre point when it is selected: as set in the
/// Attributes panel, else [`Self::shows_center_by_default`].
pub fn shows_center(&self) -> bool {
self.attrs.as_deref().and_then(|a| a.show_center).unwrap_or_else(|| self.shows_center_by_default())
}
/// Shapes drawn with the shape tools (live rectangles, ellipses and polygons) show their centre.
pub fn shows_center_by_default(&self) -> bool {
matches!(&self.kind, NodeKind::Path { live: Some(l), .. } if !matches!(l, LiveShape::Line { .. }))
}
/// Change this object's attributes with `f`; all-default attributes are dropped.
pub fn edit_attrs<R>(&mut self, f: impl FnOnce(&mut ObjectAttributes) -> R) -> R {
let mut a = self.attrs.take().unwrap_or_default();
let r = f(&mut a);
self.attrs = (*a != ObjectAttributes::default()).then_some(a);
r
}
}
impl Node {
/// Whether this object shows any transparency, itself or anything inside it: object, fill or
/// stroke opacity below 100%, a blend mode, an opacity mask, effects or transparent gradient
/// stops. Hidden objects show none.
pub fn shows_transparency(&self) -> bool {
use vectorcraft_color::Paint;
let see_through = |p: &Paint| matches!(p, Paint::Gradient(g) if g.gradient.stops.iter().any(|s| s.opacity < 1.0));
let effects = |fx: &[crate::Effect]| fx.iter().any(|e| e.visible);
self.visible
&& (self.opacity < 1.0
|| self.blend != BlendMode::Normal
|| self.mask.as_ref().is_some_and(|m| !m.disabled)
|| effects(&self.appearance.effects)
|| self
.appearance
.items
.iter()
.any(|i| i.visible() && (i.opacity() < 1.0 || i.blend() != BlendMode::Normal || see_through(i.paint()) || effects(i.effects())))
|| self.children().is_some_and(|ch| ch.iter().any(|c| c.shows_transparency())))
}
}
/// Is `a` a move plus a positive uniform scale (after which a refit gradient still matches)?
fn keeps_gradient_fit(a: Affine) -> bool {
let [m0, m1, m2, m3, _, _] = a.as_coeffs();
let eps = 1e-12 * m0.abs().max(1.0);
m0 > 0.0 && m1.abs() <= eps && m2.abs() <= eps && (m0 - m3).abs() <= eps
}
impl Node {
/// Whether a document point lies in this object's filled regions ([`Node::clip_shapes`]; true
/// everywhere for objects without them, such as type, whose boxes stand in).
pub fn contains_fn(&self) -> impl Fn(Point) -> bool + use<> {
let shapes = self.clip_shapes(None);
move |p| shapes.is_empty() || shapes.iter().any(|(bp, rule)| vectorcraft_geom::hit::fill_contains(bp, *rule, p))
}
}
#[cfg(test)]
@@ -545,6 +1105,26 @@ mod tests {
assert_eq!(l.label(), "Rectangle");
}
#[test]
fn default_transparency() {
let p = || Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
assert!(p().has_default_transparency());
let tweaks: [fn(&mut Node); 7] = [
|n| n.opacity = 0.5,
|n| n.blend = BlendMode::Multiply,
|n| n.isolate = true,
|n| n.knockout = Knockout::On,
|n| n.knockout = Knockout::Off,
|n| n.knockout_shape = true,
|n| n.mask = Some(Box::new(OpacityMask::new(n.clone(), true))),
];
for tweak in tweaks {
let mut n = p();
tweak(&mut n);
assert!(!n.has_default_transparency(), "{n:?}");
}
}
#[test]
fn transform_group_recurses() {
let p = Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
@@ -555,6 +1135,29 @@ mod tests {
assert_eq!(child.appearance.stroke_width(), 2.0);
}
#[test]
fn unplaced_gradients_pin_only_when_a_refit_would_differ() {
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, Paint};
let grad = || Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
let fresh = || Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)), Appearance::basic(grad(), grad(), 2.0));
let geoms = |n: &Node| match (n.appearance.fill_paint(), n.appearance.stroke_paint()) {
(Paint::Gradient(f), Paint::Gradient(s)) => (f.geom, s.geom),
_ => panic!("gradient fill and stroke"),
};
let mut n = fresh();
n.transform(Affine::translate((10.0, 5.0)) * Affine::scale(2.0), true);
assert_eq!(geoms(&n), (None, None), "moves and uniform scales keep refitting");
let mut n = fresh();
n.transform(Affine::rotate(std::f64::consts::FRAC_PI_2), false);
let (f, s) = geoms(&n);
// Fitted on the old bounds (strokes on the stroke-inflated box), then rotated: vertical.
let mut want = GradientGeom::fit(GradientKind::Linear, Rect::new(0.0, 0.0, 100.0, 50.0), 0.0);
want.transform(Affine::rotate(std::f64::consts::FRAC_PI_2), GradientKind::Linear);
let f = f.unwrap();
assert!(f.start.distance(want.start) < 1e-9 && f.end.distance(want.end) < 1e-9, "{f:?}");
assert!((s.unwrap().length() - 102.0).abs() < 1e-9);
}
#[test]
fn display_names() {
let p = Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
+203
View File
@@ -0,0 +1,203 @@
//! Overprinting: each fill and stroke (and each character's fill and stroke) can print its inks
//! over the inks below instead of knocking them out ([`crate::FillLayer::overprint`]).
//! Edit → Edit Colors → Overprint Black sets it on black fills and strokes ([`OverprintBlack`]).
use vectorcraft_color::{Color, Paint};
use crate::{Document, Node, NodeId, NodeKind};
/// `Document.unknown` key of files from before per-fill/stroke overprint: the ids of objects
/// marked Overprint Black ([`Document::migrate_overprint_black`]).
pub const LEGACY_OVERPRINT_KEY: &str = "overprintBlack";
/// Black ink of at least `min_k` (0..1): CMYK with that much K and, unless `rich`, no cyan,
/// magenta or yellow; or a grey that dark. RGB and Lab colours are never black ink.
pub fn is_black_ink(c: &Color, min_k: f32, rich: bool) -> bool {
const E: f32 = 0.005;
match *c {
Color::Cmyk { c, m, y, k } => k >= min_k - E && (rich || (c <= E && m <= E && y <= E)),
Color::Gray { k } => k >= min_k - E,
Color::Rgb { .. } | Color::Lab { .. } => false,
}
}
/// Edit → Edit Colors → Overprint Black: which fills and strokes count as black, and whether
/// they start or stop overprinting.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct OverprintBlack {
/// Remove overprinting instead of adding it.
pub remove: bool,
/// Minimum black ink, 0..1.
pub min_k: f32,
pub fill: bool,
pub stroke: bool,
/// Include Blacks with CMY: rich blacks count too.
pub rich: bool,
/// Include Spot Blacks: colours linked to a spot swatch count too.
pub spot: bool,
}
impl Default for OverprintBlack {
fn default() -> Self {
Self { remove: false, min_k: 1.0, fill: true, stroke: true, rich: false, spot: false }
}
}
impl OverprintBlack {
/// Whether `p` is black: a solid colour (linked to one of the spot swatches `spots` only with
/// [`Self::spot`]), or a gradient whose every stop is.
fn black(&self, p: &Paint, spots: &[String]) -> bool {
match p {
Paint::Solid { color, swatch, .. } => {
(self.spot || !swatch.as_ref().is_some_and(|s| spots.contains(s))) && is_black_ink(color, self.min_k, self.rich)
}
Paint::Gradient(g) => !g.gradient.stops.is_empty() && g.gradient.stops.iter().all(|s| is_black_ink(&s.color, self.min_k, self.rich)),
_ => false,
}
}
/// Set (or clear) the overprint of `n`'s own black fills and strokes and its characters'.
/// `spots`: the document's spot swatch names ([`Document::spot_names`]). Returns whether
/// anything changed.
pub fn apply(&self, n: &mut Node, spots: &[String]) -> bool {
let on = !self.remove;
let mut changed = false;
let mut set = |flag: &mut bool| {
changed |= *flag != on;
*flag = on;
};
for it in &mut n.appearance.items {
if (if it.is_fill() { self.fill } else { self.stroke }) && self.black(it.paint(), spots) {
set(it.overprint_mut());
}
}
if let NodeKind::Text(t) = &mut n.kind {
for r in &mut t.runs {
if self.fill && self.black(&r.style.fill, spots) {
set(&mut r.style.overprint_fill);
}
if self.stroke && self.black(&r.style.stroke, spots) {
set(&mut r.style.overprint_stroke);
}
}
}
changed
}
}
impl Node {
/// Whether any fill or stroke of this subtree overprints (characters' aside).
pub fn has_overprint(&self) -> bool {
self.appearance.items.iter().any(|i| i.overprint()) || self.children().is_some_and(|c| c.iter().any(|c| c.has_overprint()))
}
}
impl Document {
/// The names of the spot swatches.
pub fn spot_names(&self) -> Vec<String> {
self.swatches_iter().filter(|s| s.spot).map(|s| s.name.clone()).collect()
}
/// Files from before per-fill/stroke overprint listed the objects marked Overprint Black
/// under [`LEGACY_OVERPRINT_KEY`]: their 100% black fills and strokes overprint now.
pub fn migrate_overprint_black(&mut self) {
let Some(list) = self.unknown.remove(LEGACY_OVERPRINT_KEY) else { return };
let ids: Vec<NodeId> = list.as_array().into_iter().flatten().filter_map(|v| v.as_u64()).map(NodeId).collect();
// The old list marked spot-linked blacks too (so the spot names don't matter).
let op = OverprintBlack { spot: true, ..Default::default() };
for id in ids {
if let Some(n) = self.node_mut(id) {
op.apply(n, &[]);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Appearance, CharStyle, TextObject};
use vectorcraft_geom::Point;
fn cmyk(c: f32, m: f32, y: f32, k: f32) -> Paint {
Paint::solid(Color::cmyk(c, m, y, k))
}
#[test]
fn black_ink_rules() {
assert!(is_black_ink(&Color::cmyk(0.0, 0.0, 0.0, 1.0), 1.0, false));
assert!(!is_black_ink(&Color::cmyk(0.6, 0.4, 0.4, 1.0), 1.0, false), "rich black needs `rich`");
assert!(is_black_ink(&Color::cmyk(0.6, 0.4, 0.4, 1.0), 1.0, true));
assert!(is_black_ink(&Color::cmyk(0.0, 0.0, 0.0, 0.8), 0.75, false) && !is_black_ink(&Color::cmyk(0.0, 0.0, 0.0, 0.8), 0.9, false));
assert!(is_black_ink(&Color::gray(1.0), 1.0, false));
assert!(!is_black_ink(&Color::rgb(0.0, 0.0, 0.0), 1.0, true), "RGB black is not ink");
}
#[test]
fn apply_covers_items_runs_gradients_and_spots() {
let k = cmyk(0.0, 0.0, 0.0, 1.0);
let mut n = Node::path(NodeId(1), Default::default(), Appearance::basic(k.clone(), k.clone(), 1.0));
let op = OverprintBlack { fill: false, ..Default::default() };
assert!(op.apply(&mut n, &[]));
assert!(!n.appearance.fill().unwrap().overprint && n.appearance.stroke().unwrap().overprint, "fill: false marks strokes only");
assert!(!op.apply(&mut n, &[]), "nothing left to change");
assert!(OverprintBlack { remove: true, ..Default::default() }.apply(&mut n, &[]));
assert!(!n.has_overprint());
// Gradients count when every stop is black.
let mut g = vectorcraft_color::GradientPaint::new(Default::default());
for s in &mut g.gradient.stops {
s.color = Color::cmyk(0.0, 0.0, 0.0, 1.0);
}
n.appearance.set_fill(Paint::Gradient(Box::new(g.clone())));
assert!(OverprintBlack::default().apply(&mut n, &[]) && n.appearance.fill().unwrap().overprint);
g.gradient.stops[0].color = Color::cmyk(0.0, 0.0, 0.0, 0.0);
let fill = n.appearance.fill_mut().unwrap();
fill.paint = Paint::Gradient(Box::new(g));
fill.overprint = false;
assert!(!OverprintBlack::default().apply(&mut n, &[]), "the stroke was marked already");
assert!(!n.appearance.fill().unwrap().overprint, "a gradient with a white stop is not black");
// Spot-linked blacks only with `spot`; type runs too.
let spot = Paint::Solid { color: Color::cmyk(0.0, 0.0, 0.0, 1.0), swatch: Some("Ink".into()), tint: 1.0 };
let style = CharStyle { fill: spot, stroke: k, stroke_width: 1.0, ..CharStyle::default() };
let mut t = Node::new(NodeId(2), NodeKind::Text(Box::new(TextObject::point(Point::ZERO, "Hi", style))));
OverprintBlack::default().apply(&mut t, &["Ink".to_string()]);
let NodeKind::Text(tx) = &t.kind else { panic!("type") };
assert!(!tx.runs[0].style.overprint_fill && tx.runs[0].style.overprint_stroke);
OverprintBlack { spot: true, ..Default::default() }.apply(&mut t, &["Ink".to_string()]);
let NodeKind::Text(tx) = &t.kind else { panic!("type") };
assert!(tx.runs[0].style.overprint_fill);
}
#[test]
fn legacy_overprint_black_list_migrates() {
let mut d = Document::new(100.0, 100.0);
let layer = d.default_layer().unwrap();
let k = cmyk(0.0, 0.0, 0.0, 1.0);
let a = d.alloc_id();
d.insert(Some(layer), usize::MAX, Node::path(a, Default::default(), Appearance::basic(k.clone(), cmyk(1.0, 0.0, 0.0, 0.0), 1.0))).unwrap();
let b = d.alloc_id();
d.insert(Some(layer), usize::MAX, Node::path(b, Default::default(), Appearance::basic(k, Paint::None, 1.0))).unwrap();
d.unknown.insert(LEGACY_OVERPRINT_KEY.into(), serde_json::json!([a.0]));
d.migrate_overprint_black();
assert!(!d.unknown.contains_key(LEGACY_OVERPRINT_KEY));
let na = d.node(a).unwrap();
assert!(na.appearance.fill().unwrap().overprint && !na.appearance.stroke().unwrap().overprint, "the black fill, not the cyan stroke");
assert!(!d.node(b).unwrap().has_overprint(), "objects not listed keep knocking out");
}
#[test]
fn overprint_round_trips_and_old_layers_load() {
let mut f = crate::FillLayer::new(Paint::None);
assert!(!serde_json::to_string(&f).unwrap().contains("overprint"), "off is not written");
f.overprint = true;
let back: crate::FillLayer = serde_json::from_str(&serde_json::to_string(&f).unwrap()).unwrap();
assert!(back.overprint);
let old: crate::StrokeLayer = serde_json::from_str(r#"{"paint":{"type":"none"},"width":2.0}"#).unwrap();
assert!(!old.overprint);
let st = CharStyle { overprint_stroke: true, ..CharStyle::default() };
let back: CharStyle = serde_json::from_str(&serde_json::to_string(&st).unwrap()).unwrap();
assert!(back.overprint_stroke && !back.overprint_fill);
}
}
+13 -2
View File
@@ -182,6 +182,10 @@ pub struct PatternDef {
pub dim_copies: f32,
#[serde(default = "yes")]
pub show_tile_edge: bool,
/// Show Swatch Bounds: pattern editing mode outlines the part of the tiling the swatch repeats
/// (the tile, or the period of a brick or hex tiling), dashed.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub show_swatch_bounds: bool,
}
impl PatternDef {
@@ -200,6 +204,7 @@ impl PatternDef {
copies: 5,
dim_copies: 70.0,
show_tile_edge: true,
show_swatch_bounds: false,
}
}
pub fn width(&self) -> f64 {
@@ -226,6 +231,11 @@ impl PatternDef {
pub fn period(&self) -> (f64, f64) {
tile_period(self.tile_type, self.width(), self.height())
}
/// The swatch bounds (Show Swatch Bounds): the period of the tiling from the tile's origin.
pub fn swatch_bounds(&self) -> Rect {
let (w, h) = self.period();
Rect::from_origin_size(self.tile.origin(), (w, h))
}
/// Maps the art's coordinates into pattern space for instance offset `o`.
pub fn instance_xf(&self, o: Vec2) -> Affine {
Affine::translate(o - self.tile.origin().to_vec2())
@@ -493,9 +503,10 @@ impl RepeatSpec {
self.transforms().into_iter().fold(None, |acc, m| vectorcraft_geom::union_opt(acc, Some(m.transform_rect_bbox(b))))
}
/// Apply a document transform (source + arrangement parameters).
pub fn transform(&mut self, a: Affine, scale_strokes: bool) {
pub fn transform(&mut self, a: Affine, scaling: impl Into<crate::node::Scaling>) {
let scaling = scaling.into();
for c in self.source.iter_mut() {
Arc::make_mut(c).transform(a, scale_strokes);
Arc::make_mut(c).transform(a, scaling);
}
let s = a.determinant().abs().sqrt();
match &mut self.kind {
+154
View File
@@ -0,0 +1,154 @@
//! Pixel access for embedded images: a stable content key and recolouring the pixels.
use std::collections::HashMap;
use std::io::Cursor;
use std::sync::Arc;
use crate::ImageBlob;
impl ImageBlob {
/// A PNG blob of `png` bytes.
pub fn png(png: Vec<u8>) -> Self {
Self::new("image/png", png)
}
/// A key derived from the bytes (FNV-1a), the same for identical images.
pub fn content_key(&self) -> String {
crate::links::hash_bytes(&self.bytes)
}
/// The image with `f` applied to each pixel's (straight) RGB, alpha kept and fully transparent
/// pixels left alone, re-encoded as PNG. `f` runs once per distinct colour. `None` when the
/// image can't be decoded or no pixel changed.
pub fn map_rgb(&self, mut f: impl FnMut([u8; 3]) -> [u8; 3]) -> Option<ImageBlob> {
let mut img = image::load_from_memory(&self.bytes).ok()?.to_rgba8();
let mut memo: HashMap<[u8; 3], [u8; 3]> = HashMap::new();
let mut changed = false;
for px in img.pixels_mut().filter(|p| p.0[3] > 0) {
let rgb = [px.0[0], px.0[1], px.0[2]];
let out = *memo.entry(rgb).or_insert_with(|| f(rgb));
if out != rgb {
px.0[..3].copy_from_slice(&out);
changed = true;
}
}
if !changed {
return None;
}
let mut png = Vec::new();
img.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?;
Some(Self::png(png))
}
}
impl ImageBlob {
/// The colour around pixel (`x`, `y`) (pixel space): the alpha-weighted average of the `size` ×
/// `size` pixels centred on it, clipped to the image, as straight RGBA. `None` outside the
/// image, where every pixel is transparent, or when the image can't be decoded. The last image
/// decoded is kept for the next sample (the Eyedropper samples one image click after click).
pub fn sample(&self, x: f64, y: f64, size: u32) -> Option<[u8; 4]> {
sample_pixels(&*self.decoded()?, x, y, size)
}
/// [`Self::sample`] at (`x`, `y`) in the pixel space of an image object of `object` (width,
/// height) pixels, which the decoded pixels may not match (a linked image showing its preview).
pub fn sample_object(&self, x: f64, y: f64, object: (u32, u32), size: u32) -> Option<[u8; 4]> {
let img = self.decoded()?;
let k = |n: u32, of: u32| n as f64 / of.max(1) as f64;
sample_pixels(&img, x * k(img.width(), object.0), y * k(img.height(), object.1), size)
}
/// The decoded pixels (the last image decoded is kept for the next sample).
fn decoded(&self) -> Option<Arc<image::RgbaImage>> {
thread_local! {
static LAST: std::cell::RefCell<Option<(String, Arc<image::RgbaImage>)>> = const { std::cell::RefCell::new(None) };
}
let key = self.content_key();
LAST.with_borrow_mut(|last| match last {
Some((k, img)) if *k == key => Some(img.clone()),
_ => {
let img = Arc::new(image::load_from_memory(&self.bytes).ok()?.to_rgba8());
*last = Some((key, img.clone()));
Some(img)
}
})
}
}
/// [`ImageBlob::sample`] of decoded pixels `img`.
fn sample_pixels(img: &image::RgbaImage, x: f64, y: f64, size: u32) -> Option<[u8; 4]> {
let (w, h) = (img.width() as i64, img.height() as i64);
let (cx, cy) = (x.floor() as i64, y.floor() as i64);
if !(0..w).contains(&cx) || !(0..h).contains(&cy) {
return None;
}
let r = size.max(1) as i64 / 2;
let (mut sum, mut alpha, mut n) = ([0u64; 3], 0u64, 0u64);
for py in (cy - r).max(0)..=(cy + r).min(h - 1) {
for px in (cx - r).max(0)..=(cx + r).min(w - 1) {
let [pr, pg, pb, pa] = img.get_pixel(px as u32, py as u32).0;
let a = pa as u64;
sum = [sum[0] + pr as u64 * a, sum[1] + pg as u64 * a, sum[2] + pb as u64 * a];
alpha += a;
n += 1;
}
}
if alpha == 0 {
return None;
}
let c = |v: u64| ((v + alpha / 2) / alpha) as u8;
Some([c(sum[0]), c(sum[1]), c(sum[2]), ((alpha + n / 2) / n) as u8])
}
#[cfg(test)]
mod tests {
use super::*;
fn blob(px: &[[u8; 4]], w: u32, h: u32) -> ImageBlob {
let img = image::RgbaImage::from_raw(w, h, px.concat()).unwrap();
let mut png = Vec::new();
img.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
ImageBlob::png(png)
}
fn pixels(b: &ImageBlob) -> Vec<[u8; 4]> {
image::load_from_memory(&b.bytes).unwrap().to_rgba8().pixels().map(|p| p.0).collect()
}
#[test]
fn samples_a_pixel_or_the_average_around_it() {
let b = blob(&[[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [9, 9, 9, 0]], 4, 1);
assert_eq!(b.sample(1.5, 0.5, 1), Some([0, 255, 0, 255]));
assert_eq!(b.sample(1.5, 0.5, 3), Some([85, 85, 85, 255]), "red, green and blue averaged");
assert_eq!(b.sample(3.2, 0.5, 3), Some([0, 0, 255, 128]), "transparent pixels don't count towards the colour");
assert_eq!(b.sample(3.2, 0.5, 1), None, "all transparent");
assert_eq!(b.sample(-0.5, 0.5, 5), None, "outside");
assert_eq!(b.sample(0.0, 0.0, 5), Some([85, 85, 85, 255]), "clipped to the image");
}
#[test]
fn samples_in_the_pixel_space_of_an_object_with_more_pixels() {
// An object of 8 × 2 pixels showing these 4 × 1 (a linked image's preview).
let b = blob(&[[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [9, 9, 9, 255]], 4, 1);
assert_eq!(b.sample_object(5.0, 1.5, (8, 2), 1), Some([0, 0, 255, 255]));
assert_eq!(b.sample_object(1.0, 0.0, (4, 1), 1), b.sample(1.0, 0.0, 1), "the same size: the same pixels");
}
#[test]
fn maps_each_colour_once_and_keeps_alpha() {
let b = blob(&[[255, 0, 0, 255], [255, 0, 0, 128], [0, 0, 255, 255], [10, 20, 30, 0]], 2, 2);
let mut calls = 0;
let out = b
.map_rgb(|[r, g, bl]| {
calls += 1;
[255 - r, 255 - g, 255 - bl]
})
.unwrap();
assert_eq!(calls, 2, "red and blue, once each; the transparent pixel is skipped");
assert_eq!(pixels(&out), [[0, 255, 255, 255], [0, 255, 255, 128], [255, 255, 0, 255], [10, 20, 30, 0]]);
assert_ne!(out.content_key(), b.content_key());
assert_eq!(b.content_key(), blob(&[[255, 0, 0, 255], [255, 0, 0, 128], [0, 0, 255, 255], [10, 20, 30, 0]], 2, 2).content_key());
assert!(b.map_rgb(|c| c).is_none(), "nothing changed");
assert!(ImageBlob::png(vec![1, 2, 3]).map_rgb(|c| c).is_none(), "undecodable");
}
}
+75
View File
@@ -0,0 +1,75 @@
//! The colour profiles a document is tagged with (Edit → Assign Profile).
use serde::{Deserialize, Serialize};
use crate::Document;
/// `Document::unknown` key under which files before format v3 kept the assigned profiles
/// (`{rgb?, cmyk?}`); still written when saving for those versions.
pub const LEGACY_PROFILES_KEY: &str = "colorProfiles";
/// The profiles a document is tagged with, by name (`None`: the working space).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ColorProfiles {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rgb: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cmyk: Option<String>,
}
impl ColorProfiles {
pub fn is_empty(&self) -> bool {
self.rgb.is_none() && self.cmyk.is_none()
}
/// The legacy JSON form (`{"rgb": name|null, "cmyk": name|null}`).
pub fn to_legacy(&self) -> serde_json::Value {
serde_json::json!({ "rgb": self.rgb, "cmyk": self.cmyk })
}
/// Read the legacy JSON form (anything that isn't a name counts as none).
pub fn from_legacy(v: &serde_json::Value) -> Self {
let name = |k: &str| v.get(k).and_then(serde_json::Value::as_str).map(str::to_string);
Self { rgb: name("rgb"), cmyk: name("cmyk") }
}
}
impl Document {
/// Files before format v3 kept the assigned profiles under [`LEGACY_PROFILES_KEY`] in
/// `unknown`: move them to [`Document::color_profiles`].
pub fn migrate_color_profiles(&mut self) {
let Some(v) = self.unknown.remove(LEGACY_PROFILES_KEY) else { return };
if self.color_profiles.is_empty() {
self.color_profiles = ColorProfiles::from_legacy(&v);
}
}
/// The reverse of [`Document::migrate_color_profiles`], for saving in a version before v3.
pub fn store_legacy_color_profiles(&mut self) {
let profiles = std::mem::take(&mut self.color_profiles);
if !profiles.is_empty() {
self.unknown.insert(LEGACY_PROFILES_KEY.into(), profiles.to_legacy());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn legacy_profiles_migrate_both_ways() {
let mut d = Document::new(10.0, 10.0);
d.unknown.insert(LEGACY_PROFILES_KEY.into(), serde_json::json!({"rgb": "Wide RGB", "cmyk": null}));
d.migrate_color_profiles();
assert_eq!(d.color_profiles, ColorProfiles { rgb: Some("Wide RGB".into()), cmyk: None });
assert!(!d.unknown.contains_key(LEGACY_PROFILES_KEY));
d.store_legacy_color_profiles();
assert!(d.color_profiles.is_empty());
assert_eq!(d.unknown[LEGACY_PROFILES_KEY], serde_json::json!({"rgb": "Wide RGB", "cmyk": null}));
// Nothing assigned: nothing stored.
let mut e = Document::new(10.0, 10.0);
e.store_legacy_color_profiles();
assert!(e.unknown.is_empty());
}
}
+135
View File
@@ -0,0 +1,135 @@
//! Effect → Document Raster Effects Settings, besides the resolution
//! ([`crate::Document::raster_effects_ppi`]): how raster effects (shadows, glows, blurs, feathers)
//! become pixels when they are flattened for output or expanded, and the defaults of Rasterize.
//! Every field is defaulted, so documents saved before these settings existed load unchanged.
use serde::{Deserialize, Serialize};
use crate::setup::Background;
/// The colour model raster effect images are made in.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum RasterColorModel {
/// The document's colour mode (RGB or CMYK).
#[default]
Document,
Grayscale,
/// Black and white pixels only.
Bitmap,
}
impl RasterColorModel {
pub const ALL: [RasterColorModel; 3] = [RasterColorModel::Document, RasterColorModel::Grayscale, RasterColorModel::Bitmap];
pub fn id(self) -> &'static str {
match self {
RasterColorModel::Document => "document",
RasterColorModel::Grayscale => "grayscale",
RasterColorModel::Bitmap => "bitmap",
}
}
}
/// Largest Add … Around Object, in points.
pub const MAX_ADD_AROUND: f64 = 1000.0;
/// Document Raster Effects Settings (resolution aside).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct RasterEffectsSettings {
pub color_model: RasterColorModel,
/// White: images are opaque, on white. Transparent: they keep their transparency.
pub background: Background,
/// Off: the art's edges are hard (raster effects stay smooth).
pub anti_alias: bool,
/// With a white background, the white stays only under the art (outside it the image is
/// transparent); Rasterize clips its image to the art's outline.
pub clipping_mask: bool,
/// Extra room around the art, in points (0–[`MAX_ADD_AROUND`]).
pub add_around: f64,
/// Keep spot colours in raster effects (stored; effect images are made in process colour).
pub preserve_spot_colors: bool,
}
impl Default for RasterEffectsSettings {
fn default() -> Self {
Self {
color_model: RasterColorModel::Document,
background: Background::Transparent,
anti_alias: true,
clipping_mask: false,
add_around: 0.0,
preserve_spot_colors: true,
}
}
}
impl RasterEffectsSettings {
/// Turn premultiplied RGBA8 pixels into what these settings make of them: the colour model,
/// then the background. (Anti-aliasing is the renderer's.)
pub fn finish_pixels(&self, pixels: &mut [u8]) {
let white = self.background == Background::White;
if self.color_model == RasterColorModel::Document && !white {
return;
}
for px in pixels.as_chunks_mut::<4>().0 {
let a = px[3];
if self.color_model != RasterColorModel::Document && a != 0 {
// Rec. 709 luma of the premultiplied colour (still premultiplied).
let y = 0.2126 * px[0] as f32 + 0.7152 * px[1] as f32 + 0.0722 * px[2] as f32;
let y = match self.color_model {
RasterColorModel::Bitmap => {
if y >= a as f32 / 2.0 {
a
} else {
0
}
}
_ => y.round().min(a as f32) as u8,
};
px[..3].fill(y);
}
// Over white; a clipping mask leaves what the art doesn't cover transparent.
if white && !(self.clipping_mask && a == 0) {
let under = 255 - a;
for c in &mut px[..3] {
*c = c.saturating_add(under);
}
px[3] = 255;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn finish(s: RasterEffectsSettings, px: [u8; 4]) -> [u8; 4] {
let mut p = px;
s.finish_pixels(&mut p);
p
}
#[test]
fn pixels_follow_the_settings() {
let d = RasterEffectsSettings::default();
assert_eq!(finish(d.clone(), [100, 0, 0, 100]), [100, 0, 0, 100], "the defaults change nothing");
let white = RasterEffectsSettings { background: Background::White, ..d.clone() };
assert_eq!(finish(white.clone(), [0, 0, 0, 0]), [255, 255, 255, 255]);
assert_eq!(finish(white.clone(), [100, 0, 0, 100]), [255, 155, 155, 255]);
let masked = RasterEffectsSettings { clipping_mask: true, ..white };
assert_eq!(finish(masked, [0, 0, 0, 0]), [0, 0, 0, 0]);
let gray = RasterEffectsSettings { color_model: RasterColorModel::Grayscale, ..d.clone() };
assert_eq!(finish(gray, [255, 0, 0, 255]), [54, 54, 54, 255]);
let bitmap = RasterEffectsSettings { color_model: RasterColorModel::Bitmap, ..d };
assert_eq!(finish(bitmap.clone(), [255, 0, 0, 255]), [0, 0, 0, 255]);
assert_eq!(finish(bitmap, [0, 255, 0, 255]), [255, 255, 255, 255]);
}
#[test]
fn partial_settings_fill_in_the_rest() {
let s: RasterEffectsSettings = serde_json::from_str(r#"{"background":"white"}"#).unwrap();
assert_eq!(s, RasterEffectsSettings { background: Background::White, ..Default::default() });
}
}
+431
View File
@@ -0,0 +1,431 @@
//! Where a path's strokes paint, from the path's geometry without building their outlines: the
//! visual bounds and the clicks of a stroke take in its alignment, its width profile, miter spikes
//! up to the miter limit, projecting caps and arrowheads.
use vectorcraft_geom::bez::{CORNER_COS, kink, segments, subpaths, tangent, unit};
use vectorcraft_geom::hit::fill_contains;
use vectorcraft_geom::kurbo::{ParamCurveArclen, ParamCurveNearest};
use vectorcraft_geom::{BezPath, FillRule, ParamCurve, PathSeg, Point, Rect, Shape, Vec2};
use crate::appearance::HEAD_REACH;
use crate::{Appearance, ArrowAlign, Dash, LineCap, LineJoin, StrokeAlign, StrokeLayer, WidthProfile};
/// Arc-length accuracy for placing a point along a profile (document points).
const ARCLEN_ACCURACY: f64 = 1e-3;
/// The segments of a subpath that have length (zero-length ones, all of whose points coincide,
/// have no direction).
fn drawn(els: &[vectorcraft_geom::PathEl]) -> Vec<PathSeg> {
let moves = |s: &PathSeg| match s {
PathSeg::Line(l) => l.p1 != l.p0,
PathSeg::Quad(q) => q.p1 != q.p0 || q.p2 != q.p0,
PathSeg::Cubic(c) => c.p1 != c.p0 || c.p2 != c.p0 || c.p3 != c.p0,
};
segments(els).into_iter().filter(moves).collect()
}
/// The open subpath ends of `segs`: (point, unit direction out of the path) at its start and end.
fn ends(segs: &[PathSeg]) -> Option<[(Point, Vec2); 2]> {
let (first, last) = (segs.first()?, segs.last()?);
Some([(first.start(), -tangent(first, 0.0)), (last.end(), tangent(last, 1.0))])
}
/// The joints between consecutive segments (and, `closed`, where the subpath closes): the point
/// and the unit tangents in and out.
fn joints(segs: &[PathSeg], closed: bool) -> impl Iterator<Item = (Point, Vec2, Vec2)> + '_ {
let n = segs.len();
let pairs = if closed && n > 1 { n } else { n.saturating_sub(1) };
(0..pairs).map(move |i| {
let (a, b) = (&segs[i], &segs[(i + 1) % n]);
(a.end(), tangent(a, 1.0), tangent(b, 0.0))
})
}
/// The miter spike at a corner with tangents `t_in`, `t_out` of a stroke reaching `side` from its
/// path: its tip and the corner's outer offset points, or `None` when the path doesn't turn there
/// or the miter limit bevels it.
fn miter(at: Point, t_in: Vec2, t_out: Vec2, side: f64, limit: f64) -> Option<[Point; 3]> {
let cos = t_in.dot(t_out);
// cos(half the turn) is sin(half the angle between the segments): the miter is side / it.
let half = ((1.0 + cos) / 2.0).max(0.0).sqrt();
if cos >= CORNER_COS || half * limit < 1.0 {
return None;
}
let out = unit(t_in - t_out);
// The offset of each segment on the outer side of the turn.
let outer = |t: Vec2| {
let n = Vec2::new(-t.y, t.x);
if n.dot(out) >= 0.0 { n } else { -n }
};
Some([at + outer(t_in) * side, at + out * (side / half), at + outer(t_out) * side])
}
/// The heads' ends of `bp`: the start of its first subpath and the end of its last, when open (as
/// the stroke geometry places them), each with `true` for the end head.
fn head_ends(st: &StrokeLayer, bp: &BezPath) -> Vec<(Point, Vec2, bool)> {
if st.start_arrow.is_none() && st.end_arrow.is_none() {
return vec![];
}
let subs = subpaths(bp);
let open_ends = |sub: Option<&(std::ops::Range<usize>, bool)>| sub.filter(|s| !s.1).and_then(|s| ends(&drawn(&bp.elements()[s.0.clone()])));
let mut out = vec![];
if st.start_arrow.is_some()
&& let Some([(at, dir), _]) = open_ends(subs.first())
{
out.push((at, dir, false));
}
if st.end_arrow.is_some()
&& let Some([_, (at, dir)]) = open_ends(subs.last())
{
out.push((at, dir, true));
}
out
}
/// (left, right) width factors where `p`'s nearest point on the subpath `segs` sits (segment `i`
/// at `t`): the profile at that fraction of the subpath's length, or wider where an end of the
/// segment is a corner. The stroke draws the sides there straight to the corner's join and inner
/// meeting point, which can lie outside the exact width all along the segment.
fn profile_at(profile: &WidthProfile, segs: &[PathSeg], closed: bool, i: usize, t: f64) -> (f64, f64) {
let n = segs.len();
let lens: Vec<f64> = segs.iter().map(|s| s.arclen(ARCLEN_ACCURACY)).collect();
let total: f64 = lens.iter().sum::<f64>().max(1e-12);
let before = lens[..i].iter().sum::<f64>();
let mut w = profile.at((before + segs[i].subsegment(0.0..t).arclen(ARCLEN_ACCURACY)) / total);
let corner = |a: usize, b: usize| (closed || a < b) && kink(&segs[a], &segs[b]);
let starts = n > 1 && corner((i + n - 1) % n, i);
let ends = n > 1 && corner(i, (i + 1) % n);
// A closed subpath's last segment ends where it starts.
let end = if i + 1 == n { 0.0 } else { before + lens[i] };
for (is, at) in [(starts, before), (ends, end)] {
if is {
let (l, r) = profile.at(at / total);
w = (w.0.max(l), w.1.max(r));
}
}
w
}
/// Is `p` inside the convex polygon `pts` (either winding), or within `tol` of it?
fn in_convex(pts: &[Point], p: Point, tol: f64) -> bool {
let n = pts.len();
// Signed distances from each edge's line.
let (lo, hi) = (0..n).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), i| {
let e = pts[(i + 1) % n] - pts[i];
let d = e.cross(p - pts[i]) / e.hypot().max(1e-12);
(lo.min(d), hi.max(d))
});
lo >= -tol || hi <= tol
}
impl StrokeLayer {
/// Does bounding this stroke need its path's shape: miter spikes, projecting caps, arrowheads,
/// or an alignment that depends on whether the path is closed? Else it reaches evenly round it.
fn needs_shape(&self) -> bool {
self.join == LineJoin::Miter
|| self.cap == LineCap::Square
|| self.start_arrow.is_some()
|| self.end_arrow.is_some()
|| self.align != StrokeAlign::Center
}
/// The box this stroke paints along `bp`, whose geometric bounds are `bounds`: the bounds grown
/// by the body's reach, with the tips of the miter spikes the limit keeps, the corners of
/// projecting caps and the reach of the arrowheads round their end points.
pub fn bounds_on(&self, bp: &BezPath, bounds: Rect) -> Rect {
let els = bp.elements();
let mut out = bounds;
let mut side_max: f64 = 0.0;
// Dashes put a cap on every dash: projecting ones reach a corner's diagonal anywhere.
let capped_dashes = self.cap == LineCap::Square && self.dash.as_ref().is_some_and(Dash::is_dashed);
for (r, closed) in subpaths(bp) {
let side = self.side_reach(closed);
side_max = side_max.max(if capped_dashes { side * std::f64::consts::SQRT_2 } else { side });
if side <= 0.0 {
continue;
}
let segs = drawn(&els[r]);
if self.join == LineJoin::Miter {
for (at, t_in, t_out) in joints(&segs, closed) {
if let Some([_, tip, _]) = miter(at, t_in, t_out, side, self.miter_limit) {
out = out.union_pt(tip);
}
}
}
if self.cap == LineCap::Square && !closed {
for (at, dir) in ends(&segs).into_iter().flatten() {
let n = Vec2::new(-dir.y, dir.x) * side;
out = out.union_pt(at + dir * side + n).union_pt(at + dir * side - n);
}
}
}
for (at, _, end) in head_ends(self, bp) {
let r = self.head_reach(end);
out = out.union(Rect::from_center_size(at, (2.0 * r, 2.0 * r)));
}
out.union(bounds.inflate(side_max, side_max))
}
/// Does this stroke along `bp` (filled with `rule`) paint within `tol` of `p`? Inside and
/// outside strokes of closed paths count on their own side only (and within `tol` of the
/// path); a width profile counts at its width where `p` is nearest.
pub fn hits(&self, bp: &BezPath, rule: FillRule, p: Point, tol: f64) -> bool {
let reach = self.reach() + tol;
if self.width.is_nan() || self.width <= 0.0 || !bp.bounding_box().inflate(reach, reach).contains(p) {
return false;
}
if head_ends(self, bp).into_iter().any(|(at, out, end)| self.head_contains(at, out, end, p, tol)) {
return true;
}
let els = bp.elements();
// Aligned strokes paint on one side of the path only.
let on_side = |region: Option<bool>, d: f64| region.is_none_or(|inside| d <= tol || fill_contains(bp, rule, p) == inside);
// Every dash gets the caps, as wide as the stroke where the dash ends: round ones reach as
// far as the widest point, projecting ones a corner's diagonal.
let dash_caps = match self.cap {
LineCap::Butt => None,
_ if !self.dash.as_ref().is_some_and(Dash::is_dashed) => None,
LineCap::Round => Some(1.0),
LineCap::Square => Some(std::f64::consts::SQRT_2),
};
for (r, closed) in subpaths(bp) {
let segs = drawn(&els[r]);
let (half, region) = self.body(closed);
let full = half * self.profile_max() * dash_caps.unwrap_or(1.0);
// The nearest point on this subpath.
let near = segs.iter().enumerate().map(|(i, s)| (i, s.nearest(p, 1e-9))).min_by(|a, b| a.1.distance_sq.total_cmp(&b.1.distance_sq));
if let Some((i, nr)) = near
&& nr.distance_sq.sqrt() <= full + tol
{
let d = nr.distance_sq.sqrt();
let factor = if dash_caps.is_some() {
full / half.max(1e-12)
} else {
self.profile.as_ref().map_or(1.0, |pr| {
let (l, rt) = profile_at(pr, &segs, closed, i, nr.t);
// Nearest to a segment's end, `p` is beside a join or past a cap, which
// spans the widths of both sides.
let at_end = nr.t <= 1e-9 || nr.t >= 1.0 - 1e-9;
// Left of the direction of travel (y down) reads the left width.
let tn = tangent(&segs[i], nr.t);
let left = (p - segs[i].eval(nr.t)).dot(Vec2::new(tn.y, -tn.x)) >= 0.0;
if at_end {
l.max(rt)
} else if left {
l
} else {
rt
}
})
};
if d <= half * factor.max(0.0) + tol && on_side(region, d) {
return true;
}
}
if full <= 0.0 {
continue;
}
if self.join == LineJoin::Miter
&& joints(&segs, closed).any(|(at, t_in, t_out)| {
miter(at, t_in, t_out, full, self.miter_limit).is_some_and(|[a, tip, b]| in_convex(&[at, a, tip, b], p, tol))
})
&& on_side(region, f64::INFINITY)
{
return true;
}
if self.cap == LineCap::Square
&& !closed
&& ends(&segs).into_iter().flatten().any(|(at, dir)| {
let n = Vec2::new(-dir.y, dir.x) * full;
in_convex(&[at + n, at + n + dir * full, at - n + dir * full, at - n], p, tol)
})
{
return true;
}
}
false
}
/// Is `p` within `tol` of where the start (`end == false`) or end arrowhead at `at` (the path
/// leaving along `out`) can paint? Heads are at most about 4.6 weights long and 4 wide. With
/// [`ArrowAlign::Extend`] a head points along `out` with its tip up to its length (or the cap)
/// past the end; with [`ArrowAlign::Tip`] its tip is on the end and it lies back along the
/// chord the path bends through under it, so anywhere within its length of the end.
fn head_contains(&self, at: Point, out: Vec2, end: bool, p: Point, tol: f64) -> bool {
let hw = self.arrow_weight(end);
let v = p - at;
match self.arrow_align {
ArrowAlign::Tip => v.hypot() <= HEAD_REACH * hw + tol,
ArrowAlign::Extend => {
let along = v.dot(out);
v.cross(out).abs() <= 2.0 * hw + tol && along <= 4.0 * hw + self.width / 2.0 + tol && along >= -HEAD_REACH * hw - tol
}
}
}
}
impl Appearance {
/// The visual bounds of a path with geometric bounds `bounds` painted with this appearance:
/// the bounds with what each visible stroke paints round it ([`StrokeLayer::bounds_on`]).
/// `bp` (the path) is only built when a stroke needs its shape.
pub fn stroked_bounds(&self, bounds: Rect, bp: impl FnOnce() -> BezPath) -> Rect {
let mut path = Some(bp);
let mut shape = None;
self.painted_strokes().fold(bounds, |acc, s| {
if !s.needs_shape() {
let o = s.side_reach(false);
return acc.union(bounds.inflate(o, o));
}
let bp: &BezPath = shape.get_or_insert_with(|| path.take().map(|f| f()).unwrap_or_default());
acc.union(s.bounds_on(bp, bounds))
})
}
/// Does a visible stroke of this appearance along `bp` (filled with `rule`) paint within
/// `tol` of `p` ([`StrokeLayer::hits`])?
pub fn stroke_hit(&self, bp: &BezPath, rule: FillRule, p: Point, tol: f64) -> bool {
self.painted_strokes().any(|s| s.hits(bp, rule, p, tol))
}
}
#[cfg(test)]
mod tests {
use vectorcraft_color::{Color, Paint};
use vectorcraft_geom::{PathData, shapes};
use super::*;
use crate::{Arrowhead, Document, Node, StrokeAlign, hit};
fn stroke(width: f64, f: impl FnOnce(&mut StrokeLayer)) -> StrokeLayer {
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), width);
f(&mut st);
st
}
fn line() -> BezPath {
shapes::line(Point::new(0.0, 0.0), Point::new(100.0, 0.0)).to_bezpath()
}
/// A thin triangle whose 22.6° apex at (100, 20) miters about 5 times half the weight out.
fn triangle() -> BezPath {
let mut bp = BezPath::new();
bp.move_to((0.0, 0.0));
bp.line_to((100.0, 20.0));
bp.line_to((0.0, 40.0));
bp.close_path();
bp
}
fn square() -> BezPath {
shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath()
}
#[test]
fn a_bigger_arrowhead_grows_the_bounds() {
let l = line();
let b = l.bounding_box();
let at = |scale: f64| {
stroke(2.0, |s| {
s.join = LineJoin::Round;
s.end_arrow = Some(Arrowhead::Triangle);
s.arrow_scale = (100.0, scale);
})
};
let (small, big) = (at(100.0).bounds_on(&l, b), at(300.0).bounds_on(&l, b));
assert!(big.x1 > small.x1 + 15.0 && big.y1 > small.y1 + 7.0, "{small:?} → {big:?}");
// A 300% head on a 2 pt stroke is 24 pt long and wide: its tip, past the end, is inside.
assert!(big.x1 >= 100.0 + 24.0 && big.y1 >= 12.0 && big.x0 == -1.0, "{big:?}");
// The start has no head.
assert_eq!(at(300.0).head_reach(false), 0.0);
}
#[test]
fn rectangles_keep_exact_bounds_and_sharp_corners_spike_up_to_the_limit() {
let sq = square();
let b = sq.bounding_box();
let st = stroke(4.0, |_| {});
assert_eq!(st.bounds_on(&sq, b), b.inflate(2.0, 2.0), "right-angle miters stay in the box");
// A sharp spike: a thin triangle's apex miters far out unless the limit bevels it.
let tri = triangle();
let tb = tri.bounding_box();
let spiked = st.bounds_on(&tri, tb);
assert!(spiked.x1 > 100.0 + 10.0, "{spiked:?}");
let mut low = st.clone();
low.miter_limit = 2.0;
assert!(low.bounds_on(&tri, tb).x1 <= 100.0 + 2.0 + 1e-9, "beveled");
// Projecting caps reach past the ends; inside strokes stay in.
let l = line();
let cap = stroke(4.0, |s| s.cap = LineCap::Square).bounds_on(&l, l.bounding_box());
assert_eq!((cap.x0, cap.x1, cap.y1), (-2.0, 102.0, 2.0));
assert_eq!(stroke(4.0, |s| s.align = StrokeAlign::Inside).bounds_on(&sq, b), b);
assert_eq!(stroke(4.0, |s| s.align = StrokeAlign::Outside).bounds_on(&sq, b), b.inflate(4.0, 4.0));
}
#[test]
fn clicks_hit_outside_strokes_on_their_side_only() {
let sq = square();
let w = 10.0;
let st = stroke(w, |s| s.align = StrokeAlign::Outside);
assert!(st.hits(&sq, FillRule::NonZero, Point::new(-0.8 * w, 50.0), 0.5), "0.8·w outside");
assert!(!st.hits(&sq, FillRule::NonZero, Point::new(0.8 * w, 50.0), 0.5), "nothing painted inside");
assert!(st.hits(&sq, FillRule::NonZero, Point::new(0.3, 50.0), 0.5), "on the path");
let inside = stroke(w, |s| s.align = StrokeAlign::Inside);
assert!(inside.hits(&sq, FillRule::NonZero, Point::new(0.8 * w, 50.0), 0.5));
assert!(!inside.hits(&sq, FillRule::NonZero, Point::new(-0.8 * w, 50.0), 0.5));
// A centred stroke reaches half its weight each side.
let centre = stroke(w, |_| {});
assert!(centre.hits(&sq, FillRule::NonZero, Point::new(-4.0, 50.0), 0.5));
assert!(!centre.hits(&sq, FillRule::NonZero, Point::new(-8.0, 50.0), 0.5));
}
#[test]
fn clicks_follow_the_width_profile() {
let l = line();
let lens = stroke(10.0, |s| s.profile = Some(WidthProfile::lens()));
assert!(lens.hits(&l, FillRule::NonZero, Point::new(50.0, 4.8), 0.1), "the lens's widest point");
assert!(!lens.hits(&l, FillRule::NonZero, Point::new(5.0, 4.8), 0.1), "its thin end");
// A profile wider than the weight on one side.
let wide = stroke(10.0, |s| s.profile = Some(WidthProfile { points: vec![(0.0, 3.0, 1.0), (1.0, 3.0, 1.0)] }));
// Left of travel (+x, y down) is −y.
assert!(wide.hits(&l, FillRule::NonZero, Point::new(50.0, -14.0), 0.1));
assert!(!wide.hits(&l, FillRule::NonZero, Point::new(50.0, 14.0), 0.1));
assert!(wide.bounds_on(&l, l.bounding_box()).y0 <= -15.0);
}
#[test]
fn clicks_hit_heads_caps_and_spikes() {
let l = line();
let head = stroke(2.0, |s| {
s.end_arrow = Some(Arrowhead::Triangle);
s.arrow_scale = (100.0, 300.0);
});
// The 24 pt head sits past the end in extend mode, 12 pt either side of the line.
assert!(head.hits(&l, FillRule::NonZero, Point::new(110.0, 6.0), 0.1));
assert!(!head.hits(&l, FillRule::NonZero, Point::new(50.0, 6.0), 0.1));
let mut tip = head.clone();
tip.arrow_align = ArrowAlign::Tip;
assert!(tip.hits(&l, FillRule::NonZero, Point::new(90.0, 6.0), 0.1) && !tip.hits(&l, FillRule::NonZero, Point::new(130.0, 0.0), 0.1));
let cap = stroke(10.0, |s| s.cap = LineCap::Square);
assert!(cap.hits(&l, FillRule::NonZero, Point::new(104.0, 4.5), 0.1), "the cap's corner");
assert!(!stroke(10.0, |_| {}).hits(&l, FillRule::NonZero, Point::new(104.0, 4.5), 0.1), "a butt end");
let tri = triangle();
let spike = stroke(4.0, |_| {});
assert!(spike.hits(&tri, FillRule::NonZero, Point::new(106.0, 20.0), 0.1), "in the miter spike");
assert!(!spike.hits(&tri, FillRule::NonZero, Point::new(106.0, 23.0), 0.1));
}
#[test]
fn document_hit_testing_and_visual_bounds_use_the_whole_stroke() {
let mut d = Document::new(300.0, 300.0);
let (l, id) = (d.layers[0].id, d.alloc_id());
let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 10.0);
ap.stroke_mut().unwrap().align = StrokeAlign::Outside;
let sq = PathData::from_bezpath(&shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)).to_bezpath());
d.insert(Some(l), 0, Node::path(id, sq, ap)).unwrap();
let opt = hit::HitOptions { tol: 1.0, ..Default::default() };
let h = hit::hit_test(&d, Point::new(92.0, 150.0), opt).expect("0.8·w outside the path");
assert_eq!((h.leaf, h.kind), (id, hit::HitKind::Stroke));
assert!(hit::hit_test(&d, Point::new(108.0, 150.0), opt).is_none(), "an unfilled interior");
assert_eq!(d.node(id).unwrap().visual_bounds(), Some(Rect::new(90.0, 90.0, 210.0, 210.0)));
}
}
+63
View File
@@ -19,6 +19,16 @@ pub struct Selection {
/// Key object for Align.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key: Option<NodeId>,
/// The object, group or layer targeted through its target circle in the Layers panel, which
/// appearance, transparency and opacity-mask commands then act on (a targeted layer has its
/// art selected). Any other selection change clears it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target: Option<NodeId>,
/// Slices selected with the Slice Selection tool: user slice ids and the ids of objects whose
/// object slice is selected (see [`crate::slices`]). Any change to the object selection
/// clears them.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub slices: Vec<NodeId>,
}
impl Selection {
@@ -35,6 +45,8 @@ impl Selection {
self.objects.clear();
self.anchors.clear();
self.key = None;
self.target = None;
self.slices.clear();
}
pub fn set(&mut self, ids: impl IntoIterator<Item = NodeId>) {
self.clear();
@@ -43,17 +55,30 @@ impl Selection {
}
}
pub fn add(&mut self, id: NodeId) {
self.target = None;
self.slices.clear();
if !self.objects.contains(&id) {
self.objects.push(id);
}
}
pub fn remove(&mut self, id: NodeId) {
self.target = None;
self.slices.clear();
self.objects.retain(|x| *x != id);
self.anchors.remove(&id);
if self.key == Some(id) {
self.key = None;
}
}
/// Select slices `ids` (see [`Selection::slices`]) and nothing else.
pub fn set_slices(&mut self, ids: impl IntoIterator<Item = NodeId>) {
self.clear();
for id in ids {
if !self.slices.contains(&id) {
self.slices.push(id);
}
}
}
pub fn toggle(&mut self, id: NodeId) {
if self.contains(id) { self.remove(id) } else { self.add(id) }
}
@@ -68,6 +93,27 @@ impl Selection {
if self.key.is_some_and(|k| doc.node(k).is_none()) {
self.key = None;
}
if self.target.is_some_and(|t| doc.node(t).is_none()) {
self.target = None;
}
self.slices.retain(|id| doc.is_slice(*id));
}
/// Target `id` (see [`Selection::target`]): a layer gets its visible, unlocked art selected,
/// anything else is selected itself.
pub fn set_target(&mut self, doc: &Document, id: NodeId) {
match doc.node(id) {
Some(n) if n.is_layer() => self.set(n.children().into_iter().flatten().filter(|c| c.visible && !c.locked).map(|c| c.id)),
_ => self.set([id]),
}
self.target = Some(id);
}
/// What appearance and transparency edits act on: the targeted object, group or layer, else
/// the selected objects.
pub fn subjects(&self) -> &[NodeId] {
match &self.target {
Some(t) => std::slice::from_ref(t),
None => &self.objects,
}
}
/// Top-level ordering: selected ids sorted by paint order (bottom first).
pub fn in_paint_order(&self, doc: &Document) -> Vec<NodeId> {
@@ -93,4 +139,21 @@ mod tests {
s.clear();
assert!(s.is_empty());
}
#[test]
fn selection_changes_clear_the_target() {
let mut s = Selection::default();
s.set([NodeId(2), NodeId(3)]);
s.target = Some(NodeId(1));
assert_eq!(s.subjects(), &[NodeId(1)]);
s.add(NodeId(4));
assert_eq!(s.target, None);
assert_eq!(s.subjects(), &[NodeId(2), NodeId(3), NodeId(4)]);
s.target = Some(NodeId(1));
s.remove(NodeId(2));
assert_eq!(s.target, None);
s.target = Some(NodeId(1));
s.set([NodeId(3)]);
assert_eq!(s.target, None);
}
}
+317
View File
@@ -0,0 +1,317 @@
//! Document Setup (File → Document Setup): the bleed around the artboards, view options
//! (transparency grid, simulated paper, images in outline mode, substitution highlights), output
//! options and the document's typographic defaults. Every field is defaulted, so documents saved
//! before Document Setup existed load unchanged.
use serde::{Deserialize, Serialize};
use vectorcraft_color::Color;
use vectorcraft_geom::Rect;
use crate::text::ScriptMetrics;
/// Transparency grid cell size.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum GridSize {
Small,
#[default]
Medium,
Large,
}
impl GridSize {
pub const ALL: [GridSize; 3] = [GridSize::Small, GridSize::Medium, GridSize::Large];
pub fn id(self) -> &'static str {
match self {
GridSize::Small => "small",
GridSize::Medium => "medium",
GridSize::Large => "large",
}
}
pub fn label(self) -> &'static str {
match self {
GridSize::Small => "Small",
GridSize::Medium => "Medium",
GridSize::Large => "Large",
}
}
pub fn parse(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|g| g.id().eq_ignore_ascii_case(s))
}
/// Edge of one grid cell in screen points (the grid does not scale with the zoom).
pub fn cell(self) -> f32 {
match self {
GridSize::Small => 4.0,
GridSize::Medium => 8.0,
GridSize::Large => 16.0,
}
}
}
const fn hex(v: u32) -> Color {
Color::Rgb { r: ((v >> 16) & 0xff) as f32 / 255.0, g: ((v >> 8) & 0xff) as f32 / 255.0, b: (v & 0xff) as f32 / 255.0 }
}
/// Transparency grid colour presets (VectorCraft's own pairs). Any other pair is "Custom".
pub const GRID_COLOR_PRESETS: &[(&str, [Color; 2])] = &[
("Light", [hex(0xffffff), hex(0xcccccc)]),
("Medium", [hex(0xb3b3b3), hex(0x8c8c8c)]),
("Dark", [hex(0x666666), hex(0x4d4d4d)]),
("Red", [hex(0xffffff), hex(0xffc7c7)]),
("Orange", [hex(0xffffff), hex(0xffdcb8)]),
("Green", [hex(0xffffff), hex(0xc6efc6)]),
("Blue", [hex(0xffffff), hex(0xc4dcff)]),
("Purple", [hex(0xffffff), hex(0xe2c9ff)]),
];
/// How text is written to formats without live type (Document Setup → Type → Export).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ExportText {
/// Keep text as text (editable).
#[default]
Editable,
/// Write glyph outlines (exact appearance without the fonts).
Appearance,
}
impl ExportText {
pub fn id(self) -> &'static str {
match self {
ExportText::Editable => "editable",
ExportText::Appearance => "appearance",
}
}
pub fn parse(s: &str) -> Option<Self> {
[ExportText::Editable, ExportText::Appearance].into_iter().find(|e| e.id().eq_ignore_ascii_case(s))
}
}
/// Quote characters (Document Setup → Type): opening and closing double and single quotes.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Quotes {
pub double: [char; 2],
pub single: [char; 2],
}
impl Default for Quotes {
fn default() -> Self {
Self::ENGLISH
}
}
impl Quotes {
pub const ENGLISH: Self = Self { double: ['“', '”'], single: ['‘', '’'] };
/// Double-quote pairs offered by Document Setup.
pub const DOUBLE_CHOICES: &[[char; 2]] = &[['“', '”'], ['„', '“'], ['„', '”'], ['”', '”'], ['«', '»'], ['»', '«'], ['「', '」'], ['"', '"']];
/// Single-quote pairs offered by Document Setup.
pub const SINGLE_CHOICES: &[[char; 2]] = &[['‘', '’'], ['‚', '‘'], ['‚', '’'], ['’', '’'], ['‹', '›'], ['›', '‹'], ['『', '』'], ['\'', '\'']];
/// Is `c` one of these opening quotes?
fn is_open(&self, c: char) -> bool {
c == self.double[0] || c == self.single[0]
}
/// A quote opens after nothing, whitespace, an opening bracket, a dash or another opening quote.
fn opens(&self, prev: Option<char>) -> bool {
prev.is_none_or(|p| p.is_whitespace() || matches!(p, '(' | '[' | '{' | '—' | '–') || self.is_open(p))
}
/// `text` with straight quotes (`"` and `'`) replaced by these quotes: opening or closing by
/// context (an apostrophe is a closing single quote). `prev` is the character before `text`,
/// and is updated to its last character, so text can be converted in pieces.
pub fn apply(&self, text: &str, prev: &mut Option<char>) -> String {
let mut out = String::with_capacity(text.len());
for c in text.chars() {
let r = match c {
'"' => self.double[usize::from(!self.opens(*prev))],
'\'' => self.single[usize::from(!self.opens(*prev))],
c => c,
};
out.push(r);
*prev = Some(r);
}
out
}
}
/// Document languages and the quote characters each uses (choosing a language in Document Setup
/// also picks its quotes).
pub const LANGUAGES: &[(&str, Quotes)] = &[
("English: USA", Quotes::ENGLISH),
("English: UK", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
("German", Quotes { double: ['„', '“'], single: ['‚', '‘'] }),
("German: Swiss", Quotes { double: ['«', '»'], single: ['‹', '›'] }),
("French", Quotes { double: ['«', '»'], single: ['‹', '›'] }),
("Italian", Quotes { double: ['«', '»'], single: ['‘', '’'] }),
("Spanish", Quotes { double: ['«', '»'], single: ['‘', '’'] }),
("Portuguese", Quotes { double: ['«', '»'], single: ['‘', '’'] }),
("Dutch", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
("Danish", Quotes { double: ['»', '«'], single: ['›', '‹'] }),
("Swedish", Quotes { double: ['”', '”'], single: ['’', '’'] }),
("Finnish", Quotes { double: ['”', '”'], single: ['’', '’'] }),
("Norwegian", Quotes { double: ['«', '»'], single: ['‘', '’'] }),
("Polish", Quotes { double: ['„', '”'], single: ['‚', '’'] }),
("Czech", Quotes { double: ['„', '“'], single: ['‚', '‘'] }),
("Russian", Quotes { double: ['«', '»'], single: ['„', '“'] }),
("Japanese", Quotes { double: ['「', '」'], single: ['『', '』'] }),
("Chinese", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
];
/// The quotes of a [`LANGUAGES`] entry (case-insensitive).
pub fn language_quotes(language: &str) -> Option<Quotes> {
LANGUAGES.iter().find(|(l, _)| l.eq_ignore_ascii_case(language)).map(|(_, q)| *q)
}
/// The transparency flattener preset Document Setup uses unless another is chosen.
pub const DEFAULT_FLATTENER: &str = "Medium Resolution";
/// Background Contents (New Document): what shows behind the art on the artboards, and what
/// raster exports put there.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Background {
#[default]
Transparent,
/// A white artboard background (not an object): raster exports are opaque white there.
White,
}
impl Background {
pub const ALL: [Background; 2] = [Background::Transparent, Background::White];
pub fn id(self) -> &'static str {
match self {
Background::Transparent => "transparent",
Background::White => "white",
}
}
pub fn parse(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|b| b.id().eq_ignore_ascii_case(s))
}
}
/// Largest bleed on any side, in points (1 inch).
pub const MAX_BLEED: f64 = 72.0;
/// File → Document Setup.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct DocSetup {
/// Bleed beyond every artboard edge in points: top, bottom, left, right.
pub bleed: [f64; 4],
/// Show Images in Outline Mode.
pub outline_images: bool,
/// Highlight text whose font is missing (drawn in a substitute font).
pub highlight_substituted_fonts: bool,
/// Highlight glyphs the font lacks (drawn from a fallback font).
pub highlight_substituted_glyphs: bool,
/// Transparency grid cell size.
pub grid_size: GridSize,
/// Transparency grid colours; the first is also the simulated paper colour.
pub grid_colors: [Color; 2],
/// Simulate Colored Paper: artboards show the paper colour and art is multiplied onto it.
pub simulate_paper: bool,
/// Transparency flattener preset for output that can't keep transparency (None =
/// [`DEFAULT_FLATTENER`]).
pub flattener_preset: Option<String>,
/// Discard White Overprint in Output.
pub discard_white_overprint: bool,
/// Document language (one of [`LANGUAGES`]).
pub language: String,
pub quotes: Quotes,
/// Use Typographer's Quotes: typed straight quotes become [`DocSetup::quotes`].
pub typographers_quotes: bool,
pub superscript: ScriptMetrics,
pub subscript: ScriptMetrics,
/// Synthesized small capitals, percent of the font size.
pub small_caps_size: f64,
pub export_text: ExportText,
/// Background Contents chosen in New Document.
pub background: Background,
}
impl Default for DocSetup {
fn default() -> Self {
Self {
bleed: [0.0; 4],
outline_images: false,
highlight_substituted_fonts: true,
highlight_substituted_glyphs: false,
grid_size: GridSize::Medium,
grid_colors: GRID_COLOR_PRESETS[0].1,
simulate_paper: false,
flattener_preset: None,
discard_white_overprint: true,
language: LANGUAGES[0].0.to_string(),
quotes: Quotes::ENGLISH,
typographers_quotes: true,
superscript: ScriptMetrics::DEFAULT,
subscript: ScriptMetrics::DEFAULT,
small_caps_size: 70.0,
export_text: ExportText::Editable,
background: Background::Transparent,
}
}
}
impl DocSetup {
/// `artboard` grown by the bleed.
pub fn bleed_rect(&self, artboard: Rect) -> Rect {
crate::marks::outset(artboard, self.bleed)
}
pub fn has_bleed(&self) -> bool {
self.bleed.iter().any(|b| *b > 0.0)
}
/// The simulated paper colour.
pub fn paper(&self) -> Color {
self.grid_colors[0]
}
/// The name of the grid colour preset in use, or "Custom".
pub fn grid_colors_name(&self) -> &'static str {
GRID_COLOR_PRESETS.iter().find(|(_, c)| *c == self.grid_colors).map_or("Custom", |(n, _)| n)
}
/// The flattener preset in use.
pub fn flattener(&self) -> &str {
self.flattener_preset.as_deref().unwrap_or(DEFAULT_FLATTENER)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quotes_open_and_close_by_context() {
let mut prev = None;
assert_eq!(Quotes::ENGLISH.apply("\"Hi,\" it's 'x'", &mut prev), "“Hi,” it’s ‘x’");
let german = language_quotes("german").unwrap();
let mut prev = Some('(');
assert_eq!(german.apply("\"a\"", &mut prev), "„a“");
assert_eq!(prev, Some('“'));
// Converting in pieces keeps the context.
let mut prev = None;
let a = Quotes::ENGLISH.apply("\"", &mut prev);
let b = Quotes::ENGLISH.apply("\"", &mut prev);
assert_eq!((a.as_str(), b.as_str()), ("“", "“"));
}
#[test]
fn bleed_grows_the_artboard() {
let s = DocSetup { bleed: [9.0, 9.0, 4.0, 2.0], ..Default::default() };
assert_eq!(s.bleed_rect(Rect::new(0.0, 0.0, 100.0, 50.0)), Rect::new(-4.0, -9.0, 102.0, 59.0));
assert!(s.has_bleed() && !DocSetup::default().has_bleed());
}
#[test]
fn presets_and_defaults() {
let d = DocSetup::default();
assert_eq!(d.grid_colors_name(), "Light");
assert_eq!(d.flattener(), "Medium Resolution");
assert_eq!(Background::parse("WHITE"), Some(Background::White));
assert_eq!(GridSize::parse("LARGE"), Some(GridSize::Large));
assert_eq!(ExportText::parse("appearance"), Some(ExportText::Appearance));
// A partial setup fills in the rest.
let s: DocSetup = serde_json::from_str(r#"{"bleed":[1,2,3,4]}"#).unwrap();
assert_eq!(s, DocSetup { bleed: [1.0, 2.0, 3.0, 4.0], ..DocSetup::default() });
}
}
+315
View File
@@ -0,0 +1,315 @@
//! Slices (Object → Slice): rectangles that cut the artwork into the pieces web output saves.
//!
//! User slices are rectangles of their own ([`Document::slices`]); object slices follow the
//! bounds of an object with [`Node::slice`] options; auto slices fill the rest of the slice region
//! (the artboards with Clip to Artboard on, else the art and the slices) and are never stored.
//! User slice ids come from the document's id counter, so a slice id never names an object and
//! one id list can hold both kinds (an object slice goes by its object's id).
//!
//! Slices are numbered left to right, top to bottom, auto slices included.
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use vectorcraft_geom::Rect;
use crate::{Document, Node, NodeId};
/// Below this a slice edge or size is no edge or size (points).
const EPS: f64 = 1e-6;
/// Enums whose values have a stable key (params, JSON) and a label (dialogs).
macro_rules! keyed {
($(#[$m:meta])* $name:ident { $($(#[$vm:meta])* $v:ident = ($key:literal, $label:literal)),+ $(,)? }) => {
$(#[$m])*
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum $name {
$($(#[$vm])* $v,)+
}
impl $name {
pub const ALL: &'static [$name] = &[$($name::$v),+];
/// The value params and JSON use (`noImage`).
pub fn key(self) -> &'static str {
match self {
$($name::$v => $key,)+
}
}
/// The dialog label (`No Image`).
pub fn label(self) -> &'static str {
match self {
$($name::$v => $label,)+
}
}
/// The value `s` names: its key or label in any case.
pub fn parse(s: &str) -> Option<Self> {
let s = s.trim();
Self::ALL.iter().copied().find(|v| v.key().eq_ignore_ascii_case(s) || v.label().eq_ignore_ascii_case(s))
}
}
};
}
keyed!(
/// Slice Options → Slice Type: what web output puts in the slice's cell.
SliceKind {
/// The artwork as an image (with a link, alt text and a status message).
#[default]
Image = ("image", "Image"),
/// An empty cell showing `text` (HTML allowed) on the background.
NoImage = ("noImage", "No Image"),
/// The text of the sliced type object as HTML (object slices of type only).
HtmlText = ("htmlText", "HTML Text"),
}
);
keyed!(
/// Horizontal alignment of a No Image / HTML Text cell's text.
CellAlign {
#[default]
Default = ("default", "Default"),
Left = ("left", "Left"),
Center = ("center", "Center"),
Right = ("right", "Right"),
}
);
keyed!(
/// Vertical alignment of a No Image / HTML Text cell's text.
CellVAlign {
#[default]
Default = ("default", "Default"),
Top = ("top", "Top"),
Middle = ("middle", "Middle"),
Baseline = ("baseline", "Baseline"),
Bottom = ("bottom", "Bottom"),
}
);
/// Slice Options: how web output writes a slice.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct SliceOptions {
#[serde(skip_serializing_if = "crate::skip::is_default")]
pub kind: SliceKind,
/// The slice's file name in web output; empty: `<document>_<number>` ([`Document::slice_name`]).
#[serde(skip_serializing_if = "String::is_empty")]
pub name: String,
/// Image: the link the slice's image opens.
#[serde(skip_serializing_if = "String::is_empty")]
pub url: String,
/// Image: the frame the link opens in (`_blank`, `_self`, `_parent`, `_top` or a name).
#[serde(skip_serializing_if = "String::is_empty")]
pub target: String,
/// Image: the browser's status message over the slice.
#[serde(skip_serializing_if = "String::is_empty")]
pub message: String,
/// Image: the alternative text.
#[serde(skip_serializing_if = "String::is_empty")]
pub alt: String,
/// No Image: the text the cell shows (HTML allowed).
#[serde(skip_serializing_if = "String::is_empty")]
pub text: String,
/// The cell's background: empty (none), `matte` or `#rrggbb`.
#[serde(skip_serializing_if = "String::is_empty")]
pub background: String,
#[serde(skip_serializing_if = "crate::skip::is_default")]
pub h_align: CellAlign,
#[serde(skip_serializing_if = "crate::skip::is_default")]
pub v_align: CellVAlign,
}
/// A user slice: a rectangle drawn with the Slice tool or made by an Object → Slice command.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Slice {
/// From the document's id counter (never an object's id).
pub id: NodeId,
pub rect: Rect,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub options: SliceOptions,
}
/// Where a slice comes from.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum SliceSource {
/// A [`Slice`] of the document.
User,
/// The bounds of an object with slice options.
Object,
/// Generated over what no other slice covers.
Auto,
}
impl SliceSource {
pub fn key(self) -> &'static str {
match self {
SliceSource::User => "user",
SliceSource::Object => "object",
SliceSource::Auto => "auto",
}
}
}
/// One slice as laid out: its id (none for auto slices), source, rectangle (clipped to the
/// artboards with Clip to Artboard on) and number (1-based).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SliceArea {
pub id: Option<NodeId>,
pub source: SliceSource,
pub rect: Rect,
pub number: usize,
}
/// A rectangle with a size and finite coordinates.
pub fn valid_rect(r: Rect) -> bool {
[r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) && r.width() > EPS && r.height() > EPS
}
impl Document {
/// The user slice `id`.
pub fn slice(&self, id: NodeId) -> Option<&Slice> {
self.slices.iter().find(|s| s.id == id)
}
pub fn slice_mut(&mut self, id: NodeId) -> Option<&mut Slice> {
self.slices.iter_mut().find(|s| s.id == id)
}
/// Is `id` a user slice or an object with slice options?
pub fn is_slice(&self, id: NodeId) -> bool {
self.slice(id).is_some() || self.node(id).is_some_and(|n| n.slice.is_some())
}
/// The options of slice `id` (user or object).
pub fn slice_options(&self, id: NodeId) -> Option<&SliceOptions> {
match self.slice(id) {
Some(s) => Some(&s.options),
None => self.node(id)?.slice.as_deref(),
}
}
/// The rectangle of slice `id` before clipping: a user slice's own, an object slice's object's
/// visual bounds.
pub fn slice_bounds(&self, id: NodeId) -> Option<Rect> {
match self.slice(id) {
Some(s) => Some(s.rect),
None => self.node(id).filter(|n| n.slice.is_some())?.visual_bounds(),
}
}
/// The visible objects with slice options and their visual bounds, in paint order.
pub fn object_slices(&self) -> Vec<(NodeId, Rect)> {
fn walk(nodes: &[Arc<Node>], out: &mut Vec<(NodeId, Rect)>) {
for n in nodes.iter().filter(|n| n.visible) {
if n.slice.is_some()
&& let Some(b) = n.visual_bounds()
{
out.push((n.id, b));
}
if let Some(ch) = n.children() {
walk(ch, out);
}
}
}
let mut out = vec![];
walk(&self.layers, &mut out);
out
}
/// Every slice id: the user slices, then the object slices.
pub fn slice_ids(&self) -> Vec<NodeId> {
self.slices.iter().map(|s| s.id).chain(self.object_slices().into_iter().map(|(id, _)| id)).collect()
}
/// The box Clip to Artboard clips slices to: the artboards' bounds.
pub fn slice_clip(&self) -> Option<Rect> {
self.artboards.iter().map(|a| a.rect).reduce(|a, b| a.union(b))
}
/// The user and object slices with their rectangles as laid out (clipped to [`Self::slice_clip`]
/// when Clip to Artboard is on; one wholly outside stays as it is).
fn cut_slices(&self) -> Vec<(NodeId, SliceSource, Rect)> {
let clip = self.slices_clip_to_artboard.then(|| self.slice_clip()).flatten();
let clipped = |r: Rect| clip.map(|c| c.intersect(r)).filter(|c| valid_rect(*c)).unwrap_or(r);
let users = self.slices.iter().map(|s| (s.id, SliceSource::User, s.rect));
let objects = self.object_slices().into_iter().map(|(id, r)| (id, SliceSource::Object, r));
users.chain(objects).map(|(id, src, r)| (id, src, clipped(r))).collect()
}
/// The slices as laid out, numbered left to right, top to bottom: the user and object slices
/// and the auto slices filling the rest of the region (the artboards with Clip to Artboard on,
/// else the visible art and the slices). Empty while the document has no user or object slice.
pub fn slice_layout(&self) -> Vec<SliceArea> {
let cut = self.cut_slices();
if cut.is_empty() {
return vec![];
}
let region = if self.slices_clip_to_artboard { self.slice_clip() } else { None }
.or_else(|| cut.iter().fold(self.art_bounds(), |acc, (_, _, r)| vectorcraft_geom::union_opt(acc, Some(*r))));
let rects: Vec<Rect> = cut.iter().map(|(_, _, r)| *r).collect();
let autos = region.map(|g| auto_slices(g, &rects)).unwrap_or_default();
let mut out: Vec<SliceArea> = cut
.into_iter()
.map(|(id, source, rect)| SliceArea { id: Some(id), source, rect, number: 0 })
.chain(autos.into_iter().map(|rect| SliceArea { id: None, source: SliceSource::Auto, rect, number: 0 }))
.collect();
out.sort_by(|a, b| a.rect.y0.total_cmp(&b.rect.y0).then(a.rect.x0.total_cmp(&b.rect.x0)));
for (i, a) in out.iter_mut().enumerate() {
a.number = i + 1;
}
out
}
/// The name web output gives slice `area`: its options' name, else `<document>_<number>`
/// (two digits at least, `Untitled-1_03`).
pub fn slice_name(&self, area: &SliceArea) -> String {
match area.id.and_then(|id| self.slice_options(id)).filter(|o| !o.name.is_empty()) {
Some(o) => o.name.clone(),
None => {
let stem = self.title.rsplit_once('.').map_or(self.title.as_str(), |(s, _)| s);
format!("{stem}_{:02}", area.number)
}
}
}
}
/// The auto slices of `region`: rectangles covering what none of `cut` covers, without
/// overlapping each other or `cut`. The region is cut into rows at every slice's top and bottom;
/// each row's uncovered spans become slices, and a span continuing the one above it (same left and
/// right) extends it.
pub fn auto_slices(region: Rect, cut: &[Rect]) -> Vec<Rect> {
if !valid_rect(region) {
return vec![];
}
let inside: Vec<Rect> = cut.iter().map(|r| region.intersect(*r)).filter(|r| valid_rect(*r)).collect();
let mut ys: Vec<f64> = inside.iter().flat_map(|r| [r.y0, r.y1]).chain([region.y0, region.y1]).collect();
ys.sort_by(f64::total_cmp);
ys.dedup_by(|a, b| (*a - *b).abs() <= EPS);
let (mut open, mut done): (Vec<Rect>, Vec<Rect>) = (vec![], vec![]);
for band in ys.windows(2) {
let [y0, y1] = [band[0], band[1]];
let mid = (y0 + y1) / 2.0;
let mut spans: Vec<(f64, f64)> = inside.iter().filter(|r| r.y0 < mid && mid < r.y1).map(|r| (r.x0, r.x1)).collect();
spans.sort_by(|a, b| a.0.total_cmp(&b.0));
// The uncovered spans of this row.
let mut free = vec![];
let mut x = region.x0;
for (a, b) in spans {
if a - x > EPS {
free.push((x, a));
}
x = x.max(b);
}
if region.x1 - x > EPS {
free.push((x, region.x1));
}
// Spans continuing an open slice extend it; the others close.
let mut next = Vec::with_capacity(free.len());
for (a, b) in free {
match open.iter().position(|r: &Rect| (r.x0 - a).abs() <= EPS && (r.x1 - b).abs() <= EPS) {
Some(i) => {
let mut r = open.swap_remove(i);
r.y1 = y1;
next.push(r);
}
None => next.push(Rect::new(a, y0, b, y1)),
}
}
done.append(&mut open);
open = next;
}
done.append(&mut open);
done
}
+511
View File
@@ -0,0 +1,511 @@
//! Graphic style libraries: named, read-only sets of graphic styles (Window → Graphic Style
//! Libraries). The built-in ones are generated here in code ([`STYLE_LIBRARIES`]); user libraries
//! are `.vcstyles` files ([`write`], [`read`]): JSON holding the styles and the pattern
//! definitions they paint with.
//!
//! Library styles stand alone: no id, and no links to the swatches of the document they came from
//! ([`StyleLibrary::from_document`]).
use std::sync::{Arc, OnceLock};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use vectorcraft_color::{BlendMode, Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
use vectorcraft_geom::Point;
use crate::{
Appearance, AppearanceItem, Arrowhead, Dash, Document, Effect, FillLayer, GraphicStyle, Knockout, LineCap, LineJoin, PatternDef, StrokeAlign,
StrokeLayer, WidthProfile,
};
/// A graphic style library: its name, its styles and the patterns they paint with.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct StyleLibrary {
pub name: String,
#[serde(default)]
pub styles: Vec<GraphicStyle>,
/// The pattern swatches the styles' fills and strokes paint with.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub patterns: Vec<PatternDef>,
}
impl StyleLibrary {
pub fn len(&self) -> usize {
self.styles.len()
}
pub fn is_empty(&self) -> bool {
self.styles.is_empty()
}
pub fn style(&self, name: &str) -> Option<&GraphicStyle> {
self.styles.iter().find(|g| g.name == name)
}
/// The document's styles `names` (empty: all of them, in panel order) as a library named
/// `name`, standing alone ([`GraphicStyle::standalone`]) with the patterns they paint with.
pub fn from_document(d: &Document, names: &[String], name: String) -> Result<Self, String> {
if let Some(n) = names.iter().find(|n| d.graphic_style(n).is_none()) {
return Err(format!("no graphic style `{n}`"));
}
let styles: Vec<GraphicStyle> =
d.graphic_styles.iter().filter(|g| names.is_empty() || names.contains(&g.name)).map(GraphicStyle::standalone).collect();
let mut patterns: Vec<PatternDef> = vec![];
for p in styles.iter().flat_map(GraphicStyle::patterns) {
if !patterns.iter().any(|x| x.name == p)
&& let Some(def) = d.pattern(p)
{
patterns.push(def.clone());
}
}
Ok(Self { name, styles, patterns })
}
}
impl GraphicStyle {
/// Do both styles look the same (appearance and transparency; names and ids aside)?
pub fn same_look(&self, other: &GraphicStyle) -> bool {
(&self.appearance, self.opacity, self.blend, self.isolate, self.knockout, self.unit_box)
== (&other.appearance, other.opacity, other.blend, other.isolate, other.knockout, other.unit_box)
}
/// The style as a library holds it: no id, and its paints unlinked from swatches.
pub fn standalone(&self) -> GraphicStyle {
let mut g = GraphicStyle { id: 0, ..self.clone() };
for it in &mut g.appearance.items {
match it.paint_mut() {
// The colour shown stays, unlinked.
Paint::Solid { swatch, tint, .. } => (*swatch, *tint) = (None, 1.0),
Paint::Gradient(gp) => {
gp.swatch = None;
gp.gradient.stops.iter_mut().for_each(|st| st.set_color(st.color, None));
}
Paint::None | Paint::Pattern { .. } => {}
}
}
g
}
/// The names of the patterns the style's fills and strokes paint with.
pub fn patterns(&self) -> impl Iterator<Item = &str> {
self.appearance.items.iter().filter_map(|it| match it.paint() {
Paint::Pattern { pattern, .. } => Some(pattern.as_str()),
_ => None,
})
}
}
// ---------- .vcstyles files ----------
/// The extension (and `format` header) of graphic style library files.
pub const STYLES_EXT: &str = "vcstyles";
/// The file: a header around the library.
#[derive(Serialize, Deserialize)]
struct NativeFile {
format: String,
version: u32,
#[serde(flatten)]
library: StyleLibrary,
}
/// `lib` as a `.vcstyles` file.
pub fn write(lib: &StyleLibrary) -> String {
let file = NativeFile { format: STYLES_EXT.into(), version: 1, library: lib.clone() };
serde_json::to_string_pretty(&file).unwrap_or_default()
}
/// Read a `.vcstyles` file; an unnamed library is called `name`.
pub fn read(text: &str, name: &str) -> Result<StyleLibrary, String> {
let f: NativeFile = serde_json::from_str(text.trim_start_matches('\u{feff}')).map_err(|e| format!("not a graphic style library: {e}"))?;
if f.format != STYLES_EXT {
return Err(format!("not a graphic style library (format `{}`)", f.format));
}
let mut lib = f.library;
if lib.name.trim().is_empty() {
lib.name = name.into();
}
Ok(lib)
}
/// Does `text` look like a `.vcstyles` file (its header names the format)?
pub fn sniff(text: &str) -> bool {
let t = text.trim_start_matches('\u{feff}').trim_start();
let head = t.char_indices().nth(256).map_or(t, |(i, _)| &t[..i]);
t.starts_with('{') && head.contains(&format!("\"{STYLES_EXT}\""))
}
// ---------- built-in libraries ----------
/// A built-in library: a stable id, its menu name and the function that makes its styles.
pub struct BuiltinStyleLibrary {
pub id: &'static str,
pub name: &'static str,
make: fn() -> Vec<GraphicStyle>,
}
/// The built-in graphic style libraries, in menu order.
pub const STYLE_LIBRARIES: &[BuiltinStyleLibrary] = &[
BuiltinStyleLibrary { id: "shadows-glows", name: "Shadows and Glows", make: shadows_glows },
BuiltinStyleLibrary { id: "outlines-rules", name: "Outlines and Rules", make: outlines_rules },
BuiltinStyleLibrary { id: "hand-drawn", name: "Hand-Drawn", make: hand_drawn },
BuiltinStyleLibrary { id: "gradient-finishes", name: "Gradient Finishes", make: gradient_finishes },
BuiltinStyleLibrary { id: "shape-effects", name: "Shape Effects", make: shape_effects },
BuiltinStyleLibrary { id: "blends-transparency", name: "Blends and Transparency", make: blends_transparency },
];
/// Built-in library `id`, made once.
pub fn builtin_style_library(id: &str) -> Option<Arc<StyleLibrary>> {
static LIBS: OnceLock<Vec<Arc<StyleLibrary>>> = OnceLock::new();
let libs = LIBS.get_or_init(|| {
STYLE_LIBRARIES.iter().map(|b| Arc::new(StyleLibrary { name: b.name.into(), styles: (b.make)(), patterns: vec![] })).collect()
});
STYLE_LIBRARIES.iter().position(|b| b.id == id).map(|i| libs[i].clone())
}
fn hex(h: &str) -> Color {
Color::from_hex(h).unwrap_or(Color::BLACK)
}
fn solid(h: &str) -> Paint {
Paint::solid(hex(h))
}
fn fill(h: &str) -> FillLayer {
FillLayer::new(solid(h))
}
fn stroke(h: &str, width: f64) -> StrokeLayer {
StrokeLayer::new(solid(h), width)
}
/// A stroke with round caps and joins.
fn round(h: &str, width: f64) -> StrokeLayer {
StrokeLayer { cap: LineCap::Round, join: LineJoin::Round, ..stroke(h, width) }
}
/// Live effect `id` with `params` (the others keep the effect's defaults).
fn fx(id: &str, params: Value) -> Effect {
Effect { id: id.into(), params, visible: true }
}
/// A gradient of `(offset, colour, opacity)` stops.
fn gradient(kind: GradientKind, stops: &[(f32, &str, f32)]) -> Gradient {
Gradient { kind, stops: stops.iter().map(|&(offset, c, opacity)| GradientStop { opacity, ..GradientStop::new(offset, hex(c)) }).collect() }
}
/// A linear gradient fitted to each object's box at `angle` degrees (90: upwards).
fn linear(angle: f64, stops: &[(f32, &str, f32)]) -> Paint {
Paint::Gradient(Box::new(GradientPaint { angle, ..GradientPaint::new(gradient(GradientKind::Linear, stops)) }))
}
/// A radial gradient placed in the unit box (each object gets it at the same place relative to its
/// bounds): centred at `(x, y)` with `radius`.
fn radial_at(x: f64, y: f64, radius: f64, stops: &[(f32, &str, f32)]) -> Paint {
let geom = GradientGeom { start: Point::new(x, y), end: Point::new(x + radius, y), aspect: 1.0, focal: None };
Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..GradientPaint::new(gradient(GradientKind::Radial, stops)) }))
}
/// A style of fills and strokes `items` (painted first to last) and object `effects`.
fn style(name: &str, items: Vec<AppearanceItem>, effects: Vec<Effect>) -> GraphicStyle {
GraphicStyle::new(name, Appearance { items, effects, contents_index: None })
}
use AppearanceItem::{Fill as F, Stroke as S};
fn shadows_glows() -> Vec<GraphicStyle> {
let shadow = |opacity: f64, x: f64, y: f64, blur: f64, color: &str| {
fx("stylize.dropShadow", json!({"mode": "multiply", "opacity": opacity, "x": x, "y": y, "blur": blur, "color": color}))
};
vec![
style("Soft Shadow", vec![F(fill("#f4f1ea"))], vec![shadow(45.0, 4.0, 4.0, 6.0, "#1d1b2a")]),
style(
"Hard Shadow",
vec![F(fill("#ffd166")), S(stroke("#1d1b2a", 2.0))],
vec![fx("stylize.dropShadow", json!({"mode": "normal", "opacity": 100.0, "x": 5.0, "y": 5.0, "blur": 0.0, "color": "#1d1b2a"}))],
),
style("Lifted Card", vec![F(fill("#ffffff")), S(stroke("#d9d9e3", 0.5))], vec![shadow(30.0, 0.0, 8.0, 12.0, "#2b2d42")]),
style(
"Halo",
vec![F(fill("#2b2d42"))],
vec![fx("stylize.outerGlow", json!({"mode": "normal", "opacity": 90.0, "blur": 8.0, "color": "#8ecae6"}))],
),
style(
"Neon Tube",
vec![S(round("#ff4fd8", 4.0)), S(round("#ffe8fb", 1.5))],
vec![fx("stylize.outerGlow", json!({"mode": "normal", "opacity": 85.0, "blur": 7.0, "color": "#ff4fd8"}))],
),
style(
"Inner Light",
vec![F(fill("#3a86ff"))],
vec![fx("stylize.innerGlow", json!({"mode": "screen", "opacity": 70.0, "blur": 9.0, "color": "#ffffff", "source": "center"}))],
),
style(
"Ember",
vec![F(fill("#e85d04"))],
vec![
fx("stylize.innerGlow", json!({"mode": "screen", "opacity": 80.0, "blur": 6.0, "color": "#ffba08", "source": "edge"})),
fx("stylize.outerGlow", json!({"mode": "normal", "opacity": 70.0, "blur": 5.0, "color": "#dc2f02"})),
],
),
style("Feathered", vec![F(fill("#8338ec"))], vec![fx("stylize.feather", json!({"radius": 6.0}))]),
style("Haze", vec![F(FillLayer { opacity: 0.8, ..fill("#06d6a0") })], vec![fx("blur.gaussian", json!({"radius": 3.0}))]),
]
}
fn outlines_rules() -> Vec<GraphicStyle> {
let dashed =
|h: &str, width: f64, pattern: Vec<f64>| StrokeLayer { dash: Some(Dash { pattern, offset: 0.0, align_corners: true }), ..round(h, width) };
let keyline = |h: &str, width: f64, offset: f64| StrokeLayer {
effects: vec![fx("path.offsetPath", json!({"offset": offset, "joins": "round"}))],
..stroke(h, width)
};
vec![
style("Double Rule", vec![F(fill("#ffffff")), S(stroke("#14213d", 6.0)), S(stroke("#ffffff", 3.0))], vec![]),
style("Triple Rule", vec![F(fill("#fefae0")), S(stroke("#283618", 10.0)), S(stroke("#fefae0", 6.0)), S(stroke("#283618", 2.0))], vec![]),
style("Dashed Border", vec![F(fill("#e9f5f2")), S(dashed("#264653", 2.0, vec![6.0, 4.0]))], vec![]),
style("Dotted Border", vec![F(fill("#fff4e6")), S(dashed("#e76f51", 3.0, vec![0.0, 6.0]))], vec![]),
style("Offset Keyline", vec![F(fill("#e9c46a")), S(keyline("#264653", 1.5, 4.0))], vec![]),
style("Inset Keyline", vec![F(fill("#2a9d8f")), S(keyline("#ffffff", 1.5, -4.0))], vec![]),
style("Outside Stroke", vec![F(fill("#f4a261")), S(StrokeLayer { align: StrokeAlign::Outside, ..stroke("#264653", 4.0) })], vec![]),
style("Tapered Ink", vec![S(StrokeLayer { profile: Some(WidthProfile::lens()), ..round("#1b1b1b", 5.0) })], vec![]),
style("Pointer", vec![S(StrokeLayer { end_arrow: Some(Arrowhead::Triangle), ..round("#e76f51", 2.0) })], vec![]),
style("Highlighter", vec![S(StrokeLayer { opacity: 0.6, blend: BlendMode::Multiply, ..round("#ffd60a", 10.0) })], vec![]),
]
}
fn hand_drawn() -> Vec<GraphicStyle> {
let rough = |h: &str, width: f64, seed: u32| StrokeLayer {
effects: vec![fx("distort.roughen", json!({"size": 1.5, "relative": true, "detail": 8.0, "points": "smooth", "seed": seed}))],
..round(h, width)
};
let scribble = |h: &str, angle: f64, opacity: f32| FillLayer {
opacity,
effects: vec![fx("stylize.scribble", json!({"angle": angle, "strokeWidth": 1.5, "spacing": 4.0, "curviness": 5.0, "variation": 0.5}))],
..fill(h)
};
vec![
style("Sketchy Line", vec![F(fill("#fefae0")), S(rough("#1d3557", 1.25, 1)), S(rough("#1d3557", 0.75, 7))], vec![]),
style("Scribble Fill", vec![F(scribble("#e63946", 30.0, 1.0)), S(rough("#1d3557", 1.25, 3))], vec![]),
style("Crosshatch", vec![F(scribble("#457b9d", 45.0, 0.8)), F(scribble("#457b9d", -45.0, 0.8)), S(rough("#1d3557", 1.0, 5))], vec![]),
style(
"Wobble",
vec![F(fill("#a8dadc")), S(round("#1d3557", 2.0))],
vec![fx("distort.tweak", json!({"h": 3.0, "v": 3.0, "relative": true, "seed": 5}))],
),
style(
"Zig Zag Edge",
vec![
F(fill("#f1faee")),
S(StrokeLayer {
effects: vec![fx("distort.zigZag", json!({"size": 3.0, "relative": false, "ridges": 6, "points": "corner"}))],
..stroke("#e63946", 1.5)
}),
],
vec![],
),
style(
"Wavy Edge",
vec![F(fill("#bde0fe")), S(round("#3a5a9b", 1.5))],
vec![fx("distort.zigZag", json!({"size": 3.0, "relative": false, "ridges": 5, "points": "smooth"}))],
),
style("Puffy", vec![F(fill("#ffc8dd")), S(round("#c9184a", 1.5))], vec![fx("distort.puckerBloat", json!({"amount": 25.0}))]),
style("Starburst", vec![F(fill("#ffbe0b")), S(stroke("#fb5607", 1.0))], vec![fx("distort.puckerBloat", json!({"amount": -40.0}))]),
]
}
fn gradient_finishes() -> Vec<GraphicStyle> {
let rim = |h: &str| S(stroke(h, 1.0));
vec![
style(
"Glossy",
vec![
F(FillLayer::new(linear(-90.0, &[(0.0, "#7ab8ff", 1.0), (1.0, "#1d4ed8", 1.0)]))),
F(FillLayer::new(linear(-90.0, &[(0.0, "#ffffff", 0.7), (0.5, "#ffffff", 0.0)]))),
rim("#1e3a8a"),
],
vec![],
),
style(
"Brushed Metal",
vec![
F(FillLayer::new(linear(
0.0,
&[(0.0, "#bfbfbf", 1.0), (0.3, "#f5f5f5", 1.0), (0.55, "#a6a6a6", 1.0), (0.8, "#e6e6e6", 1.0), (1.0, "#8c8c8c", 1.0)],
))),
rim("#595959"),
],
vec![],
),
style(
"Spotlight",
vec![F(FillLayer::new(radial_at(0.35, 0.3, 0.9, &[(0.0, "#ffffff", 1.0), (0.35, "#ffbe0b", 1.0), (1.0, "#fb5607", 1.0)])))],
vec![],
),
style(
"Sunset Band",
vec![F(FillLayer::new(linear(-90.0, &[(0.0, "#ffbe0b", 1.0), (0.35, "#fb5607", 1.0), (0.7, "#ff006e", 1.0), (1.0, "#8338ec", 1.0)])))],
vec![],
),
style(
"Sea Glass",
vec![
F(FillLayer {
opacity: 0.85,
..FillLayer::new(linear(-45.0, &[(0.0, "#caf0f8", 1.0), (0.5, "#48cae4", 1.0), (1.0, "#0077b6", 1.0)]))
}),
rim("#03045e"),
],
vec![],
),
style(
"Pearl",
vec![
F(FillLayer::new(radial_at(0.4, 0.35, 0.8, &[(0.0, "#ffffff", 1.0), (0.5, "#e8e1f0", 1.0), (1.0, "#b8b0c8", 1.0)]))),
S(stroke("#8a7fa0", 0.75)),
],
vec![],
),
style(
"Chrome Rim",
vec![F(fill("#22223b")), S(StrokeLayer::new(linear(-90.0, &[(0.0, "#f8f9fa", 1.0), (0.5, "#6c757d", 1.0), (1.0, "#dee2e6", 1.0)]), 4.0))],
vec![],
),
style(
"Glow Orb",
vec![F(FillLayer::new(radial_at(0.5, 0.5, 0.5, &[(0.0, "#ffffff", 1.0), (0.4, "#80ffdb", 1.0), (1.0, "#5390d9", 1.0)])))],
vec![fx("stylize.outerGlow", json!({"mode": "normal", "opacity": 60.0, "blur": 6.0, "color": "#80ffdb"}))],
),
]
}
fn shape_effects() -> Vec<GraphicStyle> {
let basic = |f: &str, s: &str, w: f64| vec![F(fill(f)), S(round(s, w))];
vec![
style("Rounded", basic("#90be6d", "#43aa8b", 2.0), vec![fx("stylize.roundCorners", json!({"radius": 10.0}))]),
style(
"Pill Badge",
vec![F(fill("#f94144"))],
vec![fx("convertToShape.roundedRectangle", json!({"relative": true, "extraW": 12.0, "extraH": 6.0, "radius": 40.0}))],
),
style(
"Round Badge",
vec![F(fill("#577590")), S(stroke("#ffffff", 2.0))],
vec![fx("convertToShape.ellipse", json!({"relative": true, "extraW": 10.0, "extraH": 10.0}))],
),
style("Arc", basic("#f8961e", "#f3722c", 1.5), vec![fx("warp.arc", json!({"bend": 30.0}))]),
style("Flag Wave", basic("#277da1", "#1d4e89", 1.5), vec![fx("warp.flag", json!({"bend": 25.0}))]),
style("Bulge", basic("#f9c74f", "#f8961e", 1.5), vec![fx("warp.bulge", json!({"bend": 30.0}))]),
style("Twisted", basic("#4d908e", "#264653", 1.5), vec![fx("distort.twist", json!({"angle": 30.0}))]),
style(
"Echo",
vec![F(FillLayer { opacity: 0.55, ..fill("#43aa8b") }), S(stroke("#264653", 1.0))],
vec![fx("distort.transform", json!({"copies": 3, "moveH": 4.0, "moveV": 4.0}))],
),
style(
"Spiral Copies",
vec![F(FillLayer { opacity: 0.35, ..fill("#f94144") }), S(stroke("#9d0208", 0.75))],
vec![fx("distort.transform", json!({"copies": 5, "rotate": 12.0, "scaleH": 90.0, "scaleV": 90.0}))],
),
]
}
fn blends_transparency() -> Vec<GraphicStyle> {
let with =
|g: GraphicStyle, opacity: f32, blend: BlendMode, isolate: bool, knockout: Knockout| GraphicStyle { opacity, blend, isolate, knockout, ..g };
let one = |name: &str, h: &str| style(name, vec![F(fill(h))], vec![]);
// A fill blending with the fills under it, so the style shows on any backdrop.
let over = |paint: Paint, blend: BlendMode| F(FillLayer { blend, ..FillLayer::new(paint) });
vec![
with(one("Multiply Tint", "#ffbe0b"), 1.0, BlendMode::Multiply, false, Knockout::Neutral),
with(one("Darken Veil", "#4cc9f0"), 0.85, BlendMode::Darken, false, Knockout::Neutral),
with(style("Ghosted", vec![F(fill("#7209b7")), S(stroke("#3a0ca3", 1.0))], vec![]), 0.4, BlendMode::Normal, false, Knockout::Neutral),
with(one("Invert", "#ffffff"), 1.0, BlendMode::Difference, false, Knockout::Neutral),
style("Two Tone", vec![F(fill("#4361ee")), F(FillLayer { opacity: 0.6, blend: BlendMode::Multiply, ..fill("#f72585") })], vec![]),
style(
"Overlay Glaze",
vec![F(fill("#f72585")), over(linear(-90.0, &[(0.0, "#ffffff", 1.0), (1.0, "#000000", 1.0)]), BlendMode::Overlay)],
vec![],
),
style(
"Screen Light",
vec![F(fill("#3a0ca3")), over(radial_at(0.5, 0.4, 0.6, &[(0.0, "#4cc9f0", 1.0), (1.0, "#000000", 1.0)]), BlendMode::Screen)],
vec![],
),
with(
style(
"Soft Light Wash",
vec![F(fill("#0096c7")), over(linear(0.0, &[(0.0, "#ffffff", 1.0), (1.0, "#03045e", 1.0)]), BlendMode::SoftLight)],
vec![],
),
1.0,
BlendMode::Normal,
true,
Knockout::Neutral,
),
with(style("Knockout Group", vec![F(fill("#4895ef")), S(stroke("#3f37c9", 1.0))], vec![]), 0.7, BlendMode::Normal, true, Knockout::On),
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builtin_libraries_are_non_empty_with_unique_names() {
let mut ids: Vec<&str> = STYLE_LIBRARIES.iter().map(|b| b.id).collect();
ids.sort_unstable();
ids.dedup();
assert_eq!(ids.len(), STYLE_LIBRARIES.len(), "library ids are unique");
for b in STYLE_LIBRARIES {
let lib = builtin_style_library(b.id).unwrap();
assert_eq!(lib.name, b.name);
assert!(lib.len() >= 6, "{}", b.id);
let mut names: Vec<&str> = lib.styles.iter().map(|g| g.name.as_str()).collect();
names.sort_unstable();
names.dedup();
assert_eq!(names.len(), lib.len(), "style names in {} are unique", b.id);
assert!(lib.styles.iter().all(|g| g.id == 0 && g.unit_box && !g.appearance.items.is_empty()));
}
assert!(Arc::ptr_eq(&builtin_style_library("hand-drawn").unwrap(), &builtin_style_library("hand-drawn").unwrap()), "made once");
assert!(builtin_style_library("nope").is_none());
}
#[test]
fn vcstyles_round_trip() {
let mut lib = (*builtin_style_library("gradient-finishes").unwrap()).clone();
lib.styles.extend(builtin_style_library("blends-transparency").unwrap().styles.iter().cloned());
let text = write(&lib);
assert!(sniff(&text) && !sniff("{\"format\": \"vectorcraft\"}") && !sniff("GIMP Palette"));
assert_eq!(read(&text, "Fallback").unwrap(), lib);
// An unnamed library takes the file's name; other JSON isn't a library.
let unnamed = write(&StyleLibrary { name: " ".into(), ..lib.clone() });
assert_eq!(read(&unnamed, "Mine").unwrap().name, "Mine");
assert!(read("{\"format\": \"vcswatches\", \"version\": 1, \"name\": \"x\"}", "x").is_err());
}
#[test]
fn a_library_from_a_document_stands_alone_and_keeps_its_patterns() {
let mut d = Document::new(100.0, 100.0);
let red = Paint::Solid { color: Color::rgb(1.0, 0.0, 0.0), swatch: Some("Red".into()), tint: 0.5 };
let dots = Paint::Pattern { pattern: "Dots".into(), xf: Default::default() };
d.patterns.push(PatternDef::new("Dots", vec![]));
let mut g = GraphicStyle::new("Linked", Appearance::basic(red, dots, 2.0));
g.id = 9;
d.graphic_styles.push(g);
let lib = StyleLibrary::from_document(&d, &["Linked".into()], "Mine".into()).unwrap();
assert_eq!(lib.len(), 1);
let s = &lib.styles[0];
assert_eq!((s.id, s.appearance.fill_paint()), (0, Paint::solid(Color::rgb(1.0, 0.0, 0.0))), "unlinked, no id");
assert_eq!(lib.patterns.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["Dots"]);
assert!(s.same_look(&GraphicStyle { name: "Other".into(), id: 3, ..s.clone() }));
assert!(!s.same_look(&GraphicStyle { opacity: 0.5, ..s.clone() }));
assert_eq!(StyleLibrary::from_document(&d, &[], "All".into()).unwrap().len(), d.graphic_styles.len());
assert!(StyleLibrary::from_document(&d, &["nope".into()], "x".into()).is_err());
// Gradients lose their swatch and their stops' links too.
let mut gp = GradientPaint::new(gradient(GradientKind::Linear, &[(0.0, "#ff0000", 1.0), (1.0, "#0000ff", 1.0)]));
gp.swatch = Some("Sunset".into());
gp.gradient.stops[0].set_color(hex("#ff0000"), Some(("Red".into(), 0.5)));
let g = GraphicStyle::new("G", Appearance::basic(Paint::Gradient(Box::new(gp)), Paint::None, 1.0)).standalone();
let Paint::Gradient(gp) = g.appearance.fill_paint() else { panic!("a gradient") };
assert!(gp.swatch.is_none() && gp.gradient.stops.iter().all(|s| s.swatch.is_none() && s.tint == 1.0));
}
}
+389
View File
@@ -0,0 +1,389 @@
//! Swatch lookups across colour groups and the walks over the paints that link to swatches.
//!
//! A swatch lives either in `Document::swatches` or in one of `Document::swatch_groups`; every
//! lookup here covers both. Solid paints and gradient stops link to a global swatch by name, at a
//! tint (`Paint::Solid.swatch` / `.tint`, `GradientStop.swatch` / `.tint`); a tint swatch is a
//! swatch whose own paint links to its base that way. [`Document::map_solid_paints`] rewrites
//! those links (and colours) wherever art and swatches hold them, copying only the nodes that
//! change.
use std::sync::Arc;
use vectorcraft_color::swatch::REGISTRATION;
use vectorcraft_color::{Color, Paint, Swatch, tint_percent};
use crate::{AppearanceItem, Document, Node, NodeId, NodeKind};
impl Document {
/// Every swatch: the ungrouped ones first, then each colour group's in order.
pub fn swatches_iter(&self) -> impl Iterator<Item = &Swatch> {
self.swatches.iter().chain(self.swatch_groups.iter().flat_map(|g| g.swatches.iter()))
}
pub fn swatches_iter_mut(&mut self) -> impl Iterator<Item = &mut Swatch> {
self.swatches.iter_mut().chain(self.swatch_groups.iter_mut().flat_map(|g| g.swatches.iter_mut()))
}
/// Swatch `name`, the built-in Registration swatch ([`vectorcraft_color::swatch::registration`])
/// included.
pub fn swatch(&self, name: &str) -> Option<&Swatch> {
self.swatches_iter().find(|s| s.name == name).or_else(|| (name == REGISTRATION).then(vectorcraft_color::swatch::registration))
}
pub fn swatch_mut(&mut self, name: &str) -> Option<&mut Swatch> {
self.swatches_iter_mut().find(|s| s.name == name)
}
/// The colour of global (or spot) swatch `name`, which its tints scale ([`Document::linked_color`]
/// of its own); `None` when `name` isn't a global solid colour of its own (a tint swatch isn't).
pub fn global_color(&self, name: &str) -> Option<Color> {
let w = self.swatch(name).filter(|w| w.global)?;
match &w.paint {
Paint::Solid { color, swatch: None, .. } => Some(self.linked_color(*color, w.spot)),
_ => None,
}
}
/// The colour art linked to a swatch of `color` shows and prints from: a Lab spot colour is
/// its working-CMYK equivalent when the Spot Colors options use CMYK values
/// ([`Document::spot_use_lab`] off); any other colour is itself.
pub fn linked_color(&self, color: Color, spot: bool) -> Color {
match color {
Color::Lab { .. } if spot && !self.spot_use_lab => {
let cms = vectorcraft_color::cms::active();
cms.convert(&color, vectorcraft_color::cms::Model::Cmyk, cms.settings().intent)
}
_ => color,
}
}
/// The link a colour taken from swatch `name` gets: a global solid colour links to itself at
/// 100 %, a tint swatch to its base at its tint; `None` for other swatches.
pub fn swatch_link(&self, name: &str) -> Option<(String, f32)> {
let w = self.swatch(name)?;
match (w.tint_of(), &w.paint) {
(Some((base, tint)), _) => Some((base.to_string(), tint)),
(None, Paint::Solid { .. }) if w.global => Some((name.to_string(), 1.0)),
_ => None,
}
}
/// Tint `tint` (0..1) of global swatch `name` as a solid paint linked to it.
pub fn tint_paint(&self, name: &str, tint: f32) -> Option<Paint> {
let tint = tint.clamp(0.0, 1.0);
Some(Paint::Solid { color: self.global_color(name)?.tinted(tint), swatch: Some(name.to_string()), tint })
}
/// Index of the colour group holding swatch `name` (`None` when ungrouped or missing).
pub fn swatch_group_of(&self, name: &str) -> Option<usize> {
self.swatch_groups.iter().position(|g| g.swatches.iter().any(|s| s.name == name))
}
/// Is `name` used by a swatch or a colour group (they share one namespace)?
pub fn swatch_name_taken(&self, name: &str) -> bool {
self.swatch(name).is_some() || self.swatch_groups.iter().any(|g| g.name == name)
}
/// `base`, or the first of "base 2", "base 3"… no swatch or colour group uses.
pub fn free_swatch_name(&self, base: &str) -> String {
if !self.swatch_name_taken(base) {
return base.to_string();
}
self.numbered_swatch_name(base, 2)
}
/// The first of "base `from`", "base `from + 1`"… no swatch or colour group uses.
fn numbered_swatch_name(&self, base: &str, from: usize) -> String {
(from..).map(|i| format!("{base} {i}")).find(|n| !self.swatch_name_taken(n)).unwrap_or_else(|| base.to_string())
}
/// The name a new swatch of `paint` gets by default, unused in the document: a colour's values
/// ([`color_name`], made free like [`Document::free_swatch_name`]), or "New Gradient Swatch 1",
/// "New Pattern Swatch 1"…
pub fn new_swatch_name(&self, paint: &Paint) -> String {
let base = match paint {
Paint::Solid { swatch: Some(n), tint, .. } if *tint < 1.0 => return self.free_swatch_name(&format!("{n} {}%", tint_percent(*tint))),
Paint::Solid { color, .. } => return self.free_swatch_name(&color_name(*color)),
Paint::Gradient(_) => "New Gradient Swatch",
_ => "New Pattern Swatch",
};
self.numbered_swatch_name(base, 1)
}
/// Remove swatch `name` from wherever it lives.
pub fn remove_swatch(&mut self, name: &str) -> Option<Swatch> {
if let Some(i) = self.swatches.iter().position(|s| s.name == name) {
return Some(self.swatches.remove(i));
}
let g = self.swatch_group_of(name)?;
let list = &mut self.swatch_groups[g].swatches;
let i = list.iter().position(|s| s.name == name)?;
Some(list.remove(i))
}
/// Visit every colour that can link to a swatch — solid paints and gradient stops of fills,
/// strokes and text runs — in the art, symbol definitions, pattern tiles, graphic styles and the
/// swatches themselves (tint swatches, gradient swatches' stops). `f(colour, link, tint)` may
/// change them and returns true when it did ([`Paint::map_links`]). Only nodes holding a changed
/// paint (and the paths to them) are copied. Returns the number of changed paints.
pub fn map_solid_paints(&mut self, f: &mut LinkFn) -> usize {
let mut n = map_trees(&mut self.layers, f);
for s in &mut self.symbols {
n += map_tree(&mut s.art, f);
}
for p in &mut self.patterns {
n += map_trees(&mut p.art, f);
}
for gs in &mut self.graphic_styles {
for it in &mut gs.appearance.items {
n += usize::from(item_paint_mut(it).map_links(f));
}
}
for w in self.swatches_iter_mut() {
n += usize::from(w.paint.map_links(f));
}
n
}
/// [`Document::map_solid_paints`] limited to the art in the subtrees of `ids` (each paint
/// visited once even when an id is inside another).
pub fn map_solid_paints_in(&mut self, ids: &[NodeId], f: &mut LinkFn) -> usize {
let roots: Vec<NodeId> =
ids.iter().copied().filter(|id| !self.ancestry(*id).is_some_and(|a| a[..a.len() - 1].iter().any(|p| ids.contains(p)))).collect();
let mut n = 0;
for id in roots {
let Some(node) = self.node(id) else { continue };
if let Some((new, count)) = map_node(node, f)
&& let Some(slot) = self.node_mut(id)
{
*slot = new;
n += count;
}
}
n
}
}
impl Document {
/// Visit every paint held by the art, symbol definitions, pattern tiles and graphic styles
/// (fills, strokes and text runs), and the colour of every gradient-mesh point as a solid paint.
pub fn visit_paints(&self, f: &mut dyn FnMut(&Paint)) {
let mut tree = |n: &Node| {
n.walk(&mut |m| {
node_paints(m).for_each(&mut *f);
if let NodeKind::Mesh(mesh) = &m.kind {
mesh.points.iter().for_each(|p| f(&Paint::solid(p.color)));
}
})
};
self.layers.iter().chain(self.symbols.iter().map(|s| &s.art)).chain(self.patterns.iter().flat_map(|p| &p.art)).for_each(|n| tree(n));
self.graphic_styles.iter().flat_map(|gs| &gs.appearance.items).for_each(|it| f(item_paint(it)));
}
}
/// The default name of a solid swatch: its values in its own colour model ("C=10 M=20 Y=30 K=0",
/// "R=255 G=128 B=0", "Gray K=40", "L=52 a=70 b=-30").
pub fn color_name(c: Color) -> String {
// Clamped first, so a conversion's tiny negative never reads "-0".
let pct = |v: f32| (v.clamp(0.0, 1.0) * 100.0).round();
let byte = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round();
match c {
Color::Cmyk { c, m, y, k } => format!("C={} M={} Y={} K={}", pct(c), pct(m), pct(y), pct(k)),
Color::Rgb { r, g, b } => format!("R={} G={} B={}", byte(r), byte(g), byte(b)),
Color::Gray { k } => format!("Gray K={}", pct(k)),
// `+ 0.0` turns a rounded -0 into 0.
Color::Lab { l, a, b } => format!("L={} a={} b={}", l.round() + 0.0, a.round() + 0.0, b.round() + 0.0),
}
}
/// What [`Document::map_solid_paints`] applies to each linkable colour: `(colour, link, tint)`,
/// true when it changed them.
pub type LinkFn<'a> = dyn FnMut(&mut Color, &mut Option<String>, &mut f32) -> bool + 'a;
/// Every colour in the subtree of `n` with the swatch it links to: solid fills, strokes, text runs
/// and gradient stops (with their link), then mesh points (unlinked), in paint order.
pub fn node_colors(n: &Node, f: &mut dyn FnMut(&Color, Option<&str>)) {
n.walk(&mut |m| {
for p in node_paints(m) {
match p {
Paint::Solid { color, swatch, .. } => f(color, swatch.as_deref()),
Paint::Gradient(g) => g.gradient.stops.iter().for_each(|s| f(&s.color, s.swatch.as_deref())),
_ => {}
}
}
if let NodeKind::Mesh(mesh) = &m.kind {
mesh.points.iter().for_each(|p| f(&p.color, None));
}
});
}
fn item_paint(it: &AppearanceItem) -> &Paint {
match it {
AppearanceItem::Fill(l) => &l.paint,
AppearanceItem::Stroke(l) => &l.paint,
}
}
fn item_paint_mut(it: &mut AppearanceItem) -> &mut Paint {
match it {
AppearanceItem::Fill(l) => &mut l.paint,
AppearanceItem::Stroke(l) => &mut l.paint,
}
}
/// The paints a node holds itself (not its children): appearance items, then text runs.
fn node_paints(n: &Node) -> impl Iterator<Item = &Paint> {
let runs = match &n.kind {
NodeKind::Text(t) => t.runs.as_slice(),
_ => &[],
};
n.appearance.items.iter().map(item_paint).chain(runs.iter().flat_map(|r| [&r.style.fill, &r.style.stroke]))
}
/// Mutable [`node_paints`], in the same order.
fn node_paints_mut(n: &mut Node) -> impl Iterator<Item = &mut Paint> {
let runs = match &mut n.kind {
NodeKind::Text(t) => t.runs.as_mut_slice(),
_ => &mut [],
};
n.appearance.items.iter_mut().map(item_paint_mut).chain(runs.iter_mut().flat_map(|r| [&mut r.style.fill, &mut r.style.stroke]))
}
/// `n` with `f` applied to its subtree's linkable colours, or `None` when nothing changed (then
/// nothing was copied). Also returns the number of changed paints.
fn map_node(n: &Node, f: &mut LinkFn) -> Option<(Node, usize)> {
// Try each own solid or gradient paint on a scratch copy first, so unchanged nodes are never
// cloned.
let mut changes: Vec<(usize, Paint)> = vec![];
for (i, p) in node_paints(n).enumerate() {
if matches!(p, Paint::Solid { .. } | Paint::Gradient(_)) {
let mut q = p.clone();
if q.map_links(f) {
changes.push((i, q));
}
}
}
let mut count = changes.len();
let mut out: Option<Node> = None;
if !changes.is_empty() {
let mut m = n.clone();
let mut changes = changes.into_iter().peekable();
for (i, p) in node_paints_mut(&mut m).enumerate() {
if changes.peek().is_some_and(|c| c.0 == i)
&& let Some((_, q)) = changes.next()
{
*p = q;
}
}
out = Some(m);
}
if let Some(ch) = n.children() {
for (i, c) in ch.iter().enumerate() {
if let Some((new, k)) = map_node(c, f) {
let m = out.get_or_insert_with(|| n.clone());
if let Some(slot) = m.children_mut().and_then(|v| v.get_mut(i)) {
*slot = Arc::new(new);
}
count += k;
}
}
}
out.map(|m| (m, count))
}
fn map_tree(n: &mut Arc<Node>, f: &mut LinkFn) -> usize {
match map_node(n, f) {
Some((new, count)) => {
*n = Arc::new(new);
count
}
None => 0,
}
}
fn map_trees(v: &mut [Arc<Node>], f: &mut LinkFn) -> usize {
v.iter_mut().map(|n| map_tree(n, f)).sum()
}
#[cfg(test)]
mod tests {
use vectorcraft_color::SwatchGroup;
use vectorcraft_geom::{Rect, shapes};
use super::*;
use crate::Appearance;
fn linked(color: Color, name: &str) -> Paint {
Paint::Solid { color, swatch: Some(name.into()), tint: 1.0 }
}
/// A document with a grouped global swatch "Brand", a rectangle filled with it and an unlinked one.
fn doc() -> (Document, NodeId, NodeId) {
let mut d = Document::new(100.0, 100.0);
let red = Color::rgb(1.0, 0.0, 0.0);
d.swatch_groups.push(SwatchGroup {
name: "Mine".into(),
swatches: vec![Swatch { name: "Brand".into(), paint: Paint::solid(red), global: true, spot: false }],
});
let layer = d.layers[0].id;
let mut ids = vec![];
for paint in [linked(red, "Brand"), Paint::solid(red)] {
let id = d.alloc_id();
let r = shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0));
d.insert(Some(layer), usize::MAX, Node::path(id, r, Appearance::basic(paint, Paint::None, 0.0))).unwrap();
ids.push(id);
}
(d, ids[0], ids[1])
}
#[test]
fn lookups_cover_colour_groups() {
let (mut d, _, _) = doc();
let total = d.swatches.len() + d.swatch_groups.iter().map(|g| g.swatches.len()).sum::<usize>();
assert_eq!(d.swatches_iter().count(), total);
assert!(d.swatch("Brand").is_some_and(|s| s.global));
assert_eq!(d.swatch_group_of("Brand").map(|g| d.swatch_groups[g].name.as_str()), Some("Mine"));
d.swatch_mut("Brand").unwrap().spot = true;
assert!(d.swatch("Brand").unwrap().spot);
assert!(d.swatch_name_taken("Mine") && d.swatch_name_taken("Brand") && !d.swatch_name_taken("Nope"));
assert_eq!(d.remove_swatch("Brand").map(|s| s.name), Some("Brand".into()));
assert!(d.swatch("Brand").is_none() && d.remove_swatch("Brand").is_none());
}
#[test]
fn default_names_follow_the_colour_model() {
assert_eq!(color_name(Color::cmyk(0.1, 0.2, 0.3, -1e-7)), "C=10 M=20 Y=30 K=0");
assert_eq!(color_name(Color::rgb(1.0, 0.5, -1e-7)), "R=255 G=128 B=0");
assert_eq!(color_name(Color::Gray { k: 0.4 }), "Gray K=40");
let (d, _, _) = doc();
assert_eq!(d.free_swatch_name("Brand"), "Brand 2");
assert_eq!(d.free_swatch_name("Mine"), "Mine 2", "colour groups share the namespace");
assert_eq!(d.new_swatch_name(&Paint::solid(Color::rgb(1.0, 0.0, 0.0))), "R=255 G=0 B=0");
}
#[test]
fn mapping_copies_only_changed_nodes() {
let (mut d, a, b) = doc();
let before = d.clone();
let blue = Color::rgb(0.0, 0.0, 1.0);
let n = d.map_solid_paints(&mut |c, s, _| {
if s.as_deref() != Some("Brand") {
return false;
}
*c = blue;
true
});
assert_eq!(n, 1);
assert_eq!(d.node(a).unwrap().appearance.fill_paint(), linked(blue, "Brand"));
assert_eq!(d.node(b).unwrap().appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
let unchanged = |d: &Document| d.layers[0].children().unwrap()[1].clone();
assert!(Arc::ptr_eq(&unchanged(&d), &unchanged(&before)), "the unlinked rectangle is shared, not copied");
// Nothing to change: the tree is untouched.
let again = d.clone();
assert_eq!(d.map_solid_paints(&mut |_, _, _| false), 0);
assert!(Arc::ptr_eq(&d.layers[0], &again.layers[0]));
}
#[test]
fn mapping_within_ids_visits_each_paint_once() {
let (mut d, a, b) = doc();
let layer = d.layers[0].id;
let mut seen = 0;
let n = d.map_solid_paints_in(&[layer, a], &mut |_, s, _| {
seen += 1;
s.take().is_some()
});
assert_eq!((n, seen), (1, 2), "the layer covers `a`: each fill is visited once");
assert_eq!(d.node(a).unwrap().appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
let mut colors = vec![];
node_colors(d.node(b).unwrap(), &mut |c, s| colors.push((*c, s.map(str::to_string))));
assert_eq!(colors, [(Color::rgb(1.0, 0.0, 0.0), None)]);
}
}
+125
View File
@@ -0,0 +1,125 @@
//! Slice layout: object slices follow their objects, clipping, numbering and auto slices.
use proptest::prelude::*;
use vectorcraft_geom::{Point, Rect, shapes};
use crate::slices::auto_slices;
use crate::{Appearance, Document, Node, Slice, SliceOptions, SliceSource};
fn rect(d: &mut Document, r: Rect) -> crate::NodeId {
let l = d.layers[0].id;
let id = d.alloc_id();
d.insert(Some(l), usize::MAX, Node::path(id, shapes::rectangle(r), Appearance::basic(crate::color::Paint::None, crate::color::Paint::None, 0.0)))
.unwrap();
id
}
fn user(d: &mut Document, r: Rect) -> crate::NodeId {
let id = d.alloc_id();
d.slices.push(Slice { id, rect: r, options: SliceOptions::default() });
id
}
/// How many of `rects` hold point `p` (strictly inside).
fn holders(rects: &[Rect], p: Point) -> usize {
rects.iter().filter(|r| r.x0 < p.x && p.x < r.x1 && r.y0 < p.y && p.y < r.y1).count()
}
#[test]
fn an_object_slice_follows_its_object_and_numbers_run_by_rows() {
let mut d = Document::new(300.0, 200.0);
let a = rect(&mut d, Rect::new(100.0, 50.0, 200.0, 150.0));
assert!(d.slice_layout().is_empty(), "no slices, no auto slices");
d.node_mut(a).unwrap().slice = Some(Box::default());
let lay = d.slice_layout();
let own = lay.iter().find(|s| s.id == Some(a)).unwrap();
assert_eq!((own.source, own.rect), (SliceSource::Object, Rect::new(100.0, 50.0, 200.0, 150.0)));
// Three rows: the band above, the object's row (left, object, right) and the band below.
assert_eq!(lay.len(), 5);
assert_eq!(lay.iter().map(|s| s.number).collect::<Vec<_>>(), [1, 2, 3, 4, 5]);
assert_eq!(own.number, 3);
assert_eq!(d.slice_name(own), "Untitled-1_03");
// Moving the object moves its slice.
d.node_mut(a).unwrap().transform(vectorcraft_geom::Affine::translate((-50.0, 0.0)), crate::Scaling::default());
assert_eq!(d.slice_layout().iter().find(|s| s.id == Some(a)).unwrap().rect, Rect::new(50.0, 50.0, 150.0, 150.0));
// Hidden objects have no slice.
d.node_mut(a).unwrap().visible = false;
assert!(d.slice_layout().is_empty());
}
#[test]
fn clip_to_artboard_clips_slices_and_off_covers_the_art() {
let mut d = Document::new(100.0, 100.0);
let s = user(&mut d, Rect::new(50.0, 50.0, 150.0, 150.0));
let own = |d: &Document| d.slice_layout().into_iter().find(|a| a.id == Some(s)).unwrap().rect;
assert_eq!(own(&d), Rect::new(50.0, 50.0, 100.0, 100.0));
let lay = d.slice_layout();
let autos: Vec<Rect> = lay.iter().filter(|a| a.source == SliceSource::Auto).map(|a| a.rect).collect();
assert_eq!(autos.iter().map(|r| r.area()).sum::<f64>(), 100.0 * 100.0 - 50.0 * 50.0);
d.slices_clip_to_artboard = false;
assert_eq!(own(&d), Rect::new(50.0, 50.0, 150.0, 150.0));
// The region is now the slice itself (no art): nothing left for auto slices.
assert_eq!(d.slice_layout().len(), 1);
rect(&mut d, Rect::new(0.0, 0.0, 20.0, 20.0));
let total: f64 = d.slice_layout().iter().map(|a| a.rect.area()).sum();
assert_eq!(total, 150.0 * 150.0);
}
#[test]
fn slices_survive_a_native_round_trip_and_old_files_load() {
let mut d = Document::new(100.0, 100.0);
let a = rect(&mut d, Rect::new(10.0, 10.0, 20.0, 20.0));
d.node_mut(a).unwrap().slice = Some(Box::new(SliceOptions { kind: crate::SliceKind::NoImage, text: "<b>hi</b>".into(), ..Default::default() }));
let s = user(&mut d, Rect::new(30.0, 30.0, 60.0, 60.0));
d.slice_mut(s).unwrap().options.url = "https://example.com".into();
d.slices_clip_to_artboard = false;
let json = serde_json::to_string(&d).unwrap();
let back: Document = serde_json::from_str(&json).unwrap();
assert_eq!(back.slices, d.slices);
assert_eq!(back.node(a).unwrap().slice, d.node(a).unwrap().slice);
assert!(!back.slices_clip_to_artboard);
// A document without slices writes none of the new keys (older readers see what they knew).
let plain = serde_json::to_string(&Document::new(10.0, 10.0)).unwrap();
assert!(!plain.contains("slice"), "{plain}");
let old: Document = serde_json::from_str(&plain).unwrap();
assert!(old.slices.is_empty() && old.slices_clip_to_artboard);
}
#[test]
fn auto_slices_fill_around_a_slice_in_rows() {
let autos = auto_slices(Rect::new(0.0, 0.0, 90.0, 90.0), &[Rect::new(30.0, 30.0, 60.0, 60.0)]);
let mut got: Vec<(f64, f64, f64, f64)> = autos.iter().map(|r| (r.x0, r.y0, r.x1, r.y1)).collect();
got.sort_by(|a, b| a.1.total_cmp(&b.1).then(a.0.total_cmp(&b.0)));
assert_eq!(got, [(0.0, 0.0, 90.0, 30.0), (0.0, 30.0, 30.0, 60.0), (60.0, 30.0, 90.0, 60.0), (0.0, 60.0, 90.0, 90.0)]);
// Spans that continue down one column merge.
let autos = auto_slices(Rect::new(0.0, 0.0, 90.0, 90.0), &[Rect::new(30.0, 0.0, 60.0, 40.0), Rect::new(30.0, 40.0, 60.0, 90.0)]);
assert_eq!(autos.len(), 2, "{autos:?}");
}
fn arb_rect() -> impl Strategy<Value = Rect> {
(-20.0..120.0f64, -20.0..120.0f64, 1.0..80.0f64, 1.0..80.0f64).prop_map(|(x, y, w, h)| Rect::new(x, y, x + w, y + h))
}
proptest! {
/// Auto slices never overlap each other or the slices, and with the slices they cover the
/// whole region.
#[test]
fn auto_slices_cover_without_overlap(cut in prop::collection::vec(arb_rect(), 0..8)) {
let region = Rect::new(0.0, 0.0, 100.0, 100.0);
let autos = auto_slices(region, &cut);
for r in &autos {
prop_assert!(r.x0 >= region.x0 && r.y0 >= region.y0 && r.x1 <= region.x1 && r.y1 <= region.y1, "{r:?} leaves the region");
}
// Sample off every edge (edges are at integers plus random fractions; the samples sit at
// odd 1/8ths, which a random float edge practically never hits).
for i in 0..50 {
for j in 0..50 {
let p = Point::new(i as f64 * 2.0 + 0.125 * 7.0, j as f64 * 2.0 + 0.125 * 3.0);
let in_cut = holders(&cut, p) > 0;
let in_auto = holders(&autos, p);
prop_assert!(in_auto <= 1, "{p:?} in {in_auto} auto slices");
prop_assert!(in_cut != (in_auto == 1), "{p:?}: in a slice {in_cut}, in an auto slice {in_auto}");
}
}
}
}
+65
View File
@@ -0,0 +1,65 @@
//! Units: format/parse round-trips in every unit, typed units winning over the field's unit, names
//! and canvas readouts.
use super::Unit;
#[test]
fn every_unit_round_trips_through_its_field_text() {
for u in Unit::ALL {
// Field text keeps three decimals of the unit: half a step is the most a round trip loses.
let tol = 0.0005 * u.points() + 1e-9;
for pt in [0.0, 1.0, 12.5, 72.0, 100.0, 595.28, 1234.5678, -36.0] {
let shown = u.format(pt);
let back = u.parse(&shown).unwrap_or_else(|| panic!("{u:?} can't read back `{shown}`"));
assert!((back - pt).abs() <= tol, "{u:?}: {pt} → `{shown}` → {back}");
// The bare number reads in the field's unit, and in any unit with its suffix.
let bare = u.parse(&u.number(pt)).unwrap();
assert!((bare - pt).abs() <= tol, "{u:?}: bare `{}` → {bare}", u.number(pt));
let pts = Unit::Points.parse(&shown).unwrap();
assert!((pts - pt).abs() <= tol, "{u:?}: `{shown}` in a points field → {pts}");
}
}
}
#[test]
fn typed_units_win_over_the_fields_unit() {
let mm = 72.0 / 25.4;
assert_eq!(Unit::Millimeters.parse("10"), Some(10.0 * mm));
assert_eq!(Unit::Millimeters.parse("12 pt"), Some(12.0));
assert_eq!(Unit::Millimeters.parse("1in"), Some(72.0));
assert_eq!(Unit::Points.parse("5 mm"), Some(5.0 * mm));
assert_eq!(Unit::Centimeters.parse("2p6"), Some(30.0));
assert_eq!(Unit::Millimeters.parse("10+5"), Some(15.0 * mm));
assert_eq!(Unit::Inches.parse("1 + 36 pt"), Some(108.0));
assert_eq!(Unit::Pixels.parse("-4"), Some(-4.0));
}
#[test]
fn odd_text_reads_as_nothing_without_panicking() {
for s in ["", "é", "é5", "°+1", "×2", "5 ×", "—3", "mm", "1/0", "--", "+"] {
assert_eq!(Unit::Millimeters.parse(s), None, "{s}");
}
}
#[test]
fn units_are_named_by_label_key_or_suffix() {
for u in Unit::ALL {
assert_eq!(Unit::named(u.label()), Some(u));
assert_eq!(Unit::named(u.key()), Some(u));
assert_eq!(Unit::named(&u.label().to_uppercase()), Some(u));
}
assert_eq!(Unit::named("mm"), Some(Unit::Millimeters));
assert_eq!(Unit::named("Feet & Inches"), Some(Unit::FeetInches));
assert_eq!(Unit::named("feetInches"), Some(Unit::FeetInches));
assert_eq!(Unit::named("furlongs"), None);
assert_eq!(Unit::named(""), None);
}
#[test]
fn readouts_show_two_decimals() {
assert_eq!(Unit::Points.readout(15.0), "15.00 pt");
assert_eq!(Unit::Millimeters.readout(72.0), "25.40 mm");
assert_eq!(Unit::Inches.readout(-36.0), "-0.50 in");
assert_eq!(Unit::Millimeters.number(1.0), "0.353");
assert_eq!(Unit::Points.number(-0.0001), "0");
}
+146
View File
@@ -4,6 +4,8 @@ use serde::{Deserialize, Serialize};
use vectorcraft_color::{Color, Paint};
use vectorcraft_geom::{Affine, PathData, Point, Rect};
use crate::appearance::{Appearance, AppearanceItem, Dash, FillLayer, LineCap, LineJoin, StrokeLayer};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum Justify {
#[default]
@@ -59,8 +61,85 @@ pub struct CharStyle {
/// Character style (Character Styles panel) these attributes come from; None = Normal.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub style_name: Option<String>,
/// Overprint Fill / Overprint Stroke of these characters (see [`crate::FillLayer::overprint`]).
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub overprint_fill: bool,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub overprint_stroke: bool,
/// Cap, join, miter limit and dashes of the character stroke (Stroke panel, with type
/// selected); defaults as for object strokes.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub stroke_cap: LineCap,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub stroke_join: LineJoin,
#[serde(default = "ten", skip_serializing_if = "is_ten")]
pub stroke_miter_limit: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stroke_dash: Option<Dash>,
/// Superscript or subscript (Character panel).
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub position: CharPosition,
/// Small Caps (Character panel): lowercase letters drawn as capitals at this percentage of the
/// size (Document Setup → Type → Small Caps); None = off.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub small_caps: Option<f64>,
}
/// Superscript or subscript proportions in percent of the font size (Document Setup → Type).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct ScriptMetrics {
/// Glyph size.
pub size: f64,
/// Baseline offset: up for superscript, down for subscript.
pub position: f64,
}
impl ScriptMetrics {
pub const DEFAULT: Self = Self { size: 58.3, position: 33.3 };
}
impl Default for ScriptMetrics {
fn default() -> Self {
Self::DEFAULT
}
}
/// Character position (Character panel Superscript / Subscript). The proportions are the
/// document's when the position is applied, and follow later Document Setup changes.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum CharPosition {
#[default]
Normal,
Superscript(ScriptMetrics),
Subscript(ScriptMetrics),
}
impl CharPosition {
/// Glyph scale and baseline shift (points, positive = up) for text of `size` points.
pub fn scale_shift(self, size: f64) -> (f64, f64) {
match self {
CharPosition::Normal => (1.0, 0.0),
CharPosition::Superscript(m) => (m.size / 100.0, m.position / 100.0 * size),
CharPosition::Subscript(m) => (m.size / 100.0, -m.position / 100.0 * size),
}
}
/// `normal`, `superscript` or `subscript`.
pub fn id(self) -> &'static str {
match self {
CharPosition::Normal => "normal",
CharPosition::Superscript(_) => "superscript",
CharPosition::Subscript(_) => "subscript",
}
}
}
fn ten() -> f64 {
10.0
}
fn is_ten(v: &f64) -> bool {
*v == 10.0
}
fn regular() -> String {
"Regular".into()
}
@@ -89,6 +168,14 @@ impl Default for CharStyle {
all_caps: false,
features: vec![],
style_name: None,
overprint_fill: false,
overprint_stroke: false,
stroke_cap: LineCap::Butt,
stroke_join: LineJoin::Miter,
stroke_miter_limit: 10.0,
stroke_dash: None,
position: CharPosition::Normal,
small_caps: None,
}
}
}
@@ -97,6 +184,50 @@ impl CharStyle {
pub fn effective_leading(&self) -> f64 {
self.leading.unwrap_or(self.size * 1.2)
}
/// Do the characters draw a stroke (a paint and a positive weight)?
pub fn has_stroke(&self) -> bool {
!self.stroke.is_none() && self.stroke_width > 0.0
}
/// The character stroke as a stroke layer (paint, weight, cap, join, miter limit, dashes and
/// overprint), so type strokes share the object strokes' geometry, rendering and export.
pub fn stroke_layer(&self) -> StrokeLayer {
StrokeLayer {
overprint: self.overprint_stroke,
cap: self.stroke_cap,
join: self.stroke_join,
miter_limit: self.stroke_miter_limit,
dash: self.stroke_dash.clone(),
..StrokeLayer::new(self.stroke.clone(), self.stroke_width)
}
}
/// Take the paint, weight, cap, join, miter limit and dashes of `st` as the character stroke
/// (the options characters have no use for, such as alignment and arrowheads, are dropped).
pub fn set_stroke_layer(&mut self, st: &StrokeLayer) {
self.stroke = st.paint.clone();
self.stroke_width = st.width;
self.stroke_cap = st.cap;
self.stroke_join = st.join;
self.stroke_miter_limit = st.miter_limit;
self.stroke_dash = st.dash.clone();
}
/// The character fill as a fill layer, overprinting as the characters do.
fn fill_layer(&self) -> FillLayer {
FillLayer { overprint: self.overprint_fill, ..FillLayer::new(self.fill.clone()) }
}
/// The characters' paint as an object appearance (their outlines'): the fill, plus the
/// stroke when it draws one, overprinting as the characters do.
pub fn appearance(&self) -> Appearance {
let mut a = Appearance { items: vec![AppearanceItem::Fill(self.fill_layer())], ..Default::default() };
if self.has_stroke() {
a.items.push(AppearanceItem::Stroke(self.stroke_layer()));
}
a
}
/// The characters' paint as the basic fill and stroke rows (the stroke row even when it has
/// no paint): the appearance the Eyedropper and graphic styles take from type.
pub fn basic_appearance(&self) -> Appearance {
Appearance { items: vec![AppearanceItem::Fill(self.fill_layer()), AppearanceItem::Stroke(self.stroke_layer())], ..Default::default() }
}
}
/// Tab stop alignment (Tabs panel).
@@ -341,6 +472,21 @@ impl TextObject {
pub fn transform(&mut self, a: Affine) {
self.xf = a * self.xf;
}
/// Scale the character strokes' weights and dashes by `s` (they are drawn in text space, so
/// `1 / scale` keeps their weight through a transform that scales the type).
pub fn scale_char_strokes(&mut self, s: f64) {
for r in &mut self.runs {
r.style.stroke_width *= s;
if let Some(d) = &mut r.style.stroke_dash {
d.scale(s);
}
}
}
/// Layout bounds in text space (the layout cache, else the estimate): the box an unplaced run
/// gradient fits, as the renderers lay it out.
pub fn local_bounds(&self) -> Rect {
self.cached_bounds.unwrap_or_else(|| self.estimate_bounds())
}
}
#[cfg(test)]
+1
View File
@@ -12,6 +12,7 @@ workspace = true
vectorcraft-geom = { workspace = true }
vectorcraft-doc = { workspace = true }
vectorcraft-pathops = { workspace = true }
vectorcraft-text = { workspace = true }
kurbo = { workspace = true }
serde_json = { workspace = true }
+170 -42
View File
@@ -2,15 +2,24 @@
//!
//! SVG, PDF and the clipboard have no notion of Illustrator's live effects, so exporters receive a
//! copy of the document in which every geometry effect — object level, per fill/stroke and
//! Effect → Pathfinder — is evaluated exactly like the renderer does. Raster effects (shadows,
//! glows, blur) stay on the objects for the exporter to translate (e.g. SVG filters).
//! Effect → Pathfinder, and those on type, images, symbol instances and live objects (through their
//! outlines, [`reshape`]) — is evaluated exactly like the renderer does, and so are the own fills,
//! strokes and geometry effects of groups and layers ([`evaluate_container`]). Raster effects
//! (shadows, glows, blur) stay on the objects for the exporter to translate (e.g. SVG filters).
//!
//! Object → Expand Appearance goes further ([`expand_leaf`]): every fill and stroke becomes an
//! object of its own, strokes turned into filled art by the caller (their outlines or brush art).
use std::sync::Arc;
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeKind};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeKind, StrokeLayer};
use vectorcraft_geom::{FillRule, PathData};
use crate::{GeomContext, apply_geometry_with, has_geometry, has_pathfinder, is_geometry, is_pathfinder, pathfinder_children};
use crate::group::{has_own_paint, paints};
use crate::{
GeomContext, apply_geometry_with, crop_marks_art, evaluate_container, has_crop_marks, has_geometry, has_pathfinder, is_geometry, needs_outline,
reshape,
};
fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] {
match item {
@@ -28,7 +37,9 @@ fn clear_item_effects(item: &mut AppearanceItem) {
/// Does anything in `n`'s subtree need baking?
pub fn needs_bake(n: &Node) -> bool {
has_pathfinder(n)
has_crop_marks(n)
|| has_pathfinder(n)
|| has_own_paint(n)
|| has_geometry(&n.appearance.effects)
|| n.appearance.items.iter().any(|i| has_geometry(item_effects(i)))
|| n.children().is_some_and(|ch| ch.iter().any(|c| needs_bake(c)))
@@ -67,21 +78,23 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
if !needs_bake(n) {
return None;
}
if let Some(result) = pathfinder_children(n, None) {
let mut m = n.clone();
m.appearance.effects.retain(|e| !is_pathfinder(&e.id));
let children = result
.into_iter()
.map(|c| {
let mut c = Arc::unwrap_or_clone(c);
c.id = d.alloc_id();
Arc::new(bake_node(d, &c).unwrap_or(c))
})
.collect();
if let Some(ch) = m.children_mut() {
*ch = children;
}
return Some(m);
// Crop marks: the object and its marks.
if let Some(m) = crop_marks_art(n) {
return Some(bake_pieces(d, m));
}
// Type, images, symbol instances and live objects: reshaped through their outlines.
if needs_outline(n) && has_geometry(&n.appearance.effects) {
let symbol = match &n.kind {
NodeKind::SymbolInstance { symbol, .. } => d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()),
_ => None,
};
let m = reshape(n, symbol.as_deref())?;
return Some(bake_node(d, &m).unwrap_or(m));
}
// Groups and layers: Pathfinder, geometry effects and their own fills and strokes. The new
// pieces (results, outlines, the fills' and strokes' art) get ids of their own.
if let Some(m) = evaluate_container(n) {
return Some(bake_pieces(d, m));
}
if let Some(ch) = n.children() {
if matches!(n.kind, NodeKind::Compound { .. })
@@ -99,44 +112,90 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
bake_leaf(d, n)
}
fn bake_leaf(d: &mut Document, n: &Node) -> Option<Node> {
/// `m`, art evaluated from an object, with its children baked and given ids of their own (the
/// pieces copied from the source keep the source's ids until then).
fn bake_pieces(d: &mut Document, mut m: Node) -> Node {
let mut seen = std::collections::HashSet::from([m.id]);
let children = m.children().cloned().unwrap_or_default();
let children = children
.into_iter()
.map(|c| {
let mut c = Arc::unwrap_or_clone(c);
fresh_ids(d, &mut c, &mut seen);
Arc::new(bake_node(d, &c).unwrap_or(c))
})
.collect();
if let Some(ch) = m.children_mut() {
*ch = children;
}
m
}
/// Give `n` and its descendants a new id wherever one was already `seen` (the pieces evaluated art
/// copies from its source keep ids of their own).
pub fn fresh_ids(d: &mut Document, n: &mut Node, seen: &mut std::collections::HashSet<vectorcraft_doc::NodeId>) {
if !seen.insert(n.id) {
n.id = d.alloc_id();
seen.insert(n.id);
}
for c in n.children_mut().into_iter().flatten() {
fresh_ids(d, Arc::make_mut(c), seen);
}
}
/// A new path of `n`'s (its geometry `path`, filled under `rule`) painted by `item` alone, with
/// none of `n`'s transparency.
fn piece(d: &mut Document, n: &Node, path: PathData, rule: FillRule, item: AppearanceItem) -> Node {
let id = d.alloc_id();
let kind = path_kind(d, path, rule);
Node {
id,
name: None,
appearance: Appearance { items: vec![item], ..Default::default() },
opacity: 1.0,
blend: Default::default(),
isolate: false,
knockout: Default::default(),
knockout_shape: false,
mask: None,
trace: None,
wrap: None,
graph: None,
// The group keeps the URL (one link around the pieces).
attrs: None,
kind,
..n.clone()
}
}
/// `n`'s geometry with its object-level geometry effects applied, its fill rule, the context of
/// its effects, and `n` without those effects.
fn leaf_base(n: &Node) -> Option<(PathData, FillRule, GeomContext<'_>, Node)> {
let (base, rule) = geometry(n)?;
let w = n.appearance.stroke_width();
let ctx = GeomContext { stroke_width: if w > 0.0 { w } else { 1.0 } };
let ctx = GeomContext::of(n);
let g = apply(&n.appearance.effects, &base, &ctx);
let mut m = n.clone();
m.appearance.effects.retain(|e| !is_geometry(&e.id));
Some((g, rule, ctx, m))
}
fn bake_leaf(d: &mut Document, n: &Node) -> Option<Node> {
let (g, rule, ctx, mut m) = leaf_base(n)?;
if !n.appearance.items.iter().any(|i| has_geometry(item_effects(i))) {
m.kind = path_kind(d, g, rule);
return Some(m);
}
// Per-item geometry: one path per fill/stroke, grouped under the object's transparency and
// raster effects (what Expand Appearance produces).
// raster effects.
let children = n
.appearance
.items
.iter()
.map(|item| {
let ig = apply(item_effects(item), &g, &ctx);
let ig = apply(item_effects(item), &g, &ctx.item(item));
let mut it = item.clone();
clear_item_effects(&mut it);
let id = d.alloc_id();
let kind = path_kind(d, ig, rule);
Arc::new(Node {
id,
name: None,
appearance: Appearance { items: vec![it], effects: vec![] },
opacity: 1.0,
blend: Default::default(),
isolate: false,
knockout: false,
mask: None,
trace: None,
wrap: None,
graph: None,
kind,
..n.clone()
})
Arc::new(piece(d, n, ig, rule, it))
})
.collect();
m.appearance.items.clear();
@@ -144,6 +203,75 @@ fn bake_leaf(d: &mut Document, n: &Node) -> Option<Node> {
Some(m)
}
/// Turns one stroke of a path (the path after its geometry effects, its fill rule, the stroke
/// without geometry effects) into filled art for [`expand_leaf`]: its outline filled with its
/// paint, or its brush art. `None` when it paints nothing.
pub type StrokeArt<'a> = &'a mut dyn FnMut(&mut Document, &PathData, FillRule, &StrokeLayer) -> Option<Node>;
/// Object → Expand Appearance of a path or compound path `n`: its geometry effects applied, every
/// visible fill becomes a copy of the path painted by that fill alone and every visible stroke the
/// filled art `stroke_art` makes of it (each with its fill's or stroke's opacity and blend mode as
/// its own), in paint order, as the members of a group that keeps
/// `n`'s id, name, transparency, opacity mask and remaining (raster) effects. A single piece
/// without transparency or effects of its own takes `n`'s place itself. Hidden fills, strokes and
/// effects are dropped. `None` for other kinds of objects.
pub fn expand_leaf(d: &mut Document, n: &Node, stroke_art: StrokeArt) -> Option<Node> {
let (g, rule, ctx, mut m) = leaf_base(n)?;
m.appearance.effects.retain(|e| e.visible);
let mut pieces = vec![];
for item in n.appearance.items.iter().filter(|i| paints(i)) {
let ig = apply(item.effects(), &g, &ctx.item(item));
let mut it = item.clone();
clear_item_effects(&mut it);
it.effects_mut().retain(|e| e.visible);
let p = match &it {
AppearanceItem::Stroke(st) => stroke_art(d, &ig, rule, st),
AppearanceItem::Fill(_) => Some(piece(d, n, ig, rule, it)),
};
pieces.extend(p.map(hoist_transparency));
}
m.appearance.items.clear();
m.appearance.contents_index = None;
match pieces.as_slice() {
[one] if m.appearance.effects.is_empty() && one.has_default_transparency() && one.appearance.effects.is_empty() => {
m.kind = one.kind.clone();
m.appearance = one.appearance.clone();
}
_ => m.kind = NodeKind::Group { children: pieces.into_iter().map(Arc::new).collect(), clip: false },
}
Some(m)
}
/// `p` with the opacity and blend mode of its only fill or stroke as its own (the same look, and a
/// basic appearance).
fn hoist_transparency(mut p: Node) -> Node {
if p.has_default_transparency()
&& let [item] = p.appearance.items.as_mut_slice()
{
let (opacity, blend) = match item {
AppearanceItem::Fill(f) => (&mut f.opacity, &mut f.blend),
AppearanceItem::Stroke(s) => (&mut s.opacity, &mut s.blend),
};
p.opacity = std::mem::replace(opacity, 1.0);
p.blend = std::mem::take(blend);
}
p
}
/// [`expand_leaf`] every path and compound path in `n` (art made for Expand Appearance, such as a
/// group's own fills and strokes or outlined type), in place.
pub fn expand_art(d: &mut Document, n: &mut Node, stroke_art: StrokeArt) {
if matches!(n.kind, NodeKind::Path { clipping: false, guide: false, .. } | NodeKind::Compound { .. }) {
if let Some(m) = expand_leaf(d, n, stroke_art) {
*n = m;
}
return;
}
for c in n.children_mut().into_iter().flatten() {
expand_art(d, Arc::make_mut(c), stroke_art);
}
}
/// A copy of `doc` with every live geometry effect baked into plain paths, or `None` when the
/// document has none (export it as is).
pub fn bake_document(doc: &Document) -> Option<Document> {
+26
View File
@@ -0,0 +1,26 @@
//! The region a clip group clips to ([`Node::clip_outline`]) with text outlined by the font engine
//! and several shapes united, so the renderer and the SVG and PDF writers clip alike.
use vectorcraft_doc::{Appearance, Node, NodeKind};
use vectorcraft_geom::{BezPath, FillRule, PathData};
/// The clipping path `clip` of a clip group as one path and fill rule: compound paths keep their
/// holes, even-odd paths their rule, text clips by its glyph outlines and a group by the union of
/// its members. `None` when there is nothing to clip by (the clipped art is then hidden).
pub fn clip_outline(clip: &Node) -> Option<(BezPath, FillRule)> {
clip.clip_outline(Some(&outline_text), &unite)
}
/// Text as one path of its glyph outlines, in document space.
fn outline_text(n: &Node) -> Option<Node> {
let NodeKind::Text(t) = &n.kind else { return None };
let mut bp = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).to_bezpath();
bp.apply_affine(t.xf);
Some(Node::path(n.id, PathData::from_bezpath(&bp), Appearance::default()))
}
fn unite(shapes: &[(BezPath, FillRule)]) -> BezPath {
let paths: Vec<(PathData, FillRule)> = shapes.iter().map(|(bp, r)| (PathData::from_bezpath(bp), *r)).collect();
let refs: Vec<(&PathData, FillRule)> = paths.iter().map(|(p, r)| (p, *r)).collect();
vectorcraft_pathops::unite_all(&refs).to_bezpath()
}
+159 -11
View File
@@ -1,14 +1,20 @@
//! Effect → Pathfinder: live Pathfinder operations on a group (or layer).
//! The appearance of groups and layers.
//!
//! The group's direct children are the Pathfinder stack (back → front). In the shape modes (Add,
//! Subtract, Intersect, Exclude, Minus Back) a child group acts as one shape — the union of its
//! leaves, styled like its top leaf — as in the Pathfinder panel. The result replaces the children
//! at render time; each result keeps the appearance of the object whose paint it takes.
//! **Effect → Pathfinder**: live Pathfinder operations on a group (or layer). The group's direct
//! children are the Pathfinder stack (back → front). In the shape modes (Add, Subtract, Intersect,
//! Exclude, Minus Back) a child group acts as one shape — the union of its leaves, styled like its
//! top leaf — as in the Pathfinder panel. The result replaces the children at render time; each
//! result keeps the appearance of the object whose paint it takes.
//!
//! **Container appearance** ([`evaluate_container`]): a group's or layer's own fills and strokes
//! paint every member's geometry, below or above the members as its contents slot says
//! ([`Appearance::contents_at`]); its geometry effects reshape the members as one piece; its raster
//! effects (one combined shadow) apply to the composite and stay for the renderer and exporters.
use std::sync::Arc;
use vectorcraft_doc::{Appearance, AppearanceItem, Node, NodeKind, StrokeLayer};
use vectorcraft_geom::{FillRule, PathData};
use vectorcraft_doc::{Appearance, AppearanceItem, Node, NodeId, NodeKind, StrokeLayer};
use vectorcraft_geom::{FillRule, PathData, Rect};
use vectorcraft_pathops::{self as po, PathfinderOp};
/// Effect ids of the Pathfinder submenu with their operation, in Illustrator's menu order.
@@ -74,11 +80,11 @@ fn geometry(n: &Node) -> Option<(PathData, FillRule)> {
/// A result path styled like `src` (its appearance, opacity and blend mode).
fn styled(src: &Node, path: PathData, outline: bool) -> Node {
let mut appearance = Appearance { items: src.appearance.items.clone(), effects: vec![] };
let mut appearance = Appearance { items: src.appearance.items.clone(), ..Default::default() };
if outline {
let fill = src.appearance.fill_paint();
let paint = if fill.is_none() { src.appearance.stroke_paint() } else { fill };
appearance = Appearance { items: vec![AppearanceItem::Stroke(StrokeLayer::new(paint, 1.0))], effects: vec![] };
appearance = Appearance { items: vec![AppearanceItem::Stroke(StrokeLayer::new(paint, 1.0))], ..Default::default() };
}
let mut n = Node::path(src.id, path, appearance);
n.opacity = src.opacity;
@@ -106,8 +112,11 @@ pub fn pathfinder_children(group: &Node, hook: Option<OutlineHook>) -> Option<Ve
if shape_mode && lv.len() > 1 {
let parts: Vec<(PathData, FillRule)> = lv.iter().filter_map(geometry).collect();
let refs: Vec<(&PathData, FillRule)> = parts.iter().map(|(p, r)| (p, *r)).collect();
shapes.push(po::Shape::new(po::unite_all(&refs), FillRule::NonZero, sources.len() as u64));
sources.push(lv.pop().expect("non-empty"));
let key = sources.len() as u64;
if let Some(top) = lv.pop() {
shapes.push(po::Shape::new(po::unite_all(&refs), FillRule::NonZero, key));
sources.push(top);
}
} else {
for l in lv {
if let Some((p, r)) = geometry(&l) {
@@ -130,6 +139,145 @@ pub fn pathfinder_children(group: &Node, hook: Option<OutlineHook>) -> Option<Ve
Some(stack)
}
fn is_container(n: &Node) -> bool {
matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. })
}
/// Does `item` paint anything?
pub fn paints(item: &AppearanceItem) -> bool {
item.visible()
&& !item.paint().is_none()
&& match item {
AppearanceItem::Stroke(s) => s.width > 0.0,
AppearanceItem::Fill(_) => true,
}
}
/// Do the own fills or strokes of `n`, a group or layer, paint anything?
pub(crate) fn has_own_paint(n: &Node) -> bool {
is_container(n) && n.appearance.items.iter().any(paints)
}
/// Does `n`, a group or layer, have an appearance of its own to evaluate: a fill or stroke that
/// paints, or a visible effect other than Pathfinder (evaluated by [`pathfinder_children`])?
pub fn has_container_appearance(n: &Node) -> bool {
is_container(n) && (has_own_paint(n) || n.appearance.effects.iter().any(|e| e.visible && !is_pathfinder(&e.id)))
}
/// The regions a group's or layer's own fills and strokes paint, in document space, each with its
/// fill rule: every visible member path and compound path, type as its glyph outlines and live
/// objects evaluated; the members of a clip group or clipping layer without its clipping path.
/// Images, symbol instances and guides add nothing.
pub fn member_shapes(n: &Node) -> Vec<(PathData, FillRule)> {
fn push(n: &Node, out: &mut Vec<(PathData, FillRule)>) {
if !n.visible {
return;
}
match &n.kind {
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => out.extend(crate::reshape::leaf_geometry(n)),
NodeKind::Text(t) => {
let (_, all) = crate::reshape::glyph_outlines(t);
if !all.elements().is_empty() {
out.push((PathData::from_bezpath(&all).transformed(t.xf), FillRule::NonZero));
}
}
NodeKind::Group { .. } | NodeKind::Layer { .. } => members(n, out),
_ if vectorcraft_doc::live::is_live(n) => members(&crate::reshape::outlined_members(n), out),
_ => {}
}
}
fn members(n: &Node, out: &mut Vec<(PathData, FillRule)>) {
let clip = usize::from(n.clips());
for c in n.children().into_iter().flatten().skip(clip) {
push(c, out);
}
}
let mut out = vec![];
members(n, &mut out);
out
}
/// A path node of `id` on `path` (filled under `rule`) painted by `ap`.
fn shape_node(id: NodeId, path: PathData, rule: FillRule, ap: Appearance) -> Node {
let mut n = Node::path(id, path, ap);
if let NodeKind::Path { rule: r, .. } = &mut n.kind {
*r = rule;
}
n
}
/// One of a container's own fills or strokes as art: `item` painting every shape of `shapes`, an
/// unplaced gradient placed across `bounds` (the whole group). Several shapes share the item's
/// opacity and blend mode as one layer, as one fill would. `None` when it paints nothing.
fn item_art(id: NodeId, item: &AppearanceItem, shapes: &[(PathData, FillRule)], bounds: Option<Rect>) -> Option<Node> {
if !paints(item) || shapes.is_empty() {
return None;
}
let mut ap = Appearance { items: vec![item.clone()], ..Default::default() };
if let Some(b) = bounds {
ap.pin_gradients(b);
}
if let [(path, rule)] = shapes {
return Some(shape_node(id, path.clone(), *rule, ap));
}
let (opacity, blend) = (item.opacity(), item.blend());
match &mut ap.items[0] {
AppearanceItem::Fill(f) => (f.opacity, f.blend) = (1.0, Default::default()),
AppearanceItem::Stroke(s) => (s.opacity, s.blend) = (1.0, Default::default()),
}
let children = shapes.iter().map(|(p, r)| Arc::new(shape_node(id, p.clone(), *r, ap.clone()))).collect();
let mut g = Node::group(id, children);
(g.opacity, g.blend) = (opacity, blend);
Some(g)
}
/// `n`, a group or layer, as plain art: its Pathfinder effects evaluated, its geometry effects
/// reshaping all its members as one piece (the whole art's bounds as their reference box), and its
/// own fills and strokes turned into art painting the members' geometry ([`member_shapes`]) below
/// the members (after a clipping path) or above them, as its contents slot says. Its
/// transparency, opacity mask and raster effects stay. `None` when it has none of these.
pub fn evaluate_container(n: &Node) -> Option<Node> {
if !is_container(n) {
return None;
}
let pathfinder = has_pathfinder(n);
let fx = crate::reshape::geometry_effects(n);
let paint = has_own_paint(n);
if !pathfinder && fx.is_empty() && !paint {
return None;
}
let mut m = n.clone();
if pathfinder {
let hook: &dyn Fn(&Node) -> Option<Node> = &crate::outline_text;
let result = pathfinder_children(n, Some(hook)).unwrap_or_default();
if let Some(ch) = m.children_mut() {
*ch = result;
}
}
if !fx.is_empty() {
m = crate::reshape::outlined_members(&m);
crate::reshape::reshape_leaves(&mut m, &fx);
}
m.appearance.effects.retain(|e| !is_pathfinder(&e.id) && !crate::is_geometry(&e.id));
if paint {
let shapes = member_shapes(&m);
let bounds = m.geometric_bounds();
let art =
|items: &[AppearanceItem]| -> Vec<Arc<Node>> { items.iter().filter_map(|i| item_art(n.id, i, &shapes, bounds)).map(Arc::new).collect() };
let (below, above) = m.appearance.split_contents();
let (below, above) = (art(below), art(above));
let at = usize::from(m.clips());
if let Some(ch) = m.children_mut() {
let at = at.min(ch.len());
ch.splice(at..at, below);
ch.extend(above);
}
}
m.appearance.items.clear();
m.appearance.contents_index = None;
Some(m)
}
#[cfg(test)]
mod tests {
use super::*;
+144 -20
View File
@@ -8,31 +8,51 @@
//! - **Raster effects** (Drop Shadow, Inner/Outer Glow, Feather, Gaussian Blur) are described by
//! [`raster_effects`] and painted by the renderer; [`outset`] says how far they reach beyond
//! the geometry.
//! - **Stroke geometry** ([`stroke`]): arrowheads, dash patterns and width profiles, shared by the
//! renderer, the exporters and Outline Stroke.
//! - [`effect_catalog`] lists every effect with its menu path, parameter documentation and the
//! defaults of Illustrator's dialogs. Missing parameters always fall back to those defaults.
//! - [`reshape`] applies geometry effects to type, images, symbol instances and live objects
//! through their outlines.
//! - [`evaluate_container`] turns a group's or layer's own fills, strokes and geometry effects into
//! art painting its members (its raster effects then apply to the composite).
//! - [`clip_outline`] is the region a clip group clips to, shared by the renderer and the SVG and
//! PDF writers.
//!
//! Everything is deterministic: "random" effects (Roughen, Tweak, Scribble) use a seeded hash
//! noise (`seed` parameter, default 0).
#![forbid(unsafe_code)]
mod bake;
mod clip;
mod distort;
mod group;
mod marks;
mod raster;
mod reshape;
pub mod stroke;
mod stylize;
mod util;
mod warp;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_scale;
use serde_json::{Map, Value, json};
use vectorcraft_doc::Effect;
use vectorcraft_geom::{BezPath, PathData, Rect};
use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeKind, StrokeLayer};
use vectorcraft_geom::{BezPath, FillRule, PathData, Rect};
pub use bake::{bake_document, needs_bake};
pub use group::{OutlineHook, PATHFINDER_EFFECTS, has_pathfinder, is_pathfinder, pathfinder_children};
pub use bake::{StrokeArt, bake_document, expand_art, expand_leaf, fresh_ids, needs_bake};
pub use clip::clip_outline;
pub use group::{
OutlineHook, PATHFINDER_EFFECTS, evaluate_container, has_container_appearance, has_pathfinder, is_pathfinder, member_shapes, paints,
pathfinder_children,
};
pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks};
pub use raster::{RasterFx, outset, raster_effects};
pub use reshape::{expand_outlined, needs_outline, outline_art, outline_text, reshape};
pub use warp::{WarpStyle, warp_point};
/// Catalogue entry for one effect.
@@ -50,6 +70,50 @@ pub struct EffectInfo {
pub defaults: Value,
/// Raster (painted) rather than geometry effect.
pub raster: bool,
/// The parameters that are distances in points (Scale Strokes & Effects scales them).
pub lengths: Lengths,
}
/// Which of an effect's parameters are distances in points.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Lengths {
/// Always distances.
pub always: &'static [&'static str],
/// Distances while the effect's `relative` parameter is false (percentages of the object's
/// size otherwise).
pub absolute: &'static [&'static str],
}
impl Lengths {
/// The distance parameters while the effect's `relative` parameter is `relative`.
pub fn keys(&self, relative: bool) -> impl Iterator<Item = &'static str> + '_ {
self.always.iter().chain(if relative { &[][..] } else { self.absolute }).copied()
}
}
/// Is parameter `key` of effect `id` a distance in points while its `relative` parameter is
/// `relative`?
pub fn is_length(id: &str, key: &str, relative: bool) -> bool {
catalog_index().get(id).is_some_and(|info| info.lengths.keys(relative).any(|k| k == key))
}
/// The distance parameters of effect `id` (none for unknown effects).
fn lengths_of(id: &str) -> Lengths {
let always = |always| Lengths { always, absolute: &[] };
match id {
"convertToShape.rectangle" | "convertToShape.ellipse" => always(&["extraW", "extraH", "width", "height"]),
"convertToShape.roundedRectangle" => always(&["extraW", "extraH", "width", "height", "radius"]),
"distort.roughen" | "distort.zigZag" => Lengths { always: &[], absolute: &["size"] },
"distort.tweak" => Lengths { always: &[], absolute: &["h", "v"] },
"distort.transform" => always(&["moveH", "moveV"]),
"path.offsetPath" => always(&["offset"]),
"path.outlineStroke" => always(&["width"]),
"stylize.roundCorners" | "stylize.feather" | "blur.gaussian" => always(&["radius"]),
"stylize.scribble" => always(&["overlap", "strokeWidth", "spacing", "variation"]),
"stylize.dropShadow" => always(&["x", "y", "blur"]),
"stylize.innerGlow" | "stylize.outerGlow" => always(&["blur"]),
_ => Lengths::default(),
}
}
const WARP_DOC: &str =
@@ -83,8 +147,8 @@ pub const WARP_STYLES: [(&str, &str); 15] = [
/// Every supported effect with its dialog defaults.
pub fn effect_catalog() -> Vec<EffectInfo> {
let g = |id, label, menu, params, defaults| EffectInfo { id, label, menu, params, defaults, raster: false };
let r = |id, label, menu, params, defaults| EffectInfo { id, label, menu, params, defaults, raster: true };
let g = |id, label, menu, params, defaults| EffectInfo { id, label, menu, params, defaults, raster: false, lengths: lengths_of(id) };
let r = |id, label, menu, params, defaults| EffectInfo { id, label, menu, params, defaults, raster: true, lengths: lengths_of(id) };
let mut v = vec![
g(
"convertToShape.rectangle",
@@ -151,7 +215,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"{offset: pt (10; negative insets), joins: \"miter\"|\"round\"|\"bevel\", miterLimit: (4)}",
json!({"offset": 10.0, "joins": "miter", "miterLimit": 4.0}),
),
g("path.outlineStroke", "Outline Stroke", PATH, "{width?: pt (defaults to the object's stroke weight)}", json!({})),
g(
"path.outlineStroke",
"Outline Stroke",
PATH,
"{width?: pt (defaults to the stroke's weight)} the outline of the stroke the effect is on (on a fill or the object: the top stroke), with its caps, joins, dashes, alignment, width profile and arrowheads",
json!({}),
),
g("stylize.roundCorners", "Round Corners…", STYLIZE, "{radius: pt (10)}", json!({"radius": 10.0})),
g(
"stylize.scribble",
@@ -207,6 +277,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
for (id, label, _) in PATHFINDER_EFFECTS {
v.push(g(id, label, PATHFINDER, "{} (groups and layers: live Pathfinder over the members)", json!({})));
}
v.push(g(
CROP_MARKS,
"Crop Marks",
&["Effect"],
"{style?: \"roman\"|\"japanese\" (default: the japaneseCropMarks preference when applied)} trim marks in [Registration] around the object's bounds, following it",
json!({}),
));
v
}
@@ -246,14 +323,32 @@ pub fn new_effect(id: &str, params: &Value) -> Option<Effect> {
Some(Effect { id: id.to_string(), params: merged_params(id, params), visible: true })
}
/// Scale the distance parameters of `e` by `s` (Scale Strokes & Effects; see
/// [`EffectInfo::lengths`]). A distance left at its default is written out scaled; one the
/// effect doesn't have (Outline Stroke's weight following its stroke) stays absent.
pub fn scale_effect(e: &mut Effect, s: f64) {
let Some(info) = catalog_index().get(e.id.as_str()) else { return };
let merged = merged_params(&e.id, &e.params);
let relative = util::flag(&merged, "relative", false);
for k in info.lengths.keys(relative) {
let v = util::num(&merged, k, f64::NAN);
if v.is_finite() {
if !e.params.is_object() {
e.params = Value::Object(Map::new());
}
e.params[k] = json!(v * s);
}
}
}
/// Is `id` a raster (painted) effect?
pub fn is_raster(id: &str) -> bool {
matches!(id, "stylize.dropShadow" | "stylize.innerGlow" | "stylize.outerGlow" | "stylize.feather" | "blur.gaussian")
}
/// Does `id` change geometry?
/// Does `id` change geometry? (Crop Marks adds art of its own instead, [`crop_marks_art`].)
pub fn is_geometry(id: &str) -> bool {
!is_raster(id) && !is_pathfinder(id) && catalog_index().contains_key(id)
!is_raster(id) && !is_pathfinder(id) && id != CROP_MARKS && catalog_index().contains_key(id)
}
/// Any visible geometry effect in the list?
@@ -262,15 +357,33 @@ pub fn has_geometry(effects: &[Effect]) -> bool {
}
/// Context the geometry effects may need from the object's appearance.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct GeomContext {
/// Stroke weight used by `path.outlineStroke` when the effect has no `width`.
pub stroke_width: f64,
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct GeomContext<'a> {
/// The stroke `path.outlineStroke` outlines (its weight, caps, joins, dashes, alignment,
/// profile and arrowheads): the stroke the effect sits on, else the object's top painted
/// stroke. `None` outlines a plain 1 pt stroke.
pub stroke: Option<&'a StrokeLayer>,
/// The object's fill rule (inside and outside alignment).
pub rule: FillRule,
}
impl Default for GeomContext {
fn default() -> Self {
Self { stroke_width: 1.0 }
impl<'a> GeomContext<'a> {
/// The context of `n`'s object-level effects.
pub fn of(n: &'a Node) -> Self {
let rule = match &n.kind {
NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. } => *rule,
_ => FillRule::NonZero,
};
Self { stroke: n.appearance.stroke().filter(|s| !s.paint.is_none() && s.width > 0.0), rule }
}
/// The context of the effects on `item`, one of the same object's appearance items: a
/// stroke's own effects outline that stroke.
pub fn item(self, item: &'a AppearanceItem) -> Self {
match item {
AppearanceItem::Stroke(s) => Self { stroke: Some(s), ..self },
AppearanceItem::Fill(_) => self,
}
}
}
@@ -305,7 +418,7 @@ pub fn apply_geometry_bez(effects: &[Effect], path: &BezPath, bounds: Rect, ctx:
apply_geometry_with(effects, &PathData::from_bezpath(path), bounds, ctx).to_bezpath()
}
fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext) -> PathData {
pub(crate) fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext) -> PathData {
use util::*;
match id {
"distort.freeDistort" => distort::free_distort(path, b, p),
@@ -323,11 +436,22 @@ fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext) -
vectorcraft_pathops::offset_path(path, off, join(p, "joins"), num(p, "miterLimit", 4.0).clamp(1.0, 500.0))
}
"path.outlineStroke" => {
let w = p.get("width").and_then(Value::as_f64).unwrap_or(ctx.stroke_width);
if w <= 0.0 {
let fallback;
let base = match ctx.stroke {
Some(s) => s,
None => {
fallback = StrokeLayer::new(vectorcraft_doc::color::Paint::None, 1.0);
&fallback
}
};
let st = match p.get("width").and_then(Value::as_f64) {
Some(w) => std::borrow::Cow::Owned(StrokeLayer { width: w, ..base.clone() }),
None => std::borrow::Cow::Borrowed(base),
};
if st.width.is_nan() || st.width <= 0.0 {
return path.clone();
}
vectorcraft_pathops::outline_stroke(path, w, vectorcraft_pathops::Cap::Butt, join(p, "joins"), num(p, "miterLimit", 10.0).max(1.0))
stroke::outline_region(path, ctx.rule, &st)
}
"convertToShape.rectangle" | "convertToShape.roundedRectangle" | "convertToShape.ellipse" => stylize::convert_to_shape(id, b, p),
"stylize.roundCorners" => stylize::round_corners(path, num(p, "radius", 10.0)),
+70
View File
@@ -0,0 +1,70 @@
//! Effect → Crop Marks: live trim marks in the Registration colour around the object, which follow
//! it as it moves or changes size. The renderer, the exporters ([`crate::bake_document`]) and
//! Expand Appearance draw the object as [`crop_marks_art`].
use std::sync::Arc;
use vectorcraft_doc::Node;
use vectorcraft_doc::marks::{MarkStyle, TrimMarks};
/// The effect id.
pub const CROP_MARKS: &str = "cropMarks";
/// Does `n` carry a visible Crop Marks effect?
pub fn has_crop_marks(n: &Node) -> bool {
n.appearance.effects.iter().any(|e| e.visible && e.id == CROP_MARKS)
}
/// The art of an object with Crop Marks: a group (with the object's id, name and transparency) of
/// the object itself, without the effect, and the marks around its visual bounds (the last Crop
/// Marks effect's style). `None` without a visible Crop Marks effect.
pub fn crop_marks_art(n: &Node) -> Option<Node> {
let fx = n.appearance.effects.iter().rev().find(|e| e.visible && e.id == CROP_MARKS)?;
let style = fx.params.get("style").and_then(|v| v.as_str()).and_then(MarkStyle::parse).unwrap_or_default();
let bounds = n.visual_bounds()?;
let marks = TrimMarks::of(style).group(bounds, &mut || n.id);
let mut object = n.clone();
object.appearance.effects.retain(|e| e.id != CROP_MARKS);
// The group takes over the object's identity, transparency, mask and link.
let mut g = Node::group(n.id, vec![]);
(g.visible, g.locked) = (n.visible, n.locked);
g.name = object.name.take();
g.opacity = std::mem::replace(&mut object.opacity, 1.0);
g.blend = std::mem::take(&mut object.blend);
g.isolate = std::mem::take(&mut object.isolate);
g.mask = object.mask.take();
g.attrs = object.attrs.take();
if let Some(ch) = g.children_mut() {
*ch = vec![Arc::new(object), Arc::new(marks)];
}
Some(g)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use vectorcraft_doc::{Appearance, Effect, NodeId};
use vectorcraft_geom::{Rect, shapes};
#[test]
fn the_marks_surround_the_object_and_follow_it() {
let mut n = Node::path(NodeId(7), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)), Appearance::default_art());
assert!(crop_marks_art(&n).is_none());
n.opacity = 0.5;
n.appearance.effects.push(Effect { id: CROP_MARKS.into(), params: json!({"style": "japanese"}), visible: true });
let art = crop_marks_art(&n).unwrap();
assert_eq!((art.id, art.opacity), (NodeId(7), 0.5));
let [object, marks] = art.children().unwrap().as_slice() else { panic!() };
assert!(object.appearance.effects.is_empty() && object.opacity == 1.0);
assert_eq!(marks.children().unwrap().len(), 24, "Japanese marks");
// Around the stroked bounds (0.5 outside the path), by the marks' reach.
let reach = TrimMarks::of(MarkStyle::Japanese).reach() + 0.5;
let b = art.visual_bounds().unwrap();
assert!((b.x0 + reach).abs() < 2.0 && (b.x1 - 100.0 - reach).abs() < 2.0, "{b:?}");
// Moved, the marks move along.
n.transform(vectorcraft_geom::Affine::translate((40.0, 0.0)), false);
let b2 = crop_marks_art(&n).unwrap().visual_bounds().unwrap();
assert!((b2.x0 - b.x0 - 40.0).abs() < 1e-6);
}
}
+223
View File
@@ -0,0 +1,223 @@
//! Object-level effects on objects that aren't paths: type, placed images, symbol instances and
//! live objects (blends, envelopes, meshes, repeats).
//!
//! Geometry effects reach them through outlines: [`outline_art`] turns the object into plain paths
//! (glyph outlines, the evaluated live object, the symbol's art) and [`reshape`] runs each effect
//! over all of them as one piece, with the whole art's bounds as the effect's reference box, so a
//! warp bends a line of type as a whole. An image is clipped by its reshaped frame. Raster effects
//! (shadows, glows, blur, feather) stay on the result for the renderer and exporters.
use std::sync::Arc;
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, Node, NodeKind};
use vectorcraft_geom::{FillRule, PathData, Rect, shapes};
use crate::{GeomContext, apply_one, is_geometry, merged_params};
/// Can object-level effects apply to `n` only through [`outline_art`] / an offscreen layer (it is
/// neither a path nor a container)?
pub fn needs_outline(n: &Node) -> bool {
matches!(
n.kind,
NodeKind::Text(_)
| NodeKind::Image(_)
| NodeKind::SymbolInstance { .. }
| NodeKind::Blend { .. }
| NodeKind::Envelope { .. }
| NodeKind::Mesh(_)
| NodeKind::Repeat(_)
)
}
/// The glyph outlines of type `t` in text space: one path per run, and all of them as one.
pub(crate) fn glyph_outlines(t: &vectorcraft_doc::TextObject) -> (Vec<kurbo::BezPath>, kurbo::BezPath) {
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let mut runs = vec![kurbo::BezPath::new(); t.runs.len()];
let mut all = kurbo::BezPath::new();
for g in &layout.glyphs {
if let Some(r) = runs.get_mut(g.run) {
r.extend(g.outline.iter());
}
all.extend(g.outline.iter());
}
(runs, all)
}
/// Type as glyph outlines in document space: one path per run with the run's fill and stroke,
/// and (when the object has its own fills/strokes) the whole outline with the object's items below
/// the characters under them and the others over them, as the renderer paints them.
pub fn outline_text(n: &Node) -> Option<Node> {
let NodeKind::Text(t) = &n.kind else { return None };
let (runs, all) = glyph_outlines(t);
let path = |bp: &kurbo::BezPath| PathData::from_bezpath(bp).transformed(t.xf);
let mut children: Vec<Arc<Node>> = runs
.iter()
.zip(&t.runs)
.filter(|(bp, _)| !bp.elements().is_empty())
.map(|(bp, run)| Arc::new(Node::path(n.id, path(bp), run.style.appearance())))
.collect();
if !n.appearance.items.is_empty() && !all.elements().is_empty() {
let (below, above) = n.appearance.split_contents();
let glyphs = |items: &[AppearanceItem]| Arc::new(Node::path(n.id, path(&all), Appearance { items: items.to_vec(), ..Default::default() }));
if !below.is_empty() {
children.insert(0, glyphs(below));
}
if !above.is_empty() {
children.push(glyphs(above));
}
}
Some(Node::group(n.id, children))
}
/// `n` as plain art that geometry effects can reshape, without its transparency and object
/// effects: type outlined ([`outline_text`]), live objects evaluated (type inside them outlined),
/// a symbol instance as `symbol_art` (the symbol's art, stained as the caller draws it) placed by
/// the instance's transform. `None` for other kinds (images, paths, containers).
pub fn outline_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
let hook: &dyn Fn(&Node) -> Option<Node> = &outline_text;
let mut art = match &n.kind {
NodeKind::Text(_) => outline_text(n)?,
NodeKind::SymbolInstance { xf, .. } => {
let mut a = symbol_art?.clone();
a.transform(*xf, false);
Node::group(n.id, vec![Arc::new(a)])
}
_ if vectorcraft_doc::live::is_live(n) => vectorcraft_doc::live::expand_deep(n, Some(hook)),
_ => return None,
};
outline_nested_text(&mut art);
art.opacity = 1.0;
art.blend = Default::default();
art.isolate = false;
// One element: its pieces never knock each other out.
art.knockout = vectorcraft_doc::Knockout::Off;
art.mask = None;
art.appearance.effects.clear();
Some(art)
}
/// Replace type anywhere inside `n` by its outlines.
fn outline_nested_text(n: &mut Node) {
let Some(ch) = n.children_mut() else { return };
for c in ch.iter_mut() {
if let Some(o) = outline_text(c) {
*c = Arc::new(o);
} else if c.children().is_some() {
outline_nested_text(Arc::make_mut(c));
}
}
}
/// `n` (see [`needs_outline`]) with its object-level geometry effects applied: its [`outline_art`]
/// reshaped as one piece, or for an image the image clipped by its reshaped frame. The result is
/// a group with `n`'s id, name, transparency, opacity mask and remaining (raster) effects; `None`
/// when `n` has no visible geometry effect or no art (a symbol instance without `symbol_art`).
pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
if geometry_effects(n).is_empty() || !needs_outline(n) {
return None;
}
as_art(n, symbol_art)
}
/// `n` (see [`needs_outline`]) as plain art for Expand Appearance: [`reshape`]d when it has
/// geometry effects, else type with fills or strokes of its own outlined, in the same form. `None`
/// when it has neither.
pub fn expand_outlined(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
let own_paint = matches!(n.kind, NodeKind::Text(_)) && !n.appearance.items.is_empty();
if !needs_outline(n) || (geometry_effects(n).is_empty() && !own_paint) {
return None;
}
as_art(n, symbol_art)
}
/// [`reshape`] without its checks (no geometry effects: just the art).
fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
let fx = geometry_effects(n);
let mut art = match &n.kind {
NodeKind::Image(im) => {
let frame = shapes::rectangle(Rect::new(0.0, 0.0, im.width as f64, im.height as f64)).transformed(im.xf);
let mut clip = Node::path(n.id, frame, Appearance::default());
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
*clipping = true;
}
let image = Node { appearance: Appearance::default(), ..Node::new(n.id, n.kind.clone()) };
Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image)], clip: true })
}
_ => outline_art(n, symbol_art)?,
};
reshape_leaves(&mut art, &fx);
// The art is a group with `n`'s id (a clip group for an image).
let mut out = art;
out.name = n.name.clone();
out.opacity = n.opacity;
out.blend = n.blend;
out.isolate = n.isolate;
// One element: its pieces never knock each other out.
out.knockout = vectorcraft_doc::Knockout::Off;
out.mask = n.mask.clone();
out.appearance.effects = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect();
Some(out)
}
/// The visible geometry effects of `n`'s own effect list, in order.
pub(crate) fn geometry_effects(n: &Node) -> Vec<&Effect> {
n.appearance.effects.iter().filter(|e| e.visible && is_geometry(&e.id)).collect()
}
/// Apply `fx` to every path under `art` as one piece: each effect takes the whole art's bounds as
/// its reference box.
pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) {
for e in fx {
let Some(bounds) = art.geometric_bounds() else { break };
let params = merged_params(&e.id, &e.params);
for_each_leaf(art, &mut |leaf| {
let Some((path, rule)) = leaf_geometry(leaf) else { return };
let out = apply_one(&e.id, &params, &path, bounds, &GeomContext::of(leaf));
set_geometry(leaf, out, rule);
});
}
}
/// `n` with type anywhere inside it outlined and live objects evaluated, so geometry effects on
/// it reach every member.
pub(crate) fn outlined_members(n: &Node) -> Node {
let hook: &dyn Fn(&Node) -> Option<Node> = &outline_text;
let mut art = vectorcraft_doc::live::expand_deep(n, Some(hook));
outline_nested_text(&mut art);
art
}
/// Run `f` on every path and compound path under `n` (guides skipped).
fn for_each_leaf(n: &mut Node, f: &mut dyn FnMut(&mut Node)) {
match &mut n.kind {
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => f(n),
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => {
for c in children.iter_mut() {
for_each_leaf(Arc::make_mut(c), f);
}
}
_ => {}
}
}
pub(crate) fn leaf_geometry(n: &Node) -> Option<(PathData, FillRule)> {
match &n.kind {
NodeKind::Path { path, rule, .. } => Some((path.clone(), *rule)),
NodeKind::Compound { children, rule } => {
Some((PathData::new(children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.iter().cloned()).collect()), *rule))
}
_ => None,
}
}
/// Replace the geometry of path or compound `n` (a compound when `path` has several subpaths).
fn set_geometry(n: &mut Node, path: PathData, rule: FillRule) {
let clipping = matches!(n.kind, NodeKind::Path { clipping: true, .. });
n.kind = if path.subpaths.len() > 1 {
let id = n.id;
let children = path.subpaths.into_iter().map(|sp| Arc::new(Node::path(id, PathData::single(sp), Appearance::default()))).collect();
NodeKind::Compound { children, rule }
} else {
NodeKind::Path { path, rule, live: None, clipping, guide: false }
};
}
+141
View File
@@ -0,0 +1,141 @@
//! Stroke geometry shared by the canvas, the PDF/SVG exporters and Outline Stroke, so every
//! consumer paints the same shapes:
//!
//! - [`stroke_pieces`]: the centre line (trimmed under the arrowheads) and the arrowheads as
//! filled outlines (hollow kinds are rings), placed by [`ArrowAlign`](vectorcraft_doc::ArrowAlign);
//! - [`dash`]: the dash pattern, exact or fitted to corners and path ends; zero-length dashes
//! become [`Dot`]s that a round or projecting cap turns into discs or squares ([`dot_outline`]);
//! - [`width_outline`]: variable-width (profile) strokes, with the stroke's joins at corners;
//! - [`line_outline`]: the line part of a stroke as one filled outline (profile, dashes, dots,
//! caps and joins);
//! - [`for_writer`] and [`outline_region`]: strokes for the SVG/PDF writers and Outline Stroke;
//! - [`gradient_slices`] and [`gradient_meshes`]: gradients laid along or across a stroke.
mod arrow;
mod dash;
mod gradient;
mod outline;
mod width;
use std::borrow::Cow;
use kurbo::{BezPath, PathEl, PathSeg};
use vectorcraft_doc::{LineCap, LineJoin, StrokeAlign, StrokeLayer};
pub(crate) use vectorcraft_geom::bez::{kink, segments, subpaths, tangent, unit};
pub use arrow::Arrow;
pub use dash::{Dashed, Dot, dash, dot_outline};
pub use gradient::{Slice, WrittenSlices, gradient_meshes, gradient_slices, written_slices};
pub use outline::{OUTLINE_TOL, Written, WrittenShape, for_writer, is_plain, outline_region};
pub use width::width_outline;
/// Arc-length accuracy for dashing and trimming (document points).
const ARCLEN_ACCURACY: f64 = 1e-6;
/// The geometry one stroke paints, in the coordinate space of its path.
#[derive(Clone, Debug)]
pub struct StrokePieces<'a> {
/// The centre line to stroke: the path with its ends trimmed under the arrowheads (borrowed
/// when nothing is trimmed). Empty when the heads cover the whole path.
pub line: Cow<'a, BezPath>,
/// The arrowheads, start first. Closed ends get none.
pub heads: Vec<Arrow>,
}
/// The centre line and arrowheads of stroke `st` along `bp`.
pub fn stroke_pieces<'a>(bp: &'a BezPath, st: &StrokeLayer) -> StrokePieces<'a> {
if st.start_arrow.is_none() && st.end_arrow.is_none() {
return StrokePieces { line: Cow::Borrowed(bp), heads: vec![] };
}
arrow::pieces(bp, st)
}
/// Does `bp` end with a `ClosePath`?
pub fn is_closed(bp: &BezPath) -> bool {
bp.elements().last().is_some_and(|e| matches!(e, PathEl::ClosePath))
}
/// The width actually stroked: inside/outside strokes of closed paths are drawn twice as wide
/// and clipped to one side; open paths always stroke centred.
pub fn aligned_width(st: &StrokeLayer, closed: bool) -> f64 {
match st.align {
StrokeAlign::Inside | StrokeAlign::Outside if closed => st.width * 2.0,
_ => st.width,
}
}
/// How far a cap reaches past the end of a stroke of `width`.
pub fn cap_extent(cap: LineCap, width: f64) -> f64 {
match cap {
LineCap::Butt => 0.0,
LineCap::Round | LineCap::Square => width / 2.0,
}
}
/// The stroke style (cap, join, miter limit) of `st` at `width`, without dashes.
pub fn style(st: &StrokeLayer, width: f64) -> kurbo::Stroke {
kurbo::Stroke::new(width)
.with_join(match st.join {
LineJoin::Miter => kurbo::Join::Miter,
LineJoin::Round => kurbo::Join::Round,
LineJoin::Bevel => kurbo::Join::Bevel,
})
.with_caps(match st.cap {
LineCap::Butt => kurbo::Cap::Butt,
LineCap::Round => kurbo::Cap::Round,
LineCap::Square => kurbo::Cap::Square,
})
.with_miter_limit(st.miter_limit)
}
/// The filled outline of the line part of `st` along `line` (usually [`StrokePieces::line`]) at
/// `width` (see [`aligned_width`]), flattened/fitted to `tol`: the dash pattern with its dots and
/// the width profile (each dash and dot as wide as the profile where it sits along the path), with
/// the stroke's caps and joins. Fill it with the non-zero rule. Arrowheads are not included.
pub fn line_outline(line: &BezPath, st: &StrokeLayer, width: f64, tol: f64) -> BezPath {
if line.elements().is_empty() || width <= 0.0 || !width.is_finite() {
return BezPath::new();
}
let tol = tol.max(1e-4);
let dashed = st.dash.as_ref().and_then(|d| dash(line, d));
match (st.profile.as_ref(), dashed) {
(Some(profile), None) => width_outline(line, width, profile, st, tol),
(Some(profile), Some(d)) => {
let mut out = width::outline_spans(&d.path, &d.spans, width, profile, st, tol);
for dot in &d.dots {
// The dot spans the profile's left and right widths there.
let (l, r) = profile.at(dot.t);
let (l, r) = (l.max(0.0), r.max(0.0));
let at = dot.at + width::left(dot.dir) * (width * (l - r) / 4.0);
out.extend(dot_outline(&[Dot { at, ..*dot }], width * (l + r) / 2.0, st.cap, tol).iter());
}
out
}
(None, Some(d)) => {
let mut out = kurbo::stroke(d.path.iter(), &style(st, width), &kurbo::StrokeOpts::default(), tol);
out.extend(dot_outline(&d.dots, width, st.cap, tol).iter());
out
}
(None, None) => kurbo::stroke(line.iter(), &style(st, width), &kurbo::StrokeOpts::default(), tol),
}
}
/// Append `seg` to `out` (which must already have a current point at its start).
pub(crate) fn push_seg(out: &mut BezPath, seg: &PathSeg) {
match seg {
PathSeg::Line(l) => out.line_to(l.p1),
PathSeg::Quad(q) => out.quad_to(q.p1, q.p2),
PathSeg::Cubic(c) => out.curve_to(c.p1, c.p2, c.p3),
}
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_arrows;
#[cfg(test)]
mod tests_fit;
#[cfg(test)]
mod tests_gradient;
#[cfg(test)]
mod tests_reach;
+407
View File
@@ -0,0 +1,407 @@
//! Arrowheads. A head of weight `hw` (stroke weight × scale) fits a box `4·hw` long and `4·hw`
//! wide. Hollow kinds are rings (`hw/2` walls) or, for the open arrow, a chevron with `hw` arms.
//! The stroke ends under the head at the head's *inset* from the tip: halfway into solid heads,
//! in the back wall of hollow ones (with the cap kept out of the hole) and at the chevron's inner
//! corner. With [`ArrowAlign::Tip`] the tip sits on the end point and the stroke is shortened by
//! the inset; with [`ArrowAlign::Extend`] the path keeps its length and the tip sits the inset
//! past the end point. Heads added after the first ten are drawn in local units ([`local`]).
use std::borrow::Cow;
use std::f64::consts::{FRAC_PI_2, PI};
use kurbo::{Affine, BezPath, ParamCurve, ParamCurveArclen, PathSeg, Point, Shape, Vec2};
use vectorcraft_doc::{ArrowAlign, Arrowhead, StrokeLayer};
use super::{ARCLEN_ACCURACY, StrokePieces, cap_extent, push_seg, segments, subpaths, tangent, unit};
/// One arrowhead.
#[derive(Clone, Debug, PartialEq)]
pub struct Arrow {
pub kind: Arrowhead,
/// The filled outline (non-zero rule; hollow kinds are rings).
pub outline: BezPath,
pub tip: Point,
/// Unit direction the head points in.
pub dir: Vec2,
/// Distance from the tip back to where the stroke ends under the head.
pub inset: f64,
}
/// [`super::stroke_pieces`] for a stroke with at least one arrowhead.
pub(super) fn pieces<'a>(bp: &'a BezPath, st: &StrokeLayer) -> StrokePieces<'a> {
let subs = subpaths(bp);
let els = bp.elements();
// Heads go on the start of the first subpath and the end of the last one, when open.
let first = subs.first().filter(|s| !s.1).map(|s| s.0.clone());
let last = subs.last().filter(|s| !s.1).map(|s| s.0.clone());
let cap = cap_extent(st.cap, st.width);
let (hw0, hw1) = (st.arrow_weight(false), st.arrow_weight(true));
let start = st.start_arrow.zip(first).map(|(k, r)| (k, r, inset(k, hw0, cap)));
let end = st.end_arrow.zip(last).map(|(k, r)| (k, r, inset(k, hw1, cap)));
if start.is_none() && end.is_none() {
return StrokePieces { line: Cow::Borrowed(bp), heads: vec![] };
}
let tip_mode = st.arrow_align == ArrowAlign::Tip;
let mut heads = vec![];
// Trims per subpath index (start, end), applied in tip mode only.
let mut trims: Vec<(f64, f64)> = vec![(0.0, 0.0); subs.len()];
let mut place = |kind: Arrowhead, range: std::ops::Range<usize>, at_start: bool, inset: f64, hw: f64| {
let segs = segments(&els[range]);
let (end_pt, outward) = end_of(&segs, at_start);
let (tip, dir) = if tip_mode {
// Point along the chord from where the stroke now ends to the end point.
let dir = cut_point(&segs, at_start, inset).map(|p| end_pt - p).filter(|v| v.hypot() > 1e-9).map(unit).unwrap_or(outward);
(end_pt, dir)
} else {
(end_pt + outward * inset, outward)
};
heads.push(Arrow { kind, outline: shape(kind, tip, dir, hw), tip, dir, inset });
};
if let Some((k, r, i)) = start {
place(k, r, true, i, hw0);
if tip_mode {
trims[0].0 = i;
}
}
if let Some((k, r, i)) = end {
place(k, r, false, i, hw1);
if tip_mode {
trims[subs.len() - 1].1 = i;
}
}
if !tip_mode {
return StrokePieces { line: Cow::Borrowed(bp), heads };
}
let mut line = BezPath::new();
for ((range, _), &(s0, s1)) in subs.iter().zip(&trims) {
if s0 == 0.0 && s1 == 0.0 {
line.extend(els[range.clone()].iter().copied());
continue;
}
// Only open subpaths get heads, so a trimmed subpath stays open.
let segs = trim(&segments(&els[range.clone()]), s0, s1);
if let Some(first) = segs.first() {
line.move_to(first.start());
for s in &segs {
push_seg(&mut line, s);
}
}
}
StrokePieces { line: Cow::Owned(line), heads }
}
/// The end point of a subpath and the unit direction pointing out of it there.
fn end_of(segs: &[PathSeg], at_start: bool) -> (Point, Vec2) {
let long = |s: &&PathSeg| s.arclen(ARCLEN_ACCURACY) > 1e-9;
if at_start {
let p = segs.first().map_or(Point::ORIGIN, |s| s.start());
let d = segs.iter().find(long).map_or(Vec2::new(-1.0, 0.0), |s| -tangent(s, 0.0));
(p, d)
} else {
let p = segs.last().map_or(Point::ORIGIN, |s| s.end());
let d = segs.iter().rev().find(long).map_or(Vec2::new(1.0, 0.0), |s| tangent(s, 1.0));
(p, d)
}
}
/// The point `dist` along the subpath from its start (or back from its end), if it is that long.
fn cut_point(segs: &[PathSeg], from_start: bool, dist: f64) -> Option<Point> {
let (i, t) = if from_start { cut_from_start(segs, dist)? } else { cut_from_end(segs, dist)? };
Some(segs[i].eval(t))
}
/// (segment, parameter) at arc length `dist` from the start.
fn cut_from_start(segs: &[PathSeg], dist: f64) -> Option<(usize, f64)> {
let mut acc = 0.0;
for (i, s) in segs.iter().enumerate() {
let len = s.arclen(ARCLEN_ACCURACY);
if acc + len >= dist {
return Some((i, if len > 0.0 { s.inv_arclen(dist - acc, ARCLEN_ACCURACY) } else { 0.0 }));
}
acc += len;
}
None
}
/// (segment, parameter) at arc length `dist` back from the end.
fn cut_from_end(segs: &[PathSeg], dist: f64) -> Option<(usize, f64)> {
let mut acc = 0.0;
for (i, s) in segs.iter().enumerate().rev() {
let len = s.arclen(ARCLEN_ACCURACY);
if acc + len >= dist {
return Some((i, if len > 0.0 { s.inv_arclen(len - (dist - acc), ARCLEN_ACCURACY) } else { 1.0 }));
}
acc += len;
}
None
}
/// The segments with `s0` of arc length removed from the start and `s1` from the end (empty when
/// nothing is left).
fn trim(segs: &[PathSeg], s0: f64, s1: f64) -> Vec<PathSeg> {
let (Some((i0, t0)), Some((i1, t1))) = (cut_from_start(segs, s0), cut_from_end(segs, s1)) else { return vec![] };
if (i0, t0) >= (i1, t1) {
return vec![];
}
if i0 == i1 {
return vec![segs[i0].subsegment(t0..t1)];
}
let mut out = Vec::with_capacity(i1 - i0 + 1);
out.push(segs[i0].subsegment(t0..1.0));
out.extend_from_slice(&segs[i0 + 1..i1]);
out.push(segs[i1].subsegment(0.0..t1));
out
}
/// Head length (and width) for weight `hw`.
fn len(hw: f64) -> f64 {
4.0 * hw
}
/// Wall of the hollow ring heads.
fn ring(hw: f64) -> f64 {
0.5 * hw
}
/// Arm thickness of the open arrow.
fn arm(hw: f64) -> f64 {
hw
}
/// Thickness of the bar.
fn bar(hw: f64) -> f64 {
0.75 * hw
}
/// Where the notch of the (open) arrow sits, as a fraction of the length.
const NOTCH: f64 = 0.7;
/// Distance from the tip where the stroke ends, for a stroke whose cap reaches `cap` past its end.
fn inset(kind: Arrowhead, hw: f64, cap: f64) -> f64 {
let l = len(hw);
// Solid heads: halfway in, far enough that the cap stays behind the tip.
let solid = |back: f64| (back / 2.0).max(cap);
// Hollow heads: in the back wall, with the cap kept out of the hole.
let hollow = |back: f64| (back - ring(hw) / 2.0).max(back - ring(hw) + cap);
match kind {
Arrowhead::Triangle | Arrowhead::Circle | Arrowhead::Square | Arrowhead::Diamond => solid(l),
Arrowhead::Arrow => solid(NOTCH * l),
Arrowhead::Bar => solid(bar(hw)),
Arrowhead::TriangleOpen | Arrowhead::CircleOpen | Arrowhead::SquareOpen => hollow(l),
// The inner corner of the chevron: arm / sin(half angle).
Arrowhead::ArrowOpen => (arm(hw) * (l * l + l * l / 4.0).sqrt() / (l / 2.0)).max(cap),
_ => match local_stop(kind) {
Stop::At(u) => (u * hw).max(cap),
Stop::Wall(back) => hollow(back * hw),
},
}
}
/// Outline of a `kind` head of weight `hw` with its tip at `tip`, pointing along `dir`.
fn shape(kind: Arrowhead, tip: Point, dir: Vec2, hw: f64) -> BezPath {
let n = Vec2::new(-dir.y, dir.x);
let l = len(hw);
let half = l / 2.0;
let base = tip - dir * l;
let mut p = BezPath::new();
let triangle = [tip, base + n * half, base - n * half];
let square = [tip + n * half, base + n * half, base - n * half, tip - n * half];
let centre = tip - dir * half;
match kind {
Arrowhead::Triangle => polygon(&mut p, &triangle),
Arrowhead::TriangleOpen => ring_polygon(&mut p, &triangle, ring(hw)),
Arrowhead::Arrow => polygon(&mut p, &[tip, base + n * half, tip - dir * (NOTCH * l), base - n * half]),
Arrowhead::ArrowOpen => {
// Arms of thickness `arm` from the tip to the base corners, cut square at their ends.
let t = arm(hw);
let (a, b) = (base + n * half, base - n * half);
let (ua, ub) = (unit(a - tip), unit(b - tip));
// Inward (towards the axis) normals of the two arms.
let (na, nb) = (Vec2::new(-ua.y, ua.x), Vec2::new(ub.y, -ub.x));
let inner = tip - dir * (t * (a - tip).hypot() / half);
polygon(&mut p, &[tip, a, a + na * t, inner, b + nb * t, b]);
}
Arrowhead::Circle => p.extend(kurbo::Circle::new(centre, half).path_elements(0.01)),
Arrowhead::CircleOpen => {
p.extend(kurbo::Circle::new(centre, half).path_elements(0.01));
p.extend(kurbo::Circle::new(centre, half - ring(hw)).to_path(0.01).reverse_subpaths());
}
Arrowhead::Square => polygon(&mut p, &square),
Arrowhead::SquareOpen => ring_polygon(&mut p, &square, ring(hw)),
Arrowhead::Diamond => polygon(&mut p, &[tip, centre + n * half, base, centre - n * half]),
Arrowhead::Bar => {
let t = bar(hw);
polygon(&mut p, &[tip + n * half, tip + n * half - dir * t, tip - n * half - dir * t, tip - n * half]);
}
_ => {
// Local x runs back from the tip, y across to the right of `dir`; a unit is `hw`.
p = local(kind);
p.apply_affine(Affine::new([-dir.x * hw, -dir.y * hw, n.x * hw, n.y * hw, tip.x, tip.y]));
}
}
p
}
/// Where the stroke ends under a head drawn in local units.
enum Stop {
/// This many units back from the tip (or as far as the cap needs).
At(f64),
/// In the back wall of a ring whose back is this many units from the tip.
Wall(f64),
}
/// Flat-to-flat length of the hexagons (their corners touch the sides of the 4-unit box).
const HEX: f64 = 2.0 * 1.732_050_807_568_877_2;
/// Flattening tolerance of the curved local heads, in units.
const LOCAL_TOL: f64 = 1e-3;
fn local_stop(kind: Arrowhead) -> Stop {
match kind {
Arrowhead::Target | Arrowhead::TagOpen | Arrowhead::DiamondOpen => Stop::Wall(4.0),
Arrowhead::HexagonOpen => Stop::Wall(HEX),
Arrowhead::DoubleBar | Arrowhead::DotOnBar => Stop::At(0.375),
Arrowhead::Bracket => Stop::At(0.3),
Arrowhead::Fork => Stop::At(2.7),
Arrowhead::HalfArrowLeft | Arrowhead::HalfArrowRight | Arrowhead::Feather => Stop::At(1.3),
Arrowhead::Swallowtail => Stop::At(1.4),
Arrowhead::Chevron => Stop::At(1.0),
Arrowhead::Slash => Stop::At(1.2),
Arrowhead::DoubleSlash => Stop::At(1.05),
Arrowhead::DoubleArrow => Stop::At(1.7),
Arrowhead::HalfCircle => Stop::At(1.0),
Arrowhead::Concave => Stop::At(2.6),
Arrowhead::Hexagon => Stop::At(HEX / 2.0),
_ => Stop::At(2.0),
}
}
/// The outline of a head added after the first ten, in local units: x from the tip (0) back
/// along the line (to at most 4), y across it (−2 to 2, positive to the right of the direction
/// the head points). Every shape is generated here.
fn local(kind: Arrowhead) -> BezPath {
let mut p = BezPath::new();
let poly = |p: &mut BezPath, pts: &[(f64, f64)]| polygon(p, &pts.iter().map(|&q| Point::from(q)).collect::<Vec<_>>());
let ring_poly = |p: &mut BezPath, pts: &[(f64, f64)]| ring_polygon(p, &pts.iter().map(|&q| Point::from(q)).collect::<Vec<_>>(), ring(1.0));
let circle = |p: &mut BezPath, c: (f64, f64), r: f64| p.extend(kurbo::Circle::new(c, r).path_elements(LOCAL_TOL));
// A regular polygon of `n` corners around `c`, the first at angle `a0`, alternating radii.
let star = |c: (f64, f64), radii: &[f64], n: usize, a0: f64| -> Vec<(f64, f64)> {
(0..n).map(|i| (a0 + i as f64 * 2.0 * PI / n as f64, radii[i % radii.len()])).map(|(a, r)| (c.0 + r * a.cos(), c.1 + r * a.sin())).collect()
};
// A plus of arms `h` long and `k` thick round (2, 0), turned by `turn`.
let plus = |h: f64, k: f64, turn: f64| -> Vec<(f64, f64)> {
let arm = [(k, h), (k, k), (h, k)];
let (sin, cos) = turn.sin_cos();
(0..4)
.flat_map(|q| arm.iter().map(move |&(x, y)| [(x, y), (y, -x), (-x, -y), (-y, x)][q]))
.map(|(x, y)| (2.0 + x * cos - y * sin, x * sin + y * cos))
.collect()
};
let hexagon = star((HEX / 2.0, 0.0), &[2.0], 6, PI / 6.0);
match kind {
Arrowhead::Barbed => poly(&mut p, &[(0.0, 0.0), (3.6, 2.0), (2.6, 0.55), (4.0, 0.55), (4.0, -0.55), (2.6, -0.55), (3.6, -2.0)]),
Arrowhead::HalfArrowLeft => poly(&mut p, &[(0.0, 0.0), (4.0, -2.0), (3.0, -0.5), (3.0, 0.5), (1.0, 0.5)]),
Arrowhead::HalfArrowRight => poly(&mut p, &[(0.0, 0.0), (4.0, 2.0), (3.0, 0.5), (3.0, -0.5), (1.0, -0.5)]),
Arrowhead::Concave => {
p.move_to((0.0, 0.0));
p.quad_to((2.4, 0.4), (4.0, 2.0));
p.line_to((4.0, -2.0));
p.quad_to((2.4, -0.4), (0.0, 0.0));
p.close_path();
}
Arrowhead::DoubleBar => {
poly(&mut p, &[(0.0, -2.0), (0.75, -2.0), (0.75, 2.0), (0.0, 2.0)]);
poly(&mut p, &[(1.5, -2.0), (2.25, -2.0), (2.25, 2.0), (1.5, 2.0)]);
}
Arrowhead::Feather => poly(&mut p, &[(0.0, 0.5), (1.4, 2.0), (4.0, 2.0), (2.6, 0.5), (2.6, -0.5), (4.0, -2.0), (1.4, -2.0), (0.0, -0.5)]),
Arrowhead::DotOnBar => {
poly(&mut p, &[(0.0, -2.0), (0.75, -2.0), (0.75, 2.0), (0.0, 2.0)]);
circle(&mut p, (2.5, 0.0), 1.25);
}
Arrowhead::Chevron => poly(&mut p, &[(0.0, 0.0), (2.0, 2.0), (4.0, 2.0), (2.0, 0.0), (4.0, -2.0), (2.0, -2.0)]),
Arrowhead::DoubleArrow => poly(&mut p, &[(0.0, 0.0), (2.2, 1.8), (1.9, 0.5), (4.0, 2.0), (3.4, 0.0), (4.0, -2.0), (1.9, -0.5), (2.2, -1.8)]),
Arrowhead::Target => {
circle(&mut p, (2.0, 0.0), 2.0);
p.extend(kurbo::Circle::new((2.0, 0.0), 2.0 - ring(1.0)).to_path(LOCAL_TOL).reverse_subpaths());
circle(&mut p, (2.0, 0.0), 0.75);
}
Arrowhead::Star => poly(&mut p, &star((2.0, 0.0), &[2.0, 0.85], 10, PI)),
Arrowhead::Cross => poly(&mut p, &plus(2.2, 0.4, PI / 4.0)),
Arrowhead::Plus => poly(&mut p, &plus(1.9, 0.4, 0.0)),
Arrowhead::Hexagon => poly(&mut p, &hexagon),
Arrowhead::HexagonOpen => ring_poly(&mut p, &hexagon),
Arrowhead::Tag => poly(&mut p, &[(0.0, 0.0), (1.6, 2.0), (4.0, 2.0), (4.0, -2.0), (1.6, -2.0)]),
Arrowhead::TagOpen => ring_poly(&mut p, &[(0.0, 0.0), (1.6, 2.0), (4.0, 2.0), (4.0, -2.0), (1.6, -2.0)]),
Arrowhead::HalfCircle => {
p.move_to((2.0, 2.0));
p.extend(kurbo::Arc::new((2.0, 0.0), (2.0, 2.0), FRAC_PI_2, PI, 0.0).append_iter(LOCAL_TOL));
p.close_path();
}
Arrowhead::Drop => {
// Straight sides from the tip, tangent to a round back.
let (c, r) = (Point::new(2.6, 0.0), 1.4);
let a = PI - (r / c.x).acos();
p.move_to((0.0, 0.0));
p.line_to(c + Vec2::from_angle(a) * r);
p.extend(kurbo::Arc::new(c, (r, r), a, -2.0 * a, 0.0).append_iter(LOCAL_TOL));
p.close_path();
}
Arrowhead::Slash => poly(&mut p, &[(0.0, 2.0), (0.8, 2.0), (2.4, -2.0), (1.6, -2.0)]),
Arrowhead::DoubleSlash => {
poly(&mut p, &[(0.0, 2.0), (0.7, 2.0), (2.1, -2.0), (1.4, -2.0)]);
poly(&mut p, &[(1.6, 2.0), (2.3, 2.0), (3.7, -2.0), (3.0, -2.0)]);
}
Arrowhead::DiamondOpen => ring_poly(&mut p, &[(0.0, 0.0), (2.0, 2.0), (4.0, 0.0), (2.0, -2.0)]),
Arrowhead::TriangleReverse => poly(&mut p, &[(0.0, 2.0), (4.0, 0.0), (0.0, -2.0)]),
Arrowhead::Swallowtail => poly(&mut p, &[(0.0, 2.0), (4.0, 2.0), (2.8, 0.0), (4.0, -2.0), (0.0, -2.0)]),
Arrowhead::Bracket => poly(&mut p, &[(0.0, 2.0), (2.0, 2.0), (2.0, 1.4), (0.6, 1.4), (0.6, -1.4), (2.0, -1.4), (2.0, -2.0), (0.0, -2.0)]),
Arrowhead::Fork => poly(&mut p, &[(0.0, 2.0), (3.0, 2.0), (3.0, -2.0), (0.0, -2.0), (0.0, -1.4), (2.4, -1.4), (2.4, 1.4), (0.0, 1.4)]),
Arrowhead::Leaf => {
p.move_to((0.0, 0.0));
p.quad_to((2.0, 3.2), (4.0, 0.0));
p.quad_to((2.0, -3.2), (0.0, 0.0));
p.close_path();
}
Arrowhead::Kite => poly(&mut p, &[(0.0, 0.0), (1.3, 1.7), (4.0, 0.0), (1.3, -1.7)]),
Arrowhead::TriangleBar => {
poly(&mut p, &[(0.0, 2.0), (0.6, 2.0), (0.6, 0.25), (4.0, 1.8), (4.0, -1.8), (0.6, -0.25), (0.6, -2.0), (0.0, -2.0)])
}
Arrowhead::Oval => p.extend(kurbo::Ellipse::new((2.0, 0.0), (2.0, 1.2), 0.0).path_elements(LOCAL_TOL)),
// The first ten are drawn by `shape`.
Arrowhead::Triangle
| Arrowhead::TriangleOpen
| Arrowhead::Circle
| Arrowhead::CircleOpen
| Arrowhead::Square
| Arrowhead::SquareOpen
| Arrowhead::Bar
| Arrowhead::Diamond
| Arrowhead::Arrow
| Arrowhead::ArrowOpen => {}
}
p
}
fn polygon(p: &mut BezPath, pts: &[Point]) {
let Some((first, rest)) = pts.split_first() else { return };
p.move_to(*first);
for q in rest {
p.line_to(*q);
}
p.close_path();
}
/// A convex polygon with a hole: the polygon inset by `wall`, wound the other way.
fn ring_polygon(p: &mut BezPath, pts: &[Point], wall: f64) {
polygon(p, pts);
let n = pts.len();
// Signed area: which side of each edge is inside.
let area: f64 = (0..n).map(|i| pts[i].to_vec2().cross(pts[(i + 1) % n].to_vec2())).sum();
let inward = |e: Vec2| if area > 0.0 { Vec2::new(-e.y, e.x) } else { Vec2::new(e.y, -e.x) };
let inner: Vec<Point> = (0..n)
.map(|i| {
let (prev, cur, next) = (pts[(i + n - 1) % n], pts[i], pts[(i + 1) % n]);
let (n1, n2) = (inward(unit(cur - prev)), inward(unit(next - cur)));
// The corner where both edges, moved in by `wall`, meet.
cur + (n1 + n2) * (wall / (1.0 + n1.dot(n2)))
})
.rev()
.collect();
polygon(p, &inner);
}
+332
View File
@@ -0,0 +1,332 @@
//! Dash patterns. Dashes follow the path's arc length, restart on every subpath and join across
//! the start of a closed subpath. A dash of zero length is a [`Dot`]: with a round cap it paints
//! a disc, with a projecting cap a square turned along the path, with a butt cap nothing.
//!
//! Exact dashes keep the pattern's lengths and start `offset` into it. Dashes fitted to corners
//! ([`Dash::align_corners`]) ignore the offset: every run between corners and path ends holds a
//! whole number of periods, stretched or squeezed to fit, starting and ending in the middle of the
//! first dash, so a dash is centred on every corner and on both ends of an open path.
use kurbo::{BezPath, ParamCurve, ParamCurveArclen, PathSeg, Point, Shape, Vec2};
use vectorcraft_doc::{Dash, LineCap};
use super::{ARCLEN_ACCURACY, kink, push_seg, segments, subpaths, tangent};
/// More dashes than this (a tiny pattern on a long path) draws the line solid instead.
const MAX_DASHES: f64 = 200_000.0;
/// A pattern whose longest entry is shorter than this can't advance along the path (each entry
/// ends within the generator's tolerance of where it starts), so it draws the line solid.
const MIN_ENTRY: f64 = 1e-6;
/// A run between corners shorter than this keeps the pattern's own lengths (too short to fit).
const MIN_RUN: f64 = 1e-6;
/// Arc-length tolerance of the generator.
const EPS: f64 = 1e-9;
/// A zero-length dash: where it sits and the path's direction there.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Dot {
pub at: Point,
pub dir: Vec2,
/// Fraction of its subpath's length at the dot (where a width profile is read).
pub t: f64,
}
/// A dashed path: the dashes (open subpaths to stroke without a dash pattern) and the dots.
#[derive(Clone, Debug, Default)]
pub struct Dashed {
pub path: BezPath,
/// For each dash (subpath of `path`, in order), the fractions of its subpath's length where it
/// starts and ends. The end passes 1 for a dash running on through the start of a closed path.
pub spans: Vec<(f64, f64)>,
pub dots: Vec<Dot>,
}
/// Apply dash pattern `d` to `bp`. `None` when the pattern is invalid (a negative or non-finite
/// entry, as in SVG and PDF) or has no positive length (the line is solid), or is too fine to draw
/// (then the line is drawn solid too).
pub fn dash(bp: &BezPath, d: &Dash) -> Option<Dashed> {
if d.pattern.iter().any(|v| !v.is_finite() || *v < 0.0) || !d.offset.is_finite() {
return None;
}
let mut pat = d.pattern.clone();
if !pat.iter().any(|v| *v > MIN_ENTRY) {
return None;
}
// An odd pattern repeats with dashes and gaps swapped (as in SVG and PDF).
if pat.len() % 2 == 1 {
pat.extend_from_within(..);
}
let period: f64 = pat.iter().sum();
let plans: Vec<Plan> = subpaths(bp)
.into_iter()
.map(|(r, closed)| {
let segs = segments(&bp.elements()[r]);
let lens = segs.iter().map(|s| s.arclen(ARCLEN_ACCURACY)).collect();
if d.align_corners { Plan::fitted(segs, lens, closed, period) } else { Plan::exact(segs, lens, closed, period) }
})
.collect();
if plans.iter().map(|p| p.periods).sum::<f64>() * pat.len() as f64 > MAX_DASHES {
return None;
}
// Where an exact pattern starts: `offset` into it.
let mut off = d.offset.rem_euclid(period);
let mut ix0 = 0;
while off > pat[ix0] {
off -= pat[ix0];
ix0 = (ix0 + 1) % pat.len();
}
let phase = Phase { ix: ix0, rem: pat[ix0] - off, on: ix0 % 2 == 0 };
let mut out = Dashed::default();
for plan in &plans {
dash_subpath(&mut out, plan, &pat, phase);
}
Some(out)
}
/// How the pattern runs along one subpath.
struct Plan {
segs: Vec<PathSeg>,
lens: Vec<f64>,
closed: bool,
/// The pattern's scale on each segment (1 for exact dashes).
scale: Vec<f64>,
/// Segments that start a run between corners: the pattern restarts there in the middle of its
/// first dash (fitted dashes only).
restart: Vec<bool>,
/// Arc length from the subpath's start to the start of `segs` (a fitted closed subpath starts
/// at its first corner).
shift: f64,
/// Periods of the pattern drawn.
periods: f64,
}
impl Plan {
fn exact(segs: Vec<PathSeg>, lens: Vec<f64>, closed: bool, period: f64) -> Self {
let n = segs.len();
let periods = lens.iter().sum::<f64>() / period;
Plan { segs, lens, closed, scale: vec![1.0; n], restart: vec![false; n], shift: 0.0, periods }
}
fn fitted(mut segs: Vec<PathSeg>, mut lens: Vec<f64>, closed: bool, period: f64) -> Self {
let n = segs.len();
// Corners: where segments with length meet at a kink (zero-length ones have no direction).
let live: Vec<usize> = (0..n).filter(|&i| lens[i] > EPS).collect();
let mut corners: Vec<usize> = live.windows(2).filter(|w| kink(&segs[w[0]], &segs[w[1]])).map(|w| w[1]).collect();
let mut rot = 0;
if closed && let (Some(&first), Some(&last)) = (live.first(), live.last()) {
if kink(&segs[last], &segs[first]) {
corners.insert(0, first);
}
// Start at a corner so that no run crosses the start point.
rot = corners.first().copied().unwrap_or(0);
}
segs.rotate_left(rot);
lens.rotate_left(rot);
let shift = lens[n - rot..].iter().sum();
let mut restart = vec![false; n];
restart[0] = true;
for c in corners {
restart[(c + n - rot) % n] = true;
}
let (mut scale, mut periods) = (vec![1.0; n], 0.0);
let mut i = 0;
while i < n {
let j = (i + 1..n).find(|&j| restart[j]).unwrap_or(n);
let run: f64 = lens[i..j].iter().sum();
if run > MIN_RUN {
let k = (run / period).round().max(1.0);
scale[i..j].fill(run / (k * period));
periods += k;
} else {
periods += run / period;
}
i = j;
}
Plan { segs, lens, closed, scale, restart, shift, periods }
}
}
/// Position in the pattern: entry index, length left in it, and whether it is a dash.
#[derive(Clone, Copy)]
struct Phase {
ix: usize,
rem: f64,
on: bool,
}
/// A dash being collected: its segments and where it starts and ends along its plan.
#[derive(Default)]
struct Piece {
segs: Vec<PathSeg>,
start: f64,
end: f64,
}
impl Piece {
fn push(&mut self, seg: PathSeg, at: f64) {
if self.segs.is_empty() {
self.start = at;
}
self.segs.push(seg);
}
}
fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase) {
let first_dot = out.dots.len();
let total: f64 = plan.lens.iter().sum();
// Fraction of the subpath's length at `x` along the plan.
let frac = |x: f64| {
if total <= 0.0 {
return 0.0;
}
let f = (x + plan.shift) / total;
if f > 1.0 { f - 1.0 } else { f }
};
let mut dashes: Vec<Piece> = vec![];
let mut cur = Piece::default();
let (mut cuts, mut acc, mut starts_on) = (0, 0.0, false);
for (i, (seg, &len)) in plan.segs.iter().zip(&plan.lens).enumerate() {
let k = plan.scale[i];
if plan.restart[i] {
// The dash running into a corner goes on past it; a dot there ends it.
if pat[0] <= 0.0 && !cur.segs.is_empty() {
cur.end = acc;
dashes.push(std::mem::take(&mut cur));
}
ph = Phase { ix: 0, rem: pat[0] * k / 2.0, on: true };
}
if i == 0 {
starts_on = ph.on && ph.rem > 0.0;
}
let mut s = 0.0;
loop {
let left = len - s;
if ph.rem > left + EPS {
if ph.on && left > EPS {
cur.push(sub(seg, len, s, len), acc + s);
}
ph.rem -= left;
break;
}
let end = (s + ph.rem).min(len);
if ph.on {
if pat[ph.ix] > 0.0 {
if end > s {
cur.push(sub(seg, len, s, end), acc + s);
}
if !cur.segs.is_empty() {
cur.end = acc + end;
dashes.push(std::mem::take(&mut cur));
}
} else {
let t = param(seg, len, end);
push_dot(out, first_dot, Dot { at: seg.eval(t), dir: tangent(seg, t), t: frac(acc + end) });
}
}
cuts += 1;
s = end;
ph.ix = (ph.ix + 1) % pat.len();
ph.rem = pat[ph.ix] * k;
ph.on = !ph.on;
}
acc += len;
}
// A fitted open path whose pattern starts with a dot ends on one, whatever the rounding.
if !plan.closed
&& plan.restart[0]
&& pat[0] <= 0.0
&& let Some(seg) = plan.segs.last()
{
push_dot(out, first_dot, Dot { at: seg.end(), dir: tangent(seg, 1.0), t: 1.0 });
}
let ends_on = !cur.segs.is_empty();
if ends_on {
cur.end = total;
dashes.push(cur);
}
if plan.closed {
if cuts == 0 && ends_on {
// One dash all the way round: keep it closed so the start gets a join, not caps.
let d = dashes.pop().unwrap_or_default();
write(&mut out.path, &d.segs);
out.path.close_path();
out.spans.push((frac(0.0), frac(0.0) + 1.0));
return;
}
if starts_on && ends_on && dashes.len() > 1 {
// The dash running through the start point is one dash.
let mut last = dashes.pop().unwrap_or_default();
last.segs.append(&mut dashes[0].segs);
dashes[0] = Piece { segs: last.segs, start: last.start, end: dashes[0].end + total };
}
// A dot on the start point is reached again at the end.
if out.dots.len() > first_dot + 1 && out.dots[first_dot].at.distance(out.dots[out.dots.len() - 1].at) < 1e-6 {
out.dots.pop();
}
}
for d in &dashes {
write(&mut out.path, &d.segs);
let t0 = frac(d.start);
out.spans.push((t0, if total > 0.0 { t0 + (d.end - d.start) / total } else { t0 }));
}
}
/// Add `dot` unless the subpath's last dot (from index `first`) is already there: a run ending on
/// a dot meets the next one starting on it.
fn push_dot(out: &mut Dashed, first: usize, dot: Dot) {
if out.dots[first..].last().is_none_or(|d| d.at.distance(dot.at) > 1e-6) {
out.dots.push(dot);
}
}
fn write(out: &mut BezPath, segs: &[PathSeg]) {
let Some(first) = segs.first() else { return };
out.move_to(first.start());
for s in segs {
push_seg(out, s);
}
}
/// Curve parameter at arc length `s` along `seg` (of total length `len`).
fn param(seg: &PathSeg, len: f64, s: f64) -> f64 {
if s <= 0.0 {
0.0
} else if s >= len {
1.0
} else if let PathSeg::Line(_) = seg {
s / len
} else {
seg.inv_arclen(s, ARCLEN_ACCURACY)
}
}
/// The part of `seg` between arc lengths `s0` and `s1`.
fn sub(seg: &PathSeg, len: f64, s0: f64, s1: f64) -> PathSeg {
seg.subsegment(param(seg, len, s0)..param(seg, len, s1))
}
/// Outlines of `dots` for a stroke of `width` with `cap`: discs (round), squares turned along the
/// path (projecting) or nothing (butt). Each outline winds like kurbo's stroke outlines, so a dot
/// touching a dash doesn't cancel it under the non-zero rule.
pub fn dot_outline(dots: &[Dot], width: f64, cap: LineCap, tol: f64) -> BezPath {
let mut out = BezPath::new();
let r = width / 2.0;
if r <= 0.0 {
return out;
}
for d in dots {
match cap {
LineCap::Butt => {}
LineCap::Round => out.extend(kurbo::Circle::new(d.at, r).path_elements(tol)),
LineCap::Square => {
let (u, n) = (d.dir * r, Vec2::new(-d.dir.y, d.dir.x) * r);
out.move_to(d.at - u - n);
out.line_to(d.at + u - n);
out.line_to(d.at + u + n);
out.line_to(d.at - u + n);
out.close_path();
}
}
}
out
}
+432
View File
@@ -0,0 +1,432 @@
//! Gradients laid along or across a stroke ([`StrokeGradientMode::Along`] and
//! [`StrokeGradientMode::Across`]).
//!
//! Each subpath becomes a strip of *ribs*: cross-sections through the flattened path that reach
//! past everything the stroke paints there (its width profile, joins, caps and arrowheads). Between
//! two ribs lies a slice painted with a linear piece of the gradient: along, from the first rib's
//! centre to the next one's (padded beyond, so the outside of a turn keeps the colour of the point
//! it turns at); across, from the stroke's left edge to its right edge. Ribs sit on the bisectors
//! of gentle turns, so neighbouring slices meet where both give the same colour; a sharp corner
//! gets a fan of ribs round its outside, painted in the corner's colour.
//!
//! Clipped to the stroke's outline, the slices paint it: the canvas and the SVG and PDF writers
//! fill them ([`gradient_slices`]); Outline Stroke turns the strip into a gradient mesh
//! ([`gradient_meshes`]).
use kurbo::{BezPath, Point, Shape, Vec2};
use vectorcraft_doc::color::{Gradient, GradientGeom, GradientPaint, Paint};
use vectorcraft_doc::live::{H_DOWN, H_LEFT, H_RIGHT, H_UP, lerp_color};
use vectorcraft_doc::{GradientMesh, LineJoin, MeshPoint, StrokeAlign, StrokeGradientMode, StrokeLayer};
use vectorcraft_geom::FillRule;
use super::width::{flatten, left, length, split_at};
use super::{aligned_width, cap_extent, is_closed, segments, subpaths, unit};
/// Largest stretch of a rib on a turn's bisector (1 / cos of half the turn, about 74°) before the
/// corner gets a fan of ribs round its outside.
const SHARP: f64 = 1.25;
/// Past this stretch (a turn of about 150°) the inside of a corner is not cut on its bisector,
/// which would reach far back along both sides: the slices either side overlap there instead.
const HAIRPIN: f64 = 4.0;
/// Rows of a mesh across the stroke (more where the gradient has stops).
const MESH_ROWS: usize = 16;
/// The most of a subpath one column of a mesh along the stroke spans.
const MESH_STEP: f64 = 1.0 / 32.0;
/// The most of a subpath one slice spans while a width profile shapes the across gradient.
const PROFILE_STEP: f64 = 1.0 / 64.0;
/// Flattening tolerance, cover margin and growth (see [`gradient_slices`]) of meshes and written
/// slices (document points).
const SLICE_TOL: f64 = 0.05;
const SLICE_MARGIN: f64 = 0.5;
const SLICE_GROW: f64 = 0.25;
/// A cross-section of the strip a stroke's gradient is laid on.
#[derive(Clone, Copy, Debug)]
struct Rib {
/// Where it crosses the path.
c: Point,
/// Its left and right ends: the strip runs from `l` through `c` to `r`.
l: Point,
r: Point,
/// How far across the stroke `l` and `r` stand: a point `x` left of the path (negative:
/// right) lies `x / reach` of the way from `c` to the end on its side.
reach: (f64, f64),
/// Fraction of the subpath's length at `c`.
t: f64,
/// Where the across gradient starts and ends: this far left and right of the path.
band: (f64, f64),
}
impl Rib {
/// A rib square to direction `d`, reaching `r` either side of `c`.
fn square(c: Point, d: Vec2, r: f64, t: f64, band: (f64, f64)) -> Self {
let n = left(d);
Rib { c, l: c + n * r, r: c - n * r, reach: (r, r), t, band }
}
/// The point `x` left of the path along the rib (negative: right).
fn at(&self, x: f64) -> Point {
if x >= 0.0 {
self.c + (self.l - self.c) * (x / self.reach.0.max(1e-12))
} else {
self.c + (self.r - self.c) * (-x / self.reach.1.max(1e-12))
}
}
}
/// What lays out the ribs of one stroke.
struct Lay<'a> {
st: &'a StrokeLayer,
/// Half the stroked width ([`aligned_width`]).
hw: f64,
/// How far ribs reach either side of the path where nothing wider is near.
cover: f64,
margin: f64,
/// How far a strip runs on past the start and end of an open subpath (caps and heads).
ext: (f64, f64),
}
impl Lay<'_> {
/// The across band at fraction `t` of a subpath whose visible side is `side` (`Some(true)`:
/// left; `None`: both, a centred stroke).
fn band(&self, t: f64, side: Option<bool>) -> (f64, f64) {
let (l, r) = self.st.profile.as_ref().map_or((1.0, 1.0), |p| p.at(t));
let (l, r) = (self.hw * l.max(0.0), self.hw * r.max(0.0));
match side {
None => (l, r),
Some(true) => (l, 0.0),
Some(false) => (0.0, r),
}
}
/// The ribs at a turn at `p` from direction `din` to `dout` (unit vectors), reaching `r`
/// across: one on the bisector, or a fan round the outside of a sharp corner (its miter, or the
/// box round its round or bevel join).
fn turn(&self, p: Point, din: Vec2, dout: Vec2, r: f64, t: f64, band: (f64, f64)) -> Vec<Rib> {
let (nin, nout) = (left(din), left(dout));
let den = 1.0 + nin.dot(nout);
let stretch = if den > 1e-12 { (2.0 / den).sqrt() } else { f64::INFINITY };
if stretch <= SHARP {
let m = (nin + nout) / den;
return vec![Rib { c: p, l: p + m * r, r: p - m * r, reach: (r, r), t, band }];
}
// `s` is 1 when the outside of the turn is on the left; `out_dir` the outward bisector.
let s = if dout.dot(nin) > 0.0 { -1.0 } else { 1.0 };
let sum = nin + nout;
let out_dir = if sum.hypot() > 1e-9 { unit(sum) * s } else { din };
let (o1, o2) = (p + nin * (s * r), p + nout * (s * r));
let mitered = stretch <= std::f64::consts::SQRT_2 || (self.st.join == LineJoin::Miter && stretch <= self.st.miter_limit);
let outer = if mitered { vec![o1, p + out_dir * (stretch * r), o2] } else { vec![o1, o1 + din * r, o2 - dout * r, o2] };
let last = outer.len() - 1;
outer
.into_iter()
.enumerate()
.map(|(k, o)| {
let (inner, inner_reach) = match k {
_ if stretch <= HAIRPIN => (p - out_dir * (stretch * r), r),
0 => (p - nin * (s * r), r),
_ if k == last => (p - nout * (s * r), r),
_ => (p, 0.0),
};
let o_reach = o.distance(p);
if s > 0.0 {
Rib { c: p, l: o, r: inner, reach: (o_reach, inner_reach), t, band }
} else {
Rib { c: p, l: inner, r: o, reach: (inner_reach, o_reach), t, band }
}
})
.collect()
}
}
/// The ribs of each subpath of `bp` (filled with `rule`) under stroke `st`, flattened to `tol`,
/// reaching `margin` past what the stroke paints. Fractions `marks` of each subpath get ribs of
/// their own, and no two are more than `max_dt` of it apart.
fn strips(bp: &BezPath, rule: FillRule, st: &StrokeLayer, tol: f64, margin: f64, marks: &[f64], max_dt: f64) -> Vec<Vec<Rib>> {
let closed_path = is_closed(bp);
let hw = aligned_width(st, closed_path) / 2.0;
if !(hw > 0.0 && hw.is_finite()) {
return vec![];
}
let full = hw * st.profile_max();
let heads = (st.head_reach(false), st.head_reach(true));
let cap = cap_extent(st.cap, 2.0 * full);
let lay = Lay { st, hw, cover: full + margin, margin, ext: (cap.max(heads.0) + margin, cap.max(heads.1) + margin) };
let subs = subpaths(bp);
let count = subs.len();
let mut out = vec![];
for (k, (range, closed)) in subs.into_iter().enumerate() {
let (pts, corner) = flatten(&segments(&bp.elements()[range]), closed, tol.max(1e-4));
let side =
(st.align != StrokeAlign::Center && closed_path && closed).then(|| band_on_left(bp, rule, &pts, st.align, (4.0 * tol).min(hw / 2.0)));
// Arrowheads sit on the start of the first subpath and the end of the last one.
let heads = (if k == 0 { heads.0 } else { 0.0 }, if k + 1 == count { heads.1 } else { 0.0 });
let ribs = match pts.len() {
0 => continue,
// A subpath of no length: a dot (round and projecting caps paint it).
1 => {
let (d, b, e) = (Vec2::new(1.0, 0.0), lay.band(0.0, side), lay.ext.0.max(lay.ext.1));
vec![Rib::square(pts[0] - d * e, d, lay.cover, 0.0, b), Rib::square(pts[0] + d * e, d, lay.cover, 0.0, b)]
}
_ => {
let total = length(&pts, closed);
if total <= 1e-12 {
continue;
}
let steps = if max_dt < 1.0 { (1.0 / max_dt).ceil() as usize } else { 1 };
let profile = st.profile.iter().flat_map(|p| p.points.iter().map(|q| q.0));
let at: Vec<f64> =
marks.iter().copied().chain(profile).chain((1..steps).map(|i| i as f64 / steps as f64)).map(|t| t * total).collect();
let (pts, _) = split_at(&pts, &corner, closed, &at);
strip(&lay, &pts, closed, total, side, if closed { (0.0, 0.0) } else { heads })
}
};
out.push(ribs);
}
out
}
/// Is the visible band of an inside or outside stroke (`align`) on the left of the closed
/// subpath `pts` of `bp`? Probes `off` left of the middle of its longest segment (past where
/// flattening moved it).
fn band_on_left(bp: &BezPath, rule: FillRule, pts: &[Point], align: StrokeAlign, off: f64) -> bool {
let n = pts.len();
let Some(i) = (0..n).max_by(|&a, &b| pts[a].distance(pts[(a + 1) % n]).total_cmp(&pts[b].distance(pts[(b + 1) % n]))) else { return true };
let (a, b) = (pts[i], pts[(i + 1) % n]);
let probe = a.midpoint(b) + left(unit(b - a)) * off.max(1e-6);
let w = bp.winding(probe);
let inside = if rule == FillRule::EvenOdd { w % 2 != 0 } else { w != 0 };
inside == (align == StrokeAlign::Inside)
}
/// The ribs along one flattened subpath `pts` of length `total` (`heads`: how far arrowheads
/// reach from its start and end).
fn strip(lay: &Lay, pts: &[Point], closed: bool, total: f64, side: Option<bool>, heads: (f64, f64)) -> Vec<Rib> {
let n = pts.len();
let seg_count = if closed { n } else { n - 1 };
let dir: Vec<Vec2> = (0..seg_count).map(|i| unit(pts[(i + 1) % n] - pts[i])).collect();
// Ribs near an arrowhead reach across it.
let reach = |s: f64| {
let mut r = lay.cover;
if heads.0 > 0.0 && s <= heads.0 {
r = r.max(heads.0 + lay.margin);
}
if heads.1 > 0.0 && total - s <= heads.1 {
r = r.max(heads.1 + lay.margin);
}
r
};
let mut out = Vec::with_capacity(n + 4);
let mut s = 0.0;
if !closed {
let (d, b) = (dir[0], lay.band(0.0, side));
out.push(Rib::square(pts[0] - d * lay.ext.0, d, reach(0.0), 0.0, b));
out.push(Rib::square(pts[0], d, reach(0.0), 0.0, b));
s = pts[0].distance(pts[1]);
}
let (first, last) = if closed { (0, n) } else { (1, n - 1) };
for i in first..last {
let v = i % n;
let t = s / total;
let ribs = lay.turn(pts[v], dir[(v + seg_count - 1) % seg_count], dir[v % seg_count], reach(s), t, lay.band(t, side));
// A closed subpath starts after the fan of its first corner and ends with it.
let skip = if closed && i == 0 { ribs.len() - 1 } else { 0 };
out.extend(ribs.into_iter().skip(skip));
s += pts[v].distance(pts[(v + 1) % n]);
}
if closed {
out.extend(lay.turn(pts[0], dir[seg_count - 1], dir[0], reach(total), 1.0, lay.band(1.0, side)));
} else {
let (d, b) = (dir[seg_count - 1], lay.band(1.0, side));
out.push(Rib::square(pts[n - 1], d, reach(total), 1.0, b));
out.push(Rib::square(pts[n - 1] + d * lay.ext.1, d, reach(total), 1.0, b));
}
out
}
/// A piece of a stroke whose gradient runs along or across it ([`gradient_slices`]).
#[derive(Clone, Debug)]
pub struct Slice {
/// The area it paints (clipped to the stroke's outline).
pub shape: BezPath,
/// Its piece of the gradient runs from position `span.0` at `from` to `span.1` at `to`, and
/// on unchanged past them; one colour when the span is empty.
pub from: Point,
pub to: Point,
pub span: (f64, f64),
}
impl Slice {
/// The slice's paint: its piece of `g` as a linear gradient placed from `from` to `to`.
pub fn paint(&self, g: &Gradient) -> Paint {
let gradient = g.span(self.span.0 as f32, self.span.1 as f32);
let end = if self.from.distance(self.to) > 1e-9 { self.to } else { self.from + Vec2::new(1.0, 0.0) };
let geom = GradientGeom { start: self.from, end, aspect: 1.0, focal: None };
Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), angle: geom.angle_deg(), ..GradientPaint::new(gradient) }))
}
}
/// The slices that paint stroke `st` of `bp` (filled with `rule`) when its gradient runs along or
/// across the path (see the module docs): flattened to `tol`, reaching `margin` past the
/// stroke's edge. Clip them to the stroke's outline and fill them in order. With `grow` > 0 each
/// slice reaches that much past its edges into its neighbours, so antialiased edges show no
/// seams: filled so that each replaces what it covers (copy), they paint each point once; filled
/// over each other, only opaque gradients may grow (translucent ones would darken there).
pub fn gradient_slices(bp: &BezPath, rule: FillRule, st: &StrokeLayer, tol: f64, margin: f64, grow: f64) -> Vec<Slice> {
let across = st.gradient_mode == StrokeGradientMode::Across;
// A width profile changes the across band along the path: ribs close enough to follow it.
let max_dt = if across && st.profile.is_some() { PROFILE_STEP } else { 1.0 };
let mut out = vec![];
for ribs in strips(bp, rule, st, tol, margin, &[], max_dt) {
for w in ribs.windows(2) {
let (a, b) = (&w[0], &w[1]);
let shape = polygon(&[a.l, b.l, b.c, b.r, a.r, a.c], grow);
if shape.elements().is_empty() {
continue;
}
let (from, to, span) = if across {
let axis = b.c - a.c;
let n = if axis.hypot() > 1e-9 { left(unit(axis)) } else { unit((a.l - a.r) + (b.l - b.r)) };
let mid = a.c.midpoint(b.c);
let (l, r) = ((a.band.0 + b.band.0) / 2.0, (a.band.1 + b.band.1) / 2.0);
(mid + n * l, mid - n * r, (0.0, 1.0))
} else {
(a.c, b.c, (a.t, b.t))
};
out.push(Slice { shape, from, to, span });
}
}
out
}
/// The closed polygon through `pts`, each edge moved out by `grow` (mitred, at most 4 × `grow`
/// out at a corner). Repeated points and spikes are dropped; empty when no area is left.
fn polygon(pts: &[Point], grow: f64) -> BezPath {
let mut v: Vec<Point> = pts.to_vec();
// Drop repeated points and spikes (there and straight back), which have no outward side.
loop {
let n = v.len();
let same = |a: Point, b: Point| a.distance(b) <= 1e-9;
let Some(i) = (0..n).find(|&i| n > 2 && (same(v[i], v[(i + 1) % n]) || same(v[(i + n - 1) % n], v[(i + 1) % n]))) else { break };
v.remove(i);
}
let mut out = BezPath::new();
if v.len() < 3 {
return out;
}
let n = v.len();
if grow > 0.0 {
let area: f64 = (0..n).map(|i| v[i].to_vec2().cross(v[(i + 1) % n].to_vec2())).sum();
let side = if area >= 0.0 { 1.0 } else { -1.0 };
let normal = |i: usize| {
let d = unit(v[(i + 1) % n] - v[i]);
Vec2::new(d.y, -d.x) * side
};
v = (0..n)
.map(|i| {
let (n1, n2) = (normal((i + n - 1) % n), normal(i));
let den = 1.0 + n1.dot(n2);
let m = if den > 1e-9 { (n1 + n2) / den } else { n1 };
let m = if m.hypot() > 4.0 { m * (4.0 / m.hypot()) } else { m };
v[i] + m * grow
})
.collect();
}
out.move_to(v[0]);
for p in &v[1..] {
out.line_to(*p);
}
out.close_path();
out
}
/// What a writer paints for a stroke whose gradient runs along or across it.
#[derive(Clone, Debug)]
pub struct WrittenSlices {
/// The outlines the stroke is written as, as one path (non-zero rule) to clip the slices.
pub clip: BezPath,
/// The slices in paint order, each with its piece of the gradient.
pub slices: Vec<(BezPath, Paint)>,
}
/// How a file writer paints stroke `st` of `bp` (filled with `rule`) when its gradient runs along
/// or across it ([`StrokeLayer::path_gradient`]), given the filled outlines it writes for the
/// stroke ([`for_writer`](super::for_writer)): [`gradient_slices`] clipped to them. Where the
/// gradient is opaque the slices overlap a little, so viewers that antialias every shape show no
/// seams. `None` without such a gradient.
pub fn written_slices(bp: &BezPath, rule: FillRule, st: &StrokeLayer, outlines: &[BezPath]) -> Option<WrittenSlices> {
let g = &st.path_gradient()?.gradient;
let clip = match outlines {
[one] => one.clone(),
_ => vectorcraft_pathops::stroke_region(outlines).to_bezpath(),
};
let grow = if g.stops.iter().all(|s| s.opacity >= 1.0) { SLICE_GROW } else { 0.0 };
let slices = gradient_slices(bp, rule, st, SLICE_TOL, SLICE_MARGIN, grow)
.into_iter()
.map(|s| {
let paint = s.paint(g);
(s.shape, paint)
})
.collect();
Some(WrittenSlices { clip, slices })
}
/// Outline Stroke of stroke `st` of `bp` (filled with `rule`) whose gradient `g` runs along or
/// across it: a gradient mesh per subpath over all the stroke can paint there (clip it to the
/// stroke's outline), coloured as [`gradient_slices`] paint it.
pub fn gradient_meshes(bp: &BezPath, rule: FillRule, st: &StrokeLayer, g: &Gradient) -> Vec<GradientMesh> {
let across = st.gradient_mode == StrokeGradientMode::Across;
let color = |t: f64| g.sample_with(t.clamp(0.0, 1.0) as f32, lerp_color);
// Along: a column at each stop (colours are linear between columns), close enough to look smooth.
let marks: Vec<f64> = if across { vec![] } else { g.expanded_stops().iter().map(|s| s.0 as f64).collect() };
let max_dt = match (across, st.profile.is_some()) {
(false, _) => MESH_STEP,
(true, true) => PROFILE_STEP,
(true, false) => 1.0,
};
// Rows across: the strip's edges, then (across) the band's left edge to its right edge with a row at each stop.
let mut us: Vec<f64> = if across {
(0..=MESH_ROWS).map(|i| i as f64 / MESH_ROWS as f64).chain(g.expanded_stops().iter().map(|s| s.0 as f64)).collect()
} else {
vec![0.5]
};
us.sort_by(f64::total_cmp);
us.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
strips(bp, rule, st, SLICE_TOL, SLICE_MARGIN, &marks, max_dt)
.into_iter()
.filter(|ribs| ribs.len() > 1)
.map(|ribs| {
let (rows, cols) = (us.len() + 1, ribs.len() - 1);
// Each level across: a point on each rib and the gradient position there.
let level = |row: usize, rib: &Rib| -> (Point, f64) {
let t = |u: f64| if across { u } else { rib.t };
match row {
0 => (rib.l, t(0.0)),
_ if row == rows => (rib.r, t(1.0)),
_ if across => {
let (l, r) = rib.band;
(rib.at(l - us[row - 1] * (l + r)), us[row - 1])
}
_ => (rib.c, rib.t),
}
};
let level = &level;
let pos: Vec<Point> = (0..=rows).flat_map(|row| ribs.iter().map(move |rib| level(row, rib).0)).collect();
let at = |row: usize, col: usize| pos[row * (cols + 1) + col];
let mut points = Vec::with_capacity(pos.len());
for row in 0..=rows {
for (col, rib) in ribs.iter().enumerate() {
let p = at(row, col);
let toward = |o: Option<Point>| o.map_or(Vec2::ZERO, |o| (o - p) / 3.0);
let mut handles = [Vec2::ZERO; 4];
handles[H_RIGHT] = toward((col < cols).then(|| at(row, col + 1)));
handles[H_LEFT] = toward(col.checked_sub(1).map(|c| at(row, c)));
handles[H_DOWN] = toward((row < rows).then(|| at(row + 1, col)));
handles[H_UP] = toward(row.checked_sub(1).map(|r| at(r, col)));
let (color, opacity) = color(level(row, rib).1);
points.push(MeshPoint { p, color, opacity, handles });
}
}
GradientMesh { rows: rows as u32, cols: cols as u32, points }
})
.collect()
}
+121
View File
@@ -0,0 +1,121 @@
//! Strokes as filled geometry outside the canvas:
//!
//! - [`for_writer`]: what a file format whose strokes only have a weight, caps, joins, a miter
//! limit and a dash array (SVG, PDF) writes so a stroke looks as it does on the canvas: a plain
//! stroke where that is exact, else the canvas's filled outlines (arrowheads, width profiles,
//! dashes fitted to corners, dots);
//! - [`outline_region`]: the area a stroke paints as one clean path (Outline Stroke and the live
//! Outline Stroke effect).
use kurbo::{BezPath, Rect, Shape};
use vectorcraft_doc::{LineCap, LineJoin, StrokeAlign, StrokeLayer};
use vectorcraft_geom::{FillRule, PathData};
use vectorcraft_pathops::BoolOp;
use super::{StrokePieces, aligned_width, is_closed, line_outline, stroke_pieces};
/// Flattening/fitting tolerance of written and outlined strokes (document points).
pub const OUTLINE_TOL: f64 = 0.01;
/// What a writer draws for one stroke (in the coordinate space of its path).
#[derive(Clone, Debug)]
pub struct Written {
pub shape: WrittenShape,
/// `Some` for an inside or outside stroke of a closed path: the shape is drawn twice as wide
/// and clipped to the path's interior (inside) or has the interior removed (outside).
pub side: Option<StrokeAlign>,
}
/// The geometry of a [`Written`] stroke.
#[derive(Clone, Debug)]
pub enum WrittenShape {
/// A plain stroke of the path itself at `width`, with the layer's caps, joins, miter limit
/// and dash pattern.
Stroke { width: f64 },
/// Filled outlines (non-zero rule): the line, then each arrowhead. They overlap, so they take
/// the stroke's opacity once, as a group. A gradient along or across the stroke
/// ([`StrokeLayer::path_gradient`]) paints its [`gradient_slices`](super::gradient_slices)
/// clipped to them instead.
Fill(Vec<BezPath>),
}
/// Does `st` draw as a plain centred stroke on every path (no alignment, arrowheads, width
/// profile, brush, fitted dashes, dots or gradient along or across it)? Writers then need no
/// geometry for it.
pub fn is_plain(st: &StrokeLayer) -> bool {
st.align == StrokeAlign::Center
&& st.start_arrow.is_none()
&& st.end_arrow.is_none()
&& st.profile.is_none()
&& st.brush.is_none()
&& !needs_outline_dashes(st)
&& st.path_gradient().is_none()
}
/// Dashes a plain stroke can't draw like the canvas: fitted to corners, or zero-length dashes
/// (dots) with a cap that paints them.
fn needs_outline_dashes(st: &StrokeLayer) -> bool {
st.dash.as_ref().is_some_and(|d| d.is_dashed() && (d.align_corners || (st.cap != LineCap::Butt && d.pattern.contains(&0.0))))
}
/// Do these pieces of `st` need filled outlines (see [`is_plain`])? Alignment alone doesn't; a
/// gradient along or across the stroke paints slices clipped to them.
fn needs_outline(pieces: &StrokePieces, st: &StrokeLayer) -> bool {
!pieces.heads.is_empty() || st.profile.is_some() || needs_outline_dashes(st) || st.path_gradient().is_some()
}
/// How a writer draws stroke `st` along `bp` (brushes aside) to match the canvas.
pub fn for_writer(bp: &BezPath, st: &StrokeLayer) -> Written {
if is_plain(st) {
return Written { shape: WrittenShape::Stroke { width: st.width }, side: None };
}
let closed = is_closed(bp);
let side = (st.align != StrokeAlign::Center && closed).then_some(st.align);
let width = aligned_width(st, closed);
let pieces = stroke_pieces(bp, st);
let shape = if needs_outline(&pieces, st) {
let mut fills = vec![line_outline(&pieces.line, st, width, OUTLINE_TOL)];
fills.extend(pieces.heads.into_iter().map(|h| h.outline));
fills.retain(|f| !f.elements().is_empty());
WrittenShape::Fill(fills)
} else {
WrittenShape::Stroke { width }
};
Written { shape, side }
}
impl Written {
/// Bounds of what is drawn before the alignment clip, for a path with bounds `path_bounds`
/// (covers miter spikes and projecting caps).
pub fn reach(&self, st: &StrokeLayer, path_bounds: Rect) -> Rect {
match &self.shape {
WrittenShape::Stroke { width } => {
let miter = if st.join == LineJoin::Miter { st.miter_limit.max(1.0) } else { 1.0 };
let cap = if st.cap == LineCap::Square { std::f64::consts::SQRT_2 } else { 1.0 };
let r = width / 2.0 * miter.max(cap);
path_bounds.inflate(r, r)
}
WrittenShape::Fill(fills) => fills.iter().map(Shape::bounding_box).fold(path_bounds, |a, b| a.union(b)),
}
}
}
/// The area stroke `st` paints along `path` (fill rule `rule`) as one clean path, as on the
/// canvas: the line (width profile, dashes, caps, joins) united with the arrowheads, and kept
/// inside or outside a closed path for inside or outside alignment. Brushes are not drawn.
pub fn outline_region(path: &PathData, rule: FillRule, st: &StrokeLayer) -> PathData {
if !(st.width > 0.0 && st.width.is_finite()) || path.is_empty() {
return PathData::default();
}
let bp = path.to_bezpath();
let closed = is_closed(&bp);
let pieces = stroke_pieces(&bp, st);
let mut parts = vec![line_outline(&pieces.line, st, aligned_width(st, closed), OUTLINE_TOL)];
parts.extend(pieces.heads.into_iter().map(|h| h.outline));
let region = vectorcraft_pathops::stroke_region(&parts);
match st.align {
StrokeAlign::Inside if closed => vectorcraft_pathops::boolean(&region, FillRule::NonZero, path, rule, BoolOp::Intersect),
StrokeAlign::Outside if closed => vectorcraft_pathops::boolean(&region, FillRule::NonZero, path, rule, BoolOp::Difference),
_ => region,
}
}
+306
View File
@@ -0,0 +1,306 @@
use kurbo::{BezPath, PathEl, Point, Rect, Shape, Vec2};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{ArrowAlign, Arrowhead, Dash, LineCap, StrokeLayer, WidthProfile};
use super::*;
fn line(x0: f64, x1: f64) -> BezPath {
let mut b = BezPath::new();
b.move_to((x0, 0.0));
b.line_to((x1, 0.0));
b
}
fn square(size: f64) -> BezPath {
Rect::new(0.0, 0.0, size, size).to_path(0.1)
}
fn stroke(width: f64, f: impl FnOnce(&mut StrokeLayer)) -> StrokeLayer {
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), width);
f(&mut st);
st
}
fn dashed(pattern: &[f64], offset: f64) -> Dash {
Dash { pattern: pattern.to_vec(), offset, align_corners: false }
}
fn length(bp: &BezPath) -> f64 {
bp.segments().map(|s| kurbo::ParamCurveArclen::arclen(&s, 1e-9)).sum()
}
fn moves(bp: &BezPath) -> usize {
bp.elements().iter().filter(|e| matches!(e, PathEl::MoveTo(_))).count()
}
fn near(a: f64, b: f64, tol: f64) -> bool {
(a - b).abs() <= tol
}
// ---------------------------------------------------------------- dashes and dots
#[test]
fn zero_length_dashes_are_dots_along_the_tangent() {
let d = dash(&line(0.0, 60.0), &dashed(&[0.0, 6.0], 0.0)).unwrap();
assert!(d.path.elements().is_empty(), "no dash has length");
let xs: Vec<f64> = d.dots.iter().map(|p| p.at.x).collect();
assert_eq!(d.dots.len(), 11, "{xs:?}");
for (i, dot) in d.dots.iter().enumerate() {
assert!(near(dot.at.x, 6.0 * i as f64, 1e-9) && near(dot.at.y, 0.0, 1e-12));
assert!(near(dot.dir.x, 1.0, 1e-12));
}
// The offset shifts the dots along the path.
let d = dash(&line(0.0, 60.0), &dashed(&[0.0, 6.0], 3.0)).unwrap();
assert_eq!(d.dots.len(), 10);
assert!(near(d.dots[0].at.x, 3.0, 1e-9));
// Dots on a curve follow its direction.
let c = kurbo::Circle::new((0.0, 0.0), 50.0).to_path(1e-6);
let d = dash(&c, &dashed(&[0.0, 10.0], 0.0)).unwrap();
for dot in &d.dots {
assert!(dot.dir.dot(dot.at.to_vec2()).abs() < 1e-3, "tangent ⟂ radius at {:?}", dot.at);
}
}
#[test]
fn dots_on_a_closed_path_are_not_doubled_at_the_start() {
let d = dash(&square(60.0), &dashed(&[0.0, 6.0], 0.0)).unwrap();
assert_eq!(d.dots.len(), 40);
}
#[test]
fn dashes_follow_arc_length_and_join_across_the_start_of_closed_paths() {
let d = dash(&line(10.0, 90.0), &dashed(&[10.0, 10.0], 0.0)).unwrap();
assert_eq!(moves(&d.path), 4);
assert!(near(length(&d.path), 40.0, 1e-6));
// An odd pattern repeats with dashes and gaps swapped: 10 on, 10 off, 10 on…
let odd = dash(&line(0.0, 80.0), &dashed(&[10.0], 0.0)).unwrap();
assert_eq!(moves(&odd.path), 4);
// Perimeter 400, dashes of 20 starting 10 into the pattern: the dash at 390..410 runs
// through the start point and is one dash.
let sq = dash(&square(100.0), &dashed(&[20.0, 20.0], 10.0)).unwrap();
assert_eq!(moves(&sq.path), 10);
assert!(near(length(&sq.path), 200.0, 1e-6));
// A pattern without length is a solid line; one dash round a closed path stays closed.
assert!(dash(&line(0.0, 10.0), &dashed(&[0.0, 0.0], 0.0)).is_none());
let whole = dash(&square(10.0), &dashed(&[100.0, 1.0], 0.0)).unwrap();
assert!(is_closed(&whole.path));
// A pattern far too fine for the path is drawn solid rather than as millions of dashes.
assert!(dash(&line(0.0, 1e6), &dashed(&[0.001, 0.001], 0.0)).is_none());
}
#[test]
fn dot_outlines_take_the_cap_shape_and_wind_like_stroked_dashes() {
let dots = [Dot { at: Point::new(10.0, 0.0), dir: Vec2::new(1.0, 0.0), t: 0.0 }];
let round = dot_outline(&dots, 4.0, LineCap::Round, 1e-4);
assert!(near(round.area(), std::f64::consts::PI * 4.0, 1e-3));
let sq = dot_outline(&dots, 4.0, LineCap::Square, 1e-4);
assert!(near(sq.area(), 16.0, 1e-9));
assert!(sq.contains(Point::new(11.9, 1.9)) && !round.contains(Point::new(11.9, 1.9)));
assert!(dot_outline(&dots, 4.0, LineCap::Butt, 1e-4).elements().is_empty());
// A dot overlapping a dash adds to it under the non-zero rule instead of cancelling it.
let dash_outline = kurbo::stroke(line(0.0, 10.0).iter(), &kurbo::Stroke::new(4.0), &Default::default(), 1e-4);
for o in [&round, &sq] {
assert_eq!(o.area().signum(), dash_outline.area().signum());
}
let st = stroke(4.0, |s| {
s.cap = LineCap::Round;
s.dash = Some(dashed(&[10.0, 1.0, 0.0, 1.0], 0.0));
});
let o = line_outline(&line(0.0, 12.0), &st, 4.0, 1e-3);
assert!(o.contains(Point::new(10.5, 0.0)), "the dot overlapping the dash end is painted");
}
#[test]
fn line_outline_uses_the_profile_dashes_or_a_plain_stroke() {
let l = line(0.0, 100.0);
let plain = line_outline(&l, &stroke(10.0, |_| {}), 10.0, 1e-3);
assert!(near(plain.area().abs(), 1000.0, 1e-6));
let lens = line_outline(&l, &stroke(10.0, |s| s.profile = Some(WidthProfile::lens())), 10.0, 1e-3);
assert!(near(lens.area().abs(), 500.0, 1e-6));
// Each dash takes its widths from where it sits along the lens (0 → 1 → 0): the dash over
// 0..25 averages a quarter of the width, the one over 50..75 three quarters.
let st = stroke(10.0, |s| {
s.profile = Some(WidthProfile::lens());
s.dash = Some(dashed(&[25.0, 25.0], 0.0));
});
assert!(near(line_outline(&l, &st, 10.0, 1e-3).area().abs(), 62.5 + 187.5, 1e-6));
assert!(line_outline(&BezPath::new(), &st, 10.0, 1e-3).elements().is_empty());
}
#[test]
fn aligned_width_doubles_only_closed_inside_and_outside_strokes() {
let mut st = stroke(3.0, |s| s.align = vectorcraft_doc::StrokeAlign::Inside);
assert_eq!(aligned_width(&st, true), 6.0);
assert_eq!(aligned_width(&st, false), 3.0);
st.align = vectorcraft_doc::StrokeAlign::Center;
assert_eq!(aligned_width(&st, true), 3.0);
}
// ---------------------------------------------------------------- arrowheads
fn arrows(kind: Arrowhead, align: ArrowAlign, cap: LineCap) -> StrokeLayer {
stroke(2.0, |s| {
s.end_arrow = Some(kind);
s.arrow_align = align;
s.cap = cap;
})
}
#[test]
fn no_heads_borrows_the_path_and_closed_paths_get_none() {
let l = line(0.0, 50.0);
assert!(matches!(stroke_pieces(&l, &stroke(2.0, |_| {})).line, Cow::Borrowed(_)));
let st = stroke(2.0, |s| {
s.start_arrow = Some(Arrowhead::Triangle);
s.end_arrow = Some(Arrowhead::Triangle);
s.arrow_align = ArrowAlign::Tip;
});
let sq = square(50.0);
let p = stroke_pieces(&sq, &st);
assert!(p.heads.is_empty());
assert_eq!(p.line.as_ref(), &sq);
}
#[test]
fn tip_mode_shortens_the_stroke_by_the_head_and_puts_the_tip_on_the_end_point() {
for kind in Arrowhead::ALL {
let st = arrows(kind, ArrowAlign::Tip, LineCap::Butt);
let l = line(0.0, 100.0);
let p = stroke_pieces(&l, &st);
let [h] = &p.heads[..] else { panic!("one head") };
assert_eq!(h.tip, Point::new(100.0, 0.0), "{kind:?}");
assert!(near(h.dir.x, 1.0, 1e-9), "{kind:?}");
assert!(h.inset > 0.0 && h.inset <= 8.0, "{kind:?} inset {}", h.inset);
assert!(near(length(&p.line), 100.0 - h.inset, 1e-6), "{kind:?}");
// Nothing reaches past the tip.
assert!(h.outline.bounding_box().x1 <= 100.0 + 0.01, "{kind:?}");
assert!(h.outline.area().abs() > 0.5, "{kind:?}");
}
// Both ends, on a curve: the start head points back along the path.
let mut c = BezPath::new();
c.move_to((0.0, 0.0));
c.curve_to((30.0, -40.0), (70.0, -40.0), (100.0, 0.0));
let st = stroke(2.0, |s| {
s.start_arrow = Some(Arrowhead::Triangle);
s.end_arrow = Some(Arrowhead::Arrow);
s.arrow_align = ArrowAlign::Tip;
});
let p = stroke_pieces(&c, &st);
assert_eq!(p.heads.len(), 2);
assert_eq!(p.heads[0].tip, Point::new(0.0, 0.0));
assert!(p.heads[0].dir.x < 0.0 && p.heads[0].dir.y > 0.0, "{:?}", p.heads[0].dir);
let total = length(&c);
assert!(near(length(&p.line), total - p.heads[0].inset - p.heads[1].inset, 1e-5));
// A path shorter than its heads leaves no line.
let short = line(0.0, 3.0);
let p = stroke_pieces(&short, &arrows(Arrowhead::Triangle, ArrowAlign::Tip, LineCap::Butt));
assert!(p.line.elements().is_empty());
assert_eq!(p.heads.len(), 1);
}
#[test]
fn extend_mode_keeps_the_path_and_overhangs_by_the_inset() {
let l = line(0.0, 100.0);
let st = arrows(Arrowhead::Triangle, ArrowAlign::Extend, LineCap::Butt);
let p = stroke_pieces(&l, &st);
assert!(matches!(p.line, Cow::Borrowed(_)));
let h = &p.heads[0];
assert!(near(h.tip.x, 100.0 + h.inset, 1e-9));
// The same head as in tip mode, moved by the overhang.
let tip = stroke_pieces(&l, &arrows(Arrowhead::Triangle, ArrowAlign::Tip, LineCap::Butt));
let mut moved = tip.heads[0].outline.clone();
moved.apply_affine(kurbo::Affine::translate((h.inset, 0.0)));
let pts = |b: &BezPath| b.elements().iter().filter_map(|e| e.end_point()).collect::<Vec<_>>();
let (a, b) = (pts(&moved), pts(&h.outline));
assert_eq!(a.len(), b.len());
assert!(a.iter().zip(&b).all(|(p, q)| p.distance(*q) < 1e-9));
}
#[test]
fn hollow_heads_are_rings_and_the_stroke_stays_out_of_the_hole() {
for (kind, cap) in [(Arrowhead::CircleOpen, LineCap::Round), (Arrowhead::SquareOpen, LineCap::Square), (Arrowhead::TriangleOpen, LineCap::Butt)] {
let st = arrows(kind, ArrowAlign::Tip, cap);
let l = line(0.0, 100.0);
let p = stroke_pieces(&l, &st);
let h = &p.heads[0];
let line_o = line_outline(&p.line, &st, st.width, 1e-3);
// A point inside the hole is neither head nor line.
let hole = Point::new(100.0 - 8.0 * 0.45, 0.0);
assert_eq!(h.outline.winding(hole), 0, "{kind:?} hole");
assert_eq!(line_o.winding(hole), 0, "{kind:?} line in the hole");
// The line reaches the back wall, so they overlap (no seam).
let back_wall = Point::new(100.0 - 8.0 + 0.25, 0.0);
assert_ne!(h.outline.winding(back_wall), 0, "{kind:?} wall");
assert_ne!(line_o.winding(back_wall - Vec2::new(0.2, 0.0)), 0, "{kind:?} line reaches the wall");
}
// The open arrow is a chevron: open between its arms.
let l = line(0.0, 100.0);
let p = stroke_pieces(&l, &arrows(Arrowhead::ArrowOpen, ArrowAlign::Tip, LineCap::Butt));
let h = &p.heads[0];
assert_eq!(h.outline.winding(Point::new(93.0, 0.0)), 0);
assert_ne!(h.outline.winding(Point::new(99.0, 0.0)), 0);
}
#[test]
fn round_caps_never_poke_past_the_tip() {
for kind in Arrowhead::ALL {
for scale in [10.0, 100.0, 300.0] {
let mut st = arrows(kind, ArrowAlign::Tip, LineCap::Round);
st.arrow_scale = (scale, scale);
let l = line(0.0, 100.0);
let p = stroke_pieces(&l, &st);
let o = line_outline(&p.line, &st, st.width, 1e-3);
assert!(o.bounding_box().x1 <= 100.0 + 1e-3, "{kind:?} at {scale}%: {}", o.bounding_box().x1);
}
}
}
// ---------------------------------------------------------------- robustness
#[test]
fn degenerate_paths_and_patterns_neither_panic_nor_hang() {
let finite = |b: &BezPath| b.elements().iter().filter_map(|e| e.end_point()).all(|q| q.x.is_finite() && q.y.is_finite());
let mut point = BezPath::new();
point.move_to((0.0, 0.0));
let lone = point.clone();
point.line_to((0.0, 0.0));
let mut still_curve = BezPath::new();
still_curve.move_to((0.0, 0.0));
still_curve.curve_to((0.0, 0.0), (0.0, 0.0), (0.0, 0.0));
// Drawing on after a ClosePath, and curves whose handles sit on their end points.
let mut mixed = BezPath::new();
mixed.move_to((0.0, 0.0));
mixed.line_to((10.0, 0.0));
mixed.close_path();
mixed.line_to((5.0, 5.0));
mixed.move_to((20.0, 0.0));
mixed.curve_to((20.0, 0.0), (50.0, 50.0), (50.0, 50.0));
mixed.quad_to((50.0, 50.0), (90.0, 0.0));
let paths = [BezPath::new(), lone, point, still_curve, mixed, square(20.0)];
// Negative, non-finite and sub-tolerance entries (the last used to loop forever on a
// zero-length path), and offsets far outside the pattern.
let patterns: [&[f64]; 6] = [&[0.0, 6.0], &[-1.0, 3.0], &[f64::NAN, 1.0], &[1e-12, 1e-12], &[0.0], &[3.0, 0.0]];
for bp in &paths {
for kind in Arrowhead::ALL {
for align in [ArrowAlign::Extend, ArrowAlign::Tip] {
for cap in [LineCap::Butt, LineCap::Round, LineCap::Square] {
for pat in patterns {
for off in [0.0, -7.0, 1e12, f64::INFINITY] {
let st = stroke(2.0, |s| {
s.start_arrow = Some(kind);
s.end_arrow = Some(kind);
s.arrow_align = align;
s.cap = cap;
s.dash = Some(dashed(pat, off));
});
let p = stroke_pieces(bp, &st);
assert!(p.heads.iter().all(|h| finite(&h.outline)), "{bp:?} {kind:?}");
assert!(finite(&line_outline(&p.line, &st, 2.0, 0.01)), "{bp:?} {pat:?} {off}");
}
}
}
}
}
}
assert!(dash(&line(0.0, 1e-9), &dashed(&[1e-12, 1e-12], 0.0)).is_none(), "too fine to advance: solid");
}
+83
View File
@@ -0,0 +1,83 @@
//! Every arrowhead's outline: drawn, inside its box (4 × 4 weights; the open arrow's square-cut
//! arms reach a little further back), without self-intersections, and covering the end of the
//! stroke under it.
use kurbo::{BezPath, PathEl, Point, Shape};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{ArrowAlign, Arrowhead, StrokeLayer};
use super::stroke_pieces;
/// A 2 pt line along +x ending at (100, 0) with `kind` at its end, tip on the end point: heads
/// of weight 2, so 8 long and 8 wide.
fn head(kind: Arrowhead) -> super::Arrow {
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 2.0);
st.end_arrow = Some(kind);
st.arrow_align = ArrowAlign::Tip;
let mut l = BezPath::new();
l.move_to((0.0, 0.0));
l.line_to((100.0, 0.0));
stroke_pieces(&l, &st).heads.remove(0)
}
/// The outline's closed contours as polygons (curves flattened).
fn contours(bp: &BezPath) -> Vec<Vec<Point>> {
let mut out: Vec<Vec<Point>> = vec![];
kurbo::flatten(bp.iter(), 1e-3, |el| match el {
PathEl::MoveTo(p) => out.push(vec![p]),
PathEl::LineTo(p) => out.last_mut().unwrap().push(p),
PathEl::ClosePath => {
let c = out.last_mut().unwrap();
if c.len() > 1 && c[0].distance(*c.last().unwrap()) < 1e-9 {
c.pop();
}
}
_ => panic!("flattened"),
});
out
}
/// Do segments `a` and `b` cross at a point inside both (touching ends don't count)?
fn cross(a: (Point, Point), b: (Point, Point)) -> bool {
let orient = |p: Point, q: Point, r: Point| (q - p).cross(r - p);
let (d1, d2) = (orient(b.0, b.1, a.0), orient(b.0, b.1, a.1));
let (d3, d4) = (orient(a.0, a.1, b.0), orient(a.0, a.1, b.1));
d1 * d2 < -1e-12 && d3 * d4 < -1e-12
}
#[test]
fn every_outline_is_drawn_inside_its_box_without_self_intersections() {
let (o, d) = (Point::ORIGIN, Point::new(2.0, 2.0));
assert!(cross((o, d), (Point::new(0.0, 2.0), Point::new(2.0, 0.0))) && !cross((o, d), (d, Point::new(4.0, 0.0))), "the check itself");
for kind in Arrowhead::ALL {
let h = head(kind);
assert!(h.outline.area().abs() > 4.0, "{kind:?} is drawn");
let b = h.outline.bounding_box();
assert!(b.x0 >= 91.0 - 1e-6 && b.x1 <= 100.0 + 1e-6 && b.y0 >= -4.0 - 1e-6 && b.y1 <= 4.0 + 1e-6, "{kind:?} leaves its box: {b:?}");
let edges: Vec<(Point, Point)> = contours(&h.outline).iter().flat_map(|c| (0..c.len()).map(|i| (c[i], c[(i + 1) % c.len()]))).collect();
assert!(edges.len() >= 3, "{kind:?}");
for (i, a) in edges.iter().enumerate() {
for b in &edges[i + 1..] {
assert!(!cross(*a, *b), "{kind:?}: {a:?} crosses {b:?}");
}
}
}
}
#[test]
fn the_stroke_ends_inside_every_head() {
for kind in Arrowhead::ALL {
let h = head(kind);
// Just in front of where the line stops, on its centre line.
let end = h.tip - h.dir * (h.inset - 0.1);
assert_ne!(h.outline.winding(end), 0, "{kind:?}: the line ends outside the head (inset {})", h.inset);
}
}
#[test]
fn half_arrows_barb_on_their_own_side() {
// Pointing along +x on a y-down page, left is −y.
assert!(head(Arrowhead::HalfArrowLeft).outline.bounding_box().y0 < -3.0);
assert!(head(Arrowhead::HalfArrowLeft).outline.bounding_box().y1 < 1.5);
assert!(head(Arrowhead::HalfArrowRight).outline.bounding_box().y1 > 3.0);
}
+224
View File
@@ -0,0 +1,224 @@
//! Dashes fitted to corners and path ends, and variable-width strokes with dashes and joins.
use kurbo::{BezPath, ParamCurveArclen, PathEl, Point, Rect, Shape};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Dash, LineCap, LineJoin, StrokeLayer, WidthProfile};
use super::*;
fn fitted(pattern: &[f64], offset: f64) -> Dash {
Dash { pattern: pattern.to_vec(), offset, align_corners: true }
}
fn stroke(width: f64, f: impl FnOnce(&mut StrokeLayer)) -> StrokeLayer {
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), width);
f(&mut st);
st
}
fn line(x1: f64) -> BezPath {
let mut b = BezPath::new();
b.move_to((0.0, 0.0));
b.line_to((x1, 0.0));
b
}
fn moves(bp: &BezPath) -> usize {
bp.elements().iter().filter(|e| matches!(e, PathEl::MoveTo(_))).count()
}
fn length(bp: &BezPath) -> f64 {
bp.segments().map(|s| s.arclen(1e-9)).sum()
}
fn near(a: f64, b: f64, tol: f64) -> bool {
(a - b).abs() <= tol
}
// ---------------------------------------------------------------- dashes fitted to corners
#[test]
fn every_corner_of_a_rectangle_falls_mid_dash() {
// 100 × 50 with 12/6: the long sides hold 6 periods (scaled by 100/108), the short ones 3
// (50/54), so every half dash at a corner is 6 · 100/108 long.
let r = Rect::new(0.0, 0.0, 100.0, 50.0).to_path(0.1);
let d = dash(&r, &fitted(&[12.0, 6.0], 0.0)).unwrap();
assert_eq!(moves(&d.path), 5 + 2 + 5 + 2 + 4, "inner dashes of each side plus one per corner");
assert!(near(length(&d.path), 200.0, 1e-6), "two thirds of the perimeter");
let half = 6.0 * 100.0 / 108.0;
let o = line_outline(&r, &stroke(2.0, |s| s.dash = Some(fitted(&[12.0, 6.0], 0.0))), 2.0, 1e-3);
for (c, along) in [
((0.0, 0.0), [(1.0, 0.0), (0.0, 1.0)]),
((100.0, 0.0), [(-1.0, 0.0), (0.0, 1.0)]),
((100.0, 50.0), [(-1.0, 0.0), (0.0, -1.0)]),
((0.0, 50.0), [(1.0, 0.0), (0.0, -1.0)]),
] {
let c = Point::from(c);
assert!(o.contains(c), "corner {c:?} is painted");
for v in along {
let v = kurbo::Vec2::from(v);
assert!(o.contains(c + v * (half - 0.3)), "dash runs on from {c:?} along {v:?}");
assert!(!o.contains(c + v * (half + 0.3)), "and stops half a dash from {c:?} along {v:?}");
}
}
// The spans of the dashes cover the same length.
let covered: f64 = d.spans.iter().map(|(a, b)| b - a).sum();
assert!(near(covered * 300.0, 200.0, 1e-6), "{:?}", d.spans);
}
#[test]
fn open_paths_start_and_end_mid_dash_and_runs_hold_whole_periods() {
// 100 long, 10/10: five periods, half a dash at each end.
let d = dash(&line(100.0), &fitted(&[10.0, 10.0], 0.0)).unwrap();
assert_eq!(moves(&d.path), 6);
assert!(near(length(&d.path), 50.0, 1e-6));
let ends: Vec<f64> = d.path.elements().iter().filter_map(|e| e.end_point()).map(|p| p.x).collect();
assert!(near(ends[0], 0.0, 1e-9) && near(ends[1], 5.0, 1e-9) && near(*ends.last().unwrap(), 100.0, 1e-9), "{ends:?}");
// 92 long: 4.6 periods round to 5, squeezed to fit.
let d = dash(&line(92.0), &fitted(&[10.0, 10.0], 0.0)).unwrap();
assert_eq!(moves(&d.path), 6);
assert!(near(length(&d.path), 46.0, 1e-6));
// A run shorter than a period still gets one: half dashes at its ends.
let d = dash(&line(4.0), &fitted(&[10.0, 10.0], 0.0)).unwrap();
assert_eq!(moves(&d.path), 2);
assert!(near(length(&d.path), 2.0, 1e-6));
}
#[test]
fn fitted_dashes_ignore_the_offset_and_keep_exact_dashes_unchanged() {
let r = Rect::new(0.0, 0.0, 100.0, 50.0).to_path(0.1);
let a = dash(&r, &fitted(&[12.0, 6.0], 0.0)).unwrap();
let b = dash(&r, &fitted(&[12.0, 6.0], 5.0)).unwrap();
assert_eq!(a.path, b.path);
// Exact dashes still start at the path's start, `offset` into the pattern.
let exact = dash(&line(100.0), &Dash { pattern: vec![10.0, 10.0], offset: 5.0, align_corners: false }).unwrap();
let first: Vec<f64> = exact.path.elements().iter().take(2).filter_map(|e| e.end_point()).map(|p| p.x).collect();
assert_eq!(first, vec![0.0, 5.0]);
assert_eq!(moves(&exact.path), 6);
}
#[test]
fn smooth_closed_paths_hold_a_whole_number_of_dashes() {
// A circle has no corners: one run all the way round, 17 periods of 12/6 (314 / 18 ≈ 17.5).
let c = kurbo::Circle::new((0.0, 0.0), 50.0).to_path(1e-6);
let d = dash(&c, &fitted(&[12.0, 6.0], 0.0)).unwrap();
assert_eq!(moves(&d.path), 17, "the dash on the start point is one dash");
let circumference = 2.0 * std::f64::consts::PI * 50.0;
assert!(near(length(&d.path), circumference * 12.0 / 18.0, 1e-3));
}
#[test]
fn fitted_dots_sit_on_every_corner_and_end_once() {
// 60 pt sides with a dot every 7: 9 periods a side (60 / 63 scale), the corner dots shared.
let sq = Rect::new(0.0, 0.0, 60.0, 60.0).to_path(0.1);
let d = dash(&sq, &fitted(&[0.0, 7.0], 0.0)).unwrap();
assert_eq!(d.dots.len(), 36);
for c in [(0.0, 0.0), (60.0, 0.0), (60.0, 60.0), (0.0, 60.0)] {
assert_eq!(d.dots.iter().filter(|p| p.at.distance(Point::from(c)) < 1e-6).count(), 1, "one dot on {c:?}");
}
let d = dash(&line(60.0), &fitted(&[0.0, 7.0], 0.0)).unwrap();
assert_eq!(d.dots.len(), 10);
assert!(near(d.dots[0].at.x, 0.0, 1e-9) && near(d.dots[9].at.x, 60.0, 1e-9));
assert!(near(d.dots[9].t, 1.0, 1e-9));
}
#[test]
fn fitted_dashes_on_curves_and_degenerate_paths_stay_finite() {
let mut mixed = BezPath::new();
mixed.move_to((0.0, 0.0));
mixed.line_to((0.0, 0.0));
mixed.curve_to((30.0, -40.0), (70.0, -40.0), (100.0, 0.0));
mixed.line_to((100.0, 0.0));
mixed.line_to((50.0, 60.0));
mixed.close_path();
mixed.move_to((200.0, 0.0));
mixed.line_to((200.0, 1e-7));
for pat in [&[12.0, 6.0][..], &[0.0, 5.0], &[3.0], &[5.0, 0.0], &[1e-12, 4.0, 0.0, 2.0]] {
for profile in [None, Some(WidthProfile::lens())] {
let st = stroke(4.0, |s| {
s.dash = Some(fitted(pat, 0.0));
s.cap = LineCap::Round;
s.profile = profile.clone();
});
let o = line_outline(&mixed, &st, 4.0, 0.01);
assert!(o.elements().iter().filter_map(|e| e.end_point()).all(|p| p.x.is_finite() && p.y.is_finite()), "{pat:?}");
}
}
}
// ---------------------------------------------------------------- width profiles with dashes and joins
#[test]
fn each_dash_takes_its_width_from_the_profile_along_the_whole_path() {
let st = stroke(10.0, |s| {
s.profile = Some(WidthProfile::lens());
s.dash = Some(Dash { pattern: vec![10.0, 10.0], offset: 0.0, align_corners: false });
});
let o = line_outline(&line(100.0), &st, 10.0, 1e-3);
// The dash over 40..50 is near the lens's widest point; the one over 0..10 near its tip.
assert!(o.contains(Point::new(45.0, 4.0)) && o.contains(Point::new(45.0, -4.0)), "middle dash is wide");
assert!(!o.contains(Point::new(5.0, 2.0)) && o.contains(Point::new(5.0, 0.3)), "first dash is thin");
assert!(!o.contains(Point::new(15.0, 0.0)), "gaps stay empty");
// Dots too: a round dot near the middle is bigger than one near an end.
let st = stroke(10.0, |s| {
s.profile = Some(WidthProfile::lens());
s.cap = LineCap::Round;
s.dash = Some(Dash { pattern: vec![0.0, 10.0], offset: 0.0, align_corners: false });
});
let o = line_outline(&line(100.0), &st, 10.0, 1e-3);
assert!(o.contains(Point::new(50.0, 4.5)) && !o.contains(Point::new(10.0, 1.5)) && o.contains(Point::new(10.0, 0.5)));
}
/// An L whose corner turns 90° at (100, 0), or a sharp V turning back at (100, 0).
fn bend(sharp: bool) -> BezPath {
let mut b = line(100.0);
b.line_to(if sharp { (0.0, 20.0) } else { (100.0, 100.0) });
b
}
fn uniform(join: LineJoin, miter_limit: f64) -> StrokeLayer {
stroke(20.0, |s| {
s.profile = Some(WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] });
s.join = join;
s.miter_limit = miter_limit;
})
}
#[test]
fn profile_corners_take_the_stroke_join() {
let out = |join, limit, sharp| line_outline(&bend(sharp), &uniform(join, limit), 20.0, 1e-3);
// The L: a miter fills the outer corner, a round join leaves it, a bevel cuts it.
let corner = Point::new(109.0, -9.0);
assert!(out(LineJoin::Miter, 10.0, false).contains(corner));
assert!(!out(LineJoin::Round, 10.0, false).contains(corner));
assert!(out(LineJoin::Round, 10.0, false).contains(Point::new(106.5, -6.5)), "round join reaches 10 out");
assert!(!out(LineJoin::Bevel, 10.0, false).contains(Point::new(106.5, -6.5)));
assert!(out(LineJoin::Bevel, 10.0, false).contains(Point::new(104.5, -4.5)));
// The inside of the L is filled up to the inner miter and no further.
let l = out(LineJoin::Round, 10.0, false);
assert!(l.contains(Point::new(91.0, 9.0)) && !l.contains(Point::new(89.0, 11.0)));
// The sharp V: its miter (about 10 × the half width) is past the limit of 10, so it bevels;
// no join spikes out past the round join's reach.
for join in [LineJoin::Miter, LineJoin::Round, LineJoin::Bevel] {
let x1 = out(join, 10.0, true).bounding_box().x1;
assert!(x1 <= 110.0 + 1e-6, "{join:?}: {x1}");
}
// A limit above it keeps the full miter.
let x1 = out(LineJoin::Miter, 20.0, true).bounding_box().x1;
assert!(x1 > 195.0, "{x1}");
}
#[test]
fn smooth_points_of_curves_stay_smooth_with_any_join() {
// A circle stroked with a uniform profile is the same ring whatever the join.
let c = kurbo::Circle::new((0.0, 0.0), 50.0).to_path(1e-4);
let want = 2.0 * std::f64::consts::PI * 50.0 * 10.0;
for join in [LineJoin::Miter, LineJoin::Round, LineJoin::Bevel] {
let st = stroke(10.0, |s| {
s.profile = Some(WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] });
s.join = join;
});
let a = line_outline(&c, &st, 10.0, 1e-3).area().abs();
assert!((a - want).abs() / want < 0.005, "{join:?}: {a} vs {want}");
}
}
Loaded 100 of 642 files, more files were not shown because too many files have changed in this diff. Show more